├── .github
└── workflows
│ └── publish.yml
├── .gitignore
├── .npmrc
├── README.md
├── eslint.config.js
├── lib
├── adapters
│ └── redis.adapter.ts
├── constants
│ └── injector.constant.ts
├── controllers
│ ├── oidc-auth.controller.ts
│ └── oidc.controller.ts
├── enums
│ └── odic.constant.ts
├── index.ts
├── interfaces
│ ├── account-service.interface.ts
│ └── account.interface.ts
├── modules
│ └── oidc.module.ts
├── services
│ ├── oidc-auth.service.ts
│ └── oidc.service.ts
└── types
│ ├── account.type.ts
│ └── module-async-options.type.ts
├── nest-cli.json
├── package-lock.json
├── package.json
├── prettier.config.js
├── sample
├── app.controller.ts
├── app.module.ts
└── main.ts
├── tsconfig.build.json
├── tsconfig.json
└── tsconfig.prod.json
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Package to npmjs
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | lint:
8 | uses: hodfords-solutions/actions/.github/workflows/lint.yaml@main
9 | build:
10 | uses: hodfords-solutions/actions/.github/workflows/publish.yaml@main
11 | with:
12 | build_path: dist
13 | secrets:
14 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
15 | update-docs:
16 | uses: hodfords-solutions/actions/.github/workflows/update-doc.yaml@main
17 | needs: build
18 | secrets:
19 | DOC_SSH_PRIVATE_KEY: ${{ secrets.DOC_SSH_PRIVATE_KEY }}
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | sample/**/*.js
4 | sample/**/*.js.map
5 | sample/node_modules/
6 | .DS_Store
7 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
2 | registry=https://registry.npmjs.org/
3 | always-auth=true
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NestJS-OIDC is easy way to turn our server as oidc provider with minimum configuratiohn
6 |
7 | ## Installation
8 | This package is using redis as adapter to store authentication session and relevant stuffs, so you need to have install redis first
9 |
10 | ### Register module
11 | This is setup to register essential configuration for OIDC provider such as (client, ttls, cookies,...). You can get more at [OIDC Provider](https://github.com/panva/node-oidc-provider/tree/main/docs)
12 |
13 | ```ts
14 | import { OidcModule as NestOidc, OIDC_ACCOUNT_SERVICE } from '@hodfords/nestjs-oidc';
15 |
16 | @Module({
17 | imports: [
18 | NestOidc.forRootAsync({
19 | configuration: {
20 | useFactory: async function () {
21 | return {
22 | issuer: `${env.BACKEND_URL}/oidc`,
23 | claims: {
24 | email: ['email', 'email_verified'],
25 | profile: ['name'],
26 | groups: ['groups']
27 | },
28 | ttl: {
29 | AccessToken: 5 * 60, // 5 minutes
30 | AuthorizationCode: 60 * 10, // 10 minutes
31 | IdToken: 60 * 60, // 1 hour
32 | RefreshToken: 60 * 60 * 24 * 1, // 1 day
33 | Interaction: 60 * 60, // 1 hour
34 | Session: 60 * 60 * 24 * 14, // 14 days
35 | Grant: 5 * 60 // 5 minutes
36 | },
37 | jwks: {
38 | keys: [env.OIDC_PROVIDER.JWKS]
39 | },
40 | conformIdTokenClaims: false,
41 | cookies: {
42 | keys: ['interaction', 'session', 'state'],
43 | long: {
44 | signed: true,
45 | httpOnly: true,
46 | secure: true, // this should be false at local
47 | sameSite: 'none',
48 | path: '/',
49 | domain: 'localhost'
50 | },
51 | short: {
52 | signed: true,
53 | httpOnly: true,
54 | secure: true, // this should be false at local
55 | sameSite: 'none',
56 | path: '/',
57 | domain: 'localhost'
58 | },
59 | names: {
60 | session: '_session',
61 | interaction: '_interaction',
62 | resume: '_resume',
63 | state: '_state'
64 | }
65 | }
66 | }
67 | },
68 | imports: [],
69 | inject: []
70 | },
71 | redisHost: `redis://${env.REDIS_HOST}:${env.REDIS_PORT}/${env.REDIS_DB}`,
72 | customInteractionUrl: 'http://localhost:3000'
73 | }),
74 | ],
75 | providers: [
76 | {
77 | provide: OIDC_ACCOUNT_SERVICE,
78 | useClass: OidcService
79 | },
80 | ],
81 | })
82 | ```
83 |
84 | ### Define OidcService
85 | Basically this service will be responsible for retrieving account information that will be returned by OIDC Provider to 3rd party
86 |
87 | ```ts
88 | import { IAccount } from '@hodfords/nestjs-oidc';
89 | import { AccountClaimsType } from '@hodfords/nestjs-oidc/types/account.type';
90 |
91 | @Injectable()
92 | export class OidcService {
93 | async findAccount(ctx: any, id: string): Promise {
94 | const account = await this.userService.findById(id);
95 | return this.getAccountInfo(account);
96 | }
97 |
98 | private async getAccountInfo(account: UserEntity): Promise {
99 | return {
100 | accountId: account.id,
101 | async claims(): Promise {
102 | return snakecaseKeys({
103 | sub: account.id,
104 | email: account.email,
105 | name: account.fullName,
106 | emailVerified: true,
107 | groups: []
108 | });
109 | }
110 | };
111 | }
112 | }
113 | ```
114 |
115 | That is all you need to do to setup OIDC provider with NestJS-OIDC, you can now start your server and access to `http://localhost:3000/oidc/.well-known/openid-configuration` to see the configuration of OIDC provider
116 |
117 | ## License
118 |
119 | NestJS-OIDC is [MIT licensed](LICENSE).
120 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('@hodfords/nestjs-eslint-config');
2 |
--------------------------------------------------------------------------------
/lib/adapters/redis.adapter.ts:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis';
2 | import isEmpty from 'lodash.isempty';
3 |
4 | const grantable = new Set([
5 | 'AccessToken',
6 | 'AuthorizationCode',
7 | 'RefreshToken',
8 | 'DeviceCode',
9 | 'BackchannelAuthenticationRequest'
10 | ]);
11 |
12 | const consumable = new Set(['AuthorizationCode', 'RefreshToken', 'DeviceCode', 'BackchannelAuthenticationRequest']);
13 |
14 | function grantKeyFor(id: string) {
15 | return `grant:${id}`;
16 | }
17 |
18 | function userCodeKeyFor(userCode: string) {
19 | return `userCode:${userCode}`;
20 | }
21 |
22 | function uidKeyFor(uid: string) {
23 | return `uid:${uid}`;
24 | }
25 |
26 | export class RedisAdapter {
27 | private client: Redis;
28 |
29 | constructor(
30 | private name: string,
31 | redisHost: string = 'localhost:6379'
32 | ) {
33 | this.client = new Redis(redisHost, { keyPrefix: `oidc:${name}:` });
34 | }
35 |
36 | async upsert(id: any, payload: any, expiresIn: any) {
37 | const key = this.key(id);
38 | const store = consumable.has(this.name) ? { payload: JSON.stringify(payload) } : JSON.stringify(payload);
39 |
40 | const multi = this.client.multi();
41 | multi[consumable.has(this.name) ? 'hmset' : 'set'](key, store as any);
42 |
43 | if (expiresIn) {
44 | multi.expire(key, expiresIn);
45 | }
46 |
47 | if (grantable.has(this.name) && payload.grantId) {
48 | const grantKey = grantKeyFor(payload.grantId);
49 | multi.rpush(grantKey, key);
50 | // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM
51 | // here to trim the list to an appropriate length
52 | const ttl = await this.client.ttl(grantKey);
53 | if (expiresIn > ttl) {
54 | multi.expire(grantKey, expiresIn);
55 | }
56 | }
57 |
58 | if (payload.userCode) {
59 | const userCodeKey = userCodeKeyFor(payload.userCode);
60 | multi.set(userCodeKey, id);
61 | multi.expire(userCodeKey, expiresIn);
62 | }
63 |
64 | if (payload.uid) {
65 | const uidKey = uidKeyFor(payload.uid);
66 | multi.set(uidKey, id);
67 | multi.expire(uidKey, expiresIn);
68 | }
69 |
70 | await multi.exec();
71 | }
72 |
73 | async find(id: string) {
74 | const data = consumable.has(this.name)
75 | ? await this.client.hgetall(this.key(id))
76 | : await this.client.get(this.key(id));
77 |
78 | if (isEmpty(data)) {
79 | return undefined;
80 | }
81 |
82 | if (typeof data === 'string') {
83 | return JSON.parse(data);
84 | }
85 | const { payload, ...rest } = data;
86 | return {
87 | ...rest,
88 | ...JSON.parse(payload)
89 | };
90 | }
91 |
92 | async findByUid(uid: string) {
93 | const id = await this.client.get(uidKeyFor(uid));
94 | return this.find(id);
95 | }
96 |
97 | async findByUserCode(userCode: string) {
98 | const id = await this.client.get(userCodeKeyFor(userCode));
99 | return this.find(id);
100 | }
101 |
102 | async destroy(id: string) {
103 | const key = this.key(id);
104 | await this.client.del(key);
105 | }
106 |
107 | async revokeByGrantId(grantId: string) {
108 | const multi = this.client.multi();
109 | const tokens = await this.client.lrange(grantKeyFor(grantId), 0, -1);
110 | tokens.forEach((token) => multi.del(token));
111 | multi.del(grantKeyFor(grantId));
112 | await multi.exec();
113 | }
114 |
115 | async consume(id: string) {
116 | await this.client.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000));
117 | }
118 |
119 | key(id: string) {
120 | return `${this.name}:${id}`;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/lib/constants/injector.constant.ts:
--------------------------------------------------------------------------------
1 | export const OIDC_ACCOUNT_SERVICE = 'NESTJS:OIDC_ACCOUNT_SERVICE';
2 | export const OIDC_CONFIGURATION = 'NESTJS:OIDC_CONFIGURATION';
3 | export const OIDC_ADAPTER_REDIS_HOST = 'NESTJS:OIDC_ADAPTER_REDIS_HOST';
4 | export const OIDC_CUSTOM_INTERACTION_URL = 'NESTJS:OIDC_CUSTOM_INTERACTION_URL';
5 |
--------------------------------------------------------------------------------
/lib/controllers/oidc-auth.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post, Res, Req, HttpStatus } from '@nestjs/common';
2 | import { OidcAuthService } from '../services/oidc-auth.service';
3 | import { Request, Response } from 'express';
4 | import { OidcPromptEnums } from '../enums/odic.constant';
5 |
6 | @Controller('oidc-auth')
7 | export class OidcAuthController {
8 | constructor(private oidcAuthService: OidcAuthService) {}
9 |
10 | @Post('signin/:uid')
11 | async signIn(@Req() req: Request, @Res() res: Response) {
12 | const redirectUrl = await this.oidcAuthService.signIn(req, res);
13 |
14 | return res.status(HttpStatus.OK).json({
15 | nextPrompt: OidcPromptEnums.CONSENT,
16 | redirectUrl
17 | });
18 | }
19 |
20 | @Post('consent/:uid/confirm')
21 | async confirmConsent(@Req() req: Request, @Res() res: Response) {
22 | const redirectUrl = await this.oidcAuthService.confirmConsent(req, res);
23 |
24 | return res.status(HttpStatus.OK).json({
25 | nextPrompt: OidcPromptEnums.CALLBACK,
26 | redirectUrl
27 | });
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/lib/controllers/oidc.controller.ts:
--------------------------------------------------------------------------------
1 | import { All, Controller, Req, Res } from '@nestjs/common';
2 | import { Request, Response } from 'express';
3 | import { OidcService } from '../services/oidc.service';
4 |
5 | @Controller('oidc')
6 | export class OidcController {
7 | constructor(private oidcService: OidcService) {}
8 |
9 | @All('/{*splat}')
10 | mountedOidc(@Req() req: Request, @Res() res: Response): Promise {
11 | req.url = req.originalUrl.replace('/oidc', '');
12 |
13 | return this.oidcService.providerInstance.callback()(req, res);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/enums/odic.constant.ts:
--------------------------------------------------------------------------------
1 | export enum OidcPromptEnums {
2 | LOGIN = 'login',
3 | CONSENT = 'consent',
4 | CALLBACK = 'callback'
5 | }
6 |
--------------------------------------------------------------------------------
/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './modules/oidc.module';
2 | export * from './interfaces/account.interface';
3 | export * from './interfaces/account-service.interface';
4 | export * from './services/oidc.service';
5 | export * from './services/oidc-auth.service';
6 | export * from './constants/injector.constant';
7 | export * from './enums/odic.constant';
8 |
--------------------------------------------------------------------------------
/lib/interfaces/account-service.interface.ts:
--------------------------------------------------------------------------------
1 | import { IAccount } from './account.interface';
2 | import { Request, Response } from 'express';
3 |
4 | export interface IAccountService {
5 | findAccount(ctx: any, id: string): Promise;
6 | authenticate(req: Request, res: Response, payload: { username: string; password: string }): Promise;
7 | }
8 |
--------------------------------------------------------------------------------
/lib/interfaces/account.interface.ts:
--------------------------------------------------------------------------------
1 | import { AccountClaimsType } from '../types/account.type';
2 |
3 | export interface IAccount {
4 | accountId: string;
5 |
6 | claims(): Promise;
7 | }
8 |
--------------------------------------------------------------------------------
/lib/modules/oidc.module.ts:
--------------------------------------------------------------------------------
1 | import { DynamicModule, Provider } from '@nestjs/common';
2 | import {
3 | OIDC_ADAPTER_REDIS_HOST,
4 | OIDC_CONFIGURATION,
5 | OIDC_CUSTOM_INTERACTION_URL
6 | } from '../constants/injector.constant';
7 | import { OidcController } from '../controllers/oidc.controller';
8 | import { OidcAuthService } from '../services/oidc-auth.service';
9 | import { OidcService } from '../services/oidc.service';
10 | import { ModuleAsyncOptions } from '../types/module-async-options.type';
11 |
12 | export class OidcModule {
13 | public static forRootAsync(options: ModuleAsyncOptions): DynamicModule {
14 | const redisHost = options.redisHost;
15 | const customInteractionUrl = options.customInteractionUrl;
16 |
17 | const providers: Provider[] = [
18 | {
19 | provide: OIDC_ADAPTER_REDIS_HOST,
20 | useValue: redisHost
21 | },
22 | {
23 | provide: OIDC_CUSTOM_INTERACTION_URL,
24 | useValue: customInteractionUrl
25 | },
26 | OidcService,
27 | OidcAuthService
28 | ];
29 |
30 | if (options.configuration?.useFactory) {
31 | providers.push({
32 | provide: OIDC_CONFIGURATION,
33 | useFactory: options.configuration.useFactory,
34 | inject: options.configuration.inject
35 | });
36 | } else {
37 | providers.push({
38 | provide: OIDC_CONFIGURATION,
39 | useValue: options.configuration
40 | });
41 | }
42 |
43 | const imports = options.configuration?.imports || [];
44 |
45 | return {
46 | module: OidcModule,
47 | providers,
48 | imports,
49 | controllers: [OidcController],
50 | exports: [OidcService, OidcAuthService]
51 | };
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/lib/services/oidc-auth.service.ts:
--------------------------------------------------------------------------------
1 | import { BadRequestException, Injectable, OnApplicationBootstrap } from '@nestjs/common';
2 | import { ModuleRef } from '@nestjs/core';
3 | import { Request, Response } from 'express';
4 | import { OIDC_ACCOUNT_SERVICE } from '../constants/injector.constant';
5 | import { IAccountService } from '../interfaces/account-service.interface';
6 | import { OidcService } from './oidc.service';
7 | import { OidcPromptEnums } from '../enums/odic.constant';
8 |
9 | @Injectable()
10 | export class OidcAuthService implements OnApplicationBootstrap {
11 | private oidcAccountService: IAccountService;
12 |
13 | constructor(
14 | private oidcService: OidcService,
15 | private moduleRef: ModuleRef
16 | ) {}
17 |
18 | onApplicationBootstrap() {
19 | this.oidcAccountService = this.moduleRef.get(OIDC_ACCOUNT_SERVICE, { strict: false });
20 | }
21 |
22 | async signIn(req: Request, res: Response): Promise {
23 | const { username, password } = req.body;
24 | const account = await this.oidcAccountService.authenticate(req, res, {
25 | username,
26 | password
27 | });
28 |
29 | return this.authenticateForConsent(account.accountId, req, res);
30 | }
31 |
32 | async confirmConsent(req: Request, res: Response): Promise {
33 | const provider = this.oidcService.providerInstance;
34 | const interactionDetails = await provider.interactionDetails(req, res);
35 |
36 | const interactionResult = interactionDetails.result;
37 | const accountId = interactionResult?.login?.accountId;
38 | if (!accountId) {
39 | throw new BadRequestException('No account id found');
40 | }
41 |
42 | const grantId = await this.createGrant(provider, accountId, interactionDetails);
43 | Object.assign(interactionResult, { consent: { grantId } });
44 |
45 | return provider.interactionResult(req, res, interactionResult, { mergeWithLastSubmission: true });
46 | }
47 |
48 | async abortConsent(req: Request, res: Response): Promise {
49 | const provider = this.oidcService.providerInstance;
50 | const errorResult = {
51 | error: 'access_denied',
52 | // eslint-disable-next-line @typescript-eslint/naming-convention
53 | error_description: 'End-User aborted interaction'
54 | };
55 |
56 | return provider.interactionResult(req, res, errorResult, { mergeWithLastSubmission: false });
57 | }
58 |
59 | private async createGrant(provider: any, accountId: string, interactionDetails: any) {
60 | const {
61 | params,
62 | prompt: { details }
63 | } = interactionDetails;
64 | const grant = await this.findOrCreateGrant(provider, accountId, params.client_id, interactionDetails);
65 |
66 | grant.addOIDCScope('openid');
67 |
68 | if (details.missingOIDCScope) {
69 | grant.addOIDCScope(details.missingOIDCScope.join(' '));
70 | }
71 | if (details.missingOIDCClaims) {
72 | grant.addOIDCClaims(details.missingOIDCClaims);
73 | }
74 | if (details.missingResourceScopes) {
75 | for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
76 | grant.addResourceScope(indicator, (scopes as any).join(' '));
77 | }
78 | }
79 |
80 | return await grant.save();
81 | }
82 |
83 | async authenticateForConsent(accountId: string, req: Request, res: Response): Promise {
84 | const provider = this.oidcService.providerInstance;
85 | const loginResult = {
86 | login: {
87 | accountId
88 | }
89 | };
90 |
91 | return provider.interactionResult(req, res, loginResult, { mergeWithLastSubmission: true });
92 | }
93 |
94 | async currentUser(req: Request, res: Response): Promise {
95 | const provider = this.oidcService.providerInstance;
96 | const interactionDetails = await provider.interactionDetails(req, res);
97 |
98 | return interactionDetails.result?.login?.accountId;
99 | }
100 |
101 | async signOut(req: Request, res: Response): Promise {
102 | const provider = this.oidcService.providerInstance;
103 | const ctx = this.oidcService.createContext(req, res);
104 |
105 | const session = await provider.Session.get(ctx);
106 |
107 | await this.backchannelSignOut(session);
108 | await this.revokeTokens(ctx, session);
109 |
110 | await session.destroy();
111 |
112 | this.oidcService.emitEndSessionSuccess(ctx);
113 | }
114 |
115 | async getCurrentPrompt(req: Request, res: Response): Promise {
116 | const provider = this.oidcService.providerInstance;
117 | const interactionDetails = await provider.interactionDetails(req, res);
118 | const {
119 | prompt: { name }
120 | } = interactionDetails;
121 |
122 | return name as OidcPromptEnums;
123 | }
124 |
125 | async getCurrentClientId(req: Request, res: Response): Promise {
126 | const provider = this.oidcService.providerInstance;
127 | const interactionDetails = await provider.interactionDetails(req, res);
128 |
129 | return interactionDetails.params?.client_id;
130 | }
131 |
132 | private async revokeTokens(ctx: any, session: any) {
133 | if (!session.authorizations) {
134 | return;
135 | }
136 |
137 | await Promise.all(
138 | Object.entries(session.authorizations).map(async ([clientId, { grantId }]: any) => {
139 | if (grantId && !session.authorizationFor(clientId).persistsLogout) {
140 | const client = await this.oidcService.providerInstance.Client.find(clientId);
141 | ctx.oidc.entity('Client', client);
142 |
143 | await this.oidcService.revokeFunction(ctx, grantId);
144 | }
145 | })
146 | );
147 |
148 | await session.destroy();
149 | }
150 |
151 | private async backchannelSignOut(session: any) {
152 | const clientIds = Object.keys(session.authorizations || {});
153 |
154 | const back = [];
155 | for (const clientId of clientIds) {
156 | const client = await this.oidcService.providerInstance.Client.find(clientId);
157 | if (!client) {
158 | continue;
159 | }
160 |
161 | const { accountId } = session;
162 | const sid = session.sidFor(client.clientId);
163 | back.push(
164 | client.backchannelLogout(accountId, sid).then(
165 | () => {
166 | this.oidcService.providerInstance.emit('backchannel.success', client, accountId, sid);
167 | },
168 | (err: Error) => {
169 | this.oidcService.providerInstance.emit('backchannel.error', err, client, accountId, sid);
170 | }
171 | )
172 | );
173 | }
174 |
175 | await Promise.all(back);
176 | }
177 |
178 | private async findOrCreateGrant(provider: any, accountId: string, clientId: string, details: any) {
179 | const { grantId } = details;
180 | let grant: any;
181 | if (grantId) {
182 | grant = await provider.Grant.find(grantId);
183 | } else {
184 | grant = new provider.Grant({
185 | accountId,
186 | clientId
187 | });
188 | }
189 |
190 | return grant;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/lib/services/oidc.service.ts:
--------------------------------------------------------------------------------
1 | import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
2 | import { RedisAdapter } from '../adapters/redis.adapter';
3 | import { IAccountService } from '../interfaces/account-service.interface';
4 | import {
5 | OIDC_ACCOUNT_SERVICE,
6 | OIDC_ADAPTER_REDIS_HOST,
7 | OIDC_CONFIGURATION,
8 | OIDC_CUSTOM_INTERACTION_URL
9 | } from '../constants/injector.constant';
10 | import { ModuleRef } from '@nestjs/core';
11 |
12 | @Injectable()
13 | export class OidcService implements OnApplicationBootstrap {
14 | private provider: any;
15 | private oidcAccountService: IAccountService;
16 | private revokeFnc: (ctx: any, grantId: string) => Promise;
17 |
18 | constructor(
19 | @Inject(OIDC_CONFIGURATION) private configuration: Record,
20 | @Inject(OIDC_ADAPTER_REDIS_HOST) private redisHost: string,
21 | @Inject(OIDC_CUSTOM_INTERACTION_URL)
22 | private customInteractionUrl: (uid: string) => string | string,
23 | private moduleRef: ModuleRef
24 | ) {}
25 |
26 | get providerInstance(): any {
27 | return this.provider;
28 | }
29 |
30 | get revokeFunction(): (ctx: any, grantId: string) => Promise {
31 | return this.revokeFnc;
32 | }
33 |
34 | async onApplicationBootstrap() {
35 | this.oidcAccountService = this.moduleRef.get(OIDC_ACCOUNT_SERVICE, { strict: false });
36 | if (!this.oidcAccountService) {
37 | throw new Error(`
38 | OIDC_ACCOUNT_SERVICE not found.
39 | Please provide a service that implements IAccountService interface.
40 | And inject by
41 | {
42 | provide: OIDC_ACCOUNT_SERVICE,
43 | useClass: YourAccountService
44 | }
45 | `);
46 | }
47 |
48 | await this.initProvider();
49 | }
50 |
51 | private async initProvider() {
52 | const oidcProvider = await (eval(`import('oidc-provider')`) as Promise);
53 | const policy = oidcProvider.interactionPolicy;
54 |
55 | this.configuration.findAccount = this.oidcAccountService.findAccount.bind(this.oidcAccountService);
56 | this.provider = new oidcProvider.default(this.configuration.issuer, {
57 | interactions: this.interactionConfig(policy),
58 | adapter: (name: string) => {
59 | return new RedisAdapter(name, this.redisHost);
60 | },
61 | cookies: {
62 | keys: ['interaction', 'session', 'state'],
63 | long: {
64 | signed: true,
65 | httpOnly: false,
66 | path: '/',
67 | secure: true,
68 | sameSite: 'none'
69 | },
70 | short: {
71 | signed: true,
72 | httpOnly: false,
73 | secure: true,
74 | path: '/',
75 | sameSite: 'none'
76 | },
77 | names: {
78 | session: '_session',
79 | interaction: '_interaction',
80 | resume: '_resume',
81 | state: '_state'
82 | }
83 | },
84 | features: {
85 | revocation: { enabled: true },
86 | devInteractions: { enabled: false },
87 | jwtUserinfo: { enabled: true },
88 | userinfo: { enabled: true }
89 | },
90 | pkce: {
91 | required: () => false
92 | },
93 | ...this.configuration
94 | });
95 |
96 | this.provider.proxy = true;
97 | await this.loadRevokeFnc();
98 | }
99 |
100 | private interactionConfig(policy: any) {
101 | const interactions = policy.base();
102 | const customInteractionUrl = this.customInteractionUrl;
103 |
104 | return {
105 | policy: interactions,
106 | url(ctx: any, interaction: any) {
107 | if (typeof customInteractionUrl === 'string') {
108 | return `${customInteractionUrl}?uid=${interaction.uid}`;
109 | }
110 | return customInteractionUrl(interaction.uid);
111 | }
112 | };
113 | }
114 |
115 | private async loadRevokeFnc(): Promise {
116 | const revokeFnc = await eval(`import('oidc-provider/lib/helpers/revoke.js')`);
117 |
118 | this.revokeFnc = revokeFnc.default;
119 | }
120 |
121 | public createContext(req: any, res: any) {
122 | const provider = this.providerInstance;
123 | const ctx = provider.app.createContext(req, res);
124 | ctx.oidc = new provider.OIDCContext(ctx);
125 |
126 | return ctx;
127 | }
128 |
129 | public emitEndSessionSuccess(ctx: any) {
130 | ctx.oidc.provider.emit('end_session.success', ctx);
131 | }
132 |
133 | public async reloadConfiguration(configuration: Record) {
134 | this.configuration = {
135 | ...this.configuration,
136 | ...configuration
137 | };
138 |
139 | await this.initProvider();
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/lib/types/account.type.ts:
--------------------------------------------------------------------------------
1 | export type AccountClaimsType = {
2 | sub: string;
3 | email: string;
4 | name: string;
5 | };
6 |
--------------------------------------------------------------------------------
/lib/types/module-async-options.type.ts:
--------------------------------------------------------------------------------
1 | export type ModuleAsyncOptions = {
2 | redisHost: string;
3 | customInteractionUrl: string | ((uid: string) => string);
4 | configuration:
5 | | Record
6 | | {
7 | useFactory: (...args: any[]) => any | Promise;
8 | inject: any[];
9 | imports: any[];
10 | };
11 | };
12 |
--------------------------------------------------------------------------------
/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "collection": "@nestjs/schematics",
3 | "sourceRoot": "sample",
4 | "projects": {
5 | "nestjs-oidc": {
6 | "type": "library",
7 | "root": "lib",
8 | "entryFile": "index",
9 | "sourceRoot": "lib"
10 | }
11 | },
12 | "compilerOptions": {
13 | "webpack": false,
14 | "assets": [
15 | {
16 | "include": "../lib/public/**",
17 | "watchAssets": true
18 | }
19 | ]
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@hodfords/nestjs-oidc",
3 | "version": "11.0.3",
4 | "description": "NestJS OIDC Provider",
5 | "license": "MIT",
6 | "readmeFilename": "README.md",
7 | "author": {
8 | "name": "Minh Ngo",
9 | "email": "minh.ngo@hodfords.com"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/hodfords-solutions/nestjs-oidc"
14 | },
15 | "bugs": {
16 | "url": "https://github.com/hodfords-solutions/nestjs-oidc/issues"
17 | },
18 | "tags": [
19 | "typescript",
20 | "nestjs",
21 | "oidc"
22 | ],
23 | "devDependencies": {
24 | "@hodfords/nestjs-eslint-config": "^11.0.1",
25 | "@hodfords/nestjs-prettier-config": "^11.0.1",
26 | "@nestjs/common": "11.0.11",
27 | "@nestjs/core": "11.0.11",
28 | "@types/bcrypt": "^5.0.2",
29 | "@types/jest": "29.5.14",
30 | "@types/lodash.isempty": "^4.4.9",
31 | "@types/node": "22.13.10",
32 | "@types/oidc-provider": "8.8.1",
33 | "cspell": "8.17.5",
34 | "eslint": "9.22.0",
35 | "husky": "9.1.7",
36 | "is-ci": "4.1.0",
37 | "jest": "29.7.0",
38 | "lint-staged": "15.5.0",
39 | "lodash": "4.17.21",
40 | "openid-client": "6.3.4",
41 | "prettier": "3.5.3",
42 | "reflect-metadata": "0.2.2",
43 | "rimraf": "6.0.1",
44 | "ts-jest": "29.2.6",
45 | "ts-node": "10.9.2",
46 | "typescript": "5.8.2"
47 | },
48 | "scripts": {
49 | "prebuild": "rimraf dist",
50 | "start:dev": "npm run prebuild && nest start --watch",
51 | "start": "ts-node sample/index.ts",
52 | "build": "tsc --project tsconfig.prod.json && cp package.json dist && cp README.md dist && cp .npmrc dist",
53 | "deploy": "npm run build && npm publish dist",
54 | "format": "prettier --write \"**/*.ts\"",
55 | "check": "prettier --check \"**/*.ts\"",
56 | "test": "jest --passWithNoTests --testTimeout=450000 ",
57 | "cspell": "cspell",
58 | "prepare": "is-ci || husky",
59 | "lint": "eslint \"lib/**/*.ts\" --fix --max-warnings 0",
60 | "lint-staged": "lint-staged"
61 | },
62 | "jest": {
63 | "moduleFileExtensions": [
64 | "js",
65 | "json",
66 | "ts"
67 | ],
68 | "rootDir": ".",
69 | "testRegex": ".*\\.spec\\.ts$",
70 | "transform": {
71 | "^.+\\.(t|j)s$": "ts-jest"
72 | },
73 | "collectCoverageFrom": [
74 | "**/*.(t|j)s"
75 | ],
76 | "coverageDirectory": "../coverage",
77 | "testEnvironment": "node"
78 | },
79 | "dependencies": {
80 | "@nestjs/platform-express": "11.0.11",
81 | "ioredis": "^5.6.0",
82 | "lodash.isempty": "^4.4.0",
83 | "oidc-provider": "^8.8.1"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('@hodfords/nestjs-prettier-config');
2 |
--------------------------------------------------------------------------------
/sample/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { OidcAuthService, OidcService } from '@mint/nestjs-oidc';
2 | import { Controller, Get, Post, Req, Res } from '@nestjs/common';
3 | import { Response } from 'express';
4 | import { Request } from 'express';
5 |
6 | @Controller()
7 | export class AppController {
8 | constructor(
9 | private oidcService: OidcService,
10 | private oidcAuthService: OidcAuthService
11 | ) {}
12 |
13 | @Get()
14 | hello(): string {
15 | return 'Hello World!';
16 | }
17 |
18 | @Post('signin/:uid')
19 | async signIn(@Req() req: Request, @Res() res: Response) {
20 | const accountId = 'ff0c6866-0796-45cb-a7ca-1f956d2c7e6a';
21 |
22 | await this.oidcAuthService.authenticateForConsent(accountId, req, res);
23 | const redirectUrl = await this.oidcAuthService.confirmConsent(req, res);
24 |
25 | return res.send({
26 | redirectUrl
27 | });
28 | }
29 |
30 | @Post('oidc-auth/sign-out/:id')
31 | async signOut(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
32 | await this.oidcAuthService.signOut(req, res);
33 |
34 | return {};
35 | }
36 |
37 | private async createGrant(provider: any, accountId: string, interactionDetails: any) {
38 | const {
39 | grantId,
40 | prompt: { details }
41 | } = interactionDetails;
42 | let grant: any;
43 | if (grantId) {
44 | // we'll be modifying existing grant in existing session
45 | grant = await provider.Grant.find(grantId);
46 | } else {
47 | // we're establishing a new grant
48 | grant = new provider.Grant({
49 | accountId,
50 | clientId: 'foo'
51 | });
52 | }
53 |
54 | grant.addOIDCScope('openid');
55 | if (details.missingOIDCScope) {
56 | grant.addOIDCScope(details.missingOIDCScope.join(' '));
57 | }
58 | if (details.missingOIDCClaims) {
59 | grant.addOIDCClaims(details.missingOIDCClaims);
60 | }
61 | if (details.missingResourceScopes) {
62 | for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
63 | grant.addResourceScope(indicator, (scopes as any).join(' '));
64 | }
65 | }
66 |
67 | return await grant.save();
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/sample/app.module.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/naming-convention */
2 | import { IAccountService, OIDC_ACCOUNT_SERVICE, OidcModule } from '@mint/nestjs-oidc';
3 | import { Injectable, Module } from '@nestjs/common';
4 | import { AppController } from './app.controller';
5 |
6 | @Injectable()
7 | class UserService {}
8 |
9 | @Injectable()
10 | class AccountService implements IAccountService {
11 | constructor(private userService: UserService) {}
12 |
13 | findAccount(ctx: any, id: string): Promise {
14 | return Promise.resolve({
15 | accountId: id,
16 | async claims() {
17 | return {
18 | sub: '84b779c9-08d7-424e-9484-529582b99288',
19 | email: 'minh_1@gmail.com',
20 | name: 'John Doe (Mint)',
21 | nickname: 'john.doe (mint)',
22 | picture: 'https://myawesomeavatar.com/avatar.png',
23 | updated_at: '2017-03-30T15:13:40.474Z',
24 | email_verified: false,
25 | iss: 'http://localhost:3000',
26 | aud: '{yourClientId}',
27 | exp: 1490922820,
28 | iat: 1490886820,
29 | nonce: 'crypto-value',
30 | at_hash: 'IoS3ZGppJKUn3Bta_LgE21'
31 | };
32 | }
33 | });
34 | }
35 |
36 | authenticate(): Promise {
37 | return Promise.resolve({
38 | accountId: '84b779c9-08d7-424e-9484-529582b99288',
39 | async claims() {
40 | return {
41 | sub: '84b779c9-08d7-424e-9484-529582b99288',
42 | email: 'minh@gmail.com'
43 | };
44 | }
45 | });
46 | }
47 | }
48 |
49 | const configuration = {
50 | issuer: 'http://localhost:3000',
51 | claims: {
52 | email: ['email', 'email_verified'],
53 | profile: ['name']
54 | },
55 | ttl: {
56 | AccessToken: 60 * 60, // 1 hour
57 | AuthorizationCode: 60 * 10, // 10 minutes
58 | IdToken: 60 * 60, // 1 hour
59 | RefreshToken: 60 * 60 * 24 * 14, // 14 days,
60 | Interaction: 60 * 60, // 1 hour
61 | Session: 60 * 60 * 24 * 14 // 14 days
62 | },
63 | clients: [
64 | {
65 | client_id: 'foo',
66 | client_secret: 'bar',
67 | post_logout_redirect_uris: ['https://google.com'],
68 | redirect_uris: [
69 | 'https://499a-2402-800-629c-eb7f-440f-c018-5084-c02.ngrok-free.app/test',
70 | 'https://oidcdebugger.com/debug',
71 | 'https://880d-117-3-71-111.ngrok-free.app/cb',
72 | 'https://8b3d-2a09-bac5-d5ca-15f-00-23-2e0.ngrok-free.app/auth/oidc.callback'
73 | ],
74 | response_types: ['code id_token', 'code'],
75 | grant_types: ['authorization_code', 'implicit']
76 | }
77 | ],
78 | cookies: {
79 | keys: ['interaction', 'session', 'state'],
80 | long: {
81 | signed: true,
82 | httpOnly: true,
83 | secure: false,
84 | sameSite: 'none',
85 | path: '/'
86 | // domain: env.OIDC_PROVIDER.SUB_DOMAIN
87 | },
88 | short: {
89 | signed: true,
90 | httpOnly: true,
91 | secure: false,
92 | sameSite: 'none',
93 | path: '/'
94 | // domain: env.OIDC_PROVIDER.SUB_DOMAIN
95 | },
96 | names: {
97 | session: '_session',
98 | interaction: '_interaction',
99 | resume: '_resume',
100 | state: '_state'
101 | }
102 | }
103 | };
104 |
105 | @Injectable()
106 | class ConfigService {
107 | getConfig() {
108 | return configuration;
109 | }
110 | }
111 |
112 | @Module({
113 | providers: [ConfigService],
114 | exports: [ConfigService]
115 | })
116 | class ConfigModule {}
117 |
118 | @Module({
119 | imports: [
120 | ConfigModule,
121 | OidcModule.forRootAsync({
122 | redisHost: 'localhost',
123 | customInteractionUrl: 'http://localhost:3001/interaction/{uid}',
124 | configuration: {
125 | useFactory: (configService: ConfigService) => configService.getConfig(),
126 | inject: [ConfigService],
127 | imports: [ConfigModule]
128 | }
129 | })
130 | ],
131 | providers: [
132 | UserService,
133 | {
134 | provide: OIDC_ACCOUNT_SERVICE,
135 | useClass: AccountService
136 | }
137 | ],
138 | controllers: [AppController]
139 | })
140 | export class AppModule {}
141 |
--------------------------------------------------------------------------------
/sample/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { NestExpressApplication } from '@nestjs/platform-express';
3 | import { AppModule } from './app.module';
4 |
5 | async function bootstrap() {
6 | const app = await NestFactory.create(AppModule);
7 |
8 | app.use((req: any, res: any, next: any) => {
9 | // // Website you wish to allow to connect
10 | res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3001');
11 |
12 | // // Request method you wish to allow
13 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
14 |
15 | // // Request headers you wish to allow
16 | res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type,Authorization,skip');
17 |
18 | // Set to true if you need the website to include cookies in the request sent
19 | // to the API (e.g. in case you use sessions)
20 | res.setHeader('Access-Control-Allow-Credentials', 'true');
21 |
22 | // Pass to the next layer of middleware
23 | next();
24 | });
25 | app.enableCors({
26 | origin: true,
27 | credentials: true
28 | });
29 | await app.listen(3000);
30 | }
31 | bootstrap().then();
32 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "esModuleInterop": true,
7 | "emitDecoratorMetadata": true,
8 | "experimentalDecorators": true,
9 | "allowSyntheticDefaultImports": true,
10 | "target": "es2017",
11 | "sourceMap": true,
12 | "outDir": "./dist",
13 | "baseUrl": "./",
14 | "incremental": true,
15 | "skipLibCheck": true,
16 | "strictNullChecks": false,
17 | "noImplicitAny": false,
18 | "strictBindCallApply": false,
19 | "forceConsistentCasingInFileNames": false,
20 | "noFallthroughCasesInSwitch": false,
21 | "paths": {
22 | "@mint/nestjs-oidc": ["lib"]
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "moduleResolution": "node",
5 | "esModuleInterop": true,
6 | "declaration": true,
7 | "removeComments": true,
8 | "emitDecoratorMetadata": true,
9 | "experimentalDecorators": true,
10 | "allowSyntheticDefaultImports": true,
11 | "target": "es2017",
12 | "sourceMap": true,
13 | "outDir": "dist",
14 | "baseUrl": "./",
15 | "incremental": true
16 | },
17 | "include": [
18 | "lib",
19 | ],
20 | "exclude": [
21 | "tests",
22 | "sample",
23 | "dist",
24 | "node_modules"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------