├── .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 |
4 |
5 |
6 |
7 | We build your next generation software !
8 |
9 | # OAuth2 Server Module [](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 |
--------------------------------------------------------------------------------