@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
JavaScript
"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