├── .nvmrc ├── CHANGELOG.md ├── .npmrc ├── src ├── decorators │ ├── index.ts │ └── roles.decorator.ts ├── guards │ ├── index.ts │ ├── azure-active-directory.guard.ts │ └── azure-active-directory.guard.spec.ts ├── azure-token-validation │ ├── index.ts │ ├── mock-discovery-keys-response.json │ ├── azure-token-validation.service.ts │ └── azure-token-validation.service.spec.ts ├── models │ ├── token-header.ts │ ├── index.ts │ ├── jwt-key.ts │ ├── jwt-key.spec.ts │ ├── jwt-payload.spec.ts │ ├── token-header.spec.ts │ ├── azure-ad-user.spec.ts │ ├── jwt-payload.ts │ └── azure-ad-user.ts ├── index.ts ├── module-config.ts └── nest-azure-ad-jwt-validator.module.ts ├── .prettierrc ├── tsconfig.build.json ├── nest-cli.json ├── tsconfig.json ├── .gitignore ├── .eslintrc.js ├── LICENSE ├── azure-active-directory.svg ├── .circleci └── config.yml ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.16.0 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './roles.decorator'; 2 | -------------------------------------------------------------------------------- /src/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './azure-active-directory.guard'; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /src/azure-token-validation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './azure-token-validation.service'; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/models/token-header.ts: -------------------------------------------------------------------------------- 1 | export class TokenHeader { 2 | typ: string; 3 | alg: string; 4 | x5t: string; 5 | kid: string; 6 | } 7 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './azure-ad-user'; 2 | export * from './jwt-key'; 3 | export * from './jwt-payload'; 4 | export * from './token-header'; 5 | -------------------------------------------------------------------------------- /src/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Roles = (...roles: string[]) => SetMetadata('roles', roles); 4 | -------------------------------------------------------------------------------- /src/models/jwt-key.ts: -------------------------------------------------------------------------------- 1 | export class JwtKey { 2 | kty: string; 3 | use: string; 4 | kid: string; 5 | x5t: string; 6 | n: string; 7 | e: string; 8 | x5c: string[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/jwt-key.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtKey } from './jwt-key'; 2 | 3 | describe('JwtKey', () => { 4 | it('should be defined', () => { 5 | expect(new JwtKey()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/models/jwt-payload.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtPayload } from './jwt-payload'; 2 | 3 | describe('JwtPayload', () => { 4 | it('should be defined', () => { 5 | expect(new JwtPayload()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/models/token-header.spec.ts: -------------------------------------------------------------------------------- 1 | import { TokenHeader } from './token-header'; 2 | 3 | describe('TokenHeader', () => { 4 | it('should be defined', () => { 5 | expect(new TokenHeader()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/models/azure-ad-user.spec.ts: -------------------------------------------------------------------------------- 1 | import { AzureAdUser } from './azure-ad-user'; 2 | 3 | describe('AzureAdUser', () => { 4 | it('should be defined', () => { 5 | expect(new AzureAdUser()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './nest-azure-ad-jwt-validator.module'; 2 | export * from './azure-token-validation'; 3 | export * from './models'; 4 | export * from './module-config'; 5 | export * from './guards'; 6 | export * from './decorators'; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": [ 15 | "node_modules", "dist" 16 | ] 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | .vscode/ 6 | .vscode 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json -------------------------------------------------------------------------------- /src/models/jwt-payload.ts: -------------------------------------------------------------------------------- 1 | export class JwtPayload { 2 | aud: string; 3 | iss: string; 4 | iat: number; 5 | nbf: number; 6 | exp: number; 7 | aio: string; 8 | amr: string[]; 9 | // tslint:disable-next-line: variable-name 10 | family_name: string; 11 | // tslint:disable-next-line: variable-name 12 | given_name: string; 13 | ipaddr: string; 14 | name: string; 15 | nonce: string; 16 | oid: string; 17 | roles?: string[]; 18 | // tslint:disable-next-line: variable-name 19 | onprem_sid: string; 20 | sub: string; 21 | tid: string; 22 | // tslint:disable-next-line: variable-name 23 | unique_name: string; 24 | upn: string; 25 | uti: string; 26 | ver: string; 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/models/azure-ad-user.ts: -------------------------------------------------------------------------------- 1 | import { JwtPayload } from './jwt-payload'; 2 | 3 | export class AzureAdUser { 4 | readonly id: string; 5 | readonly email: string; 6 | readonly fullName: string; 7 | readonly roles: string[]; 8 | readonly audience: string; 9 | readonly tenant: string; 10 | readonly subject: string; 11 | readonly appId?: string; 12 | 13 | constructor(jwt?: JwtPayload & { appid?: string }) { 14 | if (!jwt) { 15 | jwt = {} as JwtPayload; 16 | } 17 | 18 | this.email = jwt.upn ?? `ClientCredentialsToken|${jwt.appid ?? ''}`; 19 | this.fullName = jwt.name ?? `ClientCredentialsToken|${jwt.appid ?? ''}`; 20 | this.id = jwt.oid; 21 | this.roles = jwt.roles; 22 | this.audience = jwt.aud; 23 | this.tenant = jwt.tid; 24 | this.subject = jwt.sub; 25 | this.appId = jwt.appid; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Benjamin Main 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/module-config.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, FactoryProvider, ValueProvider } from '@nestjs/common'; 2 | 3 | export interface ImportableFactoryProvider 4 | extends Omit, 'provide'>, 5 | Pick {} 6 | 7 | export type AsyncProvider = 8 | | ImportableFactoryProvider 9 | | Omit, 'provide'>; 10 | 11 | /** 12 | * An interface representing the shape of the tenant and app you are wanting to authenticate against. 13 | */ 14 | export interface TenantApplication { 15 | tenantId: string; 16 | audienceId: string; 17 | } 18 | 19 | export class NestAzureAdJwtValidatorModuleOptions { 20 | /** 21 | * The apps in question 22 | */ 23 | apps: TenantApplication[]; 24 | /** 25 | * Service Tokens allow you to shortcut jwt authentication for clients that are unsophisticated 26 | */ 27 | serviceTokens?: string[]; 28 | /** 29 | * Enable Debug Logging 30 | */ 31 | enableDebugLogs?: boolean; 32 | /** 33 | * Which header does the jwt appear in? If unspecified... defaults to authtoken header. 34 | */ 35 | tokenHeader?: string; 36 | 37 | constructor(partial: Partial) { 38 | Object.assign(this, partial); 39 | 40 | if (!this.apps?.length) { 41 | this.apps = []; 42 | } 43 | this.apps = this.apps.filter((x) => !!x); 44 | 45 | if (!this.serviceTokens?.length) { 46 | this.serviceTokens = []; 47 | } 48 | this.serviceTokens.push(process.env.SERVICE_TOKEN); 49 | this.serviceTokens = this.serviceTokens.filter((x) => !!x); 50 | 51 | this.enableDebugLogs = !!this.enableDebugLogs; 52 | this.tokenHeader = partial.tokenHeader; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /azure-active-directory.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: cimg/node:18.16.0 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - v1-dependencies-{{ checksum "package.json" }} 12 | # fallback to using the latest cache if no exact match is found 13 | - v1-dependencies- 14 | - run: 15 | name: Install Dependencies 16 | command: | 17 | npm install 18 | - save_cache: 19 | paths: 20 | - ./node_modules 21 | key: v1-dependencies-{{ checksum "package.json" }} 22 | - run: 23 | name: Build 24 | command: | 25 | npm run build 26 | - run: 27 | name: Test 28 | command: | 29 | npm run test:cov 30 | - run: 31 | name: Lint 32 | command: | 33 | npm run lint 34 | release: 35 | docker: 36 | - image: cimg/node:18.16.0 37 | working_directory: ~/repo 38 | steps: 39 | - checkout 40 | - restore_cache: 41 | keys: 42 | - v1-dependencies-{{ checksum "package.json" }} 43 | # fallback to using the latest cache if no exact match is found 44 | - v1-dependencies- 45 | - run: 46 | name: Install Dependencies 47 | command: | 48 | npm install 49 | - run: 50 | name: Build 51 | command: | 52 | npm run build 53 | - run: 54 | name: Release 55 | command: | 56 | npx semantic-release 57 | workflows: 58 | version: 2 59 | test_and_release: 60 | jobs: 61 | - build 62 | - release: 63 | requires: 64 | - build 65 | filters: 66 | branches: 67 | only: master -------------------------------------------------------------------------------- /src/guards/azure-active-directory.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | Logger, 6 | } from '@nestjs/common'; 7 | 8 | import { AzureTokenValidationService } from '../azure-token-validation'; 9 | import { IncomingMessage } from 'http'; 10 | import { NestAzureAdJwtValidatorModuleOptions } from '../module-config'; 11 | import { Reflector } from '@nestjs/core'; 12 | 13 | @Injectable() 14 | export class AzureActiveDirectoryGuard implements CanActivate { 15 | private readonly logger = new Logger(AzureActiveDirectoryGuard.name); 16 | constructor( 17 | private readonly reflector: Reflector, 18 | private readonly tokenValidationService: AzureTokenValidationService, 19 | private readonly options: NestAzureAdJwtValidatorModuleOptions, 20 | ) {} 21 | 22 | async canActivate(context: ExecutionContext): Promise { 23 | const accessToken = this.parseTokenFromContext(context); 24 | const [isTokenValid, user, isServiceToken] = 25 | await this.tokenValidationService.isTokenValid(accessToken); 26 | 27 | // token is not valid exit 28 | if (!isTokenValid) { 29 | return false; 30 | } 31 | 32 | // token is valid, but it is a service token, no roles 33 | if (isServiceToken) { 34 | return true; 35 | } 36 | 37 | // token is valid, and token is a user 38 | const roles = this.reflector.get('roles', context.getHandler()); 39 | // no roles and token is valid return success 40 | if (!roles) { 41 | return true; 42 | } 43 | 44 | return this.matchRoles(roles, user.roles); 45 | } 46 | 47 | private parseTokenFromContext(context: ExecutionContext): string { 48 | const request = context.switchToHttp().getRequest(); 49 | const header = this.options.tokenHeader ?? 'authtoken'; 50 | const token = request.headers[header]; 51 | return (!!token ? token.toString() : '').trim().split(' ').pop(); 52 | } 53 | 54 | private matchRoles(roles: string[], usersRoles: string[]) { 55 | const userRolesLower = usersRoles.map((key) => key.toLowerCase()); 56 | for (const role of roles) { 57 | if (userRolesLower.includes(role.toLowerCase())) { 58 | return true; 59 | } 60 | } 61 | 62 | if (this.options.enableDebugLogs) { 63 | this.logger.warn('403 Permission Denied: User not in routes role.'); 64 | } 65 | 66 | return false; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/nest-azure-ad-jwt-validator.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncProvider, 3 | ImportableFactoryProvider, 4 | NestAzureAdJwtValidatorModuleOptions, 5 | } from './module-config'; 6 | import { DynamicModule, Global, Module } from '@nestjs/common'; 7 | 8 | import { AzureActiveDirectoryGuard } from './guards/azure-active-directory.guard'; 9 | import { AzureTokenValidationService } from './azure-token-validation/azure-token-validation.service'; 10 | import { HttpModule } from '@nestjs/axios'; 11 | 12 | @Global() 13 | @Module({ 14 | imports: [HttpModule], 15 | controllers: [], 16 | providers: [AzureTokenValidationService, AzureActiveDirectoryGuard], 17 | exports: [AzureTokenValidationService, AzureActiveDirectoryGuard], 18 | }) 19 | export class NestAzureAdJwtValidatorModule { 20 | static forRoot( 21 | options: Partial, 22 | ): DynamicModule { 23 | return { 24 | module: NestAzureAdJwtValidatorModule, 25 | providers: [ 26 | AzureTokenValidationService, 27 | AzureActiveDirectoryGuard, 28 | { 29 | provide: NestAzureAdJwtValidatorModuleOptions, 30 | useValue: new NestAzureAdJwtValidatorModuleOptions(options), 31 | }, 32 | ], 33 | exports: [ 34 | AzureTokenValidationService, 35 | AzureActiveDirectoryGuard, 36 | NestAzureAdJwtValidatorModuleOptions, 37 | ], 38 | }; 39 | } 40 | 41 | static forRootAsync( 42 | options: AsyncProvider< 43 | | Partial 44 | | Promise> 45 | >, 46 | ): DynamicModule { 47 | const configToken = 'AZURE_CONFIG'; 48 | const module: DynamicModule = { 49 | global: true, 50 | module: NestAzureAdJwtValidatorModule, 51 | imports: [], 52 | providers: [ 53 | AzureTokenValidationService, 54 | AzureActiveDirectoryGuard, 55 | { 56 | provide: NestAzureAdJwtValidatorModuleOptions, 57 | useFactory: async ( 58 | config: Partial, 59 | ) => { 60 | return new NestAzureAdJwtValidatorModuleOptions(config); 61 | }, 62 | inject: [configToken], 63 | }, 64 | ], 65 | exports: [ 66 | AzureTokenValidationService, 67 | AzureActiveDirectoryGuard, 68 | NestAzureAdJwtValidatorModuleOptions, 69 | ], 70 | }; 71 | 72 | this.addAsyncProvider>( 73 | module, 74 | configToken, 75 | options, 76 | false, 77 | ); 78 | return module; 79 | } 80 | 81 | private static addAsyncProvider( 82 | module: DynamicModule, 83 | provide: string, 84 | asyncProvider: AsyncProvider>, 85 | exportable: boolean, 86 | ) { 87 | const imports = (asyncProvider as ImportableFactoryProvider).imports; 88 | if (imports?.length) { 89 | imports.forEach((i) => module.imports.push(i)); 90 | } 91 | delete (asyncProvider as ImportableFactoryProvider).imports; 92 | 93 | module.providers.push({ 94 | ...asyncProvider, 95 | provide, 96 | }); 97 | 98 | if (exportable) { 99 | module.exports.push(provide); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/azure-token-validation/mock-discovery-keys-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "kty": "RSA", 5 | "use": "sig", 6 | "kid": "u4OfNFPHwEBosHjtrauObV84LnY", 7 | "x5t": "u4OfNFPHwEBosHjtrauObV84LnY", 8 | "n": "oRRQG-ib30x09eWtDpL0wWahA-hgjc0lWoQU4lwBFjXV2PfPImiAvwxOxNG34Mgnw3K9huBYLsrvOQAbMdBmE8lwz8DFKMWqHqoH3xSqDGhIYFobQDiVRkkecpberH5hqJauSD7PiwDBSQ_RCDIjb0SOmSTpZR97Ws4k1z9158VRf4BUbGjzVt4tUAz_y2cI5JsXQfcgAPB3voP8eunxGwZ_iM8evw3hUOw7-nuiPyts7HSkvV6GMwrXfOymY_w07mYxw_2LnKInfsWBtcRIDG-Nrsj237LgtBhK7TkzuVrguq__-bkDwwF3qTRXGAX9KrwY4huRxDRslMIg30Hqgw", 9 | "e": "AQAB", 10 | "x5c": [ 11 | "MIIDBTCCAe2gAwIBAgIQdEMOjSqDVbdN3mzb2IumCzANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE5MDYwNDAwMDAwMFoXDTIxMDYwNDAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKEUUBvom99MdPXlrQ6S9MFmoQPoYI3NJVqEFOJcARY11dj3zyJogL8MTsTRt+DIJ8NyvYbgWC7K7zkAGzHQZhPJcM/AxSjFqh6qB98UqgxoSGBaG0A4lUZJHnKW3qx+YaiWrkg+z4sAwUkP0QgyI29Ejpkk6WUfe1rOJNc/defFUX+AVGxo81beLVAM/8tnCOSbF0H3IADwd76D/Hrp8RsGf4jPHr8N4VDsO/p7oj8rbOx0pL1ehjMK13zspmP8NO5mMcP9i5yiJ37FgbXESAxvja7I9t+y4LQYSu05M7la4Lqv//m5A8MBd6k0VxgF/Sq8GOIbkcQ0bJTCIN9B6oMCAwEAAaMhMB8wHQYDVR0OBBYEFNRP0Lf6MDeL11RDH0uL7H+/JqtLMA0GCSqGSIb3DQEBCwUAA4IBAQCJKR1nxp9Ij/yisCmDG7bdN1yHj/2HdVvyLfCCyReRfkB3cnTZVaIOBy5occGkdmsYJ+q8uqczkoCMAz3gvvq1c0msKEiNpqWNeU2aRXqyL3QZJ/GBmUK1I0tINPVv8j7znm0DcvHHXFvhzS8E4s8ai8vQkcpyac/7Z4PN43HtjDnkZo9Zxm7JahHshrhA8sSPvsuC4dQAcHbOrLbHG+HIo3Tq2pNl7mfQ9fVJ2FxbqlzPYr/rK8H2GTA6N55SuP3KTNvyL3RnMa3hXmGTdG1dpMFzD/IE623h/BqY6j29PyQC/+MUD4UCZ6KW9oIzpi27pKQagH1i1jpBU/ceH6AW" 12 | ] 13 | }, 14 | { 15 | "kty": "RSA", 16 | "use": "sig", 17 | "kid": "ie_qWCXhXxt1zIEsu4c7acQVGn4", 18 | "x5t": "ie_qWCXhXxt1zIEsu4c7acQVGn4", 19 | "n": "68tx2cnOfkvHf705c-ZIZfXiyxE6c9LqxVQDjIs-9DbvDEI7453kZi9tvQYzskJFdBlD8MYuVhX8Bpi_YUFF8eoJ1PFC2_82sDhF-mNJ7sPrYsgMJAL3rfzOKQapx3m9RPS-18KAZOg-SIDwbNcrDm5rYw5oXi2jbO4ctKzRKP3jznvHDeLnLCTcnDGkMCRBuENJugwh7oHjtGph1L3vNpI2lroB3HK2TcD1Mr5cYTcrd-j6bmk8LRewLCJjipZwmu3DiW4_kRnigw8O5BmYgMYkWZ7Gl048KBZL-7PyHTAXb7tMp9FdJfWY-Xu9KB9ayAW84GbCMJ7qoojwoCDb3Q", 20 | "e": "AQAB", 21 | "x5c": [ 22 | "MIIDBTCCAe2gAwIBAgIQdRnV9VlJ0JZDXnbfp+XqZjANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE5MDcxNTAwMDAwMFoXDTIxMDcxNTAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOvLcdnJzn5Lx3+9OXPmSGX14ssROnPS6sVUA4yLPvQ27wxCO+Od5GYvbb0GM7JCRXQZQ/DGLlYV/AaYv2FBRfHqCdTxQtv/NrA4RfpjSe7D62LIDCQC9638zikGqcd5vUT0vtfCgGToPkiA8GzXKw5ua2MOaF4to2zuHLSs0Sj94857xw3i5ywk3JwxpDAkQbhDSboMIe6B47RqYdS97zaSNpa6Adxytk3A9TK+XGE3K3fo+m5pPC0XsCwiY4qWcJrtw4luP5EZ4oMPDuQZmIDGJFmexpdOPCgWS/uz8h0wF2+7TKfRXSX1mPl7vSgfWsgFvOBmwjCe6qKI8KAg290CAwEAAaMhMB8wHQYDVR0OBBYEFCle84Tr3/8aZbTs2jryx2w21ANZMA0GCSqGSIb3DQEBCwUAA4IBAQAZsQq6JX4IFDXjfV9UnauPP2E5OUMQvqnNasAATucYctaeW307aQEhB4OQgFDKKUpcN4RHOLqxG4phqUhI72PzW8kNVjGvgSL+uXO7P0mYi0N+ujGXYi92ZzH9tODODQ2147ZDLDe0kiRB9KXwFLdJcY6dbkj0wVmIy4D5JtB9zTRj4R5ymWXCXz3ecN4DhjeZnjnZfxaqJJA6lbWLIcjenKjRXoW95WgtdSu2gpjaJCt4zITTw1cFL6sdHrcsT24j23EpNxUld8C/3IY8ac72HKMR5AloTRlXxwXM8XUwLcrUCVp0c61VNY6U2J0TXYdSvJHwSQ98wSbiSryT2SUk" 23 | ] 24 | }, 25 | { 26 | "kty": "RSA", 27 | "use": "sig", 28 | "kid": "M6pX7RHoraLsprfJeRCjSxuURhc", 29 | "x5t": "M6pX7RHoraLsprfJeRCjSxuURhc", 30 | "n": "xHScZMPo8FifoDcrgncWQ7mGJtiKhrsho0-uFPXg-OdnRKYudTD7-Bq1MDjcqWRf3IfDVjFJixQS61M7wm9wALDj--lLuJJ9jDUAWTA3xWvQLbiBM-gqU0sj4mc2lWm6nPfqlyYeWtQcSC0sYkLlayNgX4noKDaXivhVOp7bwGXq77MRzeL4-9qrRYKjuzHfZL7kNBCsqO185P0NI2Jtmw-EsqYsrCaHsfNRGRrTvUHUq3hWa859kK_5uNd7TeY2ZEwKVD8ezCmSfR59ZzyxTtuPpkCSHS9OtUvS3mqTYit73qcvprjl3R8hpjXLb8oftfpWr3hFRdpxrwuoQEO4QQ", 31 | "e": "AQAB", 32 | "x5c": [ 33 | "MIIC8TCCAdmgAwIBAgIQfEWlTVc1uINEc9RBi6qHMjANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMTgxMDE0MDAwMDAwWhcNMjAxMDE0MDAwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEdJxkw+jwWJ+gNyuCdxZDuYYm2IqGuyGjT64U9eD452dEpi51MPv4GrUwONypZF/ch8NWMUmLFBLrUzvCb3AAsOP76Uu4kn2MNQBZMDfFa9AtuIEz6CpTSyPiZzaVabqc9+qXJh5a1BxILSxiQuVrI2BfiegoNpeK+FU6ntvAZervsxHN4vj72qtFgqO7Md9kvuQ0EKyo7Xzk/Q0jYm2bD4SypiysJoex81EZGtO9QdSreFZrzn2Qr/m413tN5jZkTApUPx7MKZJ9Hn1nPLFO24+mQJIdL061S9LeapNiK3vepy+muOXdHyGmNctvyh+1+laveEVF2nGvC6hAQ7hBAgMBAAGjITAfMB0GA1UdDgQWBBQ5TKadw06O0cvXrQbXW0Nb3M3h/DANBgkqhkiG9w0BAQsFAAOCAQEAI48JaFtwOFcYS/3pfS5+7cINrafXAKTL+/+he4q+RMx4TCu/L1dl9zS5W1BeJNO2GUznfI+b5KndrxdlB6qJIDf6TRHh6EqfA18oJP5NOiKhU4pgkF2UMUw4kjxaZ5fQrSoD9omjfHAFNjradnHA7GOAoF4iotvXDWDBWx9K4XNZHWvD11Td66zTg5IaEQDIZ+f8WS6nn/98nAVMDtR9zW7Te5h9kGJGfe6WiHVaGRPpBvqC4iypGHjbRwANwofZvmp5wP08hY1CsnKY5tfP+E2k/iAQgKKa6QoxXToYvP7rsSkglak8N5g/+FJGnq4wP6cOzgZpjdPMwaVt5432GA==" 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-azure-ad-jwt-validator", 3 | "description": "Nest Azure Active Directory JWT Token Validator", 4 | "author": "Benjamin Main", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "keywords": [ 9 | "nest", 10 | "azure", 11 | "activedirectory", 12 | "jwt", 13 | "token", 14 | "validation" 15 | ], 16 | "homepage": "https://github.com/benMain/nest-azure-ad-jwt-validator", 17 | "bugs": { 18 | "url": "https://github.com/benMain/nest-azure-ad-jwt-validator/issues" 19 | }, 20 | "files": [ 21 | "dist" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/benMain/nest-azure-ad-jwt-validator.git" 26 | }, 27 | "publishConfig": { 28 | "registry": "https://registry.npmjs.org/", 29 | "tag": "latest" 30 | }, 31 | "scripts": { 32 | "prebuild": "rimraf dist", 33 | "build": "nest build", 34 | "format": "prettier --write \"src/**/*.ts\"", 35 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 36 | "test": "jest", 37 | "test:watch": "jest --watch", 38 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 39 | "test:cov": "jest --coverage" 40 | }, 41 | "dependencies": { 42 | "jsonwebtoken": "^9.0.0" 43 | }, 44 | "peerDependencies": { 45 | "@nestjs/axios": "^2.0.0|| ^3.0.0", 46 | "@nestjs/common": "^9.0.0 || ^10.0.0", 47 | "@nestjs/core": "^9.0.0 || ^10.0.0" 48 | }, 49 | "devDependencies": { 50 | "@nestjs/axios": "^2.0.0", 51 | "@nestjs/cli": "^9.0.0", 52 | "@nestjs/common": "^9.0.0", 53 | "@nestjs/config": "^2.2.0", 54 | "@nestjs/core": "^9.0.0", 55 | "@nestjs/schematics": "^9.0.0", 56 | "@nestjs/testing": "^9.0.0", 57 | "@semantic-release/changelog": "^6.0.3", 58 | "@semantic-release/git": "^10.0.1", 59 | "@types/jest": "28.1.8", 60 | "@types/node": "^18.0.0", 61 | "@types/supertest": "^2.0.11", 62 | "@typescript-eslint/eslint-plugin": "^5.0.0", 63 | "@typescript-eslint/parser": "^5.0.0", 64 | "eslint": "^8.0.1", 65 | "eslint-config-prettier": "^8.3.0", 66 | "eslint-plugin-prettier": "^4.0.0", 67 | "husky": "^7.0.4", 68 | "import-sort-style-eslint": "^6.0.0", 69 | "jest": "28.1.3", 70 | "lint-staged": "^10.2.7", 71 | "prettier": "^2.3.2", 72 | "prettier-plugin-import-sort": "0.0.4", 73 | "reflect-metadata": "^0.1.13", 74 | "rimraf": "^3.0.2", 75 | "rxjs": "^7.2.0", 76 | "semantic-release": "^21.0.3", 77 | "supertest": "^6.1.3", 78 | "ts-jest": "28.0.8", 79 | "ts-loader": "^9.2.3", 80 | "ts-node": "^10.0.0", 81 | "tsconfig-paths": "4.1.0", 82 | "typescript": "^4.7.4" 83 | }, 84 | "release": { 85 | "branch": "master", 86 | "plugins": [ 87 | [ 88 | "@semantic-release/commit-analyzer", 89 | { 90 | "preset": "angular", 91 | "releaseRules": [ 92 | { 93 | "type": "docs", 94 | "scope": "README", 95 | "release": "patch" 96 | }, 97 | { 98 | "type": "refactor", 99 | "release": "patch" 100 | }, 101 | { 102 | "type": "style", 103 | "release": "patch" 104 | }, 105 | { 106 | "type": "chore", 107 | "release": "patch" 108 | }, 109 | { 110 | "type": "breaking", 111 | "release": "major" 112 | } 113 | ], 114 | "parserOpts": { 115 | "noteKeywords": [ 116 | "BREAKING CHANGE", 117 | "BREAKING CHANGES" 118 | ] 119 | } 120 | } 121 | ], 122 | "@semantic-release/changelog", 123 | [ 124 | "@semantic-release/npm", 125 | { 126 | "npmPublish": true, 127 | "tarballDir": "dist" 128 | } 129 | ], 130 | [ 131 | "@semantic-release/git", 132 | { 133 | "assets": [ 134 | "package.json", 135 | "package-lock.json", 136 | "CHANGELOG.md" 137 | ], 138 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 139 | } 140 | ], 141 | [ 142 | "@semantic-release/github", 143 | { 144 | "assets": "dist/*.tgz" 145 | } 146 | ] 147 | ] 148 | }, 149 | "importSort": { 150 | ".js, .jsx, .ts, .tsx": { 151 | "parser": "typescript" 152 | } 153 | }, 154 | "husky": { 155 | "hooks": { 156 | "pre-commit": "lint-staged" 157 | } 158 | }, 159 | "lint-staged": { 160 | "*.ts": [ 161 | "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 162 | "jest --findRelatedTests --passWithNoTests" 163 | ], 164 | "./**/*.{js,json,css,md}": [ 165 | "prettier --write \"src/**/*.ts\"", 166 | "git add" 167 | ] 168 | }, 169 | "jest": { 170 | "moduleFileExtensions": [ 171 | "js", 172 | "json", 173 | "ts" 174 | ], 175 | "rootDir": "src", 176 | "testRegex": ".*\\.spec\\.ts$", 177 | "transform": { 178 | "^.+\\.(t|j)s$": "ts-jest" 179 | }, 180 | "coverageDirectory": "../coverage", 181 | "testEnvironment": "node" 182 | }, 183 | "version": "6.0.6" 184 | } 185 | -------------------------------------------------------------------------------- /src/azure-token-validation/azure-token-validation.service.ts: -------------------------------------------------------------------------------- 1 | import { AzureAdUser, JwtKey, JwtPayload, TokenHeader } from '../models'; 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | 4 | import { EOL } from 'os'; 5 | import { HttpService } from '@nestjs/axios'; 6 | import { NestAzureAdJwtValidatorModuleOptions } from '../module-config'; 7 | import { verify } from 'jsonwebtoken'; 8 | 9 | @Injectable() 10 | export class AzureTokenValidationService { 11 | private readonly logger: Logger; 12 | 13 | constructor( 14 | private readonly httpService: HttpService, 15 | private readonly options: NestAzureAdJwtValidatorModuleOptions, 16 | ) { 17 | this.logger = new Logger(AzureTokenValidationService.name); 18 | } 19 | 20 | async isTokenValid( 21 | accessToken: string, 22 | ): Promise<[boolean, AzureAdUser, boolean]> { 23 | let isServiceToken = false; 24 | const user = await this.extractUserFromToken(accessToken); 25 | let isTokenValid = !!user; 26 | if (!isTokenValid) { 27 | isServiceToken = true; 28 | isTokenValid = this.validateServiceToken(accessToken); 29 | } 30 | return [isTokenValid, user, isServiceToken]; 31 | } 32 | 33 | async getAzureUserFromToken(accessToken: string): Promise { 34 | return await this.extractUserFromToken(accessToken); 35 | } 36 | 37 | private async extractUserFromToken( 38 | accessToken: string, 39 | ): Promise { 40 | const keys = (await this.getAzureKeys()).keys; 41 | let tokenHeader: TokenHeader; 42 | try { 43 | tokenHeader = this.getTokenHeader(accessToken); 44 | if (!tokenHeader) { 45 | return null; 46 | } 47 | } catch (err) { 48 | if (this.options.enableDebugLogs) { 49 | this.logger.warn( 50 | `Unable to extract Header from AccessToken: ${accessToken} for issue ${err.toString()}`, 51 | ); 52 | } 53 | return null; 54 | } 55 | const key = keys.find((x) => x.kid === tokenHeader.kid); 56 | if (!key) { 57 | this.logger.error( 58 | `Unable to find Public Signing key matching Token Header kid(KeyId): ${tokenHeader.kid}`, 59 | ); 60 | return null; 61 | } 62 | const publicKey = `-----BEGIN CERTIFICATE-----${EOL}${key.x5c[0]}${EOL}-----END CERTIFICATE-----`; 63 | try { 64 | const payload = this.verifyToken(accessToken, publicKey); 65 | const user = new AzureAdUser(payload); 66 | const matchingTenantApp = this.options.apps.some( 67 | (a) => 68 | (`api://${a.audienceId}` === user.audience || 69 | a.audienceId === user.audience || 70 | a.audienceId === user.appId) && 71 | a.tenantId === user.tenant, 72 | ); 73 | return matchingTenantApp ? user : null; 74 | } catch (err) { 75 | this.logger.error( 76 | `Unable to validate accessToken for reason ${err.toString()}`, 77 | ); 78 | return null; 79 | } 80 | } 81 | 82 | async extractRolesFromToken(accessToken: string): Promise { 83 | const keys = (await this.getAzureKeys()).keys; 84 | let tokenHeader: TokenHeader; 85 | try { 86 | tokenHeader = this.getTokenHeader(accessToken); 87 | if (!tokenHeader) { 88 | return null; 89 | } 90 | } catch (err) { 91 | this.logger.error( 92 | `Unable to extract Header from AccessToken: ${accessToken} for issue ${err.toString()}`, 93 | ); 94 | return null; 95 | } 96 | const key = keys.find((x) => x.kid === tokenHeader.kid); 97 | if (!key) { 98 | this.logger.error( 99 | `Unable to find Public Signing key matching Token Header kid(KeyId): ${tokenHeader.kid}`, 100 | ); 101 | return null; 102 | } 103 | const publicKey = `-----BEGIN CERTIFICATE-----${EOL}${key.x5c[0]}${EOL}-----END CERTIFICATE-----`; 104 | try { 105 | const payload = this.verifyToken(accessToken, publicKey); 106 | const user = new AzureAdUser(payload); 107 | if (user.roles) { 108 | return user; 109 | } 110 | return null; 111 | } catch (err) { 112 | this.logger.error( 113 | `Unable to validate accessToken for reason ${err.toString()}`, 114 | ); 115 | return null; 116 | } 117 | } 118 | 119 | private async getAzureKeys(): Promise<{ keys: JwtKey[] }> { 120 | return ( 121 | await this.httpService 122 | .get<{ keys: JwtKey[] }>( 123 | 'https://login.microsoftonline.com/common/discovery/keys', 124 | ) 125 | .toPromise() 126 | ).data; 127 | } 128 | 129 | private verifyToken( 130 | accessToken: string, 131 | key: string, 132 | ): JwtPayload & { appid?: string } { 133 | const data = verify(accessToken, key); 134 | return data as JwtPayload; 135 | } 136 | 137 | private getTokenHeader(accessToken: string): TokenHeader { 138 | if (!accessToken.includes('.')) { 139 | this.logger.debug('Processing as service token, not as access token.'); 140 | return null; 141 | } 142 | const tokenPart = accessToken.slice(0, accessToken.indexOf('.')); 143 | const buffer = Buffer.from(tokenPart, 'base64'); 144 | const decodedToken = buffer.toString('utf8'); 145 | try { 146 | return JSON.parse(decodedToken) as TokenHeader; 147 | } catch (ex) { 148 | this.logger.debug('Processing as service token, not as access token.'); 149 | return null; 150 | } 151 | } 152 | 153 | private validateServiceToken(token: string): boolean { 154 | if (this.options.enableDebugLogs) { 155 | this.logger.debug('Attempting to validate service token...'); 156 | } 157 | 158 | if (this.options.serviceTokens.includes(token)) { 159 | return true; 160 | } 161 | 162 | if (this.options.enableDebugLogs) { 163 | this.logger.warn('Could not validate service token.'); 164 | } 165 | 166 | return false; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/guards/azure-active-directory.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { AzureAdUser, JwtKey, JwtPayload } from '../models'; 2 | import { ExecutionContext, SetMetadata } from '@nestjs/common'; 3 | import { Observable, Observer } from 'rxjs'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | 6 | import { AxiosResponse } from 'axios'; 7 | import { AzureActiveDirectoryGuard } from './azure-active-directory.guard'; 8 | import { AzureTokenValidationService } from '../azure-token-validation'; 9 | import { HttpService } from '@nestjs/axios'; 10 | import { NestAzureAdJwtValidatorModuleOptions } from '../module-config'; 11 | import { Reflector } from '@nestjs/core'; 12 | import { readFileSync } from 'fs'; 13 | 14 | export const Roles = (...roles: string[]) => SetMetadata('roles', roles); 15 | 16 | describe('AzureActiveDirectoryGuard', () => { 17 | let guard: AzureActiveDirectoryGuard; 18 | let service: AzureTokenValidationService; 19 | let httpService: HttpService; 20 | let reflector: Reflector; 21 | 22 | let tokenValidateMock: jest.SpyInstance< 23 | Promise<[boolean, AzureAdUser, boolean]>, 24 | [string] 25 | >; 26 | let getAzureUserFromTokenMock: jest.SpyInstance; 27 | let getReflectorSpy: jest.SpyInstance; 28 | let getTokensMock: jest.SpyInstance; 29 | let executionContext: ExecutionContext; 30 | 31 | const audienceToken = 'ff45a46b-5dc8-4f5e-ae2d-f92f978deade'; 32 | const tenantToken = '1f698e30-434c-4488-a068-fb417df97dc4'; 33 | const mockUser: JwtPayload = { 34 | name: 'Benjamin Main', 35 | upn: ' bmain@lumeris.com', 36 | oid: '2ccce435-038d-4ec9-9cd7-85b2df5e39f8', 37 | roles: ['admin'], 38 | aud: audienceToken, 39 | tid: tenantToken, 40 | iss: `https://sts.windows.net/${tenantToken}/`, 41 | iat: 1576190172, 42 | nbf: 1576190172, 43 | exp: 1576194072, 44 | aio: 'fake', 45 | amr: [], 46 | family_name: 'Main', 47 | given_name: 'Benjamin', 48 | ipaddr: '10.10.10.10', 49 | nonce: '0474e873-cf17-48e5-bf7b-b7763482b78d', 50 | onprem_sid: '0474e873-cf17-48e5-bf7b-b7763482b78d', 51 | sub: '0474e873-cf17-48e5-bf7b-b7763482b78d', 52 | unique_name: 'bmain@lumeris.com', 53 | uti: '0474e873-cf17-48e5-bf7b-b7763482b78d', 54 | ver: '1.0', 55 | }; 56 | 57 | beforeEach(async () => { 58 | const module: TestingModule = await Test.createTestingModule({ 59 | providers: [ 60 | AzureActiveDirectoryGuard, 61 | AzureTokenValidationService, 62 | { 63 | provide: Reflector, 64 | useValue: { 65 | get: () => null, 66 | }, 67 | }, 68 | { 69 | provide: NestAzureAdJwtValidatorModuleOptions, 70 | useValue: new NestAzureAdJwtValidatorModuleOptions({ 71 | apps: [{ tenantId: tenantToken, audienceId: audienceToken }], 72 | enableDebugLogs: false, 73 | }), 74 | }, 75 | { 76 | provide: HttpService, 77 | useValue: { 78 | get: () => null, 79 | }, 80 | }, 81 | ], 82 | }).compile(); 83 | 84 | guard = module.get(AzureActiveDirectoryGuard); 85 | service = module.get( 86 | AzureTokenValidationService, 87 | ); 88 | reflector = module.get(Reflector); 89 | tokenValidateMock = jest.spyOn(service, 'isTokenValid'); 90 | getAzureUserFromTokenMock = jest.spyOn(service, 'getAzureUserFromToken'); 91 | getReflectorSpy = jest.spyOn(reflector, 'get'); 92 | const incomingRequest: any = { 93 | headers: { 94 | authtoken: '12345A', 95 | }, 96 | }; 97 | httpService = module.get(HttpService); 98 | getTokensMock = jest.spyOn(httpService, 'get'); 99 | getTokensMock.mockReturnValue( 100 | new Observable( 101 | (observer: Observer>) => { 102 | observer.next({ 103 | data: getDiscoveryKeys(), 104 | status: 200, 105 | statusText: 'success', 106 | headers: null, 107 | config: null, 108 | }); 109 | observer.complete(); 110 | }, 111 | ), 112 | ); 113 | executionContext = { 114 | getType: () => null, 115 | getHandler: () => null, 116 | getClass: () => null, 117 | getArgByIndex: () => null, 118 | getArgs: () => null, 119 | switchToRpc: () => null, 120 | switchToWs: () => null, 121 | switchToHttp: () => ({ 122 | getRequest: () => incomingRequest, 123 | getResponse: () => null, 124 | getNext: () => null, 125 | }), 126 | }; 127 | }); 128 | 129 | it('should be defined', () => { 130 | expect(guard).toBeDefined(); 131 | }); 132 | describe('canActivate()', () => { 133 | it('should activate for valid token no roles', async () => { 134 | tokenValidateMock.mockResolvedValue([true, mockUser as any, false]); 135 | const canActivate = await guard.canActivate(executionContext); 136 | expect(canActivate).toEqual(true); 137 | expect(tokenValidateMock).toHaveBeenCalledTimes(1); 138 | expect(tokenValidateMock).toHaveBeenCalledWith('12345A'); 139 | expect(getAzureUserFromTokenMock).not.toHaveBeenCalled(); 140 | }); 141 | it('should activate for valid token with roles', async () => { 142 | tokenValidateMock.mockResolvedValue([true, mockUser as any, false]); 143 | getReflectorSpy.mockReturnValue(['admin']); 144 | const canActivate = await guard.canActivate(executionContext); 145 | expect(canActivate).toEqual(true); 146 | expect(tokenValidateMock).toHaveBeenCalledTimes(1); 147 | expect(tokenValidateMock).toHaveBeenCalledWith('12345A'); 148 | }); 149 | it('should activate for valid token with invalid role', async () => { 150 | tokenValidateMock.mockResolvedValue([true, mockUser as any, false]); 151 | getReflectorSpy.mockReturnValue(['fakeNotKnownRole']); 152 | const canActivate = await guard.canActivate(executionContext); 153 | expect(canActivate).toEqual(false); 154 | expect(tokenValidateMock).toHaveBeenCalledTimes(1); 155 | expect(tokenValidateMock).toHaveBeenCalledWith('12345A'); 156 | }); 157 | }); 158 | }); 159 | 160 | function getDiscoveryKeys(): { keys: JwtKey[] } { 161 | const buffer = readFileSync( 162 | './src/azure-token-validation/mock-discovery-keys-response.json', 163 | ); 164 | const data = buffer.toString('utf8'); 165 | return JSON.parse(data); 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nest Azure Active Directory Token Validator 2 | 3 |

4 | Azure Ad Logo 5 |

6 | 7 | ## Description 8 | 9 | [Nest](https://github.com/nestjs/nest) framework Module for validating Azure AD JWT Tokens.
10 | The Module exports an [AzureTokenValidationService](./src/azure-token-validation/azure-token-validation.service.ts) for validating tokens 11 | as well as a guard you might consider using [AzureActiveDirectoryGuard](./src/guards/zure-active-directory.guard.ts).
12 | Note: The exported guard expects the jwt(json web token) in an authtoken header (in Aws APIGateway we don't like to mess with the Authorization header). 13 | 14 | ## Installation 15 | 16 | ```bash 17 | $ npm install --save nest-azure-ad-jwt-validator 18 | ``` 19 | 20 | ## Usage 21 | 22 | 1. Import the module globally in your app module. 23 | 2. Add the Exported Guard as a global guard or use the exported service 24 | 25 | ```typescript 26 | import { 27 | AzureActiveDirectoryGuard, 28 | NestAzureAdJwtValidatorModule, 29 | } from 'nest-azure-ad-jwt-validator'; 30 | import { APP_GUARD } from '@nestjs/core'; 31 | import { AppController } from './app.controller'; 32 | import { AppService } from './app.service'; 33 | import { Module } from '@nestjs/common'; 34 | @Module({ 35 | imports: [ 36 | NestAzureAdJwtValidatorModule.forRoot({ 37 | apps: [ 38 | { 39 | tenantId: '63fca94a-4979-4ee1-b9cc-54169f68ccbf', 40 | audienceId: '6747e462-323d-4fb7-b1e0-ef99531fe611', 41 | }, 42 | ], 43 | serviceTokens: ['random-string-generated-by-you'], // option - used to allow service-to-service communication, ala AWS x-api-key 44 | enableDebugLogs: true, // optional - false by default 45 | tokenHeader: 'authorization' // The Header in which the jwt appears defaults to 'authtoken' by default. 46 | }), 47 | ], 48 | controllers: [AppController], 49 | providers: [ 50 | AppService, 51 | { 52 | provide: APP_GUARD, 53 | useClass: AzureActiveDirectoryGuard, 54 | }, 55 | ], 56 | }) 57 | export class AppModule {} 58 | ``` 59 | 60 | ## Azure Roles 61 | 62 | 1. 63 | 64 | To use azure roles go to portal.azure.com -> search for App Registrations -> find your app -> edit manifest 65 | 66 | Add your roles to the manifest, the id's should be different Guids: 67 | 68 | ```json 69 | { 70 | "id": "xxxx-xxxx-xx-xxxx-xxxxxxx", 71 | ... 72 | "appRoles": [ 73 | { 74 | "allowedMemberTypes": [ 75 | "User" 76 | ], 77 | "description": "Admin Users", 78 | "displayName": "Admin", 79 | "id": "xxxxx-xxxx-xxx-xxx-xxxxxx", 80 | "isEnabled": true, 81 | "lang": null, 82 | "origin": "Application", 83 | "value": "Admin" 84 | }, 85 | { 86 | "allowedMemberTypes": [ 87 | "User" 88 | ], 89 | "description": "Finance Users have the ability to update finance data.", 90 | "displayName": "FinanceUsers", 91 | "id": "xxxxxx-xxx-xxxx-xxxx-xxxxxx2", 92 | "isEnabled": true, 93 | "lang": null, 94 | "origin": "Application", 95 | "value": "FinanceUsers" 96 | }, 97 | ... 98 | ], 99 | "oauth2AllowUrlPathMatching": false, 100 | ... 101 | } 102 | ``` 103 | 104 | 2. 105 | 106 | Next go to Enterprise Applications -> search for your app and click on it for details -> Users and Groups (or Assign Users and Groups) -> Add User -> Here you pick azure ad users or groups and put them into the App Roles 107 | 108 | 3. 109 | 110 | Now that Azure roles are setup and returned in the token, roles can be added to the application under the routes such as 111 | appmodule.ts 112 | 113 | ```ts 114 | import { 115 | AzureActiveDirectoryGuard, 116 | NestAzureAdJwtValidatorModule, 117 | } from 'nest-azure-ad-jwt-validator'; 118 | import { APP_GUARD } from '@nestjs/core'; 119 | import { AppController } from './app.controller'; 120 | import { AppService } from './app.service'; 121 | import { Module } from '@nestjs/common'; 122 | @Module({ 123 | imports: [ 124 | NestAzureAdJwtValidatorModule.forRoot({ 125 | apps: [ 126 | { 127 | tenantId: '63fca94a-4979-4ee1-b9cc-54169f68ccbf', 128 | audienceId: '6747e462-323d-4fb7-b1e0-ef99531fe611', 129 | }, 130 | ], 131 | serviceTokens: ['random-string-generated-by-you'], // option - used to allow service-to-service communication, ala AWS x-api-key 132 | enableDebugLogs: true, // optional - false by default 133 | }), 134 | ], 135 | controllers: [AppController], 136 | providers: [AppService], 137 | }) 138 | export class AppModule {} 139 | ``` 140 | 141 | controller.ts 142 | 143 | ```ts 144 | import { Controller, Get } from '@nestjs/common'; 145 | import { AzureActiveDirectoryGuard } from 'nest-azure-ad-jwt-validator'; 146 | import { Roles } from 'nest-azure-ad-jwt-validator'; 147 | @UseGuards(AzureActiveDirectoryGuard) 148 | @Controller('test') 149 | export class TestController { 150 | @Get('') 151 | @Roles('admin', 'finance') 152 | get getTest() { 153 | return 'success'; 154 | } 155 | } 156 | ``` 157 | 158 | Or if you do not wish to add the guard globally to all endpoints you can specify specific controllers to have the guard such as: 159 | 160 | ```ts 161 | import { Controller, Get } from '@nestjs/common'; 162 | import { Roles } from 'nest-azure-ad-jwt-validator'; 163 | @Controller('test') 164 | export class TestController { 165 | @Get('') 166 | @Roles('admin', 'finance') 167 | get getTest() { 168 | return 'success'; 169 | } 170 | } 171 | ``` 172 | 173 | Note: Azure Roles have not been setup an a `@Controller` level that will require a code change to `context.getHandler()` -> `context.getClass()` 174 | 175 | Note: If the role does not exist on the role, no roles are checked and everything proceeds as if there are no roles. 176 | 177 | Note: If you are assigning users to appRoles via Azure Groups then you need to change the manifest 178 | 179 | ```json 180 | "groupMembershipClaims": null, 181 | ``` 182 | 183 | To 184 | 185 | ```json 186 | "groupMembershipClaims": "All", # or “SecurityGroups” 187 | ``` 188 | 189 | In addition you cannot nest security groups, so [you cannot take an existing group and add it to the group assigned to the appRole](https://stackoverflow.com/questions/27633510/assign-nested-group-to-role-in-azure-ad-applications-users-and-groups). 190 | 191 | Example: 192 | appRole: 'Admin' 193 | User: 'test@domain.com' 194 | AD Groups: 'AD-TEST-UI-UG' 195 | 196 | Either add ADGroups to the appRole or add the user to the appRole. You cannot add the AD Group 'AD-TEST-UI-UG' to another AD Group superset ('AD-TEST-UI-SUPERSET-UG') group. 'AD-TEST-UI-SUPERSET-UG' would never show roles. 197 | 198 | Also: 199 | [Vote for this Feature](https://feedback.azure.com/forums/169401-azure-active-directory/suggestions/)15718164-add-support-for-nested-groups-in-azure-ad-app-acc 200 | [In the Important section](https://docs.microsoft.com/en-us/azure/active-directory/users-groups-roles/groups-saasapps) 201 | 202 | ## Commit Messages 203 | 204 | Commit messages should follow the [semantic commit message by angular(https://nitayneeman.com/posts/understanding-semantic-commit-messages-using-git-and-angular/) 205 | 206 | ```bash 207 | git commit -am "fix(roles): add service token except to roles authorization" -m "The roles authorization should not run when the service now token is used because the service token is used when the application has no auth mechanism. Add warning message if user's roles does not match expected role" -m "PR Close #13" 208 | ``` 209 | 210 | ## Test 211 | 212 | ```bash 213 | # unit tests 214 | $ npm run test 215 | 216 | # test coverage 217 | $ npm run test:cov 218 | ``` 219 | 220 | ## Stay in touch 221 | 222 | - Author - [Benjamin Main](mailto:bmain@lumeris.com) 223 | 224 | ## License 225 | 226 | nest-azure-ad-jwt-validator is [MIT licensed](LICENSE). 227 | -------------------------------------------------------------------------------- /src/azure-token-validation/azure-token-validation.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import { JwtKey, JwtPayload } from '../models'; 3 | import { Observable, Observer } from 'rxjs'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | 6 | import { AzureTokenValidationService } from './azure-token-validation.service'; 7 | import { HttpService } from '@nestjs/axios'; 8 | import { NestAzureAdJwtValidatorModuleOptions } from '../module-config'; 9 | import { readFileSync } from 'fs'; 10 | 11 | interface AzureTokenValidationServicePrivate { 12 | verifyToken: () => JwtPayload; 13 | } 14 | 15 | describe('AzureTokenValidationService', () => { 16 | let service: AzureTokenValidationService; 17 | let servicePrivate: AzureTokenValidationServicePrivate; 18 | let httpService: HttpService; 19 | let getTokensMock: jest.SpyInstance< 20 | Observable>, 21 | [string, AxiosRequestConfig?] 22 | >; 23 | let verifyMock: jest.SpyInstance; 24 | // tslint:disable-next-line: max-line-length 25 | const testToken = `eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6InU0T2ZORlBId0VCb3NIanRyYXVPYlY4NExuWSIsImtpZCI6InU0T2ZORlBId0VCb3NIanRyYXVPYlY4NExuWSJ9.eyJhdWQiOiIzMjNiYWUwNC1hMjY3LTQxMWEtYTJhOS05ZDYzYTcyNWVmMmEiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zYmRlYzY1Yi0zZjZkLTQzYmItYTE5NC1hZDkyYzA5MDkyODcvIiwiaWF0IjoxNTYzODEyNjQzLCJuYmYiOjE1NjM4MTI2NDMsImV4cCI6MTU2MzgxNjU0MywiYWlvIjoiQVZRQXEvOE1BQUFBWCtCZmk0aTBoUHh2NGpESnBvL2NYR25HKzIvUGhIbEpsQTdFdXUzN3J5eHdESFlqc0Y4UDRvb1U2Y0d6OGQ4QUt2ZlB2WU9sWlV2MjdDdXo5L0R5UGx3Z3BidVg3Q0lZWDdkOExqZnJzVlk9IiwiYW1yIjpbInB3ZCIsIm1mYSJdLCJmYW1pbHlfbmFtZSI6Ik1haW4iLCJnaXZlbl9uYW1lIjoiQmVuamFtaW4iLCJpcGFkZHIiOiIxMi4xNzQuMTIzLjEyNSIsIm5hbWUiOiJCZW4gTWFpbiIsIm5vbmNlIjoiNzY3NWUzYTUtZjgxNi00NjM2LWI5Y2YtMjQ5OGVkNTA4NGRhIiwib2lkIjoiZDlhZjQ5MWMtYTdlNi00NmNlLWJkMDUtNGQ3YTNhZjdhODY4Iiwib25wcmVtX3NpZCI6IlMtMS01LTIxLTE4OTg3MjEzMjgtMjQ2OTgxNjc0NC0yNTI4MzA1Mzk3LTc1NTYiLCJzdWIiOiJKaVhPeWJOMm9nWHU0bFhaQkJoWWM4d2Jyb2Q5RllzT2ZFc0h6ZWd3WU0wIiwidGlkIjoiM2JkZWM2NWItM2Y2ZC00M2JiLWExOTQtYWQ5MmMwOTA5Mjg3IiwidW5pcXVlX25hbWUiOiJibWFpbkBsdW1lcmlzLmNvbSIsInVwbiI6ImJtYWluQGx1bWVyaXMuY29tIiwidXRpIjoidVltVnd2dmpfRWVTRE95M3RTNVJBQSIsInZlciI6IjEuMCJ9.L0cpS5NizJbPeNtUqHiO7fAWw_4OxFA0wkpJttvw5kPyetqmw-oGVFClFdfhopXJv_W4EKbD0yYgj1BvxyfkvfbNZcpwcGjP7ynmtmJproZAcwL5RRvx-A8J-bJyUq6lugRKWvGRJyTbPkTE_BvZVA3FkM942fmt46vzpIWg1vIYwRApZ5l4HhIJykQMKhyjpPuCoSGAlYCZyupeE2vRB4nIxxcarVLBhv2cBHBSClE0zMA9Tjc1_LT5LMCSmoCrVo3MK4oRPoNmpfoak44v4nDA0xTUwbIsOYxQIl01e89zQzj3zSENwxdtCKzd6STh2zZjgwFIQXYqbEuAU3cOqQ`; 26 | const testToken2 = `000a29f5-8e9d-4577-8050-194357a1d004`; 27 | const audienceToken = '53f9cdfd-f0c0-44b4-946d-fcdcbb755a82'; 28 | const tenantToken = 'bf488ae9-30f3-4ab5-8b30-d9c1e3a9b51f'; 29 | const mockUser: JwtPayload = { 30 | name: 'Benjamin Main', 31 | upn: ' bmain@lumeris.com', 32 | oid: '2ccce435-038d-4ec9-9cd7-85b2df5e39f8', 33 | roles: null, 34 | aud: audienceToken, 35 | tid: tenantToken, 36 | iss: `https://sts.windows.net/${tenantToken}/`, 37 | iat: 1576190172, 38 | nbf: 1576190172, 39 | exp: 1576194072, 40 | aio: 'fake', 41 | amr: [], 42 | family_name: 'Main', 43 | given_name: 'Benjamin', 44 | ipaddr: '10.10.10.10', 45 | nonce: '0474e873-cf17-48e5-bf7b-b7763482b78d', 46 | onprem_sid: '0474e873-cf17-48e5-bf7b-b7763482b78d', 47 | sub: '0474e873-cf17-48e5-bf7b-b7763482b78d', 48 | unique_name: 'bmain@lumeris.com', 49 | uti: '0474e873-cf17-48e5-bf7b-b7763482b78d', 50 | ver: '1.0', 51 | }; 52 | const mockClientCredential: JwtPayload & { appid?: string } = { 53 | aud: '00000002-0000-0000-c000-000000000000', 54 | iss: `https://sts.windows.net/${tenantToken}/`, 55 | iat: 1602201716, 56 | nbf: 1602201716, 57 | exp: 1602205616, 58 | aio: 'E2RgYPhhmKl24+2xzYsLRQ97+F4OAwA=', 59 | appid: audienceToken, 60 | oid: '8ae984ed-d502-41da-8594-ff74c84d8526', 61 | sub: '8ae984ed-d502-41da-8594-ff74c84d8526', 62 | tid: tenantToken, 63 | uti: '5LV9VLNjxEeeYn7ASmZkAA', 64 | ver: '1.0', 65 | } as any; 66 | beforeEach(async () => { 67 | const module: TestingModule = await Test.createTestingModule({ 68 | providers: [ 69 | AzureTokenValidationService, 70 | { 71 | provide: HttpService, 72 | useValue: { 73 | get: () => null, 74 | }, 75 | }, 76 | { 77 | provide: NestAzureAdJwtValidatorModuleOptions, 78 | useValue: new NestAzureAdJwtValidatorModuleOptions({ 79 | apps: [{ tenantId: tenantToken, audienceId: audienceToken }], 80 | enableDebugLogs: false, 81 | }), 82 | }, 83 | ], 84 | }).compile(); 85 | 86 | service = module.get( 87 | AzureTokenValidationService, 88 | ); 89 | httpService = module.get(HttpService); 90 | servicePrivate = service as any as AzureTokenValidationServicePrivate; 91 | verifyMock = jest.spyOn(servicePrivate, 'verifyToken'); 92 | getTokensMock = jest.spyOn(httpService, 'get'); 93 | getTokensMock.mockReturnValue( 94 | new Observable( 95 | (observer: Observer>) => { 96 | observer.next({ 97 | data: getDiscoveryKeys(), 98 | status: 200, 99 | statusText: 'success', 100 | headers: null, 101 | config: null, 102 | }); 103 | observer.complete(); 104 | }, 105 | ), 106 | ); 107 | }); 108 | 109 | it('should be defined', () => { 110 | expect(service).toBeDefined(); 111 | }); 112 | 113 | describe('isTokenValid()', () => { 114 | it('should validate a legit Azure token', async () => { 115 | verifyMock.mockReturnValue(mockUser as any); 116 | const response = await service.isTokenValid(testToken); 117 | const response2 = await service.isTokenValid(testToken2); 118 | expect(response[0]).toBeTruthy(); 119 | expect(response2[0]).toBeFalsy(); 120 | expect(getTokensMock).toHaveBeenCalledTimes(2); 121 | expect(verifyMock).toHaveBeenCalledTimes(1); 122 | }); 123 | it('should work with the Client Credentials Flow', async () => { 124 | verifyMock.mockReturnValue(mockClientCredential); 125 | const response = await service.isTokenValid(testToken); 126 | expect(response[0]).toBeTruthy(); 127 | expect(getTokensMock).toHaveBeenCalledTimes(1); 128 | expect(verifyMock).toHaveBeenCalledTimes(1); 129 | }); 130 | it('should return false on expired Azure token and invalid service token', async () => { 131 | process.env.SERVICE_TOKEN = 'invalid-service-token'; 132 | const [response, user, isServiceToken] = await service.isTokenValid( 133 | testToken, 134 | ); 135 | expect(response).toBeFalsy(); 136 | expect(user).toBeFalsy(); 137 | expect(isServiceToken).toBeTruthy(); 138 | expect(getTokensMock).toHaveBeenCalledTimes(1); 139 | expect(verifyMock).toHaveBeenCalledTimes(1); 140 | }); 141 | it('should return false on garbage Azure token and invalid service token', async () => { 142 | process.env.SERVICE_TOKEN = 'invalid-service-token'; 143 | const [response, user, isServiceToken] = await service.isTokenValid( 144 | 'fdae', 145 | ); 146 | expect(response).toBeFalsy(); 147 | expect(user).toBeFalsy(); 148 | expect(isServiceToken).toBeTruthy(); 149 | expect(getTokensMock).toHaveBeenCalledTimes(1); 150 | expect(verifyMock).toHaveBeenCalledTimes(0); 151 | }); 152 | it('should return true on invalid Azure token, but valid service token', async () => { 153 | process.env.SERVICE_TOKEN = 'valid-service-token'; 154 | const response = await service.isTokenValid('valid-service-token'); 155 | expect(response).toBeTruthy(); 156 | expect(getTokensMock).toHaveBeenCalledTimes(1); 157 | expect(verifyMock).toHaveBeenCalledTimes(0); 158 | }); 159 | }); 160 | describe('getAzureUserFromToken()', () => { 161 | it('should validate a legit token and return user', async () => { 162 | verifyMock.mockReturnValue(mockUser as any); 163 | const response = await service.getAzureUserFromToken(testToken); 164 | expect(response).toBeTruthy(); 165 | expect(getTokensMock).toHaveBeenCalledTimes(1); 166 | expect(verifyMock).toHaveBeenCalledTimes(1); 167 | expect(response.email).toEqual(mockUser.upn); 168 | expect(response.fullName).toEqual(mockUser.name); 169 | }); 170 | }); 171 | }); 172 | 173 | function getDiscoveryKeys(): { keys: JwtKey[] } { 174 | const buffer = readFileSync( 175 | './src/azure-token-validation/mock-discovery-keys-response.json', 176 | ); 177 | const data = buffer.toString('utf8'); 178 | return JSON.parse(data); 179 | } 180 | --------------------------------------------------------------------------------