diff --git a/toolkit/auth0/.env.dist b/toolkit/auth0/.env.dist new file mode 100644 index 0000000..c313686 --- /dev/null +++ b/toolkit/auth0/.env.dist @@ -0,0 +1,3 @@ +POST_USER_REGISTER_TOKEN=exampleToken +AUTH0_DOMAIN= +AUTH0_AUDIENCE= \ No newline at end of file diff --git a/toolkit/auth0/README.md b/toolkit/auth0/README.md new file mode 100644 index 0000000..0768592 --- /dev/null +++ b/toolkit/auth0/README.md @@ -0,0 +1,322 @@ +### Introduction + +Instruction to implement Auth0 in a project. It also contains an example of usage, which are 2 methods: + - POST `/api/example/post-user-registration`: triggered after user registration in Auth0. Method requires header: `x-auth-token`, defined in environment variables. + - GET `/api/example/me`: method that returns basic user data. Requires Authorization header (Bearer token received from Auth0 after logging in). + +## + +### Implementation + + 1. Auth0 set up. + + 1. Log into Auth0 dashboard. + 2. Navigate in sidebar to `Applications -> APIs`. Press `Create API` button. Insert API name, identifier and confirm. + You can see that, along with the API, an Application was created (`Applications -> Applications`). + 3. Navigate to `Authentication -> Database` and press the `Create Database` button. Insert Database name and confirm. After confirmation, move in the navigation bar to `Applications` tab and toggle the switch next to your Application to connect Database with it. + 4. Navigate to `Applications -> Applications` and enter your Application settings. Move to the bottom of the page to `Advenced Settings`. Move to `Grant Types` tab and check the `Password` checkbox. Save changes. + 5. Navigate to `Settings` in sidebar and find `API Authorization Settings`. In `Default Directory` enter your Database name to set is as default connection. Save changes. + + 2. Add Auth0 environment variables (Auth0 app domain and audience (identifier) and example token to validate `post-user-registration` method). + - Domain can be found under `Applications -> Applications -> your application settings -> Basic Information -> Domain`, + - Audience can be found under `Applications -> APIs -> you API settings -> General Settings -> Identifier`. + + ``` + POST_USER_REGISTER_TOKEN=exampleToken + AUTH0_DOMAIN= + AUTH0_AUDIENCE= + ``` + + 3. Set up Auth0 Actions. + + 2.1 Navigate in sidebar to `Actions -> Library`, press `Create Action` button and choose `Build from scratch` in the drop-down menu. Enter your `Post User Registration` action name and choose trigger `Post User Registration`. Press `Create` and you will see a window with the handler of your newly created action. + 2.2 Copy code from `./auth0-actions/post-user-registration-action.ts` and paste it your action handler. + 2.3 On the left side of the handler window, move to the `Dependencies` tab and press `Add Dependency`. Enter `axios` in the name input and press `Create`. + 2.3 On the left side of the handler window, move to the `Secrets` tab and press `Add Secret`. Enter `POST_USER_REGISTRATION_URL` in the name input and in Value input insert Url to your post user registration endpoint (this action will work only after the deployment of your application). Press `Create`. Press again `Add Secret` and add `X_AUTH_TOKEN` secret with Value of the `POST_USER_REGISTER_TOKEN` environment variable in our application. + 2.4 Press `Save Draft` button in upper right corner. Press `Deploy` button. + 2.5 Repat steps 2.1, 2.2 and 2.4 and create Post Login Action choosing `Login/Post Login` as Trigger and copy the handler code from `./auth0-actions/post-user-registration-action.ts`. + 2.6 Navigate in the sidebar to `Actions -> Flows` and choose Login Flow. In the Add Action window on the right side, choose `Custom` tab, then Drag you `Post Login Action` and drop in between `Start` and `Complete` steps. Press `Apply` button in upper right corner to apply changes. + 2.7 Repeat step 2.6 and add to the Post User Registration flow your `Post User Registration` action. + + Our `Post User Registration` action will create a POST request to our app and will pass registered user's id in request body. `Post Login Action` will add user's email as custom claim inside Access Token. + + 4. Install necessary packages: + + ``` + npm install sinon --save-dev + npm i --save-dev @types/sinon + npm install supertest --save-dev + npm install express-oauth2-jwt-bearer + ``` + + 5. Copy necessary files and code + + ``` + cp -r ./toolkit/auth0/actions/* ./src/app/features/example/actions/ + cp -r ./toolkit/auth0/commands/* ./src/app/features/example/commands/ + cp -r ./toolkit/auth0/handlers/* ./src/app/features/example/handlers/ + cp -r ./toolkit/auth0/queries/* ./src/app/features/example/queries/ + cp -r ./toolkit/auth0/query-handlers/* ./src/app/features/example/query-handlers/ + cp -r ./toolkit/auth0/middleware/* ./src/middleware/ + cp -r ./toolkit/auth0/tests/shared/* ./src/tests/shared/ + ``` + + All modified files can be found at `./toolkit/auth0`. + + Copy to `src/app/features/example/routing.ts` + + ``` + import { postUserRegistrationActionValidation } from "./actions/post-user-registration.action"; + import { MiddlewareType } from "../../../shared/middleware-type/middleware.type"; + import { meActionValidation } from "./actions/me.action"; + + export interface UsersRoutingDependencies { + ... + + postUserRegistrationAction: Action; + postUserRegisterTokenHandler: MiddlewareType; + checkTokenPayload: MiddlewareType; + validateAccessToken: MiddlewareType; + meAction: Action; + // ACTIONS_IMPORTS + } + + export const usersRouting = (actions: UsersRoutingDependencies) => { + ... + + router.post( + "/post-user-registration", + [actions.postUserRegisterTokenHandler, postUserRegistrationActionValidation], + actions.postUserRegistrationAction.invoke.bind(actions.postUserRegistrationAction), + ); + router.get( + "/me", + [actions.validateAccessToken, actions.checkTokenPayload, meActionValidation], + actions.meAction.invoke.bind(actions.meAction), + ); + + ... + } + ``` + + Copy to `src/config/app.ts` + + ``` + export interface AppConfig { + ... + + postUserRegisterToken: string; + auth0Domain: string; + auth0Audience: string; + } + + const loadConfig = (env: any): AppConfig => ({ + ... + + postUserRegisterToken: env.POST_USER_REGISTER_TOKEN, + auth0Domain: env.AUTH0_DOMAIN, + auth0Audience: env.AUTH0_AUDIENCE, + }); + + const validateConfig = (config: AppConfig) => { + const schema = Joi.object().keys({ + ... + + postUserRegisterToken: Joi.string().required(), + auth0Domain: Joi.string().required(), + auth0Audience: Joi.string().required(), + }); + ... + }; + ``` + + Copy to `src/container/command-handlers.ts` + + ``` + import PostUserRegistrationCommandHandler from "../app/features/example/handlers/post-user-registration.handler"; + + export async function registerCommandHandlers(container: AwilixContainer) { + container.register({ + commandHandlers: asArray([ + ... + + asClass(PostUserRegistrationCommandHandler), + ]), + }); + ... + } + ``` + + Copy to `src/container/middlewares.ts` + + ``` + import { postUserRegisterTokenHandler } from "../middleware/post-user-register-token-handler"; + import { checkTokenPayload, validateAccessToken } from "../middleware/auth0"; + + export async function registerMiddlewares(container: AwilixContainer) { + container.register({ + ... + + postUserRegisterTokenHandler: asFunction(postUserRegisterTokenHandler), + checkTokenPayload: asFunction(checkTokenPayload), + validateAccessToken: asFunction(validateAccessToken), + }); + ... + } + ``` + + Copy to `src/container/query-handlers.ts` + + ``` + import MeQueryHandler from "../app/features/example/query-handlers/me.query.handler"; + + export async function registerQueryHandlers(container: AwilixContainer) { + container.register({ + queryHandlers: asArray([ + ... + + asClass(MeQueryHandler), + // QUERY_HANDLERS_SETUP + ]), + }); + ... + } + ``` + + Copy to `src/middleware/error-handler.ts` + + ``` + import { InvalidTokenError, UnauthorizedError } from "express-oauth2-jwt-bearer"; + + export const errorHandler = + ({ logger, restrictFromProduction }: { logger: Logger; restrictFromProduction: Function }) => + (err: Error, req: Request, res: Response, _next: NextFunction) => { + ... + + if (err instanceof InvalidTokenError) { + const message = "Bad credentials"; + + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, message), + }); + } + + if (err instanceof UnauthorizedError) { + const message = "Requires authentication"; + + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, message), + }); + } + + ... + } + ``` + + Copy to `src/tests/bootstrap.ts.ts` (important: part of the code with stubbing with sinon of validataAccessToken middleware has to be pasted before `global.container = await createContainer();`) + + ``` + import sinon from "sinon"; + import * as auth0Module from "../middleware/auth0"; + + const clearDb = async (dataSource: DataSource) => { + const entities = dataSource.entityMetadatas; + + await dataSource.manager.transaction(async (transactionalEntityManager) => { + // disable checking relations + await transactionalEntityManager.query("SET session_replication_role = replica;"); + + await Promise.all(entities.map((entity) => transactionalEntityManager.query(`DELETE FROM "${entity.tableName}"`))); + + // enable checking relations + await transactionalEntityManager.query("SET session_replication_role = origin;"); + }); + }; + + before(async () => { + ... + + sinon.stub(auth0Module, "validateAccessToken").callsFake(() => (req, res, next) => { + // eslint-disable-next-line no-param-reassign + req.auth = { + payload: { + me: { + email: "test@integration.com", + }, + }, + header: {}, + token: "", + }; + + return next(); + }); + + global.container = await createContainer(); + }); + ``` + + 6. Register and login user + + When you have copied all the files and necessary code, you can build and start your app locally. When it's done, you can register and log in. You will do this by requesting the Auth0 API. Use Postman, Insomnia etc. to make such requests: + + Register user: + + ``` + curl --location 'https://{Auth0Domain}/dbconnections/signup' \ + --header 'Content-Type: application/json' \ + --data '{ + "client_id": "", + "email": "Enter your email", + "password": "someRandomPassword1!", + "connection": "Name of you Database in Auth0" + }' + ``` + + In URL replace `Auth0Domain` with your app Domain (`AUTH0_DOMAIN` in `.env` file); + In body enter `client_id` from: `Applications -> Applications -> settings of your application -> Basic Information -> Client ID`. + + Login user: + + ``` + curl --location 'https://{Auth0Domain}/oauth/token' \ + --header 'Content-Type: application/json' \ + --data '{ + "grant_type": "password", + "client_id": "", + "username": "Enter your email", + "password": "someRandomPassword1!", + "audience": "", + "client_secret" : "" + }' + ``` + You can find `client_secret` in: `Applications -> Applications -> settings of your application -> Basic Information -> Client Secret`. + Fill `audience` property with `AUTH0_AUDIENCE` from the `.env` file. + + In response body of this request, you can find all the necessary data about our token. We will need them to make a request to the GET `/api/example/me` method. + + 7. Check functionality of example methods`. + + Now you can run GET `/api/example/me` method. + + ``` + curl --location 'http://localhost:1337/api/example/me' \ + --header 'Authorization: Bearer {access_token}' + ``` + + In `Authorization` header enter `access_token` received in response from Login request. + + You will see an error saying that a user like this does not exist. You can add a user with your email to your local database. After that, you will receive all user's data in response of this method. + + Without `Authorization` header or with wrong one, you will see Unauthorized errors received from Auth0. + + You can also check `Post User Registration` method by requesting: + + ``` + curl --location 'http://localhost:1337/api/example/post-user-registration' \ + --header 'x-auth-token: exampleToken' \ + --header 'Content-Type: application/json' \ + --data '{ + "userId": "someUserId" + }' + ``` +## \ No newline at end of file diff --git a/toolkit/auth0/actions/me.action.ts b/toolkit/auth0/actions/me.action.ts new file mode 100644 index 0000000..26a83a5 --- /dev/null +++ b/toolkit/auth0/actions/me.action.ts @@ -0,0 +1,31 @@ +import { Request, Response } from "express"; +import { celebrate, Joi } from "celebrate"; +import { QueryBus } from "@tshio/query-bus"; +import { MeQuery } from "../queries/me"; +import { Action } from "../../../../shared/http/types"; + +export interface MeActionDependencies { + queryBus: QueryBus; +} + +export const meActionValidation = celebrate( + { + headers: Joi.object(), + }, + { abortEarly: false }, +); + +class MeAction implements Action { + constructor(private dependencies: MeActionDependencies) {} + + async invoke(req: Request, res: Response) { + const queryResult = await this.dependencies.queryBus.execute( + new MeQuery({ + email: res.locals.auth.email, + }), + ); + + res.json(queryResult.result); + } +} +export default MeAction; diff --git a/toolkit/auth0/actions/post-user-registration.action.ts b/toolkit/auth0/actions/post-user-registration.action.ts new file mode 100644 index 0000000..f68338a --- /dev/null +++ b/toolkit/auth0/actions/post-user-registration.action.ts @@ -0,0 +1,34 @@ +import { Request, Response } from "express"; +import { celebrate, Joi } from "celebrate"; +import { CommandBus } from "@tshio/command-bus"; +import { PostUserRegistrationCommand } from "../commands/post-user-registration.command"; +import { Action } from "../../../../shared/http/types"; + +export interface PostUserRegistrationActionDependencies { + commandBus: CommandBus; +} + +export const postUserRegistrationActionValidation = celebrate( + { + headers: Joi.object(), + body: Joi.object().keys({ + userId: Joi.string().required(), + }), + }, + { abortEarly: false }, +); + +class PostUserRegistrationAction implements Action { + constructor(private dependencies: PostUserRegistrationActionDependencies) {} + + async invoke({ body }: Request, res: Response) { + const commandResult = await this.dependencies.commandBus.execute( + new PostUserRegistrationCommand({ + userId: body.userId, + }), + ); + + res.json(commandResult.result); + } +} +export default PostUserRegistrationAction; diff --git a/toolkit/auth0/app.ts b/toolkit/auth0/app.ts new file mode 100644 index 0000000..d297529 --- /dev/null +++ b/toolkit/auth0/app.ts @@ -0,0 +1,43 @@ +import { Joi } from "celebrate"; +import { pipeline } from "ts-pipe-compose"; + +export interface AppConfig { + appName: string; + port: string; + env: string; + deployedCommit: string; + postUserRegisterToken: string; + auth0Domain: string; + auth0Audience: string; +} + +const loadConfig = (env: any): AppConfig => ({ + appName: env.APP_NAME ?? "boilerplate_api", + port: env.PORT ?? "1337", + env: env.STAGE, + deployedCommit: env.BITBUCKET_COMMIT, + postUserRegisterToken: env.POST_USER_REGISTER_TOKEN, + auth0Domain: env.AUTH0_DOMAIN, + auth0Audience: env.AUTH0_AUDIENCE, +}); + +const validateConfig = (config: AppConfig) => { + const schema = Joi.object().keys({ + appName: Joi.string().required(), + port: Joi.string().required(), + env: Joi.string().required(), + deployedCommit: Joi.string().required(), + postUserRegisterToken: Joi.string().required(), + auth0Domain: Joi.string().required(), + auth0Audience: Joi.string().required(), + }); + const { error, value } = schema.validate(config); + + if (error) { + throw error; + } + + return value; +}; + +export const appConfigFactory = pipeline(loadConfig, validateConfig); diff --git a/toolkit/auth0/auth0-actions/post-login-action.ts b/toolkit/auth0/auth0-actions/post-login-action.ts new file mode 100644 index 0000000..7e949c5 --- /dev/null +++ b/toolkit/auth0/auth0-actions/post-login-action.ts @@ -0,0 +1,21 @@ +/* +* Handler that will be called during the execution of a PostLogin flow. +* +* @param {Event} event - Details about the user and the context in which they are logging in. +* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login. +*/ +exports.onExecutePostLogin = async (event, api) => { + api.accessToken.setCustomClaim("me", { + email: event.user.email, + }) +}; + +/* +* Handler that will be invoked when this action is resuming after an external redirect. If your +* onExecutePostLogin function does not perform a redirect, this function can be safely ignored. +* +* @param {Event} event - Details about the user and the context in which they are logging in. +* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login. +*/ +// exports.onContinuePostLogin = async (event, api) => { +// }; diff --git a/toolkit/auth0/auth0-actions/post-user-registration-action.ts b/toolkit/auth0/auth0-actions/post-user-registration-action.ts new file mode 100644 index 0000000..ea10aaa --- /dev/null +++ b/toolkit/auth0/auth0-actions/post-user-registration-action.ts @@ -0,0 +1,27 @@ +/* +* Handler that will be called during the execution of a PostUserRegistration flow. +* +* @param {Event} event - Details about the context and user that has registered. +* @param {PostUserRegistrationAPI} api - Methods and utilities to help change the behavior after a signup. +*/ + +exports.onExecutePostUserRegistration = async (event, api) => { + const axios = require('axios'); + + try { + await axios.post( + event.secrets.POST_USER_REGISTRATION_URL, + { + userId: event.user.user_id, + }, + { + headers: { + "Content-Type": "application/json", + "x-auth-token": event.secrets.X_AUTH_TOKEN, + }, + }, + ); + } catch (error) { + throw new Error(JSON.stringify(error)); + } +}; diff --git a/toolkit/auth0/bootstrap.ts b/toolkit/auth0/bootstrap.ts new file mode 100644 index 0000000..3fd07aa --- /dev/null +++ b/toolkit/auth0/bootstrap.ts @@ -0,0 +1,64 @@ +import "mocha"; +import { use } from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { DataSource } from "typeorm"; +import sinon from "sinon"; +import "express-async-errors"; +import { createContainer } from "../container"; +import { config } from "../config/db"; +import "express-async-errors"; +import * as auth0Module from "../middleware/auth0"; + +use(chaiAsPromised); + +const clearDb = async (dataSource: DataSource) => { + const entities = dataSource.entityMetadatas; + + await dataSource.manager.transaction(async (transactionalEntityManager) => { + // disable checking relations + await transactionalEntityManager.query("SET session_replication_role = replica;"); + + await Promise.all(entities.map((entity) => transactionalEntityManager.query(`DELETE FROM "${entity.tableName}"`))); + + // enable checking relations + await transactionalEntityManager.query("SET session_replication_role = origin;"); + }); +}; + +before(async () => { + const dbConnection = await new DataSource({ + ...config, + logging: false, + }).initialize(); + + global.dbConnection = dbConnection; + await dbConnection.dropDatabase(); + sinon.stub(auth0Module, "validateAccessToken").callsFake(() => (req, res, next) => { + // eslint-disable-next-line no-param-reassign + req.auth = { + payload: { + me: { + email: "test@integration.com", + }, + }, + header: {}, + token: "", + }; + + return next(); + }); + + global.container = await createContainer(); +}); + +beforeEach(async () => { + if (global.dbConnection) { + await clearDb(global.dbConnection); + } +}); + +after(async () => { + if (global.dbConnection) { + await global.dbConnection.destroy(); + } +}); diff --git a/toolkit/auth0/command-handlers.ts b/toolkit/auth0/command-handlers.ts new file mode 100644 index 0000000..b02e05c --- /dev/null +++ b/toolkit/auth0/command-handlers.ts @@ -0,0 +1,20 @@ +import { AwilixContainer, asClass } from "awilix"; +import { asArray } from "@tshio/awilix-resolver"; + +import LoginHandler from "../app/features/example/handlers/login.handler"; +import DeleteUserHandler from "../app/features/example/handlers/delete-user.handler"; +import PostUserRegistrationCommandHandler from "../app/features/example/handlers/post-user-registration.handler"; +// HANDLERS_IMPORTS + +export async function registerCommandHandlers(container: AwilixContainer) { + container.register({ + commandHandlers: asArray([ + asClass(LoginHandler), + asClass(DeleteUserHandler), + asClass(PostUserRegistrationCommandHandler), + // COMMAND_HANDLERS_SETUP + ]), + }); + + return container; +} diff --git a/toolkit/auth0/commands/post-user-registration.command.ts b/toolkit/auth0/commands/post-user-registration.command.ts new file mode 100644 index 0000000..e1fcb65 --- /dev/null +++ b/toolkit/auth0/commands/post-user-registration.command.ts @@ -0,0 +1,13 @@ +import { Command } from "@tshio/command-bus"; + +export const POST_USER_REGISTRATION_COMMAND_TYPE = "example/POST_USER_REGISTRATION"; + +export interface PostUserRegistrationCommandPayload { + userId: string; +} + +export class PostUserRegistrationCommand implements Command { + public type: string = POST_USER_REGISTRATION_COMMAND_TYPE; + + constructor(public payload: PostUserRegistrationCommandPayload) {} +} diff --git a/toolkit/auth0/error-handler.ts b/toolkit/auth0/error-handler.ts new file mode 100644 index 0000000..2d72a1a --- /dev/null +++ b/toolkit/auth0/error-handler.ts @@ -0,0 +1,79 @@ +import { Request, Response, NextFunction } from "express"; +import { CelebrateError, isCelebrateError } from "celebrate"; +import { StatusCodes } from "http-status-codes"; +import { Logger } from "@tshio/logger"; +import { InvalidTokenError, UnauthorizedError } from "express-oauth2-jwt-bearer"; +import { AppError } from "../errors/app.error"; +import { HttpError } from "../errors/http.error"; +import { Translation } from "../shared/translation/translation"; +import { ErrorCode } from "../shared/constants/error-code.enum"; + +type ValidationError = { [key: string]: string[] }; + +export const celebrateToValidationError = (error: CelebrateError): ValidationError => { + const validationErrors: ValidationError = {}; + + error.details.forEach((detail) => { + detail.details.forEach((validationError) => { + const key = validationError.path.join("."); + const errorType = `validation.${validationError.type}`; + + validationErrors[key] = validationErrors[key] || []; + validationErrors[key].push(errorType); + }); + }); + + return validationErrors; +}; + +export const errorHandler = + ({ logger, restrictFromProduction }: { logger: Logger; restrictFromProduction: Function }) => + (err: Error, req: Request, res: Response, _next: NextFunction) => { + logger.error(err.toString()); + + if (isCelebrateError(err)) { + try { + return res.status(StatusCodes.BAD_REQUEST).json({ + errors: celebrateToValidationError(err), + }); + } catch (e) { + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: new Translation(ErrorCode.VALIDATION_PARSE), + stack: restrictFromProduction(err.stack), + }); + } + } + + if (err instanceof HttpError) { + return res.status(err.status).json({ + error: new Translation(ErrorCode.HTTP, err.message), + }); + } + + if (err instanceof AppError) { + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: new Translation(ErrorCode.APP, err.message), + }); + } + + if (err instanceof InvalidTokenError) { + const message = "Bad credentials"; + + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, message), + }); + } + + if (err instanceof UnauthorizedError) { + const message = "Requires authentication"; + + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, message), + }); + } + + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: new Translation(ErrorCode.UNKNOWN), + stack: restrictFromProduction(err.stack), + }); + }; diff --git a/toolkit/auth0/handlers/post-user-registration.handler.ts b/toolkit/auth0/handlers/post-user-registration.handler.ts new file mode 100644 index 0000000..ec9f466 --- /dev/null +++ b/toolkit/auth0/handlers/post-user-registration.handler.ts @@ -0,0 +1,27 @@ +import { CommandHandler } from "@tshio/command-bus"; +import { Logger } from "@tshio/logger"; +import { StatusCodes } from "http-status-codes"; +import { + POST_USER_REGISTRATION_COMMAND_TYPE, + PostUserRegistrationCommand, +} from "../commands/post-user-registration.command"; + +export interface PostUserRegistrationHandlerDependencies { + logger: Logger; +} + +export default class PostUserRegistrationHandler implements CommandHandler { + public commandType: string = POST_USER_REGISTRATION_COMMAND_TYPE; + + constructor(private dependencies: PostUserRegistrationHandlerDependencies) {} + + async execute(command: PostUserRegistrationCommand) { + this.dependencies.logger.info(`Post user ${command.payload.userId} registration action executed`); + + return { + result: { + status: StatusCodes.OK, + }, + }; + } +} diff --git a/toolkit/auth0/middleware/auth0.spec.ts b/toolkit/auth0/middleware/auth0.spec.ts new file mode 100644 index 0000000..30a3217 --- /dev/null +++ b/toolkit/auth0/middleware/auth0.spec.ts @@ -0,0 +1,69 @@ +import sinon from "sinon"; +import { Translation } from "../shared/translation/translation"; +import { ErrorCode } from "../shared/constants/error-code.enum"; +import { checkTokenPayload } from "./auth0"; + +const jsonFuncStub = sinon.stub(); + +const res: any = { + status: () => res, + json: jsonFuncStub, + locals: {}, +}; + +const stubConfig = { + logger: { + error: sinon.stub(), + }, + appConfig: {}, +} as any; + +const next = sinon.stub(); + +describe("Auth0 validation", () => { + beforeEach(() => { + sinon.reset(); + }); + + it("should return unauthorized if token does not contain payload", async () => { + const req: any = {}; + + await checkTokenPayload(stubConfig)(req, res, next); + + sinon.assert.calledOnce(jsonFuncStub); + sinon.assert.calledWith(jsonFuncStub, { + error: new Translation(ErrorCode.HTTP, "Authorization token is not valid"), + }); + }); + + it("should return unauthorized if payload does not contain me details", async () => { + const req: any = { + auth: { + payload: {}, + }, + }; + + await checkTokenPayload(stubConfig)(req, res, next); + + sinon.assert.calledOnce(jsonFuncStub); + sinon.assert.calledWith(jsonFuncStub, { + error: new Translation(ErrorCode.HTTP, "Authorization token is not valid"), + }); + }); + + it("should call next function if payload is valid", async () => { + const req: any = { + auth: { + payload: { + me: { + email: "john@doe@example.com", + }, + }, + }, + }; + + await checkTokenPayload(stubConfig)(req, res, next); + + sinon.assert.calledOnce(next); + }); +}); diff --git a/toolkit/auth0/middleware/auth0.ts b/toolkit/auth0/middleware/auth0.ts new file mode 100644 index 0000000..aafbb43 --- /dev/null +++ b/toolkit/auth0/middleware/auth0.ts @@ -0,0 +1,41 @@ +import { JWTPayload, auth } from "express-oauth2-jwt-bearer"; +import { Request, Response, NextFunction } from "express"; +import { StatusCodes } from "http-status-codes"; +import { Logger } from "winston"; +import { AppConfig } from "../config/app"; +import { Translation } from "../shared/translation/translation"; +import { ErrorCode } from "../shared/constants/error-code.enum"; + +interface Auth0Dependencies { + appConfig: AppConfig; + logger: Logger; +} + +interface AuthPayloadInterface extends JWTPayload { + me?: { + email?: string; + }; +} + +export const validateAccessToken = (dependencies: Auth0Dependencies) => + auth({ + issuerBaseURL: `https://${dependencies.appConfig.auth0Domain}`, + audience: dependencies.appConfig.auth0Audience, + }); + +export const checkTokenPayload = + (dependencies: Auth0Dependencies) => async (req: Request, res: Response, next: NextFunction) => { + const payload = req.auth?.payload as AuthPayloadInterface; + const email = payload?.me?.email; + + if (!email) { + dependencies.logger.error("Authorization token is not valid"); + + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, "Authorization token is not valid"), + }); + } + + res.locals.auth = { email }; // eslint-disable-line no-param-reassign + return next(); + }; diff --git a/toolkit/auth0/middleware/post-user-register-token-handler.spec.ts b/toolkit/auth0/middleware/post-user-register-token-handler.spec.ts new file mode 100644 index 0000000..c8ba392 --- /dev/null +++ b/toolkit/auth0/middleware/post-user-register-token-handler.spec.ts @@ -0,0 +1,66 @@ +import sinon from "sinon"; +import { env } from "process"; +import { postUserRegisterTokenHandler } from "./post-user-register-token-handler"; +import { Translation } from "../shared/translation/translation"; +import { ErrorCode } from "../shared/constants/error-code.enum"; + +const jsonFuncStub = sinon.stub(); + +const res: any = { + status: () => res, + json: jsonFuncStub, +}; + +const stubConfig = { + logger: { + error: sinon.stub(), + }, + appConfig: { + postUserRegisterToken: env.POST_USER_REGISTER_TOKEN, + }, +} as any; + +const next = sinon.stub(); + +describe("Post user register token handler", () => { + beforeEach(() => { + sinon.reset(); + }); + + it("should return unauthorized if token does not exist in header", async () => { + const req: any = { + headers: {}, + }; + + await postUserRegisterTokenHandler(stubConfig)(req, res, next); + sinon.assert.calledOnce(jsonFuncStub); + sinon.assert.calledWith(jsonFuncStub, { + error: new Translation(ErrorCode.HTTP, "Token not found in headers"), + }); + }); + + it("should return unauthorized if token is not valid", async () => { + const req: any = { + headers: { + "x-auth-token": "WrongToken", + }, + }; + + await postUserRegisterTokenHandler(stubConfig)(req, res, next); + sinon.assert.calledOnce(jsonFuncStub); + sinon.assert.calledWith(jsonFuncStub, { + error: new Translation(ErrorCode.HTTP, "Wrong authorization token"), + }); + }); + + it("should call next function if token is valid", async () => { + const req: any = { + headers: { + "x-auth-token": env.POST_USER_REGISTER_TOKEN, + }, + }; + + await postUserRegisterTokenHandler(stubConfig)(req, res, next); + sinon.assert.calledOnce(next); + }); +}); diff --git a/toolkit/auth0/middleware/post-user-register-token-handler.ts b/toolkit/auth0/middleware/post-user-register-token-handler.ts new file mode 100644 index 0000000..363d3b2 --- /dev/null +++ b/toolkit/auth0/middleware/post-user-register-token-handler.ts @@ -0,0 +1,33 @@ +import { Request, Response, NextFunction } from "express"; +import { Logger } from "@tshio/logger"; +import { StatusCodes } from "http-status-codes"; +import { AppConfig } from "../config/app"; +import { Translation } from "../shared/translation/translation"; +import { ErrorCode } from "../shared/constants/error-code.enum"; + +interface PostUserRegisterTokenHandlerDependencies { + appConfig: AppConfig; + logger: Logger; +} + +export const postUserRegisterTokenHandler = + (dependencies: PostUserRegisterTokenHandlerDependencies) => + async (req: Request, res: Response, next: NextFunction) => { + const token = req.headers["x-auth-token"]; + + if (!token) { + dependencies.logger.error("Token not found in headers"); + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, "Token not found in headers"), + }); + } + + if (token !== dependencies.appConfig.postUserRegisterToken) { + dependencies.logger.error("Wrong authorization token"); + return res.status(StatusCodes.UNAUTHORIZED).json({ + error: new Translation(ErrorCode.HTTP, "Wrong authorization token"), + }); + } + + return next(); + }; diff --git a/toolkit/auth0/middlewares.ts b/toolkit/auth0/middlewares.ts new file mode 100644 index 0000000..bb071d8 --- /dev/null +++ b/toolkit/auth0/middlewares.ts @@ -0,0 +1,15 @@ +import { AwilixContainer, asFunction } from "awilix"; +import { errorHandler } from "../middleware/error-handler"; +import { postUserRegisterTokenHandler } from "../middleware/post-user-register-token-handler"; +import { checkTokenPayload, validateAccessToken } from "../middleware/auth0"; + +export async function registerMiddlewares(container: AwilixContainer) { + container.register({ + errorHandler: asFunction(errorHandler), + postUserRegisterTokenHandler: asFunction(postUserRegisterTokenHandler), + checkTokenPayload: asFunction(checkTokenPayload), + validateAccessToken: asFunction(validateAccessToken), + }); + + return container; +} diff --git a/toolkit/auth0/queries/me/index.ts b/toolkit/auth0/queries/me/index.ts new file mode 100644 index 0000000..052ad71 --- /dev/null +++ b/toolkit/auth0/queries/me/index.ts @@ -0,0 +1,2 @@ +export * from "./me.query"; +export * from "./me.query.result"; diff --git a/toolkit/auth0/queries/me/me.query.result.ts b/toolkit/auth0/queries/me/me.query.result.ts new file mode 100644 index 0000000..18a79fa --- /dev/null +++ b/toolkit/auth0/queries/me/me.query.result.ts @@ -0,0 +1,5 @@ +import { QueryResult } from "@tshio/query-bus"; + +export class MeQueryResult implements QueryResult { + constructor(public result: any) {} +} diff --git a/toolkit/auth0/queries/me/me.query.ts b/toolkit/auth0/queries/me/me.query.ts new file mode 100644 index 0000000..2078372 --- /dev/null +++ b/toolkit/auth0/queries/me/me.query.ts @@ -0,0 +1,13 @@ +import { Query } from "@tshio/query-bus"; + +export const ME_QUERY_TYPE = "example/ME"; + +export interface MeQueryPayload { + email: string; +} + +export class MeQuery implements Query { + public type: string = ME_QUERY_TYPE; + + constructor(public payload: MeQueryPayload) {} +} diff --git a/toolkit/auth0/query-handlers.ts b/toolkit/auth0/query-handlers.ts new file mode 100644 index 0000000..bb38954 --- /dev/null +++ b/toolkit/auth0/query-handlers.ts @@ -0,0 +1,18 @@ +import { AwilixContainer, asClass } from "awilix"; +import { asArray } from "@tshio/awilix-resolver"; + +import UsersQueryHandler from "../app/features/example/query-handlers/users.query.handler"; +import MeQueryHandler from "../app/features/example/query-handlers/me.query.handler"; +// HANDLERS_IMPORTS + +export async function registerQueryHandlers(container: AwilixContainer) { + container.register({ + queryHandlers: asArray([ + asClass(UsersQueryHandler), + asClass(MeQueryHandler), + // QUERY_HANDLERS_SETUP + ]), + }); + + return container; +} diff --git a/toolkit/auth0/query-handlers/me.query.handler.ts b/toolkit/auth0/query-handlers/me.query.handler.ts new file mode 100644 index 0000000..fec43fa --- /dev/null +++ b/toolkit/auth0/query-handlers/me.query.handler.ts @@ -0,0 +1,30 @@ +import { QueryHandler } from "@tshio/query-bus"; +import { Repository } from "typeorm"; +import { StatusCodes } from "http-status-codes"; +import { Logger } from "winston"; +import { ME_QUERY_TYPE, MeQuery, MeQueryResult } from "../queries/me"; +import { UserEntity } from "../models/user.entity"; +import { HttpError } from "../../../../errors/http.error"; + +export interface MeQueryDependencies { + logger: Logger; + userRepository: Repository; +} + +export default class MeQueryHandler implements QueryHandler { + public queryType: string = ME_QUERY_TYPE; + + constructor(private dependencies: MeQueryDependencies) {} + + async execute(query: MeQuery): Promise { + const { email } = query.payload; + const profile = await this.dependencies.userRepository.findOne({ where: { email } }); + + if (!profile) { + this.dependencies.logger.error(`User with email "${email}" does not exist`); + throw new HttpError(StatusCodes.NOT_FOUND, `User with email "${email}" does not exist`); + } + + return new MeQueryResult(profile); + } +} diff --git a/toolkit/auth0/routing.ts b/toolkit/auth0/routing.ts new file mode 100644 index 0000000..2dbb4d1 --- /dev/null +++ b/toolkit/auth0/routing.ts @@ -0,0 +1,40 @@ +import express from "express"; +import { Action } from "../../../shared/http/types"; + +import { loginActionValidation } from "./actions/login.action"; +import { usersActionValidation } from "./actions/users.action"; +import { postUserRegistrationActionValidation } from "./actions/post-user-registration.action"; +import { MiddlewareType } from "../../../shared/middleware-type/middleware.type"; +import { meActionValidation } from "./actions/me.action"; +// VALIDATION_IMPORTS + +export interface UsersRoutingDependencies { + loginAction: Action; + usersAction: Action; + postUserRegistrationAction: Action; + postUserRegisterTokenHandler: MiddlewareType; + checkTokenPayload: MiddlewareType; + validateAccessToken: MiddlewareType; + meAction: Action; + // ACTIONS_IMPORTS +} + +export const usersRouting = (actions: UsersRoutingDependencies) => { + const router = express.Router(); + + router.post("/login", [loginActionValidation], actions.loginAction.invoke.bind(actions.loginAction)); + router.get("/users", [usersActionValidation], actions.usersAction.invoke.bind(actions.usersAction)); + router.post( + "/post-user-registration", + [actions.postUserRegisterTokenHandler, postUserRegistrationActionValidation], + actions.postUserRegistrationAction.invoke.bind(actions.postUserRegistrationAction), + ); + router.get( + "/me", + [actions.validateAccessToken, actions.checkTokenPayload, meActionValidation], + actions.meAction.invoke.bind(actions.meAction), + ); + // ACTIONS_SETUP + + return router; +}; diff --git a/toolkit/auth0/tests/bootstrap.ts b/toolkit/auth0/tests/bootstrap.ts new file mode 100644 index 0000000..3fd07aa --- /dev/null +++ b/toolkit/auth0/tests/bootstrap.ts @@ -0,0 +1,64 @@ +import "mocha"; +import { use } from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { DataSource } from "typeorm"; +import sinon from "sinon"; +import "express-async-errors"; +import { createContainer } from "../container"; +import { config } from "../config/db"; +import "express-async-errors"; +import * as auth0Module from "../middleware/auth0"; + +use(chaiAsPromised); + +const clearDb = async (dataSource: DataSource) => { + const entities = dataSource.entityMetadatas; + + await dataSource.manager.transaction(async (transactionalEntityManager) => { + // disable checking relations + await transactionalEntityManager.query("SET session_replication_role = replica;"); + + await Promise.all(entities.map((entity) => transactionalEntityManager.query(`DELETE FROM "${entity.tableName}"`))); + + // enable checking relations + await transactionalEntityManager.query("SET session_replication_role = origin;"); + }); +}; + +before(async () => { + const dbConnection = await new DataSource({ + ...config, + logging: false, + }).initialize(); + + global.dbConnection = dbConnection; + await dbConnection.dropDatabase(); + sinon.stub(auth0Module, "validateAccessToken").callsFake(() => (req, res, next) => { + // eslint-disable-next-line no-param-reassign + req.auth = { + payload: { + me: { + email: "test@integration.com", + }, + }, + header: {}, + token: "", + }; + + return next(); + }); + + global.container = await createContainer(); +}); + +beforeEach(async () => { + if (global.dbConnection) { + await clearDb(global.dbConnection); + } +}); + +after(async () => { + if (global.dbConnection) { + await global.dbConnection.destroy(); + } +}); diff --git a/toolkit/auth0/tests/shared/get-me.integration.spec.ts b/toolkit/auth0/tests/shared/get-me.integration.spec.ts new file mode 100644 index 0000000..6bf4222 --- /dev/null +++ b/toolkit/auth0/tests/shared/get-me.integration.spec.ts @@ -0,0 +1,33 @@ +import request from "supertest"; +import { StatusCodes } from "http-status-codes"; +import { expect } from "chai"; +import { dataSource } from "../../config/db"; +import { UserEntity } from "../../app/features/example/models/user.entity"; + +describe("GET /api/example/me", () => { + const authToken = "Bearer example.token"; + const userRepository = dataSource.getRepository(UserEntity); + + it("should return proper profile data", async () => { + const expectedUserEntity = await userRepository.save({ + firstName: "Test", + lastName: "Integration", + email: "test@integration.com", + }); + + await request(await global.container.cradle.app) + .get("/api/example/me") + .set("Authorization", authToken) + .expect(StatusCodes.OK) + .expect((response) => { + expect(response.body).to.deep.equal(expectedUserEntity); + }); + }); + + it("should return Not Found if there is no such user in database", async () => { + await request(await global.container.cradle.app) + .get("/api/example/me") + .set("Authorization", authToken) + .expect(StatusCodes.NOT_FOUND); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 41e7de4..154f803 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ "src/**/*.ts", "typings/*" ], - "exclude": ["node_modules", "build/**"], + "exclude": ["node_modules", "build/**", "toolkit"], "watchOptions": { "watchFile": "DynamicPriorityPolling", "watchDirectory": "DynamicPriorityPolling"