├── .circleci └── config.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── docker-stack.yml ├── lib ├── app │ ├── command │ │ ├── create-access-token.command.ts │ │ ├── create-access-token.handler.ts │ │ ├── create-client.command.ts │ │ ├── create-client.handler.ts │ │ └── index.ts │ ├── event │ │ ├── access-token-created.event.ts │ │ ├── client-created.event.ts │ │ └── index.ts │ ├── index.ts │ ├── interfaces │ │ ├── index.ts │ │ ├── oauth2-async-options.interface.ts │ │ ├── oauth2-options-factory.interface.ts │ │ └── oauth2-options.type.ts │ ├── oauth2-core.module.ts │ ├── oauth2.constants.ts │ └── oauth2.module.ts ├── domain │ ├── access-token.entity.ts │ ├── client.entity.ts │ ├── exception │ │ ├── access-token-not-found.exception.ts │ │ ├── client-not-found.exception.ts │ │ ├── index.ts │ │ └── invalid-user.exception.ts │ ├── index.ts │ ├── interface │ │ ├── client-payload.interface.ts │ │ ├── index.ts │ │ ├── oauth2-payload.interface.ts │ │ ├── user-loader.interface.ts │ │ ├── user-payload.interface.ts │ │ ├── user-validator.interface.ts │ │ └── user.interface.ts │ ├── repository │ │ ├── access-token-repository.interface.ts │ │ ├── client-repository.interface.ts │ │ └── index.ts │ └── strategy │ │ ├── decorator │ │ └── oauth2-grant-strategy.decorator.ts │ │ ├── index.ts │ │ ├── oauth2-grant-strategy.interface.ts │ │ ├── strategy.explorer.ts │ │ └── strategy.registry.ts ├── index.ts ├── infrastructure │ ├── index.ts │ ├── oauth2-grant-strategy │ │ ├── client-credentials.strategy.ts │ │ ├── index.ts │ │ ├── password.strategy.ts │ │ └── refresh-token.strategy.ts │ ├── strategy │ │ ├── access-token.strategy.ts │ │ └── index.ts │ └── typeorm │ │ ├── access-token.repository.ts │ │ ├── client.repository.ts │ │ └── index.ts └── ui │ ├── controller │ ├── index.ts │ └── oauth2.controller.ts │ ├── dto │ ├── index.ts │ ├── oauth2-request.dto.ts │ └── oauth2-response.dto.ts │ └── index.ts ├── package.json ├── test ├── e2e │ ├── controller │ │ └── test-secure.controller.ts │ ├── fixtures-loader.service.ts │ ├── fixtures │ │ ├── access-token │ │ │ ├── 01-clients.yaml │ │ │ └── 02-access-token.yaml │ │ ├── client-credentials │ │ │ └── 01-clients.yaml │ │ └── password │ │ │ └── 01-clients.yaml │ ├── mock-user │ │ ├── user.loader.ts │ │ ├── user.module.ts │ │ ├── user.validator.ts │ │ └── users.ts │ ├── modules │ │ └── oauth2-async-use-factory.module.ts │ └── oauth2-async-use-factory.e2e-spec.ts ├── jest-e2e.json └── unit │ └── app │ └── command │ ├── create-access-token.handler.spec.ts │ └── create-client.handler.spec.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | aliases: 4 | - &restore-cache 5 | restore_cache: 6 | name: Restore Yarn Package Cache 7 | keys: 8 | - yarn-packages-{{ checksum "yarn.lock" }} 9 | - &save-cache 10 | save_cache: 11 | name: Save Yarn Package Cache 12 | key: yarn-packages-{{ checksum "yarn.lock" }} 13 | paths: 14 | - ~/.cache/yarn 15 | - &install-deps 16 | run: 17 | name: Install dependencies 18 | command: yarn install --frozen-lockfile 19 | - &build 20 | run: 21 | name: Build package 22 | command: NODE_ENV=production yarn build 23 | - &run-unit-tests 24 | run: 25 | name: Test 26 | command: yarn test:unit 27 | - &run-e2e-tests 28 | run: 29 | name: Test 30 | command: yarn test:e2e 31 | 32 | run-template: &run-template 33 | working_directory: ~/oauth2-server 34 | docker: 35 | - image: circleci/node:12 36 | 37 | jobs: 38 | build: 39 | <<: *run-template 40 | steps: 41 | - checkout 42 | - *restore-cache 43 | - *install-deps 44 | - *save-cache 45 | - *build 46 | 47 | unit_tests: 48 | <<: *run-template 49 | steps: 50 | - checkout 51 | - *restore-cache 52 | - *install-deps 53 | - *save-cache 54 | - *run-unit-tests 55 | 56 | integration_tests: 57 | working_directory: ~/oauth2-server 58 | docker: 59 | - image: circleci/node:12 60 | - image: circleci/postgres:9.6.2-alpine 61 | environment: 62 | POSTGRES_USER: postgres 63 | POSTGRES_PASSWORD: postgres 64 | POSTGRES_DB: oauth2-server 65 | steps: 66 | - checkout 67 | - *restore-cache 68 | - *install-deps 69 | - *save-cache 70 | - *run-e2e-tests 71 | 72 | workflows: 73 | version: 2 74 | build-and-test: 75 | jobs: 76 | - unit_tests 77 | - integration_tests 78 | - build: 79 | filters: 80 | branches: 81 | only: master 82 | requires: 83 | - unit_tests 84 | - integration_tests 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /var 5 | .env 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 NICOLAS MACHEREY 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!make 2 | .PHONY: help deploy clean 3 | 4 | .DEFAULT_GOAL := help 5 | 6 | # PROJECT_NAME 7 | NODE_ENV?=development 8 | PROJECT_NAME?=switchit-oauth2 9 | 10 | # INITIALIZATIONS 11 | DOCKER_COMPOSE_FILE?=./docker-stack.yml 12 | DOCKER_COMPOSE=docker stack deploy ${PROJECT_NAME} --compose-file=${DOCKER_COMPOSE_FILE} 13 | 14 | help: 15 | @clear 16 | @printf "\033[36m%-30s\033[0m %s\n" help Help 17 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | cut -d: -f2- | sort -t: -k 2,2 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 18 | 19 | # Project containers 20 | deploy: ## (Docker) Deploy the required stack 21 | @docker stack deploy ${PROJECT_NAME} --compose-file=${DOCKER_COMPOSE_FILE} 22 | 23 | clean: ## (Docker) Stops and removes the current stack 24 | @docker stack rm ${PROJECT_NAME} 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | SwitchIt - Conseil Logo 4 | 5 |

6 | 7 |

We build your next generation software !

8 | 9 | # OAuth2 Server Module [![CircleCI](https://circleci.com/gh/switchit-conseil/nestjs-oauth2-server-module.svg?style=svg)](https://circleci.com/gh/switchit-conseil/nestjs-oauth2-server-module) 10 | 11 | This module turns your [NestJS Application](https://nestjs.com) into an OAuth2 Server. It is based on 12 | 13 | * [TypeORM](https://typeorm.io): For entities and database model 14 | * [NestJS CQRS Module](https://github.com/nestjs/cqrs): For commands and queries 15 | * [Passport](http://www.passportjs.org/): For securing routes 16 | 17 | ## Installation 18 | 19 | ```bash 20 | npm install --save @switchit/nestjs-oauth2-server # or yarn install @switchit/nestjs-oauth2-server 21 | ``` 22 | 23 | ## Usage 24 | 25 | ### Implement the `UserValidatorInterface` 26 | 27 | The `UserValidatorInterface` lets you validate a user using the `PasswordGrantStrategy`. It is queried in your application 28 | to validate that the given `username` and `password` matches with a user in your application's DB. 29 | 30 | **IMPORTANT**: When the user is not found your have to throw an `InvalidUserException`. 31 | 32 | ```typescript 33 | import {Injectable} from "@nestjs/common"; 34 | import {UserValidatorInterface, UserInterface, InvalidUserException} from "@switchit/nestjs-oauth2-server"; 35 | 36 | @Injectable() 37 | export class UserValidator implements UserValidatorInterface { 38 | async validate(username, password): Promise { 39 | // check if the user exists with the given username and password 40 | // ... 41 | // or 42 | throw InvalidUserException.withUsernameAndPassword(username, password); 43 | } 44 | } 45 | ``` 46 | 47 | The validate method should return an instance of the `UserInterface`: 48 | 49 | ```typescript 50 | export interface UserInterface { 51 | id: string; 52 | username: string; 53 | email: string; 54 | } 55 | ``` 56 | 57 | The user interface is then accessible in the payload once connected ot the `AccessToken` is used. 58 | 59 | ### Implement the `UserLoaderInterface` 60 | 61 | The `UserLoaderInterface` lets you load a user from the database when the `AccessToken` is user specific. 62 | The user is then accessible in the request context of your application. 63 | 64 | **IMPORTANT**: When the user is not found your have to throw an `InvalidUserException`. 65 | 66 | ```typescript 67 | import {Injectable} from "@nestjs/common"; 68 | import {UserLoaderInterface, UserInterface, InvalidUserException} from "@switchit/nestjs-oauth2-server"; 69 | 70 | @Injectable() 71 | export class UserLoader implements UserLoaderInterface { 72 | async load(userId: string): Promise { 73 | // Load the user from the database 74 | // ... 75 | // or throw and 76 | throw InvalidUserException.withId(userId); 77 | } 78 | } 79 | ``` 80 | 81 | ### Implement the module in your application 82 | 83 | Import the OAuth2Module into the root AppModule. 84 | 85 | ```typescript 86 | //app.module.ts 87 | import { Module } from '@nestjs/common'; 88 | import { OAuth2Module } from '@switchit/nestjs-oauth2-server'; 89 | 90 | @Module({ 91 | imports: [ 92 | OAuth2Module.forRoot({ 93 | userLoader: new UserLoader(), 94 | userValidator: new UserValidator(), 95 | }), 96 | ], 97 | }) 98 | export class AppModule {} 99 | ``` 100 | 101 | Of course you can use an async configuration: 102 | 103 | ```typescript 104 | //app.module.ts 105 | import { Module } from '@nestjs/common'; 106 | import { OAuth2Module } from '@switchit/nestjs-oauth2-server'; 107 | 108 | @Module({ 109 | imports: [ 110 | OAuth2Module.forRootAsync({ 111 | useFactory: () => ({ 112 | userLoader: new UserLoader(), 113 | userValidator: new UserValidator(), 114 | }) 115 | }), 116 | ], 117 | }) 118 | export class AppModule {} 119 | ``` 120 | 121 | ## The OAuth2 `Controller` 122 | 123 | The modules is shipped with a specific controller that lets you expose the `oauth2/token` endpoint in your application 124 | and use the different implemented strategies accordingly to the request sent. 125 | 126 | ### Client Credentials 127 | 128 | Used for server-to-server communications. 129 | 130 | ```bash 131 | curl -X POST http://localhost:3000/oauth2/token -d '{"grant_type":"client_credentials", "client_id":"6ab1cfab-0b3d-418b-8ca2-94d98663fb6f", "client_secret": "6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu", "scopes": ["app-1"]}' 132 | ``` 133 | 134 | ### Refresh Token 135 | 136 | Used to refresh an existing token 137 | 138 | ```bash 139 | curl -X POST http://localhost:3000/oauth2/token -d '{"grant_type":"refresh_token", "client_id":"6ab1cfab-0b3d-418b-8ca2-94d98663fb6f", "client_secret": "6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu", "refresh_token": "6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb6fgqstyudhjqskdqsd"}' 140 | ``` 141 | 142 | ### Password 143 | 144 | Used to refresh an existing token 145 | 146 | ```bash 147 | curl -X POST http://localhost:3000/oauth2/token -d '{"grant_type":"password", "client_id":"6ab1cfab-0b3d-418b-8ca2-94d98663fb6f", "username": "j.doe@change.me", "password": "changeme", "scopes": ["app-1"]}' 148 | ``` 149 | 150 | ## Securing your routes using the `AccessTokenStrategy` 151 | 152 | The module comes with a `PassportJS` strategy of type `http-bearer`. You can secure your routes using the passport 153 | `AuthGuard` with the `access-token` strategy name: 154 | 155 | ```typescript 156 | @Controller('oauth2-secured') 157 | export class TestSecuredController { 158 | @Get('me') 159 | @UseGuards(AuthGuard('access-token')) 160 | async auth(): Promise { 161 | return {message: 'hello'}; 162 | } 163 | } 164 | ``` 165 | 166 | ## Adding the entities to your TypeORM configuration 167 | 168 | **IMPORTANT**: The module comes with entities you have to add the configuration `node_modules/@switchit/**/*.entity.js` 169 | to let typeorm scan your entities or add them to the `entitie` configuration variable in TypeORM. 170 | 171 | ## Using the global validation pipes 172 | 173 | **IMPORTANT**: In addition, you should enable the global validation pipe in your NestJS application. In your `main.ts` 174 | you should use the `useGlobaPipes` with the `ValidationPipe` and the `transform` options set to `true`: 175 | 176 | ```typescript 177 | async function bootstrap() { 178 | const app = await NestFactory.create(AppModule); 179 | useContainer(app.select(AppModule), {fallbackOnErrors: true}); 180 | app.useGlobalPipes(new ValidationPipe({ 181 | transform: true, 182 | })); 183 | 184 | await app.listen(3000); 185 | } 186 | bootstrap(); 187 | ``` -------------------------------------------------------------------------------- /docker-stack.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | ################################################ 4 | # SERVICES 5 | ################################################ 6 | services: 7 | postgres: 8 | image: postgres:11 9 | user: "root" 10 | environment: 11 | POSTGRES_DB: oauth2-server 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: postgres 14 | ports: 15 | - "5432:5432" 16 | networks: 17 | switchit: 18 | aliases: 19 | - postgres.switchit.local 20 | 21 | ################################################ 22 | # NETWORKS 23 | ################################################ 24 | networks: 25 | switchit: 26 | driver: overlay 27 | attachable: true 28 | -------------------------------------------------------------------------------- /lib/app/command/create-access-token.command.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create an access token 3 | */ 4 | import {OAuth2Request} from "../../ui/dto"; 5 | 6 | export class CreateAccessTokenCommand { 7 | constructor( 8 | public readonly clientId: string, 9 | public readonly scope: string, 10 | public readonly exp: number, 11 | public readonly iat: number, 12 | public readonly request: OAuth2Request, 13 | public readonly userId?: string, 14 | ) {} 15 | } 16 | -------------------------------------------------------------------------------- /lib/app/command/create-access-token.handler.ts: -------------------------------------------------------------------------------- 1 | import {CommandHandler, EventBus, ICommandHandler} from '@nestjs/cqrs'; 2 | import {Inject} from "@nestjs/common"; 3 | import {CreateAccessTokenCommand} from "./create-access-token.command"; 4 | import { 5 | ClientEntity, 6 | AccessTokenEntity, 7 | AccessTokenRepositoryInterface, 8 | ClientRepositoryInterface 9 | } from "../../domain"; 10 | import * as crypto from "crypto"; 11 | import {AccessTokenCreatedEvent} from "../event"; 12 | 13 | @CommandHandler(CreateAccessTokenCommand) 14 | export class CreateAccessTokenHandler implements ICommandHandler { 15 | constructor( 16 | @Inject('AccessTokenRepositoryInterface') 17 | private readonly accessTokenRepository: AccessTokenRepositoryInterface, 18 | @Inject('ClientRepositoryInterface') 19 | private readonly clientRepository: ClientRepositoryInterface, 20 | private readonly eventBus: EventBus 21 | ) { 22 | } 23 | 24 | /** 25 | * Execute the create AccessToken Command 26 | * 27 | * @param command 28 | */ 29 | async execute(command: CreateAccessTokenCommand) { 30 | const client: ClientEntity = await this.clientRepository.find(command.clientId); 31 | // @fixme: Shall we remove old tokens ? 32 | 33 | const accessToken = new AccessTokenEntity(); 34 | accessToken.client = client; 35 | accessToken.createdAt = new Date(); 36 | accessToken.createdFrom = command.request; 37 | accessToken.scope = command.scope; 38 | 39 | // generate access & refresh tokens 40 | const now = Date.now(); 41 | // Ensure we have an expiration 42 | const requestedExp = command.exp || (new Date(now + client.accessTokenLifetime*1000)).getTime()/1000; 43 | const exp = (now + client.accessTokenLifetime*1000 < requestedExp * 1000) ? 44 | (now + client.accessTokenLifetime*1000) : (requestedExp * 1000); 45 | 46 | accessToken.refreshTokenExpiresAt = new Date(now + client.refreshTokenLifetime*1000); 47 | accessToken.accessTokenExpiresAt = new Date(exp); 48 | accessToken.refreshToken = crypto.randomBytes(32).toString('hex'); 49 | accessToken.accessToken = crypto.randomBytes(32).toString('hex'); 50 | if (command.userId) { 51 | accessToken.userId = command.userId; 52 | } 53 | 54 | const token = await this.accessTokenRepository.create(accessToken); 55 | 56 | // emit an access token created event 57 | this.eventBus.publish(new AccessTokenCreatedEvent( 58 | token.id, 59 | command.clientId, 60 | token.accessToken, 61 | token.accessTokenExpiresAt, 62 | token.refreshToken, 63 | token.refreshTokenExpiresAt, 64 | token.scope, 65 | command.userId 66 | )); 67 | 68 | return token; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/app/command/create-client.command.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Command used to create clients 3 | */ 4 | export class CreateClientCommand { 5 | constructor( 6 | public readonly name: string, 7 | public readonly scope: string, 8 | public readonly clientId ?: string, 9 | public readonly grants ?: string[], 10 | public readonly noSecret ?: boolean, 11 | public readonly accessTokenLifetime?: number, 12 | public readonly refreshTokenLifetime?: number, 13 | ) {} 14 | } 15 | -------------------------------------------------------------------------------- /lib/app/command/create-client.handler.ts: -------------------------------------------------------------------------------- 1 | import {CommandHandler, EventBus, ICommandHandler} from '@nestjs/cqrs'; 2 | import {Inject} from "@nestjs/common"; 3 | import {CreateClientCommand} from "./create-client.command"; 4 | import { 5 | ClientEntity, 6 | ClientRepositoryInterface, 7 | } from "../../domain"; 8 | import * as uuid from 'uuid/v4'; 9 | import * as crypto from "crypto"; 10 | import * as selfsigned from 'selfsigned'; 11 | import {ClientCreatedEvent} from "../event"; 12 | 13 | @CommandHandler(CreateClientCommand) 14 | export class CreateClientHandler implements ICommandHandler { 15 | constructor( 16 | @Inject('ClientRepositoryInterface') 17 | private readonly clientRepository: ClientRepositoryInterface, 18 | private readonly eventBus: EventBus 19 | ) { 20 | } 21 | 22 | /** 23 | * Execute the create Client Command 24 | * 25 | * @param command 26 | */ 27 | async execute(command: CreateClientCommand) { 28 | const client = new ClientEntity(); 29 | client.name = command.name; 30 | client.clientId = command.clientId || uuid(); 31 | if (!command.noSecret) { 32 | client.clientSecret = crypto.randomBytes(32).toString('hex'); 33 | } 34 | 35 | client.scope = command.scope; 36 | client.accessTokenLifetime = command.accessTokenLifetime || 3600; 37 | client.refreshTokenLifetime = command.refreshTokenLifetime || 7200; 38 | client.grants = command.grants || ['client_credentials', 'refresh_token']; 39 | 40 | // generate keys 41 | const attrs = [{ name: 'commonName', value: command.name }]; 42 | const pem = selfsigned.generate(attrs, { days: 365 }); 43 | 44 | client.privateKey = pem.private; 45 | client.publicKey = pem.public; 46 | client.cert = pem.cert; 47 | 48 | var exp = new Date(); 49 | exp.setDate(exp.getDate() + 365); 50 | client.certExpiresAt = exp; 51 | 52 | const createdClient = await this.clientRepository.create(client); 53 | 54 | // emit an access token created event 55 | this.eventBus.publish(new ClientCreatedEvent( 56 | createdClient.id, 57 | createdClient.name, 58 | createdClient.clientId, 59 | createdClient.certExpiresAt 60 | )); 61 | 62 | return createdClient; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/app/command/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-access-token.command'; 2 | export * from './create-access-token.handler'; 3 | export * from './create-client.command'; 4 | export * from './create-client.handler'; -------------------------------------------------------------------------------- /lib/app/event/access-token-created.event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event generated when an access token is generated 3 | */ 4 | export class AccessTokenCreatedEvent { 5 | constructor( 6 | public readonly id: string, 7 | public readonly clientId: string, 8 | public readonly accessToken: string, 9 | public readonly accessTokenExpiresAt: Date, 10 | public readonly refreshToken: string, 11 | public readonly refreshTokenExpiresAt: Date, 12 | public readonly scope: string, 13 | public readonly userId?: string, 14 | ) {} 15 | } -------------------------------------------------------------------------------- /lib/app/event/client-created.event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event published when a client is created 3 | */ 4 | export class ClientCreatedEvent { 5 | constructor( 6 | public readonly id: string, 7 | public readonly name: string, 8 | public readonly clientId: string, 9 | public readonly certExpiresAt: Date 10 | ) {} 11 | } -------------------------------------------------------------------------------- /lib/app/event/index.ts: -------------------------------------------------------------------------------- 1 | export * from './access-token-created.event'; 2 | export * from './client-created.event'; 3 | -------------------------------------------------------------------------------- /lib/app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command'; 2 | export * from './event'; 3 | export * from './interfaces'; 4 | export * from './oauth2.module'; 5 | export * from './oauth2-core.module'; 6 | export * from './oauth2.constants'; 7 | -------------------------------------------------------------------------------- /lib/app/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './oauth2-options.type'; 2 | export * from './oauth2-options-factory.interface'; 3 | export * from './oauth2-async-options.interface'; 4 | -------------------------------------------------------------------------------- /lib/app/interfaces/oauth2-async-options.interface.ts: -------------------------------------------------------------------------------- 1 | import {Type} from '@nestjs/common'; 2 | import {ModuleMetadata} from '@nestjs/common/interfaces'; 3 | import {Oauth2OptionsFactoryInterface} from "./oauth2-options-factory.interface"; 4 | import {OAuth2Options} from "./oauth2-options.type"; 5 | 6 | export interface Oauth2AsyncOptionsInterface extends Pick { 7 | useExisting?: Type; 8 | useClass?: Type; 9 | useFactory?: (...args: any[]) => Promise | OAuth2Options; 10 | inject?: any[]; 11 | } 12 | -------------------------------------------------------------------------------- /lib/app/interfaces/oauth2-options-factory.interface.ts: -------------------------------------------------------------------------------- 1 | import {OAuth2Options} from "./oauth2-options.type"; 2 | 3 | export interface Oauth2OptionsFactoryInterface { 4 | createOauth2Options(): Promise | OAuth2Options; 5 | } 6 | -------------------------------------------------------------------------------- /lib/app/interfaces/oauth2-options.type.ts: -------------------------------------------------------------------------------- 1 | import {UserLoaderInterface, UserValidatorInterface} from "../../domain/interface"; 2 | 3 | export type OAuth2Options = { 4 | userLoader: UserLoaderInterface, 5 | userValidator: UserValidatorInterface, 6 | }; 7 | -------------------------------------------------------------------------------- /lib/app/oauth2-core.module.ts: -------------------------------------------------------------------------------- 1 | import {DynamicModule, Global, Inject, Module, OnModuleInit, Provider} from "@nestjs/common"; 2 | import {AccessTokenEntity, ClientEntity, Oauth2GrantStrategyRegistry, StrategyExplorer} from "../domain"; 3 | import {Oauth2AsyncOptionsInterface, OAuth2Options, Oauth2OptionsFactoryInterface} from "./interfaces"; 4 | import {OAUTH2_SERVER_OPTIONS} from "./oauth2.constants"; 5 | import {CreateAccessTokenHandler, CreateClientHandler} from "./command"; 6 | import {AccessTokenRepository, ClientRepository} from "../infrastructure/typeorm"; 7 | import { 8 | ClientCredentialsStrategy, 9 | PasswordStrategy, 10 | RefreshTokenStrategy 11 | } from "../infrastructure/oauth2-grant-strategy"; 12 | import {AccessTokenStrategy} from "../infrastructure/strategy"; 13 | import {Oauth2Controller} from "../ui/controller"; 14 | import {TypeOrmModule} from "@nestjs/typeorm"; 15 | import {CqrsModule} from "@nestjs/cqrs"; 16 | 17 | 18 | export const CommandHandlers = [ 19 | CreateClientHandler, 20 | CreateAccessTokenHandler, 21 | ]; 22 | 23 | export const EventHandlers = []; 24 | 25 | export const QueryHandlers = []; 26 | 27 | export const Sagas = []; 28 | 29 | export const Services = [ 30 | {provide: 'ClientRepositoryInterface', useClass: ClientRepository}, 31 | {provide: 'AccessTokenRepositoryInterface', useClass: AccessTokenRepository}, 32 | ]; 33 | 34 | export const ServiceNames = [ 35 | 'ClientRepositoryInterface', 36 | 'AccessTokenRepositoryInterface', 37 | ]; 38 | 39 | export const Resolvers = []; 40 | 41 | export const Oauth2Strategies = [ 42 | ClientCredentialsStrategy, 43 | RefreshTokenStrategy, 44 | PasswordStrategy, 45 | ]; 46 | 47 | export const Providers = [ 48 | StrategyExplorer, 49 | Oauth2GrantStrategyRegistry, 50 | ]; 51 | 52 | @Global() 53 | @Module({}) 54 | export class Oauth2CoreModule implements OnModuleInit { 55 | constructor( 56 | @Inject(OAUTH2_SERVER_OPTIONS) 57 | private readonly options: OAuth2Options, 58 | private readonly explorerService: StrategyExplorer, 59 | private readonly strategyRegistry: Oauth2GrantStrategyRegistry, 60 | ) { 61 | } 62 | 63 | /** 64 | * Create the static for Root Options 65 | * 66 | * @param options 67 | */ 68 | public static forRoot(options: OAuth2Options): DynamicModule { 69 | 70 | const oAuth2OptionsProvider = { 71 | provide: OAUTH2_SERVER_OPTIONS, 72 | useValue: options, 73 | }; 74 | 75 | const userLoaderProvider = { 76 | provide: 'UserLoaderInterface', 77 | useFactory: async (options) => { 78 | return options.userLoader; 79 | }, 80 | inject: [OAUTH2_SERVER_OPTIONS], 81 | }; 82 | 83 | const userValidatorProvider = { 84 | provide: 'UserValidatorInterface', 85 | useFactory: async (options) => { 86 | return options.userValidator; 87 | }, 88 | inject: [OAUTH2_SERVER_OPTIONS], 89 | }; 90 | 91 | return { 92 | module: Oauth2CoreModule, 93 | imports: [ 94 | CqrsModule, 95 | TypeOrmModule.forFeature([ 96 | ClientEntity, 97 | AccessTokenEntity, 98 | ]), 99 | ], 100 | controllers: [ 101 | Oauth2Controller 102 | ], 103 | providers: [ 104 | oAuth2OptionsProvider, 105 | userValidatorProvider, 106 | userLoaderProvider, 107 | ...Providers, 108 | ...Services, 109 | ...Resolvers, 110 | ...Oauth2Strategies, 111 | ...CommandHandlers, 112 | ...EventHandlers, 113 | ...QueryHandlers, 114 | ...Sagas, 115 | AccessTokenStrategy, 116 | ], 117 | exports: [ 118 | ...Providers, 119 | ...ServiceNames, 120 | userValidatorProvider, 121 | userLoaderProvider 122 | ] 123 | }; 124 | } 125 | 126 | public static forRootAsync(options: Oauth2AsyncOptionsInterface): DynamicModule { 127 | const providers: Provider[] = this.createAsyncProviders(options); 128 | 129 | const userLoaderProvider = { 130 | provide: 'UserLoaderInterface', 131 | useFactory: async (options) => { 132 | return options.userLoader; 133 | }, 134 | inject: [OAUTH2_SERVER_OPTIONS], 135 | }; 136 | 137 | const userValidatorProvider = { 138 | provide: 'UserValidatorInterface', 139 | useFactory: async (options) => { 140 | return options.userValidator; 141 | }, 142 | inject: [OAUTH2_SERVER_OPTIONS], 143 | }; 144 | 145 | return { 146 | module: Oauth2CoreModule, 147 | imports: [ 148 | ...(options.imports || []), 149 | CqrsModule, 150 | TypeOrmModule.forFeature([ 151 | ClientEntity, 152 | AccessTokenEntity, 153 | ]), 154 | ], 155 | providers: [ 156 | ...providers, 157 | userValidatorProvider, 158 | userLoaderProvider, 159 | ...Providers, 160 | ...Services, 161 | ...Resolvers, 162 | ...Oauth2Strategies, 163 | ...CommandHandlers, 164 | ...EventHandlers, 165 | ...QueryHandlers, 166 | ...Sagas, 167 | AccessTokenStrategy, 168 | ], 169 | controllers: [ 170 | Oauth2Controller 171 | ], 172 | exports: [ 173 | ...Providers, 174 | ...ServiceNames, 175 | userValidatorProvider, 176 | userLoaderProvider 177 | ] 178 | }; 179 | } 180 | 181 | private static createAsyncProviders(options: Oauth2AsyncOptionsInterface): Provider[] { 182 | const providers: Provider[] = [ 183 | this.createAsyncOptionsProvider(options), 184 | ]; 185 | 186 | return providers; 187 | } 188 | 189 | private static createAsyncOptionsProvider(options: Oauth2AsyncOptionsInterface): Provider { 190 | if (options.useFactory) { 191 | return { 192 | provide: OAUTH2_SERVER_OPTIONS, 193 | useFactory: options.useFactory, 194 | inject: options.inject || [], 195 | }; 196 | } 197 | 198 | return { 199 | provide: OAUTH2_SERVER_OPTIONS, 200 | useFactory: async (optionsFactory: Oauth2OptionsFactoryInterface) => { 201 | return optionsFactory.createOauth2Options(); 202 | }, 203 | inject: [options.useExisting || options.useClass], 204 | }; 205 | } 206 | 207 | onModuleInit() { 208 | const {strategies} = this.explorerService.explore(); 209 | this.strategyRegistry.register(strategies); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/app/oauth2.constants.ts: -------------------------------------------------------------------------------- 1 | export const OAUTH2_SERVER_OPTIONS = 'OAUTH2_SERVER_OPTIONS'; -------------------------------------------------------------------------------- /lib/app/oauth2.module.ts: -------------------------------------------------------------------------------- 1 | import {DynamicModule, Module} from "@nestjs/common"; 2 | import {Oauth2CoreModule} from "./oauth2-core.module"; 3 | import {Oauth2AsyncOptionsInterface, OAuth2Options} from "./interfaces"; 4 | 5 | @Module({}) 6 | export class Oauth2Module { 7 | public static forRoot(options?: OAuth2Options): DynamicModule { 8 | return { 9 | module: Oauth2Module, 10 | imports: [ 11 | /** Modules **/ 12 | Oauth2CoreModule.forRoot(options), 13 | ], 14 | }; 15 | } 16 | 17 | public static forRootAsync(options: Oauth2AsyncOptionsInterface): DynamicModule { 18 | return { 19 | module: Oauth2Module, 20 | imports: [ 21 | /** Modules **/ 22 | Oauth2CoreModule.forRootAsync(options), 23 | ], 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/domain/access-token.entity.ts: -------------------------------------------------------------------------------- 1 | import {Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn} from "typeorm"; 2 | import {ClientEntity} from "./client.entity"; 3 | import {OAuth2Request} from "../ui"; 4 | 5 | @Entity('gb_oauth_access_token') 6 | export class AccessTokenEntity { 7 | @PrimaryGeneratedColumn('uuid') 8 | id: string; 9 | 10 | @Column({ 11 | name: 'access_token', 12 | primary: true, 13 | nullable: false, 14 | length: 80 15 | }) 16 | accessToken: string; 17 | 18 | @Column({ 19 | name: 'refresh_token', 20 | unique: true, 21 | nullable: false, 22 | length: 80 23 | }) 24 | refreshToken: string; 25 | 26 | @Column('timestamp', {name: 'access_token_expires_at', nullable: false}) 27 | accessTokenExpiresAt: Date; 28 | 29 | @Column('timestamp', {name: 'refresh_token_expires_at', nullable: false}) 30 | refreshTokenExpiresAt: Date; 31 | 32 | @ManyToOne(type => ClientEntity, {nullable: false}) 33 | @JoinColumn({name: 'client_id', referencedColumnName: 'id'}) 34 | client: ClientEntity; 35 | 36 | @Column({nullable: true}) 37 | userId: string; 38 | 39 | /** 40 | * JSON List of api IDs granted with this token for the client 41 | */ 42 | @Column({nullable: true, length: 500}) 43 | scope: string; 44 | 45 | @Column('timestamp', {name: 'created_on', nullable: false, default: () => 'now()'}) 46 | createdAt: Date; 47 | 48 | @Column({name: 'created_from', type: 'jsonb', nullable: true}) 49 | createdFrom: OAuth2Request; 50 | } 51 | -------------------------------------------------------------------------------- /lib/domain/client.entity.ts: -------------------------------------------------------------------------------- 1 | import {Column, Entity, PrimaryGeneratedColumn, Unique} from "typeorm"; 2 | 3 | @Entity('gb_oauth_client') 4 | @Unique(['clientId']) 5 | export class ClientEntity { 6 | @PrimaryGeneratedColumn('uuid') 7 | id: string; 8 | 9 | @Column({type: 'text', nullable: false}) 10 | name: string; 11 | 12 | @Column({name: 'client_id', type: 'text', nullable: false}) 13 | clientId: string; 14 | 15 | @Column({name: 'client_secret', type: 'text', nullable: true}) 16 | clientSecret: string; 17 | 18 | @Column({ 19 | name: 'grants', 20 | type: 'simple-array', 21 | nullable: false, 22 | default: 'client_credentials,refresh_token' 23 | }) 24 | grants: string[]; 25 | 26 | /** 27 | * Client scope. The scope should contain the list of third party applications 28 | * the client has access to 29 | */ 30 | @Column({length: 500, nullable: false}) 31 | scope: string; 32 | 33 | @Column({name: 'access_token_lifetime', nullable: false, default: 3600}) 34 | accessTokenLifetime: number; 35 | 36 | @Column({name: 'refresh_token_lifetime', nullable: false, default: 7200}) 37 | refreshTokenLifetime: number; 38 | 39 | @Column({type: 'text', nullable: false}) 40 | privateKey: string; 41 | 42 | @Column({type: 'text', nullable: false}) 43 | publicKey: string; 44 | 45 | @Column({type: 'text', nullable: false}) 46 | cert: string; 47 | 48 | @Column({type: 'timestamp', name: 'cert_expires_at', nullable: false}) 49 | certExpiresAt: Date; 50 | 51 | @Column({type: 'timestamp', name: 'created_at', nullable: false, default: () => 'now()'}) 52 | createdAt: Date; 53 | 54 | @Column({type: 'timestamp', name: 'deleted_at', nullable: true}) 55 | deletedAt: Date; 56 | } 57 | -------------------------------------------------------------------------------- /lib/domain/exception/access-token-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import {NotFoundException, UnauthorizedException} from "@nestjs/common"; 2 | 3 | /** 4 | * Exception thrown when an access token was not found 5 | */ 6 | export class AccessTokenNotFoundException extends NotFoundException { 7 | 8 | /** 9 | * Kind message with id 10 | * 11 | * @param id 12 | */ 13 | static withId(id: string): AccessTokenNotFoundException { 14 | return new AccessTokenNotFoundException(`The access toekn with id "${id}" was not found`); 15 | } 16 | 17 | /** 18 | * Kind message with accessToken 19 | * 20 | * @param accessToken 21 | */ 22 | static withAccessToken(accessToken: string): UnauthorizedException { 23 | return new UnauthorizedException(`The access token with accessToken "${accessToken}" was not found`); 24 | } 25 | 26 | /** 27 | * Kind message with refreshToken 28 | * 29 | * @param refreshToken 30 | */ 31 | static withRefreshToken(refreshToken: string): UnauthorizedException { 32 | return new UnauthorizedException(`The refresh token with refreshToken "${refreshToken}" was not found`); 33 | } 34 | } -------------------------------------------------------------------------------- /lib/domain/exception/client-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import {InternalServerErrorException, NotFoundException, UnauthorizedException} from "@nestjs/common"; 2 | 3 | /** 4 | * Exception thrown when a client was not found 5 | */ 6 | export class ClientNotFoundException extends NotFoundException { 7 | 8 | /** 9 | * Kind message with id 10 | * 11 | * @param id 12 | */ 13 | static withId(id: string): ClientNotFoundException { 14 | return new ClientNotFoundException(`The client with id "${id}" was not found`); 15 | } 16 | 17 | /** 18 | * Kind message with client ID 19 | * 20 | * @param clientId 21 | */ 22 | static withClientId(clientId: string): UnauthorizedException { 23 | return new UnauthorizedException(`The client with clientId "${clientId}" was not found`); 24 | } 25 | 26 | /** 27 | * Kind message with client ID 28 | * 29 | * @param name 30 | */ 31 | static withName(name: string): InternalServerErrorException { 32 | return new UnauthorizedException(`The client with name "${name}" was not found`); 33 | } 34 | } -------------------------------------------------------------------------------- /lib/domain/exception/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-not-found.exception'; 2 | export * from './access-token-not-found.exception'; 3 | export * from './invalid-user.exception'; -------------------------------------------------------------------------------- /lib/domain/exception/invalid-user.exception.ts: -------------------------------------------------------------------------------- 1 | import {UnauthorizedException} from "@nestjs/common"; 2 | 3 | /** 4 | * Exception thrown when a user is invalid 5 | */ 6 | export class InvalidUserException extends UnauthorizedException { 7 | 8 | /** 9 | * Kind message with username and password 10 | * 11 | * @param username 12 | * @param password 13 | */ 14 | static withUsernameAndPassword(username: string, password: string): InvalidUserException { 15 | return new InvalidUserException(`The user with username "${username}" and password "${password}" was not found`); 16 | } 17 | 18 | /** 19 | * Kind message with id 20 | * 21 | * @param userId 22 | */ 23 | static withId(userId: string): InvalidUserException { 24 | return new InvalidUserException(`The user with id "${userId}" was not found`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client.entity'; 2 | export * from './access-token.entity'; 3 | export * from './repository'; 4 | export * from './exception'; 5 | export * from './strategy'; 6 | export * from './interface'; 7 | -------------------------------------------------------------------------------- /lib/domain/interface/client-payload.interface.ts: -------------------------------------------------------------------------------- 1 | import {Oauth2PayloadInterface, Oauth2PayloadType} from "./oauth2-payload.interface"; 2 | import {AccessTokenEntity} from "../access-token.entity"; 3 | 4 | /** 5 | * Represents a client's payload 6 | */ 7 | export class ClientPayload implements Oauth2PayloadInterface { 8 | // Store the current type of payload user or client 9 | // When the client is connected he should only be able to access his own resources 10 | readonly type: Oauth2PayloadType = Oauth2PayloadType.CLIENT; 11 | 12 | constructor( 13 | public readonly accessToken: AccessTokenEntity, 14 | public readonly id: string, 15 | public readonly clientId: string, 16 | public readonly name: string, 17 | ) {} 18 | } -------------------------------------------------------------------------------- /lib/domain/interface/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-payload.interface'; 2 | export * from './oauth2-payload.interface'; 3 | export * from './user-payload.interface'; 4 | export * from './user-loader.interface'; 5 | export * from './user-validator.interface'; 6 | export * from './user.interface'; 7 | -------------------------------------------------------------------------------- /lib/domain/interface/oauth2-payload.interface.ts: -------------------------------------------------------------------------------- 1 | import {AccessTokenEntity} from "../access-token.entity"; 2 | 3 | /** define payload types */ 4 | export enum Oauth2PayloadType { 5 | CLIENT ='client', 6 | USER = 'user', 7 | } 8 | 9 | /** 10 | * User payloads are used in the guard when the user still finish 11 | */ 12 | export interface Oauth2PayloadInterface { 13 | // Store the current type of payload user or client 14 | // When the client is connected he should only be able to access his own resources 15 | readonly type: Oauth2PayloadType; 16 | 17 | // This is the access token which is currently connected within the application 18 | readonly accessToken: AccessTokenEntity; 19 | 20 | // The ID is common to all 21 | readonly id: string; 22 | } 23 | -------------------------------------------------------------------------------- /lib/domain/interface/user-loader.interface.ts: -------------------------------------------------------------------------------- 1 | import {UserInterface} from "./user.interface"; 2 | 3 | /** 4 | * This is the main interface you have to implement in order to have the appropriate 5 | */ 6 | export interface UserLoaderInterface { 7 | /** 8 | * Implement this interface to load your user into the payload from its id 9 | * 10 | * @param userId 11 | */ 12 | load(userId: string): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /lib/domain/interface/user-payload.interface.ts: -------------------------------------------------------------------------------- 1 | import {AccessTokenEntity} from "../access-token.entity"; 2 | import {Oauth2PayloadInterface, Oauth2PayloadType} from "./oauth2-payload.interface"; 3 | 4 | /** 5 | * Represents a UserPayload 6 | */ 7 | export class UserPayload implements Oauth2PayloadInterface { 8 | /** 9 | * The current payload type should not be changed as it should always be user in this case 10 | */ 11 | readonly type: Oauth2PayloadType = Oauth2PayloadType.USER; 12 | 13 | constructor( 14 | public readonly accessToken: AccessTokenEntity, 15 | public readonly id: string, 16 | public readonly username: string, 17 | public readonly email: string, 18 | ) {} 19 | } 20 | -------------------------------------------------------------------------------- /lib/domain/interface/user-validator.interface.ts: -------------------------------------------------------------------------------- 1 | import {UserInterface} from "./user.interface"; 2 | 3 | /** 4 | * Validates that the usernanme exists and has the given password 5 | */ 6 | export interface UserValidatorInterface { 7 | /** 8 | * Implement this method to validate the user existence 9 | * 10 | * @param username 11 | * @param password 12 | * 13 | * @return UserInterface 14 | * @throws InvalidUserException 15 | */ 16 | validate(username, password): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /lib/domain/interface/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UserInterface { 2 | id: string; 3 | username: string; 4 | email: string; 5 | } 6 | -------------------------------------------------------------------------------- /lib/domain/repository/access-token-repository.interface.ts: -------------------------------------------------------------------------------- 1 | import {AccessTokenEntity} from "../access-token.entity"; 2 | 3 | /** 4 | * Main interface you have to implement if you want to deal with access tokens in your 5 | * Application 6 | */ 7 | export interface AccessTokenRepositoryInterface { 8 | /** 9 | * Find by access token 10 | * 11 | * @param accessToken 12 | * 13 | * @throws AccessTokenNotFoundException 14 | */ 15 | findByAccessToken(accessToken: string): Promise; 16 | 17 | /** 18 | * Find by access token 19 | * 20 | * @param refreshToken 21 | * 22 | * @throws AccessTokenNotFoundException 23 | */ 24 | findByRefreshToken(refreshToken: string): Promise; 25 | 26 | /** 27 | * Register a new access token into the storage 28 | * 29 | * @param accessToken 30 | */ 31 | create(accessToken: AccessTokenEntity): Promise; 32 | } 33 | -------------------------------------------------------------------------------- /lib/domain/repository/client-repository.interface.ts: -------------------------------------------------------------------------------- 1 | import {ClientEntity} from "../client.entity"; 2 | 3 | /** 4 | * This is the main repository you have to implement if you want to 5 | * store clients in the database 6 | */ 7 | export interface ClientRepositoryInterface { 8 | /** 9 | * Find the client with the given ID 10 | * 11 | * @param id 12 | * 13 | * @throws ClientNotFoundException 14 | */ 15 | find(id: string): Promise; 16 | 17 | /** 18 | * Finds a client using its clientId 19 | * 20 | * @param clientId 21 | * 22 | * @throws ClientNotFoundException 23 | */ 24 | findByClientId(clientId: string): Promise; 25 | 26 | /** 27 | * Finds a client using its name 28 | * 29 | * @param name 30 | * 31 | * @throws ClientNotFoundException 32 | */ 33 | findByName(name: string): Promise; 34 | 35 | /** 36 | * Create a new oAuth2 client 37 | * 38 | * @param client 39 | */ 40 | create(client: ClientEntity): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /lib/domain/repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-repository.interface'; 2 | export * from './access-token-repository.interface'; -------------------------------------------------------------------------------- /lib/domain/strategy/decorator/oauth2-grant-strategy.decorator.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { OAUTH2_STRATEGY_METADATA } from '../strategy.explorer'; 3 | 4 | export const Oauth2GrantStrategy = (name: string): ClassDecorator => { 5 | return (target: object) => { 6 | Reflect.defineMetadata(OAUTH2_STRATEGY_METADATA, name, target); 7 | }; 8 | }; -------------------------------------------------------------------------------- /lib/domain/strategy/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorator/oauth2-grant-strategy.decorator'; 2 | export * from './oauth2-grant-strategy.interface'; 3 | export * from './strategy.explorer'; 4 | export * from './strategy.registry'; -------------------------------------------------------------------------------- /lib/domain/strategy/oauth2-grant-strategy.interface.ts: -------------------------------------------------------------------------------- 1 | import {OAuth2Request, OAuth2Response} from "../../ui/dto"; 2 | import {ClientEntity} from "../client.entity"; 3 | 4 | /** 5 | * Implement this interface to provide an oauth2 grant type handler. Handlers must be registered using the 6 | * decorator @Oauth2Strategy('grant_type') 7 | */ 8 | export interface Oauth2GrantStrategyInterface { 9 | /** 10 | * Validate the request return false if the request is not valid within the context of this strategy 11 | * 12 | * @param request 13 | * @param client 14 | */ 15 | validate(request: OAuth2Request, client: ClientEntity): Promise; 16 | 17 | /** 18 | * Get a request from the given response 19 | * 20 | * @param request 21 | * @param client 22 | */ 23 | getOauth2Response(request: OAuth2Request, client: ClientEntity): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /lib/domain/strategy/strategy.explorer.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Type} from '@nestjs/common'; 2 | import {InstanceWrapper} from '@nestjs/core/injector/instance-wrapper'; 3 | import {Module} from '@nestjs/core/injector/module'; 4 | import {ModulesContainer} from '@nestjs/core/injector/modules-container'; 5 | import {Oauth2GrantStrategyInterface} from "./oauth2-grant-strategy.interface"; 6 | 7 | export const OAUTH2_STRATEGY_METADATA = '__oauth2GrantStrategy__'; 8 | 9 | export interface Oauth2StrategyOptions { 10 | strategies: Type[]; 11 | } 12 | 13 | @Injectable() 14 | export class StrategyExplorer { 15 | constructor(private readonly modulesContainer: ModulesContainer) { 16 | } 17 | 18 | explore(): Oauth2StrategyOptions { 19 | const modules = [...this.modulesContainer.values()]; 20 | const strategies = this.flatMap(modules, instance => 21 | this.filterProvider(instance, OAUTH2_STRATEGY_METADATA), 22 | ); 23 | 24 | return {strategies}; 25 | } 26 | 27 | flatMap( 28 | modules: Module[], 29 | callback: (instance: InstanceWrapper) => Type | undefined, 30 | ): Type[] { 31 | const items = modules 32 | .map(module => [...module.providers.values()].map(callback)) 33 | .reduce((a, b) => a.concat(b), []); 34 | return items.filter(element => !!element) as Type[]; 35 | } 36 | 37 | filterProvider( 38 | wrapper: InstanceWrapper, 39 | metadataKey: string, 40 | ): Type | undefined { 41 | const {instance} = wrapper; 42 | if (!instance) { 43 | return undefined; 44 | } 45 | return this.extractMetadata(instance, metadataKey); 46 | } 47 | 48 | extractMetadata(instance: Object, metadataKey: string): Type { 49 | if (!instance.constructor) { 50 | return; 51 | } 52 | const metadata = Reflect.getMetadata(metadataKey, instance.constructor); 53 | return metadata ? (instance.constructor as Type) : undefined; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/domain/strategy/strategy.registry.ts: -------------------------------------------------------------------------------- 1 | import {Oauth2GrantStrategyInterface} from "./oauth2-grant-strategy.interface"; 2 | import {OAuth2Request, OAuth2Response} from "../../ui/dto"; 3 | import {HttpException, Injectable, Type} from "@nestjs/common"; 4 | import {ModuleRef} from "@nestjs/core"; 5 | import {OAUTH2_STRATEGY_METADATA} from "./strategy.explorer"; 6 | import {ClientEntity} from "../client.entity"; 7 | 8 | export type Oauth2GrantStrategyType = Type; 9 | 10 | /** 11 | * This is the main class used to execute strategies 12 | */ 13 | @Injectable() 14 | export class Oauth2GrantStrategyRegistry { 15 | /** 16 | * Store all available granted strategy 17 | */ 18 | private registry: { [s: string]: Oauth2GrantStrategyInterface } = {}; 19 | 20 | constructor( 21 | private readonly moduleRef: ModuleRef, 22 | ) {} 23 | 24 | /** 25 | * Register a single strategy 26 | * 27 | * @param strategy 28 | */ 29 | protected registerStrategy(strategy: Oauth2GrantStrategyType): void { 30 | const instance = this.moduleRef.get(strategy, {strict: false}); 31 | if (!instance) { 32 | return; 33 | } 34 | 35 | const strategyName = this.reflectStrategyName(strategy); 36 | this.registry[strategyName] = instance as Oauth2GrantStrategyInterface; 37 | } 38 | 39 | /** 40 | * Register all strategies with the decorator 41 | * 42 | * @param strategies 43 | */ 44 | register(strategies: Oauth2GrantStrategyType[] = []) { 45 | strategies.forEach(strategy => this.registerStrategy(strategy)); 46 | } 47 | 48 | /** 49 | * Validate the client associated to the given request 50 | * 51 | * @param request 52 | * @param client 53 | */ 54 | async validate(request: OAuth2Request, client: ClientEntity): Promise { 55 | if (!(request.grantType in this.registry)) { 56 | throw new HttpException(`Cannot find the a strategy for the grant type "${request.grantType}"`, 400); 57 | } 58 | 59 | return await this.registry[request.grantType].validate(request, client); 60 | } 61 | 62 | /** 63 | * Get the response 64 | * 65 | * @param request 66 | * @param client 67 | */ 68 | async getOauth2Response(request: OAuth2Request, client: ClientEntity): Promise { 69 | if (!(request.grantType in this.registry)) { 70 | throw new HttpException(`Cannot find the a strategy for the grant type "${request.grantType}"`, 400); 71 | } 72 | 73 | return await this.registry[request.grantType].getOauth2Response(request, client); 74 | } 75 | 76 | private reflectStrategyName(strategy: Oauth2GrantStrategyType): string { 77 | return Reflect.getMetadata(OAUTH2_STRATEGY_METADATA, strategy); 78 | } 79 | } -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | export * from './domain'; 3 | export * from './infrastructure'; 4 | export * from './ui'; 5 | -------------------------------------------------------------------------------- /lib/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './typeorm'; 2 | export * from './strategy'; 3 | export * from './oauth2-grant-strategy'; -------------------------------------------------------------------------------- /lib/infrastructure/oauth2-grant-strategy/client-credentials.strategy.ts: -------------------------------------------------------------------------------- 1 | import {Oauth2GrantStrategy, Oauth2GrantStrategyInterface} from "../../domain/strategy"; 2 | import {OAuth2Request, OAuth2Response} from "../../ui/dto"; 3 | import {Inject} from "@nestjs/common"; 4 | import {AccessTokenEntity, ClientEntity, ClientRepositoryInterface} from "../../domain"; 5 | import {CreateAccessTokenCommand} from "../../app/command"; 6 | import {CommandBus} from "@nestjs/cqrs"; 7 | 8 | @Oauth2GrantStrategy('client_credentials') 9 | export class ClientCredentialsStrategy implements Oauth2GrantStrategyInterface { 10 | 11 | /** 12 | * Constructor 13 | * 14 | * @param clientRepository 15 | * @param commandBus 16 | */ 17 | constructor( 18 | @Inject('ClientRepositoryInterface') 19 | private readonly clientRepository: ClientRepositoryInterface, 20 | private readonly commandBus: CommandBus 21 | ) { 22 | } 23 | 24 | async validate(request: OAuth2Request, client: ClientEntity): Promise { 25 | if (client.clientSecret !== request.clientSecret || !request.clientSecret || client.deletedAt !== null || !client.grants.includes(request.grantType)) { 26 | return false; 27 | } 28 | 29 | const scopes: string[] = JSON.parse(client.scope); 30 | const requestScopes = typeof request.scopes === 'string' ? [request.scopes] : request.scopes; 31 | return requestScopes.every((scope) => (scopes.includes(scope))); 32 | } 33 | 34 | async getOauth2Response(request: OAuth2Request, client: ClientEntity): Promise { 35 | const requestScopes = typeof request.scopes === 'string' ? [request.scopes] : request.scopes; 36 | const accessToken: AccessTokenEntity = await this.commandBus.execute(new CreateAccessTokenCommand( 37 | client.id, 38 | JSON.stringify(requestScopes), 39 | request.exp, 40 | request.iat, 41 | request 42 | )); 43 | 44 | return new OAuth2Response( 45 | accessToken.accessToken, 46 | accessToken.refreshToken, 47 | ~~((accessToken.accessTokenExpiresAt.getTime() - Date.now()) / 1000), 48 | ~~((accessToken.refreshTokenExpiresAt.getTime() - Date.now()) / 1000), 49 | ); 50 | } 51 | } -------------------------------------------------------------------------------- /lib/infrastructure/oauth2-grant-strategy/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-credentials.strategy'; 2 | export * from './refresh-token.strategy'; 3 | export * from './password.strategy'; 4 | -------------------------------------------------------------------------------- /lib/infrastructure/oauth2-grant-strategy/password.strategy.ts: -------------------------------------------------------------------------------- 1 | import {Oauth2GrantStrategy, Oauth2GrantStrategyInterface} from "../../domain/strategy"; 2 | import {OAuth2Request, OAuth2Response} from "../../ui/dto"; 3 | import {Inject} from "@nestjs/common"; 4 | import {AccessTokenEntity, ClientEntity, ClientRepositoryInterface, UserValidatorInterface} from "../../domain"; 5 | import {CreateAccessTokenCommand} from "../../app/command"; 6 | import {CommandBus} from "@nestjs/cqrs"; 7 | 8 | @Oauth2GrantStrategy('password') 9 | export class PasswordStrategy implements Oauth2GrantStrategyInterface { 10 | 11 | /** 12 | * Constructor 13 | * 14 | * @param clientRepository 15 | * @param userValidator 16 | * @param commandBus 17 | */ 18 | constructor( 19 | @Inject('ClientRepositoryInterface') 20 | private readonly clientRepository: ClientRepositoryInterface, 21 | @Inject('UserValidatorInterface') 22 | private readonly userValidator: UserValidatorInterface, 23 | private readonly commandBus: CommandBus 24 | ) { 25 | } 26 | 27 | async validate(request: OAuth2Request, client: ClientEntity): Promise { 28 | if ( 29 | (client.clientSecret && client.clientSecret !== request.clientSecret) || 30 | client.deletedAt !== null || 31 | !client.grants.includes(request.grantType) 32 | ) { 33 | return false; 34 | } 35 | 36 | return true; 37 | } 38 | 39 | async getOauth2Response(request: OAuth2Request, client: ClientEntity): Promise { 40 | const user = await this.userValidator.validate(request.username, request.password); 41 | const requestScopes = typeof request.scopes === 'string' ? [request.scopes] : request.scopes; 42 | const accessToken: AccessTokenEntity = await this.commandBus.execute(new CreateAccessTokenCommand( 43 | client.id, 44 | JSON.stringify(requestScopes), 45 | request.exp, 46 | request.iat, 47 | request, 48 | user.id 49 | )); 50 | 51 | return new OAuth2Response( 52 | accessToken.accessToken, 53 | accessToken.refreshToken, 54 | ~~((accessToken.accessTokenExpiresAt.getTime() - Date.now()) / 1000), 55 | ~~((accessToken.refreshTokenExpiresAt.getTime() - Date.now()) / 1000), 56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /lib/infrastructure/oauth2-grant-strategy/refresh-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import {Oauth2GrantStrategy, Oauth2GrantStrategyInterface} from "../../domain/strategy"; 2 | import {OAuth2Request, OAuth2Response} from "../../ui/dto"; 3 | import {Inject, UnauthorizedException} from "@nestjs/common"; 4 | import {AccessTokenEntity, AccessTokenRepositoryInterface, ClientEntity, ClientRepositoryInterface} from "../../domain"; 5 | import {CreateAccessTokenCommand} from "../../app/command"; 6 | import {CommandBus} from "@nestjs/cqrs"; 7 | 8 | @Oauth2GrantStrategy('refresh_token') 9 | export class RefreshTokenStrategy implements Oauth2GrantStrategyInterface { 10 | 11 | /** 12 | * Constructor 13 | * 14 | * @param clientRepository 15 | * @param accessTokenRepository 16 | * @param commandBus 17 | */ 18 | constructor( 19 | @Inject('ClientRepositoryInterface') 20 | private readonly clientRepository: ClientRepositoryInterface, 21 | @Inject('AccessTokenRepositoryInterface') 22 | private readonly accessTokenRepository: AccessTokenRepositoryInterface, 23 | private readonly commandBus: CommandBus, 24 | ) { 25 | } 26 | 27 | async validate(request: OAuth2Request, client: ClientEntity): Promise { 28 | if ( 29 | (client.clientSecret && client.clientSecret !== request.clientSecret) || 30 | client.deletedAt !== null || 31 | !client.grants.includes(request.grantType)) { 32 | return false; 33 | } 34 | 35 | return true; 36 | } 37 | 38 | async getOauth2Response(request: OAuth2Request, client: ClientEntity): Promise { 39 | const expiredToken = await this.accessTokenRepository.findByRefreshToken(request.refreshToken); 40 | if (expiredToken.refreshTokenExpiresAt < new Date(Date.now()) || expiredToken.client.clientId !== client.clientId) { 41 | throw new UnauthorizedException("You are not allowed to access the given resource"); 42 | } 43 | 44 | // Create a new AccessToken 45 | const exp = (Date.now() + expiredToken.client.accessTokenLifetime * 1000) / 1000; 46 | const iat = Date.now() / 1000; 47 | const accessToken: AccessTokenEntity = await this.commandBus.execute(new CreateAccessTokenCommand( 48 | expiredToken.client.id, 49 | expiredToken.scope, 50 | exp, 51 | iat, 52 | { 53 | clientId: expiredToken.client.clientId, 54 | clientSecret: expiredToken.client.clientSecret, 55 | exp, 56 | iat, 57 | scopes: JSON.parse(expiredToken.scope), 58 | } as OAuth2Request, 59 | (expiredToken.userId !== null) ? expiredToken.userId : undefined 60 | )); 61 | 62 | return new OAuth2Response( 63 | accessToken.accessToken, 64 | accessToken.refreshToken, 65 | ~~((accessToken.accessTokenExpiresAt.getTime() - Date.now()) / 1000), 66 | ~~((accessToken.refreshTokenExpiresAt.getTime() - Date.now()) / 1000), 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/infrastructure/strategy/access-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import {Strategy} from 'passport-http-bearer'; 2 | import {PassportStrategy} from '@nestjs/passport'; 3 | import {Inject, Injectable, UnauthorizedException} from '@nestjs/common'; 4 | import {AccessTokenRepositoryInterface} from "../../domain/repository"; 5 | import {ClientPayload, Oauth2PayloadInterface, UserPayload} from "../../domain/interface"; 6 | import {UserLoaderInterface} from "../../domain/interface/user-loader.interface"; 7 | 8 | @Injectable() 9 | export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') { 10 | constructor( 11 | @Inject('AccessTokenRepositoryInterface') 12 | private readonly accessTokenRepository: AccessTokenRepositoryInterface, 13 | @Inject('UserLoaderInterface') 14 | private readonly userLoader: UserLoaderInterface 15 | ) { 16 | super(); 17 | } 18 | 19 | /** 20 | * Validate the bearer (accessToken) using the HTTP Bearer Header strategy 21 | * 22 | * @param bearer 23 | */ 24 | async validate(bearer: string): Promise { 25 | const accessToken = await this.accessTokenRepository.findByAccessToken(bearer); 26 | if (!accessToken || accessToken.accessTokenExpiresAt < new Date(Date.now())) { 27 | throw new UnauthorizedException(); 28 | } 29 | 30 | if (accessToken.userId) { 31 | const user = await this.userLoader.load(accessToken.userId); 32 | return new UserPayload( 33 | accessToken, 34 | accessToken.userId, 35 | user.username, 36 | user.email); 37 | } 38 | 39 | return new ClientPayload( 40 | accessToken, 41 | accessToken.client.id, 42 | accessToken.client.clientId, 43 | accessToken.client.name); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/infrastructure/strategy/index.ts: -------------------------------------------------------------------------------- 1 | export * from './access-token.strategy'; -------------------------------------------------------------------------------- /lib/infrastructure/typeorm/access-token.repository.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@nestjs/common'; 2 | import {InjectRepository} from '@nestjs/typeorm'; 3 | import { 4 | AccessTokenRepositoryInterface, 5 | AccessTokenEntity, 6 | AccessTokenNotFoundException 7 | } from "../../domain"; 8 | import {DeleteResult, Repository} from "typeorm"; 9 | 10 | @Injectable() 11 | export class AccessTokenRepository implements AccessTokenRepositoryInterface { 12 | 13 | constructor( 14 | @InjectRepository(AccessTokenEntity) 15 | private readonly repository: Repository 16 | ){} 17 | 18 | async findByAccessToken(accessToken: string): Promise { 19 | const token = await this.repository.findOne({ 20 | where: { 21 | accessToken: accessToken 22 | }, 23 | relations: ['client'] 24 | }); 25 | 26 | if (!token) { 27 | throw AccessTokenNotFoundException.withAccessToken(accessToken); 28 | } 29 | 30 | return token; 31 | } 32 | 33 | async findByRefreshToken(refreshToken: string): Promise { 34 | const token = await this.repository.findOne({ 35 | where: { 36 | refreshToken: refreshToken 37 | }, 38 | relations: ['client'] 39 | }); 40 | 41 | if (!token) { 42 | throw AccessTokenNotFoundException.withRefreshToken(refreshToken); 43 | } 44 | 45 | return token; 46 | } 47 | 48 | async create(accessToken: AccessTokenEntity): Promise { 49 | return await this.repository.save(accessToken); 50 | } 51 | 52 | async delete(accessToken: AccessTokenEntity): Promise { 53 | return await this.repository.delete(accessToken.id); 54 | } 55 | 56 | async deleteById(id: string): Promise { 57 | return await this.repository.delete(id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/infrastructure/typeorm/client.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientRepositoryInterface, 3 | ClientEntity, ClientNotFoundException 4 | } from "../../domain"; 5 | import {Repository} from "typeorm"; 6 | import {Injectable} from '@nestjs/common'; 7 | import {InjectRepository} from '@nestjs/typeorm'; 8 | 9 | @Injectable() 10 | export class ClientRepository implements ClientRepositoryInterface { 11 | constructor( 12 | @InjectRepository(ClientEntity) 13 | private readonly repository: Repository 14 | ) {} 15 | 16 | async find(id: string): Promise { 17 | const client = await this.repository.findOne(id); 18 | 19 | if (!client) { 20 | throw ClientNotFoundException.withId(id); 21 | } 22 | 23 | return client; 24 | } 25 | 26 | async findByClientId(clientId: string): Promise { 27 | const client = await this.repository.findOne({ 28 | where: { 29 | clientId: clientId 30 | } 31 | }); 32 | 33 | if (!client) { 34 | throw ClientNotFoundException.withClientId(clientId); 35 | } 36 | 37 | return client; 38 | } 39 | 40 | async findByName(name: string): Promise { 41 | const client = await this.repository.findOne({ 42 | where: { 43 | name: name 44 | } 45 | }); 46 | 47 | if (!client) { 48 | throw ClientNotFoundException.withName(name); 49 | } 50 | 51 | return client; 52 | } 53 | 54 | async create(client: ClientEntity): Promise { 55 | return await this.repository.save(client); 56 | } 57 | 58 | async delete(client: ClientEntity): Promise { 59 | client.deletedAt = new Date(); 60 | 61 | return await this.repository.save(client); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/infrastructure/typeorm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client.repository'; 2 | export * from './access-token.repository'; 3 | -------------------------------------------------------------------------------- /lib/ui/controller/index.ts: -------------------------------------------------------------------------------- 1 | export * from './oauth2.controller'; -------------------------------------------------------------------------------- /lib/ui/controller/oauth2.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassSerializerInterceptor, 3 | Controller, 4 | ForbiddenException, 5 | Inject, 6 | Post, 7 | Query, 8 | UseInterceptors, 9 | } from "@nestjs/common"; 10 | import { 11 | OAuth2Request, 12 | OAuth2Response 13 | } from "../dto"; 14 | 15 | import {Oauth2GrantStrategyRegistry} from "../../domain/strategy"; 16 | import {ClientRepositoryInterface} from "../../domain/repository"; 17 | 18 | @Controller('oauth2') 19 | @UseInterceptors(ClassSerializerInterceptor) 20 | export class Oauth2Controller { 21 | 22 | /** 23 | * Constructor 24 | * 25 | * @param clientRepository 26 | * @param strategyRegistry 27 | */ 28 | constructor( 29 | @Inject('ClientRepositoryInterface') 30 | private readonly clientRepository: ClientRepositoryInterface, 31 | private readonly strategyRegistry: Oauth2GrantStrategyRegistry 32 | ) { 33 | } 34 | 35 | @Post('token') 36 | async token(@Query() request: OAuth2Request): Promise { 37 | const client = await this.clientRepository.findByClientId(request.clientId); 38 | if (!await this.strategyRegistry.validate(request,client)) { 39 | throw new ForbiddenException("You are not allowed to access the given resource"); 40 | } 41 | 42 | return await this.strategyRegistry.getOauth2Response(request, client); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/ui/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './oauth2-request.dto'; 2 | export * from './oauth2-response.dto'; 3 | -------------------------------------------------------------------------------- /lib/ui/dto/oauth2-request.dto.ts: -------------------------------------------------------------------------------- 1 | import {ApiModelProperty} from "@nestjs/swagger"; 2 | import {IsNotEmpty} from "class-validator"; 3 | import {Expose} from "class-transformer"; 4 | 5 | /** 6 | * Main object used to transport data 7 | */ 8 | export class OAuth2Request { 9 | @ApiModelProperty({ 10 | type:String, 11 | description: 'The type of grant you are requesting, must be "client_credentials"', 12 | required: true 13 | }) 14 | @IsNotEmpty() 15 | @Expose({ name: "grant_type" }) 16 | grantType: string; 17 | 18 | @ApiModelProperty({ 19 | type:String, 20 | description: 'The API Key given by the application', 21 | required: true 22 | }) 23 | @IsNotEmpty() 24 | @Expose({ name: "client_id" }) 25 | clientId: string; 26 | 27 | @ApiModelProperty({ 28 | type:String, 29 | description: 'The API Token given by the application', 30 | required: true 31 | }) 32 | @Expose({ name: "client_secret" }) 33 | clientSecret: string; 34 | 35 | @ApiModelProperty({ 36 | type: Number, 37 | description: 'The expiration time of the assertion, specified as seconds since 00:00:00 UTC, January 1, 1970. This value has a maximum of 1 hour after the issued time.', 38 | }) 39 | @Expose({ name: "exp" }) 40 | exp?: number; 41 | 42 | @ApiModelProperty({ 43 | type: Number, 44 | description: 'The time the assertion was issued, specified as seconds since 00:00:00 UTC, January 1, 1970.', 45 | }) 46 | @Expose({ name: "iat" }) 47 | iat?: number; 48 | 49 | @ApiModelProperty({ 50 | type: String, 51 | description: 'The list of the permissions (tpApps) that the application requests.', 52 | isArray: true 53 | }) 54 | @Expose({ name: "scopes"}) 55 | scopes?: string | string[]; 56 | 57 | @ApiModelProperty({ 58 | type: String, 59 | description: 'The refresh token only when grant_type is set to "refresh_token"', 60 | }) 61 | @Expose({ name: "refresh_token"}) 62 | refreshToken?: string; 63 | 64 | @ApiModelProperty({ 65 | type: String, 66 | description: 'The username only when grant_type is set to "refresh_token"', 67 | }) 68 | @Expose({ name: "username"}) 69 | username?: string; 70 | 71 | @ApiModelProperty({ 72 | type: String, 73 | description: 'The password when grant_type is set to "password_grant"', 74 | }) 75 | @Expose({ name: "password"}) 76 | password?: string; 77 | } 78 | -------------------------------------------------------------------------------- /lib/ui/dto/oauth2-response.dto.ts: -------------------------------------------------------------------------------- 1 | import {ApiModelProperty} from "@nestjs/swagger"; 2 | import {Exclude, Expose} from "class-transformer"; 3 | 4 | /** 5 | * Main object used to transport data 6 | */ 7 | export class OAuth2Response { 8 | @ApiModelProperty({ 9 | type:String, 10 | description: 'The generated access token', 11 | required: true 12 | }) 13 | @Expose({name: 'access_token'}) 14 | accessToken: string; 15 | 16 | @ApiModelProperty({ 17 | type:String, 18 | description: 'The type of token, in our case should always be "bearer"', 19 | required: true 20 | }) 21 | @Expose({name: 'token_type'}) 22 | tokenType: string = 'bearer'; 23 | 24 | @ApiModelProperty({ 25 | type: String, 26 | description: 'The generated refresh token', 27 | required: true 28 | }) 29 | @Expose({name: 'refresh_token'}) 30 | refreshToken: string; 31 | 32 | @ApiModelProperty({ 33 | type: Number, 34 | description: 'Number of seconds until the acess token expires', 35 | required: true 36 | }) 37 | @Expose({name: 'expires_in'}) 38 | accessTokenExp: number; 39 | 40 | @ApiModelProperty({ 41 | type: Number, 42 | description: 'The list of the permissions (tpApps) that the application requests.', 43 | required: true 44 | }) 45 | @Exclude() 46 | refreshTokenExp: number; 47 | 48 | @ApiModelProperty({ 49 | type: String, 50 | description: 'Scopes you are allowed to use if any requested', 51 | required: true 52 | }) 53 | @Expose({name: 'scope'}) 54 | scope?: string; 55 | 56 | /** 57 | * Main method used to build this object 58 | * 59 | * @param accessToken 60 | * @param refreshToken 61 | * @param accessTokenExp 62 | * @param refreshTokenExp 63 | * @param scope 64 | */ 65 | constructor(accessToken: string, refreshToken: string, accessTokenExp: number, refreshTokenExp: number, scope?: string) { 66 | this.accessToken = accessToken; 67 | this.refreshToken = refreshToken; 68 | this.accessTokenExp = accessTokenExp; 69 | this.refreshTokenExp = refreshTokenExp; 70 | this.scope = scope; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dto'; 2 | export * from './controller'; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@switchit/nestjs-oauth2-server", 3 | "version": "0.1.4", 4 | "description": "NestJS oauth2 server by SWITCH IT CONSULTING", 5 | "author": "Switch IT - Consulting", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "types": "dist/index", 9 | "private": false, 10 | "keywords": [ 11 | "nestjs", 12 | "oauth2", 13 | "server", 14 | "typescript", 15 | "web", 16 | "framework" 17 | ], 18 | "bugs": { 19 | "url": "https://github.com/switchit-conseil/nestjs-oauth2-server-module/issues" 20 | }, 21 | "homepage": "https://github.com/switchit-conseil/nestjs-oauth2-server-module#readme", 22 | "contributors": [ 23 | "Nicolas Macherey " 24 | ], 25 | "scripts": { 26 | "build": "./node_modules/.bin/rimraf dist && tsc -p tsconfig.json", 27 | "format": "prettier --write \"src/**/*.ts\"", 28 | "lint": "tslint -p tsconfig.json -c tslint.json", 29 | "test": "jest --runInBand --verbose ./test", 30 | "test:unit": "jest --runInBand --verbose ./test/unit", 31 | "test:watch": "jest --watch ./test", 32 | "test:cov": "jest --coverage --runInBand ./test", 33 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand ./test/e2e", 34 | "test:e2e": "jest --config ./test/jest-e2e.json --runInBand --verbose" 35 | }, 36 | "dependencies": { 37 | "@nestjs/common": "^6.0.0", 38 | "@nestjs/core": "^6.0.0", 39 | "@nestjs/cqrs": "^6.0.0", 40 | "@nestjs/passport": "^6.1.0", 41 | "@nestjs/swagger": "^3.1.0", 42 | "@nestjs/typeorm": "^6.1.3", 43 | "class-transformer": "^0.2.3", 44 | "class-validator": "^0.9.1", 45 | "passport": "^0.4.0", 46 | "passport-http-bearer": "^1.0.1", 47 | "rimraf": "^3.0.0", 48 | "rxjs": "^6.3.3", 49 | "selfsigned": "^1.10.6", 50 | "typeorm": "^0.2.18" 51 | }, 52 | "devDependencies": { 53 | "@nestjs/platform-express": "^6.5.3", 54 | "@nestjs/testing": "^6.1.1", 55 | "@types/chai": "^4.2.4", 56 | "@types/cucumber": "^4.0.7", 57 | "@types/express": "4.16.1", 58 | "@types/jest": "24.0.11", 59 | "@types/node": "11.13.4", 60 | "@types/supertest": "2.0.7", 61 | "cucumber": "^6.0.2", 62 | "gherkin-jest": "^0.24.0", 63 | "jest": "24.7.1", 64 | "nodemailer-stub": "^1.2.1", 65 | "pg": "^7.12.1", 66 | "prettier": "1.17.0", 67 | "supertest": "4.0.2", 68 | "ts-jest": "24.0.2", 69 | "ts-node": "8.1.0", 70 | "tsc-watch": "2.2.1", 71 | "tsconfig-paths": "3.8.0", 72 | "tslint": "5.16.0", 73 | "typeorm-fixtures-cli": "^1.3.5", 74 | "typescript": "3.4.3" 75 | }, 76 | "jest": { 77 | "moduleFileExtensions": [ 78 | "js", 79 | "json", 80 | "ts" 81 | ], 82 | "rootDir": ".", 83 | "testRegex": ".spec.ts$", 84 | "transform": { 85 | "^.+\\.(t|j)s$": "ts-jest" 86 | }, 87 | "collectCoverageFrom": [ 88 | "src/**/*.ts" 89 | ], 90 | "testEnvironment": "node" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/e2e/controller/test-secure.controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller, Get, UseGuards} from "@nestjs/common"; 2 | import {AuthGuard} from "@nestjs/passport"; 3 | 4 | @Controller('oauth2-secured') 5 | export class TestSecuredController { 6 | @Get('me') 7 | @UseGuards(AuthGuard('access-token')) 8 | async auth(): Promise { 9 | return {message: 'hello'}; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/e2e/fixtures-loader.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@nestjs/common"; 2 | import {Connection, DeepPartial, getRepository} from "typeorm"; 3 | import * as path from 'path'; 4 | import { Builder, fixturesIterator, Loader, Parser, Resolver } from 'typeorm-fixtures-cli/dist'; 5 | 6 | @Injectable() 7 | export class FixturesLoaderService { 8 | constructor(private readonly connection: Connection) {} 9 | 10 | loadFixtures = async (fixturesPath: string) => { 11 | try { 12 | const loader = new Loader(); 13 | loader.load(path.resolve(fixturesPath)); 14 | 15 | const resolver = new Resolver(); 16 | const fixtures = resolver.resolve(loader.fixtureConfigs); 17 | const builder = new Builder(this.connection, new Parser()); 18 | 19 | for (const fixture of fixturesIterator(fixtures)) { 20 | const entity: any = await builder.build(fixture); 21 | await getRepository(entity.constructor.name).save(entity); 22 | } 23 | } catch (err) { 24 | console.log(err); 25 | throw err; 26 | } 27 | }; 28 | } -------------------------------------------------------------------------------- /test/e2e/fixtures/access-token/01-clients.yaml: -------------------------------------------------------------------------------- 1 | entity: ClientEntity 2 | items: 3 | client_1: 4 | name: 'Client1' 5 | clientId: '6ab1cfab-0b3d-418b-8ca2-94d98663fb6f' 6 | clientSecret: '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu' 7 | scope: '["app-1", "app-2"]' 8 | accessTokenLifetime: 3600 9 | refreshTokenLifetime: 7200 10 | privateKey: 'privateKey' 11 | publicKey: 'publicKey' 12 | cert: 'cert' 13 | certExpiresAt: '2020-10-10' 14 | client_2: 15 | name: 'Client2' 16 | clientId: 'f9f9f9ef-34b3-428e-a742-669aecd6c889' 17 | clientSecret: '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd' 18 | scope: '["app-1"]' 19 | accessTokenLifetime: 3600 20 | refreshTokenLifetime: 7200 21 | privateKey: 'privateKey' 22 | publicKey: 'publicKey' 23 | cert: 'cert' 24 | certExpiresAt: '2021-10-10' 25 | client_3: 26 | name: 'Client3' 27 | clientId: '051d6291-2ba7-4dd9-8a18-b590b4a9a457' 28 | clientSecret: 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq' 29 | scope: '["app-3"]' 30 | accessTokenLifetime: 3600 31 | refreshTokenLifetime: 7200 32 | privateKey: 'privateKey' 33 | publicKey: 'publicKey' 34 | cert: 'cert' 35 | certExpiresAt: '2022-10-10' 36 | -------------------------------------------------------------------------------- /test/e2e/fixtures/access-token/02-access-token.yaml: -------------------------------------------------------------------------------- 1 | entity: AccessTokenEntity 2 | items: 3 | access_token_client_1_valid: 4 | accessToken: 'ERTYUIOKJHGFDZSXCFGHYJKNBHYUJ' 5 | refreshToken: 'TYUIKNBVGSZ345678IUJHGVHJKL' 6 | accessTokenExpiresAt: '{{date.recent}}' 7 | refreshTokenExpiresAt: '{{date.future}}' 8 | client: '@client_1' 9 | scope: '["app-1", "app-2"]' 10 | createdAt: '{{date.recent}}' 11 | access_token_client_1_valid_access_token: 12 | accessToken: 'NHGFVBHYTFGHKOOOONBHK' 13 | refreshToken: 'IOJHBCDRTYHGFGYUJNBGH' 14 | accessTokenExpiresAt: '{{date.future}}' 15 | refreshTokenExpiresAt: '{{date.future}}' 16 | client: '@client_1' 17 | scope: '["app-1", "app-2"]' 18 | createdAt: '{{date.recent}}' 19 | access_token_client_2_valid: 20 | accessToken: 'ERTYUIFGHJKLNBVCDFRTYHJK' 21 | refreshToken: 'TYUIOLNBVFTYUJNBVGHYUIL' 22 | accessTokenExpiresAt: '{{date.recent}}' 23 | refreshTokenExpiresAt: '{{date.future}}' 24 | client: '@client_2' 25 | scope: '["app-1"]' 26 | createdAt: '{{date.recent}}' 27 | access_token_client_3_invalid: 28 | accessToken: '7789OIGBNSJDQKSJDIKNBHYUIO' 29 | refreshToken: 'RTNJSHGQHSJDNJSKDLNAJZKEA' 30 | accessTokenExpiresAt: '{{date.past}}' 31 | refreshTokenExpiresAt: '{{date.recent}}' 32 | client: '@client_3' 33 | scope: '["app-3"]' 34 | createdAt: '{{date.recent}}' 35 | -------------------------------------------------------------------------------- /test/e2e/fixtures/client-credentials/01-clients.yaml: -------------------------------------------------------------------------------- 1 | entity: ClientEntity 2 | items: 3 | client_1: 4 | name: 'Client1' 5 | clientId: '6ab1cfab-0b3d-418b-8ca2-94d98663fb6f' 6 | clientSecret: '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu' 7 | scope: '["app-1", "app-2"]' 8 | accessTokenLifetime: 3600 9 | refreshTokenLifetime: 7200 10 | privateKey: 'privateKey' 11 | publicKey: 'publicKey' 12 | cert: 'cert' 13 | certExpiresAt: '2020-10-10' 14 | client_2: 15 | name: 'Client2' 16 | clientId: 'f9f9f9ef-34b3-428e-a742-669aecd6c889' 17 | clientSecret: '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd' 18 | scope: '["app-1"]' 19 | accessTokenLifetime: 3600 20 | refreshTokenLifetime: 7200 21 | privateKey: 'privateKey' 22 | publicKey: 'publicKey' 23 | cert: 'cert' 24 | certExpiresAt: '2021-10-10' 25 | client_3: 26 | name: 'Client3' 27 | clientId: '051d6291-2ba7-4dd9-8a18-b590b4a9a457' 28 | clientSecret: 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq' 29 | scope: '["app-3"]' 30 | accessTokenLifetime: 3600 31 | refreshTokenLifetime: 7200 32 | privateKey: 'privateKey' 33 | publicKey: 'publicKey' 34 | cert: 'cert' 35 | certExpiresAt: '2022-10-10' 36 | -------------------------------------------------------------------------------- /test/e2e/fixtures/password/01-clients.yaml: -------------------------------------------------------------------------------- 1 | entity: ClientEntity 2 | items: 3 | client_1: 4 | name: 'Client1' 5 | clientId: '6ab1cfab-0b3d-418b-8ca2-94d98663fb6f' 6 | clientSecret: '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu' 7 | grants: ['password', 'refresh_token'] 8 | scope: '["app-1", "app-2"]' 9 | accessTokenLifetime: 3600 10 | refreshTokenLifetime: 7200 11 | privateKey: 'privateKey' 12 | publicKey: 'publicKey' 13 | cert: 'cert' 14 | certExpiresAt: '2020-10-10' 15 | client_2: 16 | name: 'Client2' 17 | clientId: 'f9f9f9ef-34b3-428e-a742-669aecd6c889' 18 | clientSecret: '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd' 19 | grants: ['password', 'refresh_token'] 20 | scope: '["app-1"]' 21 | accessTokenLifetime: 3600 22 | refreshTokenLifetime: 7200 23 | privateKey: 'privateKey' 24 | publicKey: 'publicKey' 25 | cert: 'cert' 26 | certExpiresAt: '2021-10-10' 27 | client_3: 28 | name: 'Client3' 29 | clientId: '051d6291-2ba7-4dd9-8a18-b590b4a9a457' 30 | clientSecret: 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq' 31 | grants: ['password', 'refresh_token'] 32 | scope: '["app-3"]' 33 | accessTokenLifetime: 3600 34 | refreshTokenLifetime: 7200 35 | privateKey: 'privateKey' 36 | publicKey: 'publicKey' 37 | cert: 'cert' 38 | certExpiresAt: '2022-10-10' 39 | -------------------------------------------------------------------------------- /test/e2e/mock-user/user.loader.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@nestjs/common"; 2 | import {UserInterface, UserLoaderInterface} from "../../../lib/domain/interface"; 3 | import {InvalidUserException} from "../../../lib/domain/exception"; 4 | import {users} from "./users"; 5 | 6 | 7 | @Injectable() 8 | export class UserLoader implements UserLoaderInterface { 9 | async load(userId: string): Promise { 10 | if (users[userId] !== undefined) { 11 | return { 12 | id: users[userId], 13 | username: users[userId], 14 | email: users[userId], 15 | } 16 | } 17 | 18 | throw InvalidUserException.withId(userId); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/e2e/mock-user/user.module.ts: -------------------------------------------------------------------------------- 1 | import {Module} from "@nestjs/common"; 2 | import {UserValidator} from "./user.validator"; 3 | import {UserLoader} from "./user.loader"; 4 | 5 | @Module({ 6 | providers: [ 7 | UserValidator, 8 | UserLoader, 9 | ], 10 | exports: [ 11 | UserValidator, 12 | UserLoader, 13 | ] 14 | }) 15 | export class UserModule { 16 | } -------------------------------------------------------------------------------- /test/e2e/mock-user/user.validator.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@nestjs/common"; 2 | import {UserInterface, UserValidatorInterface} from "../../../lib/domain/interface"; 3 | import {InvalidUserException} from "../../../lib/domain/exception"; 4 | import {users} from "./users"; 5 | 6 | @Injectable() 7 | export class UserValidator implements UserValidatorInterface { 8 | async validate(username, password): Promise { 9 | if (users[username] !== undefined && users[username] === password) { 10 | return { 11 | id: users[username], 12 | username: users[username], 13 | email: users[username], 14 | } 15 | } 16 | 17 | throw InvalidUserException.withUsernameAndPassword(username, password); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/e2e/mock-user/users.ts: -------------------------------------------------------------------------------- 1 | export const users: {[s:string]: string} = { 2 | 'alice@change.me': 'alice', 3 | 'bob@change.me': 'bob', 4 | 'kyle@change.me': 'kyle', 5 | }; 6 | -------------------------------------------------------------------------------- /test/e2e/modules/oauth2-async-use-factory.module.ts: -------------------------------------------------------------------------------- 1 | import {Oauth2Module} from "../../../lib/app"; 2 | import {Module} from "@nestjs/common"; 3 | import {UserValidator} from "../mock-user/user.validator"; 4 | import {UserLoader} from "../mock-user/user.loader"; 5 | import {TypeOrmModule} from "@nestjs/typeorm"; 6 | import {FixturesLoaderService} from "../fixtures-loader.service"; 7 | import {TestSecuredController} from "../controller/test-secure.controller"; 8 | import {UserModule} from "../mock-user/user.module"; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forRoot({ 13 | type: 'postgres', 14 | host: 'localhost', 15 | port: 5432, 16 | username: 'postgres', 17 | password: 'postgres', 18 | database: 'oauth2-server', 19 | entities: [process.cwd() + '/lib/**/*.entity{.ts,.js}'], 20 | dropSchema: true, 21 | synchronize: true 22 | }), 23 | Oauth2Module.forRootAsync({ 24 | imports: [UserModule], 25 | useFactory: (userValidator, userLoader) => ({ 26 | userValidator, 27 | userLoader, 28 | }), 29 | inject: [UserValidator, UserLoader], 30 | }), 31 | ], 32 | providers: [ 33 | FixturesLoaderService, 34 | ], 35 | controllers: [ 36 | TestSecuredController, 37 | ] 38 | }) 39 | export class Oauth2AsyncUseFactoryModule {} 40 | -------------------------------------------------------------------------------- /test/e2e/oauth2-async-use-factory.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import {INestApplication, ValidationPipe} from "@nestjs/common"; 3 | import {FixturesLoaderService} from "./fixtures-loader.service"; 4 | import {Oauth2AsyncUseFactoryModule} from "./modules/oauth2-async-use-factory.module"; 5 | import {Test} from "@nestjs/testing"; 6 | 7 | describe('OAuth2 Async Module Use Factory Controller (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeEach(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [Oauth2AsyncUseFactoryModule], 13 | }).compile(); 14 | 15 | app = module.createNestApplication(); 16 | app.useGlobalPipes(new ValidationPipe({ 17 | transform: true, 18 | })); 19 | await app.init(); 20 | }); 21 | 22 | describe('POST /oauth2/token "client_credentials"', () => { 23 | beforeEach(async () => { 24 | const fixturesLoader: FixturesLoaderService = app.get(FixturesLoaderService); 25 | await fixturesLoader.loadFixtures(__dirname + '/fixtures/client-credentials'); 26 | }); 27 | 28 | it.each([ 29 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1']], 30 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-2']], 31 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1', 'app-2']], 32 | ['f9f9f9ef-34b3-428e-a742-669aecd6c889', '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd', ['app-1']], 33 | ['051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq', ['app-3']], 34 | ])('Should authenticate client (%s, %s, "[%s]")', (clientId, clientSecret, scopes) => { 35 | return request(app.getHttpServer()) 36 | .post('/oauth2/token') 37 | .query({ 38 | grant_type: 'client_credentials', 39 | client_id: clientId, 40 | client_secret: clientSecret, 41 | exp: ~~((Date.now() + 600000) / 1000), 42 | iat: ~~(Date.now() / 1000), 43 | scopes: scopes, 44 | }) 45 | .set('Accept', 'application/json') 46 | .expect('Content-Type', /json/) 47 | .expect(201) 48 | .then(response => { 49 | expect(response.body.access_token.length).toBe(64); 50 | expect(response.body.refresh_token.length).toBe(64); 51 | expect(response.body.token_type).toBe('bearer'); 52 | }); 53 | }); 54 | 55 | it.each([ 56 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1']], 57 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-2']], 58 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1', 'app-2']], 59 | ['f9f9f9ef-34b3-428e-a742-669aecd6c889', '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd', ['app-1']], 60 | ['051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq', ['app-3']], 61 | ])('Should authenticate client without expiration (%s, %s, "[%s]")', (clientId, clientSecret, scopes) => { 62 | return request(app.getHttpServer()) 63 | .post('/oauth2/token') 64 | .query({ 65 | grant_type: 'client_credentials', 66 | client_id: clientId, 67 | client_secret: clientSecret, 68 | scopes: scopes, 69 | }) 70 | .set('Accept', 'application/json') 71 | .expect('Content-Type', /json/) 72 | .expect(201) 73 | .then(response => { 74 | expect(response.body.access_token.length).toBe(64); 75 | expect(response.body.refresh_token.length).toBe(64); 76 | expect(response.body.token_type).toBe('bearer'); 77 | }); 78 | }); 79 | 80 | it.each([ 81 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-3']], 82 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1', 'app-3']], 83 | ['f9f9f9ef-34b3-428e-a742-669aecd6c889', '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd', ['app-2']], 84 | ['051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq', ['app-1']], 85 | ['051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq', ['app-2']], 86 | ['051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq', ['app-1', 'app-2']], 87 | ['f9f9f9ef-34b3-428e-a742-669aecd6c889', 'invalid', ['app-1']], 88 | ])('Fails when scope is invalid (%s, %s, "[%s]")', (clientId, clientSecret, scopes) => { 89 | return request(app.getHttpServer()) 90 | .post('/oauth2/token') 91 | .query({ 92 | grant_type: 'client_credentials', 93 | client_id: clientId, 94 | client_secret: clientSecret, 95 | exp: ~~((Date.now() + 600000) / 1000), 96 | iat: ~~(Date.now() / 1000), 97 | scopes: scopes, 98 | }) 99 | .set('Accept', 'application/json') 100 | .expect('Content-Type', /json/) 101 | .expect(403); 102 | }); 103 | 104 | it.each([ 105 | ['unkown', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1']], 106 | ])('Fails the client is unknown or the secret is invalid (%s, %s, "[%s]")', (clientId, clientSecret, scopes) => { 107 | return request(app.getHttpServer()) 108 | .post('/oauth2/token') 109 | .query({ 110 | grant_type: 'client_credentials', 111 | client_id: clientId, 112 | client_secret: clientSecret, 113 | exp: ~~((Date.now() + 600000) / 1000), 114 | iat: ~~(Date.now() / 1000), 115 | scopes: scopes, 116 | }) 117 | .set('Accept', 'application/json') 118 | .expect('Content-Type', /json/) 119 | .expect(401); 120 | }); 121 | }); 122 | 123 | describe('POST /oauth2/token "refresh_token"', () => { 124 | beforeEach(async () => { 125 | const fixturesLoader: FixturesLoaderService = app.get(FixturesLoaderService); 126 | await fixturesLoader.loadFixtures(__dirname + '/fixtures/access-token'); 127 | }); 128 | 129 | it.each([ 130 | ['TYUIKNBVGSZ345678IUJHGVHJKL', '6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu'], 131 | ['TYUIOLNBVFTYUJNBVGHYUIL', 'f9f9f9ef-34b3-428e-a742-669aecd6c889', '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd'], 132 | ])('Should renew using the refresh token (%s)', (refreshToken, clientId, clientSecret) => { 133 | return request(app.getHttpServer()) 134 | .post('/oauth2/token') 135 | .query({ 136 | grant_type: 'refresh_token', 137 | client_id: clientId, 138 | client_secret: clientSecret, 139 | refresh_token: refreshToken 140 | }) 141 | .set('Accept', 'application/json') 142 | .expect(201) 143 | .then(response => { 144 | expect(response.body.access_token.length).toBe(64); 145 | expect(response.body.refresh_token.length).toBe(64); 146 | expect(response.body.token_type).toBe('bearer'); 147 | }); 148 | }); 149 | 150 | it.each([ 151 | ['RTNJSHGQHSJDNJSKDLNAJZKEA', '051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq'], 152 | ['unkonwn', '051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq'], 153 | ['TYUIOLNBVFTYUJNBVGHYUIL', '6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu'], 154 | ])('Should reject past valid or unkown refresh token renewal (%s)', (refreshToken, clientId, clientSecret) => { 155 | return request(app.getHttpServer()) 156 | .post('/oauth2/token') 157 | .query({ 158 | grant_type: 'refresh_token', 159 | client_id: clientId, 160 | client_secret: clientSecret, 161 | refresh_token: refreshToken 162 | }) 163 | .set('Accept', 'application/json') 164 | .expect(401); 165 | }); 166 | }); 167 | 168 | describe('AuthGuard(bearer) should secure routes using access tokens', () => { 169 | beforeEach(async () => { 170 | const fixturesLoader: FixturesLoaderService = app.get(FixturesLoaderService); 171 | await fixturesLoader.loadFixtures(__dirname + '/fixtures/access-token'); 172 | }); 173 | 174 | it.each([ 175 | ['ERTYUIOKJHGFDZSXCFGHYJKNBHYUJ'], 176 | ['ERTYUIFGHJKLNBVCDFRTYHJK'], 177 | ['7789OIGBNSJDQKSJDIKNBHYUIO'], 178 | ['invalid'], 179 | ])('Reject calls with invalid access tokens (%s)', (accessToken: string) => { 180 | return request(app.getHttpServer()) 181 | .get('/oauth2-secured/me') 182 | .auth(accessToken, {type: 'bearer'}) 183 | .set('Accept', 'application/json') 184 | .expect(401); 185 | }); 186 | 187 | it('Should accept request with valid access token', () => { 188 | return request(app.getHttpServer()) 189 | .get('/oauth2-secured/me') 190 | .auth('NHGFVBHYTFGHKOOOONBHK', {type: 'bearer'}) 191 | .set('Accept', 'application/json') 192 | .expect(200) 193 | .then(response => { 194 | expect(response.body.message).toBe('hello'); 195 | }); 196 | }); 197 | }); 198 | 199 | describe('POST /oauth2/token "password"', () => { 200 | beforeEach(async () => { 201 | const fixturesLoader: FixturesLoaderService = app.get(FixturesLoaderService); 202 | await fixturesLoader.loadFixtures(__dirname + '/fixtures/password'); 203 | }); 204 | 205 | it.each([ 206 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1'], 'alice@change.me', 'alice'], 207 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-2'], 'bob@change.me', 'bob'], 208 | ['6ab1cfab-0b3d-418b-8ca2-94d98663fb6f', '6nV9GGm1pu8OY0HDZ3Y7QsVnxtkb60wu', ['app-1', 'app-2'], 'alice@change.me', 'alice'], 209 | ['f9f9f9ef-34b3-428e-a742-669aecd6c889', '4Xg6JgvWfmIT3P5cCev2wehH8sWD3lrd', ['app-1'], 'kyle@change.me', 'kyle'], 210 | ['051d6291-2ba7-4dd9-8a18-b590b4a9a457', 'YLbvzkTRG40SKMm5DMfoWZD3BRZCV5Dq', ['app-3'], 'kyle@change.me', 'kyle'], 211 | ])('Should authenticate the user with the client (%s, %s, "[%s]", %s, %s)', (clientId, clientSecret, scopes, username, password) => { 212 | return request(app.getHttpServer()) 213 | .post('/oauth2/token') 214 | .query({ 215 | grant_type: 'password', 216 | client_id: clientId, 217 | client_secret: clientSecret, 218 | scopes: scopes, 219 | username: username, 220 | password: password 221 | }) 222 | .set('Accept', 'application/json') 223 | .expect('Content-Type', /json/) 224 | .expect(201) 225 | .then(response => { 226 | expect(response.body.access_token.length).toBe(64); 227 | expect(response.body.refresh_token.length).toBe(64); 228 | expect(response.body.token_type).toBe('bearer'); 229 | }); 230 | }); 231 | }); 232 | 233 | afterEach(async () => { 234 | await app.close(); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "./e2e", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/app/command/create-access-token.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid/v4'; 2 | import {Test, TestingModule} from '@nestjs/testing'; 3 | import {CqrsModule, EventBus} from '@nestjs/cqrs'; 4 | import { 5 | CreateAccessTokenCommand, 6 | CreateAccessTokenHandler 7 | } from "../../../../lib/app/command"; 8 | import {OAuth2Request} from "../../../../lib/ui/dto"; 9 | import { 10 | AccessTokenEntity, 11 | ClientEntity 12 | } from "../../../../lib/domain"; 13 | import {AccessTokenCreatedEvent} from "../../../../lib/app/event"; 14 | 15 | function mockDateNow() { 16 | // mock now = 1462361249717ms = 4th May 2016 17 | return 1462361249717; 18 | } 19 | 20 | const originalDateNow = Date.now; 21 | 22 | describe('Create AccessToken Command Handler', () => { 23 | let app: TestingModule; 24 | let handler: CreateAccessTokenHandler; 25 | let eventBus: EventBus; 26 | 27 | const accessTokenRepositoryMock = { 28 | create(accessToken: AccessTokenEntity) { 29 | accessToken.id = uuid(); 30 | return accessToken; 31 | } 32 | }; 33 | 34 | const clientRepositoryMock = { 35 | find(id: string) { 36 | const client = new ClientEntity(); 37 | client.id = id; 38 | client.accessTokenLifetime = 3600; 39 | client.refreshTokenLifetime = 86400; 40 | 41 | return client; 42 | } 43 | }; 44 | 45 | beforeAll(async () => { 46 | app = await Test.createTestingModule({ 47 | imports: [ 48 | CqrsModule 49 | ], 50 | providers: [ 51 | CreateAccessTokenHandler, 52 | {provide: 'AccessTokenRepositoryInterface', useValue: accessTokenRepositoryMock }, 53 | {provide: 'ClientRepositoryInterface', useValue: clientRepositoryMock }, 54 | ], 55 | }).compile(); 56 | 57 | eventBus = app.get(EventBus); 58 | handler = app.get(CreateAccessTokenHandler); 59 | }); 60 | 61 | beforeEach(function () { 62 | Date.now = mockDateNow; 63 | }); 64 | 65 | afterEach(function () { 66 | Date.now = originalDateNow; 67 | }); 68 | 69 | it('"CreateAccessTokenHandler::execute": should create the AccessToken', async () => { 70 | const serviceSpy = jest.spyOn(accessTokenRepositoryMock, 'create'); 71 | 72 | await handler.execute(new CreateAccessTokenCommand( 73 | 'client-1', 74 | '["app-1", "app-2"]', 75 | Date.now() + 3600, 76 | Date.now(), 77 | { 78 | grantType: 'client_credentials', 79 | clientId: 'client-1', 80 | clientSecret: 'client-secret', 81 | clientToken: 'client-token', 82 | exp: Date.now() + 3600, 83 | iat: Date.now(), 84 | scopes: ['app-1', 'app-2'], 85 | } as OAuth2Request 86 | )); 87 | 88 | expect(serviceSpy).toBeCalledWith(expect.any(AccessTokenEntity)); 89 | serviceSpy.mockRestore(); 90 | }); 91 | 92 | it('"CreateAccessTokenHandler::execute": should emit an AccessTokenCreatedEvent', async () => { 93 | const publishSpy = jest.spyOn(eventBus, 'publish'); 94 | 95 | await handler.execute(new CreateAccessTokenCommand( 96 | 'client-1', 97 | '["app-1", "app-2"]', 98 | Date.now() + 3600, 99 | Date.now(), 100 | { 101 | grantType: 'client_credentials', 102 | clientId: 'client-1', 103 | clientSecret: 'client-secret', 104 | clientToken: 'client-token', 105 | exp: Date.now() + 3600, 106 | iat: Date.now(), 107 | scopes: ['app-1', 'app-2'], 108 | } as OAuth2Request 109 | )); 110 | 111 | expect(publishSpy).toBeCalledWith(expect.any(AccessTokenCreatedEvent)); 112 | 113 | publishSpy.mockRestore(); 114 | }); 115 | 116 | it('"CreateAccessTokenHandler::execute": should set the token expiration', async () => { 117 | const serviceSpy = jest.spyOn(accessTokenRepositoryMock, 'create'); 118 | const exp = (Date.now() + 600000)/1000; 119 | 120 | await handler.execute(new CreateAccessTokenCommand( 121 | 'client-1', 122 | '["app-1", "app-2"]', 123 | exp, 124 | Date.now(), 125 | { 126 | grantType: 'client_credentials', 127 | clientId: 'client-1', 128 | clientSecret: 'client-secret', 129 | clientToken: 'client-token', 130 | exp: exp, 131 | iat: Date.now(), 132 | scopes: ['app-1', 'app-2'], 133 | } as OAuth2Request 134 | )); 135 | 136 | expect(serviceSpy).toBeCalledWith(expect.objectContaining({ 137 | accessTokenExpiresAt: new Date(exp*1000), 138 | refreshTokenExpiresAt: new Date(Date.now()+ 86400000), 139 | })); 140 | 141 | serviceSpy.mockRestore(); 142 | }); 143 | 144 | it('"CreateAccessTokenHandler::execute": should set the max token expiration from client', async () => { 145 | const serviceSpy = jest.spyOn(accessTokenRepositoryMock, 'create'); 146 | const exp = Date.now() + 15000000; 147 | 148 | await handler.execute(new CreateAccessTokenCommand( 149 | 'client-1', 150 | '["app-1", "app-2"]', 151 | exp, 152 | Date.now(), 153 | { 154 | grantType: 'client_credentials', 155 | clientId: 'client-1', 156 | clientSecret: 'client-secret', 157 | clientToken: 'client-token', 158 | exp: exp, 159 | iat: Date.now(), 160 | scopes: ['app-1', 'app-2'], 161 | } as OAuth2Request 162 | )); 163 | 164 | expect(serviceSpy).toBeCalledWith(expect.objectContaining({ 165 | accessTokenExpiresAt: new Date(Date.now() + 3600000), 166 | refreshTokenExpiresAt: new Date(Date.now()+ 86400000), 167 | })); 168 | 169 | serviceSpy.mockRestore(); 170 | }); 171 | 172 | it('"CreateAccessTokenHandler::execute": should set user in the access token', async () => { 173 | const exp = Date.now() + 15000000; 174 | 175 | const token = await handler.execute(new CreateAccessTokenCommand( 176 | 'client-1', 177 | '["app-1", "app-2"]', 178 | exp, 179 | Date.now(), 180 | { 181 | grantType: 'client_credentials', 182 | clientId: 'client-1', 183 | clientSecret: 'client-secret', 184 | clientToken: 'client-token', 185 | exp: exp, 186 | iat: Date.now(), 187 | scopes: ['app-1', 'app-2'] 188 | } as OAuth2Request, 189 | 'user-1' 190 | )); 191 | 192 | expect(token.userId).not.toBeNull(); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /test/unit/app/command/create-client.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid/v4'; 2 | import {Test, TestingModule} from '@nestjs/testing'; 3 | import {CqrsModule, EventBus} from '@nestjs/cqrs'; 4 | import { 5 | CreateClientCommand, 6 | CreateClientHandler 7 | } from "../../../../lib/app/command"; 8 | import { 9 | ClientEntity 10 | } from "../../../../lib/domain"; 11 | import {ClientCreatedEvent} from "../../../../lib/app/event"; 12 | 13 | describe('Create Client Command Handler', () => { 14 | let app: TestingModule; 15 | let handler: CreateClientHandler; 16 | let eventBus: EventBus; 17 | 18 | const clientRepositoryMock = { 19 | create(client: ClientEntity) { 20 | client.id = uuid(); 21 | return client; 22 | } 23 | }; 24 | 25 | beforeAll(async () => { 26 | app = await Test.createTestingModule({ 27 | imports: [ 28 | CqrsModule 29 | ], 30 | providers: [ 31 | CreateClientHandler, 32 | {provide: 'ClientRepositoryInterface', useValue: clientRepositoryMock} 33 | ], 34 | }).compile(); 35 | 36 | eventBus = app.get(EventBus); 37 | handler = app.get(CreateClientHandler); 38 | }); 39 | 40 | it('"CreateClientHandler::execute": should create the Client from name and scope', async () => { 41 | const serviceSpy = jest.spyOn(clientRepositoryMock, 'create'); 42 | 43 | await handler.execute(new CreateClientCommand( 44 | 'client-1', 45 | '["app-1", "app-2"]' 46 | )); 47 | 48 | expect(serviceSpy).toBeCalledWith(expect.objectContaining({ 49 | name: 'client-1', 50 | scope: '["app-1", "app-2"]', 51 | clientId: expect.any(String), 52 | clientSecret: expect.any(String), 53 | accessTokenLifetime: 3600, 54 | refreshTokenLifetime: 7200, 55 | privateKey: expect.any(String), 56 | publicKey: expect.any(String), 57 | cert: expect.any(String) 58 | })); 59 | 60 | serviceSpy.mockRestore(); 61 | }); 62 | 63 | it('"CreateClientHandler::execute": should emit an ClientCreatedEvent', async () => { 64 | const publishSpy = jest.spyOn(eventBus, 'publish'); 65 | 66 | await handler.execute(new CreateClientCommand( 67 | 'client-1', 68 | '["app-1", "app-2"]' 69 | )); 70 | 71 | expect(publishSpy).toBeCalledWith(expect.any(ClientCreatedEvent)); 72 | 73 | publishSpy.mockRestore(); 74 | }); 75 | 76 | it('"CreateClientHandler::execute": should create the Client using optional parameters', async () => { 77 | const serviceSpy = jest.spyOn(clientRepositoryMock, 'create'); 78 | 79 | await handler.execute(new CreateClientCommand( 80 | 'client-1', 81 | '["app-1", "app-2"]', 82 | 'client-id', 83 | ['client_credentials', 'refresh_token'], 84 | false, 85 | 1000, 86 | 3600 87 | )); 88 | 89 | expect(serviceSpy).toBeCalledWith(expect.objectContaining({ 90 | name: 'client-1', 91 | scope: '["app-1", "app-2"]', 92 | clientId: 'client-id', 93 | clientSecret: expect.any(String), 94 | grants: ['client_credentials', 'refresh_token'], 95 | accessTokenLifetime: 1000, 96 | refreshTokenLifetime: 3600, 97 | })); 98 | 99 | serviceSpy.mockRestore(); 100 | }); 101 | 102 | it('"CreateClientHandler::execute": should create the Client with no secret when asked', async () => { 103 | const serviceSpy = jest.spyOn(clientRepositoryMock, 'create'); 104 | 105 | await handler.execute(new CreateClientCommand( 106 | 'client-1', 107 | '["app-1", "app-2"]', 108 | 'client-id', 109 | ['password_grant', 'refresh_token'], 110 | true, 111 | 1000, 112 | 3600 113 | )); 114 | 115 | expect(serviceSpy).toBeCalledWith(expect.objectContaining({ 116 | name: 'client-1', 117 | scope: '["app-1", "app-2"]', 118 | clientId: 'client-id', 119 | grants: ['password_grant', 'refresh_token'], 120 | accessTokenLifetime: 1000, 121 | refreshTokenLifetime: 3600, 122 | })); 123 | 124 | serviceSpy.mockRestore(); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "baseUrl": "./lib", 7 | "allowJs": false, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "moduleResolution": "node", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "target": "es6", 14 | "lib": [ 15 | "es2015", 16 | "es2016", 17 | "es2017", 18 | "dom" 19 | ] 20 | }, 21 | "include": [ 22 | "lib/**/*" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "**/*.spec.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } 19 | --------------------------------------------------------------------------------