# OIDC

alpha Contributors are welcome

Oidc-provider (opens new window) is an OAuth 2.0 Authorization Server with OpenID Connect and many additional features and standards implemented.

Certification

Filip Skokan has certified (opens new window) that oidc-provider (opens new window) conforms to the following profiles of the OpenID Connectâ„¢ protocol

  • OP Basic, Implicit, Hybrid, Config, Dynamic, Form Post, and 3rd Party-Init
  • OP Front-Channel Logout, Back-Channel Logout, RP-Initiated Logout, and Session Management
  • OP FAPI R/W MTLS and Private Key

# Features

Ts.ED provides decorators and services to create an Oidc-provider with your Ts.ED application.

  • Create interactions policies,
  • Create views,
  • Use adapters to connect Oidc-provider with redis/mongo/etc...
  • Create automatically jwks keys on startup

# Installation

Before using the @tsed/oidc-provider package, we need to install the oidc-provider (opens new window) module.

npm install --save oidc-provider ajv
npm install --save @tsed/oidc-provider @tsed/ajv @tsed/adapters
1
2

Then we need to follow these steps:

  • Configure the oidc server,
  • Create the Interactions controller,
  • Create our first Login interaction and views,
  • Create the Accounts provider

# Configuration

Create Oidc server with Ts.ED requires some other Ts.ED features to work properly.

  • Adapters to manage database connection,
  • Ajv to validate
  • Views to display pages.
import {Env} from "@tsed/core";
import {Configuration, Inject, Constant} from "@tsed/di";
import {FileSyncAdapter} from "@tsed/adapter";
import "@tsed/ajv";
import "@tsed/swagger";
import {OidcSecureMiddleware} from "@tsed/oidc-provider";
import {PlatformApplication} from "@tsed/common";
import {Accounts} from "./services/Accounts"; 
import {InteractionsCtrl} from "./controllers/oidc/InteractionsCtrl"; 

export const rootDir = __dirname;

@Configuration({
  httpPort: 8081,
  mount: {
   "/": [InteractionsCtrl]
  },
  adapters: {
    lowdbDir: join(rootDir, "..", '.db'),
    Adapter: FileSyncAdapter
  },
  oidc: {
    issuer: "http://localhost:8081",
    jwksPath: join(__dirname, "..", "keys", "jwks.json"),
    Accounts: Accounts, // Injectable service to manage your accounts
    clients: [ // statics clients
      {
        client_id: "client_id",
        client_secret: "client_secret",
        redirect_uris: [
          "http://localhost:8081"
        ],
        response_types: ["id_token"],
        grant_types: ["implicit"],
        token_endpoint_auth_method: "none"
      }
    ],
    claims: {
      openid: ["sub"],
      email: ["email", "email_verified"]
    },
    formats: {
      AccessToken: "jwt"
    },
    features: {
      // disable the packaged interactions
      devInteractions: {enabled: false},
      encryption: {enabled: true},
      introspection: {enabled: true},
      revocation: {enabled: true}
    }
  },
  views: {
    root: `${rootDir}/views`,
    extensions: {
      ejs: "ejs"
    }
  },
  swagger: [
    {
      path: "/v3/doc",
      specVersion: "3.0.1",
      showExplorer: true
    }
  ]
})
export class Server {
  @Inject()
  app: PlatformApplication;
 
  @Constant("env")
  env: Env;

  $beforeRoutesInit() {
    if (this.env === "production") {
      this.app.use(OidcSecureMiddleware) // ensure the https protocol
    } 
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

# Options

import {Type} from "@tsed/core";
import {Configuration} from "oidc-provider";
import {OidcAccountsMethods, OidcClientsMethods} from "@tsed/oidc-provider";

export interface OidcSettings extends Configuration {
  /**
   * Issuer URI. By default Ts.ED creates issuer with http://localhost:${httpPort}
   */
  issuer?: string;
  /**
   * Path to store jwks keys.
   */
  jwksPath?: string;
  /**
   * Secure keys.
   */
  secureKey?: string[];
  /**
   * Enable proxy.
   */
  proxy?: boolean;
  /**
   * Injectable service to manage accounts.
   */
  Accounts?: Type<OidcAccountsMethods>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

Documentation on other options properties can be found on the oidc-provider (opens new window) documentation page.

# Interactions

Interactions is the User flows in Oidc provider. For example the login page is considered by Oidc-provider as an interaction. We can define many interactions during the authentication flow, for example:

  • Login,
  • E-mail verification,
  • Password recovery,
  • Sharing account data consent,
  • etc.

To have a working Oidc server with Ts.ED, we need to create at least one interaction. To begin, we have to create the Interactions controller which will be responsible to run all of our future custom interactions.

In your controllers directory, create the oidc/InteractionCtrl.ts file and copy the following code:

import {Get} from "@tsed/common";
import {Interactions, OidcCtx, DefaultPolicy} from "@tsed/oidc-provider";
import {LoginInteraction} from "../../interactions/LoginInteraction";

@Interactions({
  path: "/interaction/:uid",
  children: [
    LoginInteraction // register its children interations 
  ]
})
export class InteractionsCtrl {
  @Get("/")
  async promptInteraction(@OidcCtx() oidcCtx: OidcCtx) {
    return oidcCtx.runInteraction();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Note

The controller Interactions exposes the routes to display any interaction. Here we expose the route GET /interation/:uid

The uid is the unique session id used by Oidc-provider to identify the current user flow.

Now that we have our interactions controller, we can create our first interaction.

Create a new directory interactions. We will store all custom interactions in this directory.

import {BodyParams, Inject, Post, View} from "@tsed/common";
import {Env} from "@tsed/core";
import {Constant} from "@tsed/di";
import {BadRequest, Unauthorized} from "@tsed/exceptions";
import {Interaction, OidcCtx, OidcSession, Params, Prompt, Uid} from "@tsed/oidc-provider";
import {Accounts} from "../services/Accounts";

@Interaction({
  name: "login"
})
export class LoginInteraction {
  @Constant("env")
  env: Env;

  @Inject()
  accounts: Accounts;

  @View("login")
  async $prompt(@OidcCtx() oidcCtx: OidcCtx,
                @Prompt() prompt: Prompt,
                @OidcSession() session: OidcSession,
                @Params() params: Params,
                @Uid() uid: Uid): Promise<any> {
    const client = await oidcCtx.findClient();

    if (!client) {
      throw new Unauthorized(`Unknown client_id ${params.client_id}`);
    }

    return {
      client,
      uid,
      details: prompt.details,
      params,
      title: "Sign-in",
      flash: false,
      ...oidcCtx.debug()
    };
  }

  @Post("/login")
  @View("login")
  async submit(@BodyParams() payload: any,
               @Params() params: Params,
               @Uid() uid: Uid,
               @OidcSession() session: OidcSession,
               @Prompt() prompt: Prompt,
               @OidcCtx() oidcCtx: OidcCtx) {
    if (prompt.name !== "login") {
      throw new BadRequest("Bad interaction name");
    }

    const client = await oidcCtx.findClient();

    const account = await this.accounts.authenticate(payload.email, payload.password);

    if (!account) {
      return {
        client,
        uid,
        details: prompt.details,
        params: {
          ...params,
          login_hint: payload.email
        },
        title: "Sign-in",
        flash: "Invalid email or password.",
        ...oidcCtx.debug()
      };
    }

    return oidcCtx.interactionFinished({
      login: {
        account: account.accountId
      }
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

TIP

$prompt is a special hook called by your Interactions controller.

TIP

To start the server properly, create the Accounts class in services directory with the authenticate and findAccount methods:

import {Injectable} from "@tsed/di";
import {AccessToken, AuthorizationCode, DeviceCode} from "@tsed/oidc-provider";

@Injectable()
export class Accounts {
  async findAccount(id: string, token: AuthorizationCode | AccessToken | DeviceCode | undefined, ctx: PlatformContext) {
    return undefined;
  }
  
  async authenticate(email: string, password: string) {
    return undefined;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

We will implement these methods later!

At this step, you can start the Oidc server and check the logs server to see if the well-known configuration has been correctly exposed:

[2021-01-04T07:35:31.523] [INFO ] [TSED] - WellKnown is available on http://0.0.0.0:8081/.well-known/openid-configuration
1

Try also to open the link in your browser!

Now, we need to add the Views to display our login page. Create a views directory on root level and create the following files:

    The login page is ready to be displayed. To test it, open the following link:

    http://0.0.0.0:8081/auth?client_id=client_id&response_type=id_token&scope=openid&nonce=foobar&redirect_uri=http://localhost:8081
    
    1
    Oidc login page

    # Accounts

    An Accounts provider can be given to the Oidc configuration. It'll be responsible to manage accounts and resolve the user authentication.

    Copy the following code in the Accounts.ts file:

    import {Adapter, InjectAdapter} from "@tsed/adapters";
    import {PlatformContext} from "@tsed/common";
    import {Injectable} from "@tsed/di";
    import {deserialize} from "@tsed/json-mapper";
    import {AccessToken, AuthorizationCode, DeviceCode} from "@tsed/oidc-provider";
    import {Account} from "../models/Account";
    
    @Injectable()
    export class Accounts {
      @InjectAdapter("Accounts", Account)
      adapter: Adapter<Account>;
    
      async $onInit() {
        const accounts = await this.adapter.findAll();
    
        // We create a default account if the database is empty
        if (!accounts.length) {
          await this.adapter.create(deserialize({
            email: "test@test.com",
            emailVerified: true
          }, {useAlias: false}));
        }
      }
    
      async findAccount(id: string, token: AuthorizationCode | AccessToken | DeviceCode | undefined, ctx: PlatformContext) {
        return this.adapter.findById(id);
      }
    
      async authenticate(email: string, password: string) {
        return this.adapter.findOne({email});
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32

    TIP

    We use the $onInit hook to create the first account automatically. You can adapt the script to your needs.

    Then, create the Account model:

    import {Email, Name, Property} from "@tsed/schema";
    
    export class Account {
      @Name("id")
      _id: string;
    
      @Email()
      email: string;
    
      @Property()
      @Name("email_verified")
      emailVerified: boolean;
      
      // Added in v7
      [key: string]: unknown;
    
      get accountId() {
        return this._id;
      }
    
      async claims() {
        return {
          sub: this._id,
          email: this.email,
          email_verified: this.emailVerified
        };
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28

    TIP

    Claims method is used by Oidc to expose this information in the userInfo endpoint.

    # Alter Oidc policy

    Ts.ED emits a special $alterOidcPolicy event when @tsed/oidc-provider links interactions with Oidc policy. You can change the policy configuration by adding $alterOidcPolicy on InteractionsCtrl:

    import {Get} from "@tsed/common";
    import {Interactions, OidcCtx, DefaultPolicy} from "@tsed/oidc-provider";
    import {LoginInteraction} from "../../interactions/LoginInteraction";
    
    @Interactions({
      path: "/interaction/:uid",
      children: [
        LoginInteraction // register its children interations 
      ]
    })
    export class InteractionsCtrl {
      @Get("/")
      async promptInteraction(@OidcCtx() oidcCtx: OidcCtx) {
        return oidcCtx.runInteraction();
      }
    
      $alterOidcPolicy(policy: DefaultPolicy) {
        // do something
       
        return policy
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    OIDC provider allow you to change the Consent page interaction. With @tsed/oidc-provider you can do that by creating a new ContentInteraction as following:

    import {Inject, Post, View} from "@tsed/common";
    import {BadRequest} from "@tsed/exceptions";
    import {Interaction, OidcCtx, OidcProvider, OidcSession, Params, Prompt, Uid} from "@tsed/oidc-provider";
    import {Name} from "@tsed/schema";
    
    @Interaction({
       name: "consent"
    })
    @Name("Oidc")
    export class ConsentInteraction {
       @Inject()
       oidc: OidcProvider;
    
       @View("interaction")
       async $prompt(@OidcCtx() oidcCtx: OidcCtx,
                     @Prompt() prompt: Prompt,
                     @OidcSession() session: OidcSession,
                     @Params() params: Params,
                     @Uid() uid: Uid): Promise<any> {
          const client = await oidcCtx.findClient();
    
          return {
             client,
             uid,
             details: prompt.details,
             params,
             title: "Authorize",
             ...oidcCtx.debug()
          };
       }
    
       @Post("/confirm")
       async confirm(@OidcCtx() oidcCtx: OidcCtx, @Prompt() prompt: Prompt) {
          if (prompt.name !== "consent") {
             throw new BadRequest("Bad interaction name");
          }
    
          const grant = await oidcCtx.getGrant();
          const details = prompt.details as {
             missingOIDCScope: string[],
             missingResourceScopes: Record<string, string[]>,
             missingOIDClaims: string[]
          };
    
          const {missingOIDCScope, missingOIDClaims, missingResourceScopes} = details;
    
          if (missingOIDCScope) {
             grant.addOIDCScope(missingOIDCScope.join(" "));
             // use grant.rejectOIDCScope to reject a subset or the whole thing
          }
          if (missingOIDClaims) {
             grant.addOIDCClaims(missingOIDCScope);
             // use grant.rejectOIDCClaims to reject a subset or the whole thing
          }
    
          if (missingResourceScopes) {
             // eslint-disable-next-line no-restricted-syntax
             for (const [indicator, scopes] of Object.entries(missingResourceScopes)) {
                grant.addResourceScope(indicator, scopes.join(" "));
                // use grant.rejectResourceScope to reject a subset or the whole thing
             }
          }
    
          const grantId = await grant.save();
    
          const consent: any = {};
    
          if (!oidcCtx.grantId) {
             // we don't have to pass grantId to consent, we're just modifying existing one
             consent.grantId = grantId;
          }
    
          return oidcCtx.interactionFinished({consent}, {mergeWithLastSubmission: true});
       }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75

    Then add the ConsentInteraction in the InteractionsCtrl:

    import {Get} from "@tsed/common";
    import {Interactions, OidcCtx, DefaultPolicy} from "@tsed/oidc-provider";
    import {ConsentInteraction} from "../../interactions/ConsentInteraction";
    
    @Interactions({
      path: "/interaction/:uid",
      children: [
         ConsentInteraction // register its children interactions 
      ]
    })
    export class InteractionsCtrl {
      @Get("/")
      async promptInteraction(@OidcCtx() oidcCtx: OidcCtx) {
        return oidcCtx.runInteraction();
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    Sometimes your use-case doesn't need a consent screen. This use-case might occur if your provider has only first-party clients configured. To achieve that you want to add the requested claims/scopes/resource scopes to the grant:

    import {Configuration} from "@tsed/common";
    import {KoaContextWithOIDC} from "oidc-provider";
    
    async function loadExistingGrant(ctx: KoaContextWithOIDC) {
      const grantId = (ctx.oidc.result
        && ctx.oidc.result.consent
        && ctx.oidc.result.consent.grantId) || ctx.oidc.session.grantIdFor(ctx.oidc.client.clientId);
    
      if (grantId) {
         return ctx.oidc.provider.Grant.find(grantId);
      } 
      
      if (isFirstParty(ctx.oidc.client)) { // implement isFirstParty function to determine if client is a firstParty
         const grant = new ctx.oidc.provider.Grant({
            clientId: ctx.oidc.client.clientId,
            accountId: ctx.oidc.session.accountId,
         });
    
         grant.addOIDCScope('openid email profile');
         grant.addOIDCClaims(['first_name']);
         grant.addResourceScope('urn:example:resource-indicator', 'api:read api:write');
         await grant.save();
         return grant;
      }
    }
    
    @Configuration({
      oidc: {
        loadExistingGrant
      }
    })
    export class Server {}
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32

    WARNING

    • No guarantees this is bug-free, no support will be provided for this, you've been warned, you're on your own
    • It's not recommended to have consent-free flows for the obvious issues this poses for native applications

    TIP

    This example is based on the original Recipe provided by oidc-provider. See more details on skip_consent page (opens new window).

    # Support Oidc-provider

    If you or your business uses oidc-provider (opens new window), please consider becoming a sponsor, so we can continue maintaining it and adding new features carefree.

    # Author

      # Maintainers

        Last Updated: 5/12/2021, 9:50:03 AM

        Other topics