import iron from '@hapi/iron'; import { Request, Response, NextFunction } from 'express'; import { Role, IDecoded, GatemanOptions, CreateSessionOptions, CreateHeadlessTokenOptions, } from './typings'; import { validateAuthHeader } from './utils/validateAuthHeader'; import { validateRole } from './utils/validateRole'; import { jsendError } from './jsendError'; export class Gateman { options: GatemanOptions; /** * Sets the redis object for persisting tokens, the secret key for sealing & unsealing * objects and the default session duration. * @param options Gateman config options */ constructor(options: GatemanOptions) { if (!options.service) throw new Error('Please provide a service name'); if (!options.authScheme) throw new Error('Please provide the service auth scheme'); if (!options.redis) throw new Error('Please provide the redis client'); if (!options.secret) throw new Error('Please provide the secret'); if (options.secret.length < iron.defaults.encryption.minPasswordlength) throw new Error( `Secret string too short (min ${iron.defaults.encryption.minPasswordlength} characters required)` ); this.options = { ...options, authScheme: options.authScheme, sessionDuration: options.sessionDuration || 600, }; } /** * Persists a token using the user's id in Redis for a specific period of time * @param id The user's id * @param token The user's token * @param sessionDuration How long the token should be peristed to Redis in seconds. */ private async persistSession( id: string, token: string, sessionDuration?: number ) { await this.options.redis.set( id, token, 'EX', sessionDuration || this.options.sessionDuration ); } /** * Deletes a token from Redis using the user's id * @param id The user's id */ async clearSession(id: string) { const result = await this.options.redis.del(id); return result; } /** * Creates an encrypted token using the user's id and role, and persists it to Redis for a specific period of time. * Used for creating `admin` and `user` sessions. Creates a `user` session token by default * @param options Options for creating the session */ async createSession(options: CreateSessionOptions) { const { id, role, sessionDuration, data } = options; const token = await iron.seal( { id, role: role || 'user', data }, this.options.secret, iron.defaults ); await this.persistSession(id, token, sessionDuration); return token; } /** * Creates a token that can be used for headless (i.e not triggered by a human user) inter-service calls by encrypting * the service name and the id of the user whom the call is made for. * The token is not persisted instead a TTL of 1 minute is attached to the token after which the token becomes invalidated. * @param options Options for creating the headless token */ async createHeadlessToken(options: CreateHeadlessTokenOptions) { const { id, data } = options; const sealOptions = { ...iron.defaults, // The headless token is only valid for 60 seconds ttl: 60 * 1000, }; const token = await iron.seal( { id, data, role: 'service', service: this.options.service }, this.options.secret, sealOptions ); return token; } /** * Decrypts an encrypted token and returns the data contained within * @param token Encrypted token */ async decrypt(token: string) { //@ts-ignore const data: T = await iron.unseal( token, this.options.secret, iron.defaults ); return data; } /** * Returns an Express middleware that guards requests to a particular endpoint using the token in the `Authorization` header against recognized `roles`. * @param roles The role(s) allowed to call the endpoint, defaults to `user`. Should be either `user` or `admin` * @param service The optional service(s) allowed to call the endpoint. `roles` should either contain or be `service` when this argument is provided. * If `service` is `"*"` all services can call the endpoint */ guard(roles: Role | Role[] = 'user', service?: string | string[]) { return async (req: Request, res: Response, next: NextFunction) => { try { if (!req.headers.authorization) throw new Error('Required Authorization header not found'); const { token, scheme } = validateAuthHeader( this.options.authScheme, req.headers.authorization ); const data = await this.decrypt(token); validateRole({ serviceAuthScheme: this.options.authScheme, roles, service, scheme, data, }); if (scheme === 'Bearer') { // Check if the user has an existing session and refresh it const sessionToken = await this.options.redis.get(data.id); if (!sessionToken) throw new Error('Invalid session token'); if (sessionToken !== token) throw new Error('Expired session token'); await this.persistSession(data.id, token); } req.user = data.id; req.data = data.data; next(); } catch (err) { jsendError(res, err.message); } }; } }