UNPKG

jwt-smith

Version:

Enhanced JWT Authentication and Authorization Module

688 lines (676 loc) 27.1 kB
// src/lib/core.ts import Joi3 from "joi"; // src/lib/logger.ts var LOGGER_PREFIX = "[JWT-Smith] "; var currentLogger = console; var logSettings = { setPrefix: true }; var setLogger = (logger, options) => { currentLogger = logger; logSettings = { ...logSettings, ...options || {} }; }; var getLogger = () => { return currentLogger; }; var logFormat = (message) => { return `${logSettings.setPrefix ? LOGGER_PREFIX : ""}${message}`; }; var log = (level, message, ...args) => { const logger = getLogger(); if (logger[level]) { logger[level](logFormat(message), ...args); } else { console.error(logFormat(`Invalid log level: ${level}`)); } }; // src/lib/signing-token.ts import jsonwebtoken from "jsonwebtoken"; import Joi from "joi"; var signTokenOptionsSchema = Joi.object({ algorithm: Joi.string().default("HS256"), keyid: Joi.string(), expiresIn: Joi.alternatives().try(Joi.string(), Joi.number()), notBefore: Joi.alternatives().try(Joi.string(), Joi.number()), audience: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())), subject: Joi.string(), issuer: Joi.string(), jwtid: Joi.string(), mutatePayload: Joi.bool(), noTimestamp: Joi.bool(), header: Joi.object(), encoding: Joi.string(), allowInsecureKeySizes: Joi.bool(), allowInvalidAsymmetricKeyTypes: Joi.boolean() }); var secretSchema = Joi.alternatives().try( Joi.string(), Joi.binary(), Joi.object().instance(Buffer), Joi.object({ key: Joi.alternatives().try(Joi.string(), Joi.binary()), passphrase: Joi.string() }) ).not(""); var signTokenParamsSchema = Joi.object({ payload: Joi.alternatives().try(Joi.string(), Joi.object(), Joi.binary()).required(), secret: secretSchema.required(), options: signTokenOptionsSchema.optional() }); var defaultSignOptions = {}; var sign = (parameters) => { return new Promise((resolve, reject) => { const { error, warning, value } = signTokenParamsSchema.validate(parameters); if (error) { reject(logFormat(`Parameter Validation Error: ${error.message}`)); } else if (warning) { log("warn", "Parameter Validation Warning:", { warning_details: warning }); } const { payload, secret, options = {} } = value; const signOptions = { ...defaultSignOptions, ...options }; jsonwebtoken.sign(payload, secret, signOptions, (err, token) => { if (err) { reject(err); } else { resolve(token); } }); }); }; var setDefaultSignOptions = (options) => { const { error, warning, value } = signTokenOptionsSchema.validate(options); if (error) { throw new Error(logFormat(`Parameter Validation Error: ${error.message}`)); } else if (warning) { log("warn", "Parameter Validation Warning:", { warning_details: warning }); } defaultSignOptions = value; }; // src/lib/verify-token.ts import jsonwebtoken2 from "jsonwebtoken"; import Joi2 from "joi"; var verifyTokenOptionsSchema = Joi2.object({ algorithms: Joi2.array().items(Joi2.string()), audience: Joi2.alternatives().try(Joi2.string(), Joi2.array().items(Joi2.string())), clockTimestamp: Joi2.number(), clockTolerance: Joi2.number(), complete: Joi2.bool(), issuer: Joi2.alternatives().try(Joi2.string(), Joi2.array().items(Joi2.string())), ignoreExpiration: Joi2.boolean(), ignoreNotBefore: Joi2.boolean(), jwtid: Joi2.string(), nonce: Joi2.string(), subject: Joi2.string(), maxAge: Joi2.alternatives().try(Joi2.string(), Joi2.number()), allowInvalidAsymmetricKeyTypes: Joi2.boolean() }); var secretSchema2 = Joi2.alternatives().try( Joi2.string().not(""), Joi2.binary(), Joi2.object().instance(Buffer), Joi2.object({ key: Joi2.alternatives().try(Joi2.string(), Joi2.binary()), passphrase: Joi2.string() }) ); var verifyTokenParamsSchema = Joi2.object({ token: Joi2.string().not("").required(), secret: secretSchema2.required(), options: verifyTokenOptionsSchema.optional() }); var defaultVerifyOptions = {}; var verify = (parameters) => { return new Promise((resolve, reject) => { const { error, warning, value } = verifyTokenParamsSchema.validate(parameters); if (error) { reject(logFormat(`Parameter Validation Error: ${error.message}`)); } else if (warning) { log("warn", "Parameter Validation Warning:", { warning_details: warning }); } const { token, secret, options = {} } = value; const verifyOptions = { ...defaultVerifyOptions, options }; jsonwebtoken2.verify(token, secret, verifyOptions, (err, decoded) => { if (err) { reject(err); } else { resolve(decoded); } }); }); }; var setDefaultVerifyOptions = (options) => { const { error, warning, value } = verifyTokenOptionsSchema.validate(options); if (error) { throw new Error(logFormat(`Parameter Validation Error: ${error.message}`)); } else if (warning) { log("warn", "Parameter Validation Warning:", { warning_details: warning }); } defaultVerifyOptions = value; }; // src/helper/utils.ts var extractAuthHeaderValue = (header) => { let tokenValue; if (header && header.split(" ")[1]) { tokenValue = header.split(" ")[1]; } return tokenValue; }; var appendTokenPayloadToRequest = (req, appendToRequest, decodedTokenPayload) => { if (Array.isArray(appendToRequest) && appendToRequest?.length > 0 && decodedTokenPayload && typeof decodedTokenPayload !== "string") { log("debug", `Properties to append to the request: ${appendToRequest}`); try { const castedPayload = decodedTokenPayload; appendToRequest.forEach((item) => { if (Object.hasOwn(castedPayload, item)) { req[item] = castedPayload[item]; } }); } catch (error) { log("error", "Token payload appending to the request failed!", error); } } else if (typeof appendToRequest === "boolean" && typeof decodedTokenPayload === "string") { log("debug", `Token payload appending to the request: ${decodedTokenPayload}`); req.tokenPayload = decodedTokenPayload; } }; var defaultTokenGenerationHandler = async (refreshTokenPayload) => { if (process.env.NODE_ENV === "production") { throw new Error("Token generation handler not implemented."); } else { log("warn", "Token generation handler not implemented. Using default handler."); console.debug({ refreshTokenPayload }); } return { token: "new-token", refreshToken: "new-refresh-token" }; }; var defaultAuthTokenPayloadVerifier = async (tokenPayload) => { if (!tokenPayload) { throw new Error("Empty payload in the auth token."); } }; var defaultRefreshTokenPayloadVerifier = async (tokenPayload) => { const user = tokenPayload?.user; const userId = user?.id; if (!userId) { throw new Error("Refresh token process failed. User ID not found in the refresh payload."); } }; var defaultRefreshTokenHolderVerifier = async (tokenHolder, tokenPayload) => { const user = tokenPayload?.user; const userId = user?.id || user?.userId; const tokenHolderId = tokenHolder?.id || tokenHolder?.userId; return tokenHolderId === userId; }; var defaultExtractApiVersion = async (req) => { const version = req.headers["api-version"]; return version ?? req.baseUrl.split("/")[1]; }; // src/lib/core.ts var publicKey; var refreshTokenKey; var middlewareConfigs = { tokenStorage: void 0, authHeaderName: "authorization", appendToRequest: [], cookieSettings: { accessTokenCookieName: "accessToken", accessCookieOptions: {}, refreshTokenCookieName: void 0 }, authTokenExtractor: extractAuthHeaderValue, tokenGenerationHandler: defaultTokenGenerationHandler, refreshTokenPayloadVerifier: defaultRefreshTokenPayloadVerifier, refreshTokenHolderVerifier: defaultRefreshTokenHolderVerifier, extractApiVersion: defaultExtractApiVersion }; var secretSchema3 = Joi3.alternatives().try( Joi3.string(), Joi3.binary(), Joi3.object().instance(Buffer), Joi3.object({ key: Joi3.alternatives().try(Joi3.string(), Joi3.binary()), passphrase: Joi3.string() }) ); var configOptionsSchema = Joi3.object({ logger: Joi3.object().optional(), publicKey: secretSchema3.optional(), refreshTokenKey: secretSchema3.optional(), signOptions: Joi3.object().optional(), verifyOptions: Joi3.object().optional(), middlewareConfigs: Joi3.object().optional() }); var JwtManager = (options) => { const { error, warning, value } = configOptionsSchema.validate(options); if (error) { throw new Error(logFormat(`Parameter Validation Error: ${error.message}`)); } else if (warning) { log("warn", "Parameter Validation Warning:", { warning_details: warning }); } if (value.logger) setLogger(value.logger); if (value.publicKey) publicKey = value.publicKey; if (value.refreshTokenKey) refreshTokenKey = value.refreshTokenKey; if (value.signOptions) setDefaultSignOptions(value.signOptions); if (value.verifyOptions) setDefaultVerifyOptions(value.verifyOptions); if (value.middlewareConfigs) middlewareConfigs = { ...middlewareConfigs, ...value.middlewareConfigs }; }; // src/module/token-storage.ts var DefaultTokenStorage = class { tokens = /* @__PURE__ */ new Map(); defectedTokens = /* @__PURE__ */ new Map(); async saveOrUpdateToken(userId, tokenOrRefreshToken, token) { const existingData = this.tokens.get(userId) || { refreshTokens: [], tokens: [] }; let update = { refreshTokens: [] }; update = { ...existingData, refreshTokens: [...existingData.refreshTokens || [], tokenOrRefreshToken] }; if (token) { update = { ...update, tokens: [...existingData.tokens || [], token] }; } this.tokens.set(userId, update); } async getRefreshTokenHolder(refreshToken) { let holder = null; this.tokens.forEach((data, userId) => { if (data.refreshTokens?.includes(refreshToken)) { holder = { id: userId, ...this.tokens.get(userId) }; return; } }); return holder; } async getRefreshToken(userId) { return this.tokens.get(userId)?.refreshTokens || null; } async deleteToken(userId) { this.tokens.delete(userId); } async getToken(userId) { return this.tokens.get(userId)?.tokens || null; } async blackListRefreshToken(token, relatedData) { this.defectedTokens.set(token, relatedData || {}); } async checkBlackListedRefreshToken(token) { return this.defectedTokens.get(token); } }; // src/module/refresh-token-handler.ts import { TokenExpiredError } from "jsonwebtoken"; var TokenHandler = class { refreshTokenStorage; tokenGenerationHandler; authTokenPayloadVerifier; refreshTokenPayloadVerifier; refreshTokenHolderVerifier; constructor(options) { this.refreshTokenStorage = options.refreshTokenStorage || new DefaultTokenStorage(); this.tokenGenerationHandler = options.tokenGenerationHandler; this.authTokenPayloadVerifier = options.authTokenPayloadVerifier || defaultAuthTokenPayloadVerifier; this.refreshTokenPayloadVerifier = options.refreshTokenPayloadVerifier || defaultRefreshTokenPayloadVerifier; this.refreshTokenHolderVerifier = options.refreshTokenHolderVerifier || defaultRefreshTokenHolderVerifier; if (!options.refreshTokenStorage) { const logType = process.env.NODE_ENV === "production" ? "error" : "warn"; log(logType, "[TokenHandler]: Using default in-memory token storage. This is not recommended for production."); } } async validateOrRefreshAuthToken(authToken, refreshToken) { let decodedToken; let token = authToken; let nextRefreshToken = refreshToken; await this.validateAuthToken(authToken).then((tokenPayload) => { decodedToken = tokenPayload; }).catch(async (error) => { if (error instanceof TokenExpiredError && refreshToken) { const response = await this.rotateRefreshToken(refreshToken, authToken); token = response.token; nextRefreshToken = response.refreshToken; decodedToken = await verify({ token, secret: publicKey }); } else { log("info", "Refresh token not found."); throw error; } }); return { decodedToken, nextRefreshToken, token }; } async validateAuthToken(authToken) { const tokenPayload = await verify({ token: authToken, secret: publicKey }); await this.authTokenPayloadVerifier(tokenPayload); log("info", "Auth token validation complete!"); return tokenPayload; } async rotateRefreshToken(refreshToken, token) { try { const isBlackListed = await this.refreshTokenStorage.checkBlackListedRefreshToken(refreshToken); log("debug", "Checking if the refresh token is blacklisted."); if (isBlackListed) { log("error", "Blacklisted refresh token received!", { refreshToken }); throw new Error("Blacklisted refresh token found!"); } const decodedTokenPayload = await verify({ token: refreshToken, secret: refreshTokenKey || publicKey }); log("debug", "Decoded the refresh token payload."); if (!decodedTokenPayload) { throw new Error("Refresh token payload is undefined!"); } await this.refreshTokenPayloadVerifier(decodedTokenPayload); log("debug", "Refresh token payload verification complete."); const tokenHolder = await this.refreshTokenStorage.getRefreshTokenHolder(refreshToken); log("debug", "Retrieved the refresh token holder."); if (!tokenHolder) { throw new Error("Could not find a matching token holder for the refresh token."); } const isHolderVerified = await this.refreshTokenHolderVerifier(tokenHolder, decodedTokenPayload); log("debug", "Refresh token holder verification complete."); if (!isHolderVerified) { await this.refreshTokenStorage.blackListRefreshToken(refreshToken); throw new Error("Refresh token holder verification failed."); } const response = await this.tokenGenerationHandler(decodedTokenPayload, tokenHolder); log("debug", "Generated new tokens."); const userId = tokenHolder.id || (typeof decodedTokenPayload !== "string" && "user" in decodedTokenPayload ? decodedTokenPayload.user?.id : void 0); await this.refreshTokenStorage.saveOrUpdateToken(userId, response.refreshToken, response.token); log("debug", "Saved the new tokens."); return response; } catch (error) { await this.cleanupInvalidRefreshToken(refreshToken, token); throw error; } } async cleanupInvalidRefreshToken(refreshToken, token) { const tokenHolder = await this.refreshTokenStorage.getRefreshTokenHolder(refreshToken); if (tokenHolder && Object.hasOwn(tokenHolder, "id")) { const userId = tokenHolder.id; await this.refreshTokenStorage.deleteToken(userId, token, refreshToken); } } }; // src/middleware/auth-header-verification.middleware.ts var validateJwtHeaderMiddleware = async (req, res, next) => { try { const { appendToRequest = [], authHeaderName, refreshTokenHeaderName, authTokenExtractor, tokenGenerationHandler, cookieSettings = {}, authTokenPayloadVerifier, refreshTokenPayloadVerifier, refreshTokenHolderVerifier, tokenStorage } = middlewareConfigs; let authHeader = req.headers[authHeaderName ?? ""]; log("debug", "Auth header and middleware configurations extracted."); if (Array.isArray(authHeader)) authHeader = authHeader.join("__"); log("debug", `Auth header: ${authHeader}`); if (!authHeader || !authHeader.startsWith("Bearer ")) { throw new Error("Valid auth header not found"); } if (authTokenExtractor) { log("debug", "Auth token extractor method found."); const tokenValue = authTokenExtractor(authHeader); log("debug", `Auth token value: ${tokenValue}`); if (!tokenValue) { throw new Error("Auth token not found"); } let refreshToken = req.cookies && cookieSettings.refreshTokenCookieName ? req.cookies[cookieSettings.refreshTokenCookieName] : void 0; if (!refreshToken && refreshTokenHeaderName) { refreshToken = req.headers[refreshTokenHeaderName]; } log("debug", "Refresh token extracted."); const refreshTokenHandler = new TokenHandler({ refreshTokenStorage: tokenStorage, tokenGenerationHandler, authTokenPayloadVerifier, refreshTokenPayloadVerifier, refreshTokenHolderVerifier }); log("debug", "Token handler created."); log("debug", `Auth token: ${tokenValue} | Refresh token: ${refreshToken}`); const { decodedToken, nextRefreshToken, token } = await refreshTokenHandler.validateOrRefreshAuthToken( tokenValue, refreshToken ); log("debug", "Token handler validated or refreshed the auth token."); if (!decodedToken) { throw new Error("Auth cookie payload is undefined!"); } appendTokenPayloadToRequest(req, appendToRequest, decodedToken); log("debug", "Token payload appended to the request object."); res.setHeader(authHeaderName ?? "authorization", token); if (cookieSettings.refreshTokenCookieName && nextRefreshToken) { log("debug", "New refresh token set in the cookie."); res.cookie(cookieSettings.refreshTokenCookieName, nextRefreshToken, cookieSettings.refreshCookieOptions || {}); } else if (refreshTokenHeaderName && nextRefreshToken) { log("debug", "New refresh token set in the header."); res.setHeader(refreshTokenHeaderName, nextRefreshToken); } log("debug", "Next middleware called."); return next(); } else { throw new Error("Token value extractor method not found"); } } catch (error) { log("error", "Error occurred while authenticating the JST token.", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; res.status(401).json({ message: "Unauthorized", error: errorMessage }); } }; var auth_header_verification_middleware_default = validateJwtHeaderMiddleware; // src/middleware/role-based-authentication.middleware.ts import fs from "node:fs/promises"; import Joi4 from "joi"; // src/helper/constants.ts var RolePermissionConfigFilePath = "./.auth-permissions.json"; // src/middleware/role-based-authentication.middleware.ts var permissionSchema = Joi4.object({ roles: Joi4.array().items(Joi4.string().required()).required(), actions: Joi4.array().items(Joi4.string().required()).required() }); var endpointSchema = Joi4.object({ path: Joi4.string().required(), methods: Joi4.array().items(Joi4.string().valid("GET", "POST", "PUT", "PATCH", "DELETE")).required(), permissions: Joi4.array().items(permissionSchema).required() }); var groupSchema = Joi4.object({ basePath: Joi4.string().required(), permissions: Joi4.array().items(permissionSchema).required(), endpoints: Joi4.array().items(endpointSchema).required() }); var commonRolesSchema = Joi4.object({ name: Joi4.string().required(), permissions: Joi4.array().items(Joi4.string().required()).required() }); var permissionsConfigSchema = Joi4.object({ versioned: Joi4.boolean().optional(), activeVersions: Joi4.array().items(Joi4.string().required()).optional(), common: Joi4.object({ roles: Joi4.array().items(commonRolesSchema).required() }).optional(), groups: Joi4.object().pattern(Joi4.string(), groupSchema).optional(), endpoints: Joi4.array().items(endpointSchema).optional() }); var getPermissionConfigs = async () => { await fs.stat(RolePermissionConfigFilePath).catch((e) => { log("error", "Auth permissions configuration file could not found"); throw e; }); return JSON.parse(await fs.readFile(RolePermissionConfigFilePath, "utf-8")); }; var roleBasedAuthenticationMiddleware = (requiredAction) => { return async (req, res, next) => { const { extractApiVersion } = middlewareConfigs; const { user, role } = req; const userRole = user && Object.hasOwn(user, "role") ? user.role : role; const endpointPath = req.baseUrl; const method = req.method; let requestVersion = void 0; let versionValidationError = void 0; log("debug", "Role-based authentication middleware invoked."); if (extractApiVersion) { const version = await extractApiVersion(req); if (version) { requestVersion = version; } } log("debug", "Role-based authentication middleware configurations extracted."); log( "debug", `User role: ${userRole} | Required action: ${requiredAction} | Endpoint: ${endpointPath} | Method: ${method}` ); log("debug", `API version extracted from the request: ${requestVersion}`); if (!userRole) { res.status(403).json({ error: "Access denied. Role not found." }); } else { const permissionsConfig = await getPermissionConfigs(); log("debug", "Auth permissions configuration file loaded."); const { error } = permissionsConfigSchema.validate(permissionsConfig); log("debug", "Auth permissions configuration file validated."); if (error) { log("error", "Auth Permissions config file's validation failed.", error); throw error; } if (!permissionsConfig.common && !permissionsConfig.groups && !permissionsConfig.endpoints) { log("error", "At least one permission set should be in the configs."); throw new Error("Permission configurations is empty."); } if (permissionsConfig.versioned) { if (!requestVersion) { versionValidationError = "Access denied. Insufficient permissions."; } if (requestVersion && !permissionsConfig.activeVersions?.includes(requestVersion)) { versionValidationError = "Unsupported API version."; } } log("debug", "Permission configurations validated."); if (versionValidationError) { log("error", versionValidationError); res.status(400).json({ error: versionValidationError }); } else { log("debug", "Permission configurations validating..."); if (permissionsConfig.endpoints) { const standaloneEndpoint = permissionsConfig.endpoints.find( (ep) => ep.path === endpointPath && ep.methods.includes(method) ); if (standaloneEndpoint) { if (checkPermissions(userRole, requiredAction, [], standaloneEndpoint.permissions)) { return next(); } } } if (permissionsConfig.groups) { const groups = Object.values(permissionsConfig.groups); const matchedGroup = groups.find( (group) => endpointPath.startsWith(group.basePath) && group.endpoints.some((ep) => ep.path === endpointPath) ); if (matchedGroup) { const groupPermissions = matchedGroup.permissions || []; const endpointPermissions = matchedGroup.endpoints.find( (ep) => ep.path === endpointPath && ep.methods.includes(method) )?.permissions || []; if (checkPermissions(userRole, requiredAction, groupPermissions, endpointPermissions)) { return next(); } } } if (permissionsConfig.common) { const commonRoles = permissionsConfig.common.roles || []; const commonRole = commonRoles.find((role2) => role2.name === userRole); if (commonRole && commonRole.permissions.includes("*:*")) { return next(); } } res.status(403).json({ error: "Access denied. Insufficient permissions." }); } } }; }; function checkPermissions(userRole, requiredAction, groupPermissions, endpointPermissions) { const combinedPermissions = [...groupPermissions, ...endpointPermissions]; return combinedPermissions.some((permission) => { if (permission.roles.includes(userRole)) { return permission.actions.includes(requiredAction) || permission.actions.includes("*:*"); } return false; }); } var role_based_authentication_middleware_default = roleBasedAuthenticationMiddleware; // src/middleware/auth-cookie-verification.middleware.ts var validateJwtCookieMiddleware = async (req, res, next) => { try { const { appendToRequest = [], tokenGenerationHandler, cookieSettings = {}, authTokenPayloadVerifier, refreshTokenPayloadVerifier, refreshTokenHolderVerifier, tokenStorage } = middlewareConfigs; const accessToken = req.cookies && cookieSettings.accessTokenCookieName ? req.cookies[cookieSettings.accessTokenCookieName] : void 0; const refreshToken = req.cookies && cookieSettings.refreshTokenCookieName ? req.cookies[cookieSettings.refreshTokenCookieName] : void 0; if (!accessToken && !refreshToken) { throw new Error("Auth cookie not found!"); } log("debug", "Auth cookie and middleware configurations extracted."); log("debug", `Access token: ${accessToken} | Refresh token: ${refreshToken}`); const refreshTokenHandler = new TokenHandler({ refreshTokenStorage: tokenStorage, tokenGenerationHandler, authTokenPayloadVerifier, refreshTokenPayloadVerifier, refreshTokenHolderVerifier }); log("debug", "Token handler created."); const { decodedToken, nextRefreshToken, token } = await refreshTokenHandler.validateOrRefreshAuthToken( accessToken, refreshToken ); log("debug", "Token handler validated or refreshed the auth token."); if (!decodedToken) { throw new Error("Auth cookie payload is undefined!"); } appendTokenPayloadToRequest(req, appendToRequest, decodedToken); log("debug", "Token payload appended to the request object."); if (cookieSettings.accessTokenCookieName) { log("debug", "New access token set in the cookie."); res.cookie(cookieSettings.accessTokenCookieName, token, cookieSettings.accessCookieOptions || {}); } if (cookieSettings.refreshTokenCookieName && nextRefreshToken) { log("debug", "New refresh token set in the cookie."); res.cookie(cookieSettings.refreshTokenCookieName, nextRefreshToken, cookieSettings.refreshCookieOptions || {}); } log("debug", "Token handler completed successfully."); next(); } catch (error) { log("error", "Error occurred while authenticating the JST token.", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; res.status(401).json({ message: "Unauthorized", error: errorMessage }); } }; var auth_cookie_verification_middleware_default = validateJwtCookieMiddleware; export { JwtManager, role_based_authentication_middleware_default as roleBasedAuthenticationMiddleware, setDefaultSignOptions, setDefaultVerifyOptions, sign, auth_cookie_verification_middleware_default as validateJwtCookieMiddleware, auth_header_verification_middleware_default as validateJwtHeaderMiddleware, verify }; //# sourceMappingURL=index.mjs.map