├── .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 | Hodfords Logo 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 | --------------------------------------------------------------------------------