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.

380 lines (379 loc) 14.9 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let _ssense_exceptions = require("@ssense/exceptions"); let _ssense_http = require("@ssense/http"); let _ssense_logger = require("@ssense/logger"); let _ssense_redis_pubsub = require("@ssense/redis-pubsub"); let jsonwebtoken = require("jsonwebtoken"); let node_crypto = require("node:crypto"); let node_url = require("node:url"); let zod = require("zod"); //#region src/AuthModule.ts const AuthServerPubSubConfigSchema = zod.z.object({ type: zod.z.string(), host: zod.z.string(), port: zod.z.number(), messagesSignatureAlgorithm: zod.z.string() }); const AuthServerConfigSchema = zod.z.object({ authHtmlTemplate: zod.z.string(), forbiddenHtmlTemplate: zod.z.string(), keysUrl: zod.z.string(), invalidatedTokensUrl: zod.z.string(), cookieName: zod.z.string(), jwtIssuer: zod.z.string(), jwtAudience: zod.z.string(), pubsub: AuthServerPubSubConfigSchema }); const AuthServerPubSubMessageSchema = zod.z.object({ body: zod.z.any(), kid: zod.z.string(), signature: zod.z.string() }); const ScopesSchema = zod.z.record(zod.z.record(zod.z.array(zod.z.string()))); const AuthInfoTypeSchema = zod.z.enum(["user", "service"]); const RemoteAuthInfoSchema = zod.z.object({ id: zod.z.string(), type: AuthInfoTypeSchema, scopes: ScopesSchema }); var AuthInfo = class { id; type; scopes; token; tokenId; tokenExpirationTime; enabled = true; 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; } }; var AuthModule = class AuthModule { userAgent; client; logger; publicRoutes; publicHttpMethods; alwaysCreateAuthObject; cookieName; publicKeysUrl; invalidatedTokensUrl; publicKeys; publicKeysExpire = -1; invalidatedTokens = {}; jwtOptions; pubSubClient; pubSubMessagesSignatureAlgorithm; static enabled; static templates; static forbiddenCallback; constructor(options) { 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; this.client = new _ssense_http.HttpClient({ host: options && typeof options.authServerHost === "string" ? options.authServerHost : "auth.ssense.com", secure: options && typeof options.authServerSecure === "boolean" ? options.authServerSecure : true, userAgent: this.userAgent }); this.logger = new _ssense_logger.AppLogger(process.env.npm_package_name, _ssense_logger.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 !== void 0 && Object.values(_ssense_logger.LogLevel).includes(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.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); setInterval(this.cleanExpiredInvalidatedTokens.bind(this), 9e5).unref(); } catch (e) { if (e instanceof zod.ZodError) throw new _ssense_exceptions.InternalErrorException("Error while retrieving authentication server configuration", void 0, { issues: e.issues }); throw e; } } initializePubSubClient(config) { this.pubSubMessagesSignatureAlgorithm = config.messagesSignatureAlgorithm; switch (config.type) { case "redis": this.pubSubClient = new _ssense_redis_pubsub.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", void 0, [ "@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] * 1e3; } } 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: /* @__PURE__ */ new Date(tokenBody.exp * 1e3) }; } 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() / 1e3); 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(void 0); (0, jsonwebtoken.verify)(token, tokenInfo.publicKey, this.jwtOptions, (err, data) => { if (err || !data || typeof data !== "object" || !data.hasOwnProperty("data")) return resolve(void 0); 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(void 0); } }); }); } 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.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.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() / 1e3); 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(); } }; //#endregion exports.AuthInfo = AuthInfo; exports.AuthModule = AuthModule;