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