UNPKG

@ssense/auth

Version:

The Auth Module is a combination for an HTTP middleware (compatible with express and restify) and a Typescript decorator. Used together, they allow protection for all the routes of your application, handling user authentication and authorizations.

477 lines 19.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AuthModule = exports.AuthInfo = void 0; const exceptions_1 = require("@ssense/exceptions"); const http_1 = require("@ssense/http"); const logger_1 = require("@ssense/logger"); const redis_pubsub_1 = require("@ssense/redis-pubsub"); const jsonwebtoken_1 = require("jsonwebtoken"); const node_crypto_1 = require("node:crypto"); const node_url_1 = require("node:url"); const zod_1 = require("zod"); const AuthServerPubSubConfigSchema = zod_1.z.object({ type: zod_1.z.string(), host: zod_1.z.string(), port: zod_1.z.number(), messagesSignatureAlgorithm: zod_1.z.string(), }); const AuthServerConfigSchema = zod_1.z.object({ authHtmlTemplate: zod_1.z.string(), forbiddenHtmlTemplate: zod_1.z.string(), keysUrl: zod_1.z.string(), invalidatedTokensUrl: zod_1.z.string(), cookieName: zod_1.z.string(), jwtIssuer: zod_1.z.string(), jwtAudience: zod_1.z.string(), pubsub: AuthServerPubSubConfigSchema, }); const AuthServerPubSubMessageSchema = zod_1.z.object({ body: zod_1.z.any(), kid: zod_1.z.string(), signature: zod_1.z.string(), }); const ScopesSchema = zod_1.z.record(zod_1.z.record(zod_1.z.array(zod_1.z.string()))); const AuthInfoTypeSchema = zod_1.z.enum(['user', 'service']); const RemoteAuthInfoSchema = zod_1.z.object({ id: zod_1.z.string(), type: AuthInfoTypeSchema, scopes: ScopesSchema, }); class AuthInfo { constructor() { this.enabled = true; this.publicRoute = false; } hasScope(scope) { if (!this.isActive()) { return true; } return Array.isArray(this.scopes) && this.scopes.indexOf(scope) >= 0; } hasScopes(scopes) { if (!this.isActive()) { return true; } if (!Array.isArray(scopes) || !Array.isArray(this.scopes)) { return false; } return !scopes.some((s) => this.scopes.indexOf(s) < 0); } isActive() { return this.enabled && !this.publicRoute; } } exports.AuthInfo = AuthInfo; class AuthModule { constructor(options) { this.publicKeysExpire = -1; this.invalidatedTokens = {}; if (typeof process.env.npm_package_name !== 'string' || process.env.npm_package_name.trim() === '') { throw new Error('process.env.npm_package_name is undefined'); } else if (typeof process.env.npm_package_version !== 'string' || process.env.npm_package_version.trim() === '') { throw new Error('process.env.npm_package_version is undefined'); } this.userAgent = `${process.env.npm_package_name}@${process.env.npm_package_version}`; AuthModule.enabled = options && typeof options.enabled === 'boolean' ? options.enabled : true; const host = options && typeof options.authServerHost === 'string' ? options.authServerHost : 'auth.ssense.com'; const secure = options && typeof options.authServerSecure === 'boolean' ? options.authServerSecure : true; this.client = new http_1.HttpClient({ host, secure, userAgent: this.userAgent }); this.logger = new logger_1.AppLogger(process.env.npm_package_name, logger_1.LogLevel.Warn); this.logger.setPretty(process.env.NODE_ENV === 'development'); this.alwaysCreateAuthObject = typeof options?.alwaysCreateAuthObject === 'boolean' && options.alwaysCreateAuthObject; if (options) { if (Array.isArray(options.publicRoutes) && options.publicRoutes.length > 0) { for (const route of options.publicRoutes) { if (route instanceof RegExp !== true) { throw new TypeError('options.publicRoutes must be a RegExp[]'); } } this.publicRoutes = options.publicRoutes; } if (Array.isArray(options.publicHttpMethods) && options.publicHttpMethods.length > 0) { for (const method of options.publicHttpMethods) { if (typeof method !== 'string') { throw new TypeError('options.publicHttpMethods must be a string[]'); } } this.publicHttpMethods = options.publicHttpMethods.map((m) => m.trim().toUpperCase()); } if (options.onForbidden) { if (typeof options.onForbidden !== 'function') { throw new TypeError('options.onForbidden must be a valid callback function'); } AuthModule.forbiddenCallback = options.onForbidden; } if (options.logger) { if (typeof options.logger.enabled === 'boolean') { this.logger.enable(options.logger.enabled); } if (options.logger.level && logger_1.LogLevel[options.logger.level]) { this.logger.setLevel(options.logger.level); } if (typeof options.logger.pretty === 'boolean') { this.logger.setPretty(options.logger.pretty); } } } } authenticate() { return async (req, res, next) => { if (!AuthModule.enabled) { if (this.alwaysCreateAuthObject) { req.auth = new AuthInfo(); req.auth.enabled = false; } return next(); } const requestId = req.xRequestId || (0, node_crypto_1.randomUUID)(); try { if (this.isPublicHttpMethod(req) || this.isPublicRoute(req)) { if (this.alwaysCreateAuthObject) { req.auth = new AuthInfo(); req.auth.publicRoute = true; } return next(); } if (!AuthModule.templates) { await this.initialize(requestId); } const auth = await this.getAuthInfoFromRequest(requestId, req); if (auth) { req.auth = auth; return next(); } AuthModule.showAuthenticationPage(res); } catch (e) { this.logger.getRequestLogger(requestId).error(e.message, [], { stack: e.stack }); next(e); throw e; } }; } static enable(enabled) { if (typeof enabled === 'boolean') { AuthModule.enabled = enabled; } } async initialize(requestId) { try { const result = await this.client.sendRequest(requestId, 'client/config'); const serverConfig = AuthServerConfigSchema.parse(result.body); this.invalidatedTokensUrl = serverConfig.invalidatedTokensUrl; const [authHtmlTemplate, forbiddenHtmlTemplate] = await Promise.all([ this.client.sendRequest(requestId, serverConfig.authHtmlTemplate), this.client.sendRequest(requestId, serverConfig.forbiddenHtmlTemplate), this.getInvalidatedTokens(requestId), ]); this.cookieName = serverConfig.cookieName; this.publicKeysUrl = serverConfig.keysUrl; this.jwtOptions = { issuer: serverConfig.jwtIssuer, audience: serverConfig.jwtAudience, }; AuthModule.templates = { auth: authHtmlTemplate.body, forbidden: forbiddenHtmlTemplate.body, }; this.initializePubSubClient(serverConfig.pubsub); const interval = setInterval(this.cleanExpiredInvalidatedTokens.bind(this), 900000); interval.unref(); } catch (e) { if (e instanceof zod_1.ZodError) { throw new exceptions_1.InternalErrorException('Error while retrieving authentication server configuration', undefined, { issues: e.issues }); } throw e; } } initializePubSubClient(config) { this.pubSubMessagesSignatureAlgorithm = config.messagesSignatureAlgorithm; switch (config.type) { case 'redis': this.pubSubClient = new redis_pubsub_1.RedisPubSub({ host: config.host, port: config.port, mode: 'read', }); break; default: break; } if (this.pubSubClient) { this.pubSubClient.on('error', (e) => { this.logger.warn('An error occurred in AuthModule pub/sub client', undefined, ['@ssense/auth', 'AuthModule', 'pubsub'], { message: e.message, stack: e.stack, }); }); this.pubSubClient.on('Auth:Tokens:Invalidated', this.onPubSubAuthTokenInvalidated.bind(this)); } } async getAuthInfoFromRequest(requestId, req) { let result; if (req.headers) { let token; if (req.headers.authorization) { const authorization = Array.isArray(req.headers.authorization) ? req.headers.authorization[0] : req.headers.authorization; if (authorization.startsWith('Bearer ')) { token = authorization.slice(7); } } else if (req.headers.cookie) { const cookies = Array.isArray(req.headers.cookie) ? req.headers.cookie : [req.headers.cookie]; for (const c of cookies) { const cookieItems = c.split(';'); for (const item of cookieItems) { const cookie = item.trim().split('='); if (cookie.length === 2 && cookie[0] === this.cookieName) { token = cookie[1]; break; } } if (token) { break; } } } if (token) { const tokenInfo = await this.getTokenInfo(requestId, req, token); if (tokenInfo) { result = await this.verifyToken(token, tokenInfo); } } } return result; } async getPublicKeyFromKid(requestId, kid) { if (!this.publicKeys || this.publicKeysExpire < Date.now()) { const data = await this.client.sendRequest(requestId ?? this.logger.generateRequestId(), this.publicKeysUrl); this.publicKeys = data.body; const cacheControl = data.headers ? ((Array.isArray(data.headers['cache-control']) ? data.headers['cache-control'][0] : data.headers['cache-control'])) : null; if (cacheControl) { const maxAge = /.*max-age=(\d+).*/.exec(cacheControl); if (maxAge) { this.publicKeysExpire = Date.now() + +maxAge[1] * 1000; } } } return this.publicKeys[kid]; } async getTokenInfo(requestId, req, token) { let result; const tokenParts = token.split('.'); if (tokenParts.length === 3) { try { const tokenHeader = JSON.parse(Buffer.from(tokenParts[0], 'base64').toString()); const tokenBody = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); if (tokenHeader && typeof tokenHeader === 'object' && typeof tokenHeader.kid === 'string' && tokenBody && typeof tokenBody === 'object' && typeof tokenBody.jti === 'string' && typeof tokenBody.exp === 'number') { result = { tokenId: tokenBody.jti, publicKey: await this.getPublicKeyFromKid(requestId, tokenHeader.kid), expirationTime: new Date(tokenBody.exp * 1000), }; } } catch (e) { this.logger.getRequestLogger(requestId).warn(e.message, [], { stack: e.stack }); } } return result; } async getInvalidatedTokens(requestId) { const result = await this.client.sendRequest(requestId, this.invalidatedTokensUrl); this.invalidatedTokens = Object.assign(this.invalidatedTokens, result.body); } cleanExpiredInvalidatedTokens() { const now = Math.floor(Date.now() / 1000); for (const jwtId of Object.keys(this.invalidatedTokens)) { if (this.invalidatedTokens[jwtId] < now) { delete this.invalidatedTokens[jwtId]; } } } verifyToken(token, tokenInfo) { return new Promise((resolve) => { if (!tokenInfo.tokenId || this.invalidatedTokens.hasOwnProperty(tokenInfo.tokenId) || !tokenInfo.publicKey) { return resolve(undefined); } (0, jsonwebtoken_1.verify)(token, tokenInfo.publicKey, this.jwtOptions, (err, data) => { if (err || !data || typeof data !== 'object' || !data.hasOwnProperty('data')) { return resolve(undefined); } try { const { id, type, scopes } = RemoteAuthInfoSchema.parse(data.data); const result = new AuthInfo(); result.id = id; result.type = type; result.scopes = this.parseScopes(scopes); result.token = token; result.tokenId = tokenInfo.tokenId; result.tokenExpirationTime = tokenInfo.expirationTime; resolve(result); } catch (e) { resolve(undefined); } }); }); } parseScopes(scopes) { const result = []; if (typeof scopes === 'object' && process.env.npm_package_name && scopes.hasOwnProperty(process.env.npm_package_name)) { for (const entity of Object.keys(scopes[process.env.npm_package_name])) { for (const action of scopes[process.env.npm_package_name][entity]) { result.push(`${entity}:${action}`); } } } return result; } isPublicRoute(req) { if (!this.publicRoutes || !req.url) { return false; } const path = (0, node_url_1.parse)(req.url).pathname?.replace(/\/+/g, '/'); if (!path) { return false; } for (const route of this.publicRoutes) { if (route.test(path)) { return true; } } return false; } isPublicHttpMethod(req) { if (!this.publicHttpMethods || !req.method) { return false; } return this.publicHttpMethods.indexOf(req.method.toUpperCase()) >= 0; } async validatePubSubMessageSignature(message) { if (message && message.data) { try { const data = AuthServerPubSubMessageSchema.parse(message.data); const publicKey = await this.getPublicKeyFromKid(null, data.kid); if (publicKey) { const verifier = (0, node_crypto_1.createVerify)(this.pubSubMessagesSignatureAlgorithm); verifier.update(JSON.stringify(data.body)); verifier.end(); return verifier.verify(publicKey, data.signature, 'base64'); } } catch (e) { } } return false; } async onPubSubAuthTokenInvalidated(message) { if (await this.validatePubSubMessageSignature(message)) { const now = Math.floor(Date.now() / 1000); for (const jwtId of Object.keys(message.data.body)) { if (message.data.body[jwtId] > now) { this.invalidatedTokens[jwtId] = message.data.body[jwtId]; } } } } static requireScope(scope) { return AuthModule.checkScopes(scope, false); } static requireAllScopes(scopes) { return AuthModule.checkScopes(scopes, true); } static checkScopes(scope, checkAllScopes) { return (target, propertyKey, descriptor) => { const middleware = AuthModule.scopeMiddleware(scope, checkAllScopes); const originalMethod = descriptor.value; descriptor.value = async function (...args) { if (!Array.isArray(args) || args.length < 2) { return originalMethod.apply(this, args); } const req = args[0]; const res = args[1]; return middleware(req, res, () => { return originalMethod.apply(this, args); }); }; }; } static scopeMiddleware(scope, checkAllScopes = true) { const scopesToCheck = Array.isArray(scope) ? scope.reduce((acc, s) => { if (typeof s === 'string') { const scopeName = s.toLowerCase().trim(); if (scopeName !== '' && acc.indexOf(scopeName) < 0) { acc.push(scopeName); } } return acc; }, []) : typeof scope === 'string' && scope.trim() !== '' ? [scope.trim().toLowerCase()] : []; if (scopesToCheck.length === 0) { throw new TypeError('scope param must be a non-empty string or string[]'); } return async (req, res, next) => { if (!AuthModule.enabled) { return next(); } if (req.auth && !req.auth.isActive()) { return next(); } if (!req.auth || !Array.isArray(req.auth.scopes)) { AuthModule.showAuthenticationPage(res); } else if (AuthModule.hasRequiredScope(scopesToCheck, req.auth.scopes, checkAllScopes)) { return next(); } else { res.statusCode = 403; if (AuthModule.forbiddenCallback) { AuthModule.forbiddenCallback(req, res, next); } else { res.setHeader('Content-Type', 'text/html'); res.write(AuthModule.templates.forbidden); res.end(); } } }; } static hasRequiredScope(scopesToCheck, authScopes, checkAllScopes) { return checkAllScopes ? !scopesToCheck.some((s) => authScopes.indexOf(s) < 0) : scopesToCheck.some((s) => authScopes.indexOf(s) >= 0); } static showAuthenticationPage(res) { if (!res.headersSent) { res.writeHead(401, { 'Content-Type': 'text/html' }); res.write(AuthModule.templates.auth); } res.end(); } } exports.AuthModule = AuthModule; //# sourceMappingURL=AuthModule.js.map