UNPKG

@smertins27/jwt-auth-manager

Version:

Modernes JWT-Management mit Access & Refresh Token Rotation

631 lines (623 loc) 24 kB
'use strict'; var jose = require('jose'); var crypto = require('crypto'); // src/types.ts var HTTP_STATUS = { OK: 200, CREATED: 201, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, INTERNAL_SERVER_ERROR: 500 }; var JwtAuthError = class extends Error { constructor(message, statusCode = HTTP_STATUS.UNAUTHORIZED, code) { super(message); this.statusCode = statusCode; this.code = code; this.name = "JwtAuthError"; } }; function parseExpiry(expiry) { if (typeof expiry === "number") return expiry; const match = expiry.match(/^(\d+)([smhd])$/); if (!match) return 15 * 60; const num = parseInt(match[1], 10); switch (match[2]) { case "s": return num; case "m": return num * 60; case "h": return num * 3600; case "d": return num * 86400; default: return 15 * 60; } } function generateTokenId() { return crypto.randomBytes(16).toString("hex"); } // src/token-manager.ts var JwtTokenManager = class { secret; config; constructor(config) { this.secret = typeof config.secret === "string" ? new TextEncoder().encode(config.secret) : config.secret; this.config = { algorithm: config.algorithm || "HS256", accessTokenExpiry: config.accessTokenExpiry || "15m", refreshTokenExpiry: config.refreshTokenExpiry || "7d", issuer: config.issuer || "jwt-auth-manager", audience: config.audience ?? "jwt-audience" }; if (this.config.algorithm === "none") { throw new JwtAuthError('Algorithm "none" is not allowed for security reasons', 500, "INVALID_ALGORITHM"); } } /** * Generates a pair of tokens (access token and refresh token) based on the provided payload. * * @param {TokenPayload} payload - The data to include in the token payload. Must include a `uid` property representing the user ID. * @return {Promise<TokenPair>} A promise resolving to an object containing the access token, refresh token, and the access token's expiration time. * @throws {JwtAuthError} Throws an error if the `uid` property is missing from the payload. */ async generateTokenPair(payload) { if (!payload.uid) { throw new JwtAuthError("User ID (uid) is required in payload", 400, "MISSING_UID"); } const accessToken = await new jose.SignJWT({ ...payload }).setProtectedHeader({ alg: this.config.algorithm }).setIssuedAt().setIssuer(this.config.issuer).setExpirationTime(this.config.accessTokenExpiry).setAudience(this.config.audience || "access").sign(this.secret); const jti = generateTokenId(); const refreshPayload = { ...payload, jti, type: "refresh" }; const refreshToken = await new jose.SignJWT(refreshPayload).setProtectedHeader({ alg: this.config.algorithm }).setIssuedAt().setIssuer(this.config.issuer).setJti(jti).setExpirationTime(this.config.refreshTokenExpiry).setAudience(this.config.audience || "refresh").sign(this.secret); const expiresIn = parseExpiry(this.config.accessTokenExpiry); return { accessToken, refreshToken, expiresIn }; } /** * Generates a signed access token based on the given payload. * * @param {TokenPayload} payload - The payload object containing user-specific claims and details required for token generation. * @return {Promise<string>} A promise that resolves to the generated access token as a string. * @throws {JwtAuthError} If the payload does not include a valid user ID (uid). */ async generateAccessToken(payload) { if (!payload.uid) { throw new JwtAuthError("User ID (uid) is required in payload", 400, "MISSING_UID"); } return await new jose.SignJWT({ ...payload }).setProtectedHeader({ alg: this.config.algorithm }).setIssuedAt().setIssuer(this.config.issuer).setExpirationTime(this.config.accessTokenExpiry).setAudience(this.config.audience || "access").sign(this.secret); } /** * Verifies the provided access token and returns the token payload and protected header if valid. * * @param {string} token - The access token to be verified. * @return {Promise<VerifiedToken<TokenPayload>>} A promise that resolves with the verified payload and protected header if the token is valid. * @throws {JwtAuthError} Throws an error if the token is invalid, expired, or has a signature verification failure. */ async verifyAccessToken(token) { try { const { payload, protectedHeader } = await jose.jwtVerify(token, this.secret, { algorithms: [this.config.algorithm], issuer: this.config.issuer, audience: this.config.audience || "access" }); if (!payload.uid || typeof payload.uid !== "string") { throw new JwtAuthError("Invalid token payload: uid missing", 401, "INVALID_PAYLOAD"); } return { payload, protectedHeader }; } catch (error) { if (error.code === "ERR_JWT_EXPIRED") { throw new JwtAuthError("Access token has expired", 401, "TOKEN_EXPIRED"); } if (error.code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") { throw new JwtAuthError("Invalid token signature", 401, "INVALID_SIGNATURE"); } throw new JwtAuthError("Access token is invalid", 401, "INVALID_TOKEN"); } } /** * Verifies the provided refresh token and extracts its payload and protected header. * * @param {string} token - The refresh token to be verified. * @return {Promise<VerifiedToken<RefreshTokenPayload>>} A promise that resolves to a VerifiedToken object containing the * payload and protected header if the token is valid and meets all requirements. * @throws {JwtAuthError} Throws an error if the token is invalid, expired, has an invalid payload, is of the wrong type, * or fails signature verification. */ async verifyRefreshToken(token) { try { const { payload, protectedHeader } = await jose.jwtVerify(token, this.secret, { algorithms: [this.config.algorithm], issuer: this.config.issuer, audience: this.config.audience || "refresh" }); if (!payload.uid || !payload.jti || typeof payload.uid !== "string") { throw new JwtAuthError("Invalid refresh token payload", 401, "INVALID_PAYLOAD"); } if (payload.type !== "refresh") { throw new JwtAuthError("Token is not a refresh token", 401, "INVALID_TOKEN_TYPE"); } return { payload, protectedHeader }; } catch (error) { if (error.code === "ERR_JWT_EXPIRED") { throw new JwtAuthError("Refresh token has expired", 401, "REFRESH_TOKEN_EXPIRED"); } if (error.code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") { throw new JwtAuthError("Invalid refresh token signature", 401, "INVALID_SIGNATURE"); } throw new JwtAuthError("Refresh token is invalid", 401, "INVALID_REFRESH_TOKEN"); } } /** * Decodes a given JSON Web Token (JWT) and extracts its payload. * * @param {string} token - The JSON Web Token to decode. * @return {JWTPayload} The payload object extracted from the token. * @throws {JwtAuthError} If the token format is invalid or cannot be decoded. */ decodeToken(token) { const parts = token.split("."); if (parts.length !== 3) { throw new JwtAuthError("Invalid token format", 400, "MALFORMED_TOKEN"); } return JSON.parse(Buffer.from(parts[1], "base64url").toString()); } }; var RefreshTokenManager = class { constructor(tokenManager, tokenStore, config) { this.tokenManager = tokenManager; this.tokenStore = tokenStore; this.config = { reuseWindowSeconds: config?.reuseWindowSeconds ?? 10 }; } config; /** * Rotates the provided refresh token by validating its authenticity, checking for reuse, and generating a new token pair. * If the token has been reused within a specific grace period, it returns the existing new token pair. * If the token is reused outside the grace period, it invalidates the entire token family. * If the token is unused, it generates a new token pair with the same token family. * * @param {string} refreshToken The refresh token to be validated and rotated. * @return {Promise<TokenPair>} A Promise that resolves to a new token pair containing a new refresh token and an access token. * @throws {JwtAuthError} If the refresh token is invalid, expired, or reused suspiciously. */ async rotateRefreshToken(refreshToken) { const { payload } = await this.tokenManager.verifyRefreshToken(refreshToken); const tokenData = await this.tokenStore.get(payload.jti); if (!tokenData) { throw new JwtAuthError( "Refresh token not found or expired", 401, "TOKEN_NOT_FOUND" ); } const now = /* @__PURE__ */ new Date(); if (tokenData.usedAt) { const ageSeconds = (now.getTime() - tokenData.usedAt.getTime()) / 1e3; if (ageSeconds <= this.config.reuseWindowSeconds && tokenData.nextToken) { const { payload: nextPayload } = await this.tokenManager.verifyRefreshToken(tokenData.nextToken); const accessToken = await this.tokenManager.generateAccessToken({ uid: payload.uid, ...this.stripJwtClaims(payload) }); return { accessToken, refreshToken: tokenData.nextToken, expiresIn: this.tokenManager["config"].accessTokenExpiry }; } await this.tokenStore.invalidateTokenFamily(tokenData.tokenFamily); throw new JwtAuthError( "Refresh token reuse detected - all tokens in family revoked", 401, "TOKEN_REUSE_DETECTED" ); } const { uid, ...userPayload } = this.stripJwtClaims(payload); const newTokenPair = await this.createTokenPair( { uid, ...userPayload }, tokenData.tokenFamily ); await this.tokenStore.markAsUsed(payload.jti, now, newTokenPair.refreshToken); return newTokenPair; } /** * Erstellt initiales Token Pair mit neuer Token-Familie */ async createTokenPair(payload, tokenFamily) { const family = tokenFamily ?? this.generateTokenFamily(); const tokenPair = await this.tokenManager.generateTokenPair(payload); const decoded = this.tokenManager.decodeToken(tokenPair.refreshToken); const expiryDate = new Date((decoded.exp || 0) * 1e3); await this.tokenStore.save(decoded.jti, payload.uid, expiryDate, family); return tokenPair; } /** * Revoke alle Tokens eines Users */ async revokeAllTokens(userId) { await this.tokenStore.invalidateAllForUser(userId); } /** * Generiert eine eindeutige Token-Familie-ID */ generateTokenFamily() { return crypto.randomBytes(16).toString("hex"); } /** * Entfernt JWT-Standard-Claims aus Payload */ stripJwtClaims(payload) { const { type, jti, iat, exp, iss, aud, ...rest } = payload; return rest; } }; // src/middleware.ts function createAuthMiddleware(config, tokenManager) { const { excludedEndpoints = [], tokenExtractor, isBlacklisted } = config; return async function authMiddleware(req, res, next) { const shouldExclude = excludedEndpoints.some((e) => { const methodMatch = e.methods.includes(req.method); if (typeof e.endpoint === "string") { return methodMatch && req.url === e.endpoint; } return methodMatch && e.endpoint.test(req.url); }); if (shouldExclude) return next(); let token; if (typeof tokenExtractor === "function") { token = tokenExtractor(req); } if (!token) { const header = req.header("Authorization"); if (header && header.startsWith("Bearer ")) { token = header.slice(7); } else { token = req.query.accessToken; } } if (!token) { return res.status(HTTP_STATUS.UNAUTHORIZED).json({ error: "No access token set" }); } try { const { payload } = await tokenManager.verifyAccessToken(token); if (isBlacklisted && await isBlacklisted(token, payload)) { return res.status(HTTP_STATUS.UNAUTHORIZED).json({ error: "Access token revoked" }); } if (!payload.uid) { return res.status(HTTP_STATUS.UNAUTHORIZED).json({ error: "User id not set" }); } req.payload = { ...payload }; req.token = token; next(); } catch (error) { return res.status(error?.statusCode ?? HTTP_STATUS.UNAUTHORIZED).json({ error: error?.message ?? "Invalid access token" }); } }; } // src/blacklist.ts var MemoryTokenBlacklist = class { blacklisted = /* @__PURE__ */ new Map(); async add(token, expirySeconds) { const expiry = Date.now() + expirySeconds * 1e3; this.blacklisted.set(token, expiry); } async isBlacklisted(token) { const expiry = this.blacklisted.get(token); return typeof expiry === "number" && Date.now() < expiry; } async cleanup() { for (const [token, expiry] of this.blacklisted.entries()) { if (Date.now() >= expiry) { this.blacklisted.delete(token); } } } }; // src/refresh-store/memory-refresh-store.ts var MemoryRefreshTokenStore = class { store = /* @__PURE__ */ new Map(); /** * Saves a token's metadata into the store. * * @param {string} jti - The unique token identifier. * @param {string} userId - The user ID associated with the token. * @param {Date} expiryDate - The expiration date of the token. * @param {string} tokenFamily - The family or category of the token. * @return {Promise<void>} A promise that resolves when the token is saved. */ async save(jti, userId, expiryDate, tokenFamily) { this.store.set(jti, { userId, expiryDate, tokenFamily, usedAt: void 0, nextToken: void 0 }); } /** * Retrieves refresh token data identified by the given jti (JSON Token Identifier). * Ensures the token has not expired; deletes expired tokens and returns null. * * @param {string} jti - The unique identifier of the refresh token to retrieve. * @return {Promise<RefreshTokenData | null>} A promise that resolves to the refresh token data if found and valid, or null if not found or expired. */ async get(jti) { const data = this.store.get(jti); if (!data) return null; if (data.expiryDate < /* @__PURE__ */ new Date()) { this.store.delete(jti); return null; } return data; } /** * Checks if a given identifier (jti) exists in the storage. * * @param {string} jti - The unique identifier to check for existence. * @return {Promise<boolean>} A promise that resolves to true if the identifier exists, false otherwise. */ async exists(jti) { return await this.get(jti) !== null; } /** * Marks a token as used by updating its associated data with the provided usage timestamp and next token. * * @param {string} jti - The unique identifier of the token to be marked as used. * @param {Date} usedAt - The timestamp indicating when the token was used. * @param {string} nextToken - The next token to associate with the current token. * @return {Promise<void>} A promise that resolves once the operation is complete. */ async markAsUsed(jti, usedAt, nextToken) { const data = this.store.get(jti); if (data) { data.usedAt = usedAt; data.nextToken = nextToken; } } /** * Invalidates a token by its unique identifier (jti). * * @param {string} jti - The unique identifier of the token to invalidate. * @return {Promise<void>} A promise that resolves when the token has been successfully invalidated. */ async invalidate(jti) { this.store.delete(jti); } /** * Invalidates all tokens belonging to a specific token family by removing their entries from the store. * * @param {string} tokenFamily - The identifier of the token family to be invalidated. * @return {Promise<void>} Resolves when all tokens in the specified family have been invalidated. */ async invalidateTokenFamily(tokenFamily) { for (const [jti, data] of this.store.entries()) { if (data.tokenFamily === tokenFamily) { this.store.delete(jti); } } } /** * Invalidates all entries in the store associated with the specified user. * * @param {string} userId - The unique identifier of the user for whom all entries should be invalidated. * @return {Promise<void>} A promise that resolves when all relevant entries have been invalidated. */ async invalidateAllForUser(userId) { for (const [jti, entry] of this.store.entries()) { if (entry.userId === userId) { this.store.delete(jti); } } } }; // src/refresh-store/redis-refresh-store.ts var RedisRefreshTokenStore = class { redis; tokenPrefix; userIndexPrefix; familyIndexPrefix; constructor(redis, options = {}) { this.redis = redis; this.tokenPrefix = options.tokenPrefix ?? "refresh:token:"; this.userIndexPrefix = options.userIndexPrefix ?? "refresh:user:"; this.familyIndexPrefix = options.familyIndexPrefix ?? "refresh:family:"; } /** * Generates a token key by appending the provided token identifier (jti) to the token prefix. * * @param {string} jti - The unique token identifier. * @return {string} The concatenated token key. */ tokenKey(jti) { return `${this.tokenPrefix}${jti}`; } /** * Generates a unique key for a user by appending the specified user ID to a predefined prefix. * * @param {string} userId - The unique identifier of the user. * @return {string} A string representing the generated user key. */ userKey(userId) { return `${this.userIndexPrefix}${userId}`; } /** * Generates a unique key by combining a predefined prefix with the provided tokenFamily string. * * @param {string} tokenFamily - The identifier for the token family to be used in the key generation. * @return {string} The generated family key as a string. */ familyKey(tokenFamily) { return `${this.familyIndexPrefix}${tokenFamily}`; } /** * Saves a refresh token and its associated data into Redis with the specified time-to-live (TTL). * * @param {string} jti - The unique identifier of the token. * @param {string} userId - The ID of the user associated with the token. * @param {Date} expiryDate - The date and time when the token expires. * @param {string} tokenFamily - The family identifier of the token used for grouping related tokens. * @return {Promise<void>} A promise that resolves once the token and associated data have been saved. */ async save(jti, userId, expiryDate, tokenFamily) { const ttlMs = expiryDate.getTime() - Date.now(); if (ttlMs <= 0) return; const tokenKey = this.tokenKey(jti); const userKey = this.userKey(userId); const familyKey = this.familyKey(tokenFamily); const tokenData = { userId, expiryDate, tokenFamily, usedAt: void 0, nextToken: void 0 }; const multi = this.redis.multi(); multi.set(tokenKey, JSON.stringify(tokenData), { PX: ttlMs }); multi.sAdd(userKey, jti); multi.pExpire(userKey, ttlMs); multi.sAdd(familyKey, jti); multi.pExpire(familyKey, ttlMs); await multi.exec(); } /** * Retrieves refresh token data by its unique token identifier (jti). * * @param {string} jti - The unique token identifier. * @return {Promise<RefreshTokenData | null>} A promise that resolves to the refresh token data if found, or null if not found. */ async get(jti) { const tokenKey = this.tokenKey(jti); const value = await this.redis.get(tokenKey); if (!value) return null; const data = JSON.parse(value); data.expiryDate = new Date(data.expiryDate); if (data.usedAt) data.usedAt = new Date(data.usedAt); return data; } /** * Checks if a token identifier (JTI) exists by verifying its presence. * * @param {string} jti - The token identifier to check for existence. * @return {Promise<boolean>} A promise that resolves to true if the token identifier exists, otherwise false. */ async exists(jti) { return await this.get(jti) !== null; } /** * Marks a token as used by updating its metadata and resetting its expiration time. * * @param {string} jti - The unique identifier of the token to mark as used. * @param {Date} usedAt - The date and time when the token was used. * @param {string} nextToken - The next token value to associate with the current token. * @return {Promise<void>} A promise that resolves when the token metadata is successfully updated. */ async markAsUsed(jti, usedAt, nextToken) { const data = await this.get(jti); if (!data) return; data.usedAt = usedAt; data.nextToken = nextToken; const ttlMs = data.expiryDate.getTime() - Date.now(); if (ttlMs <= 0) return; await this.redis.set(this.tokenKey(jti), JSON.stringify(data), { PX: ttlMs }); } /** * Invalidates a token by its unique JSON Token Identifier (JTI). * * @param {string} jti - The unique identifier of the token to be invalidated. * @return {Promise<void>} A promise that resolves once the token is invalidated. */ async invalidate(jti) { const tokenKey = this.tokenKey(jti); const data = await this.get(jti); if (!data) return; const multi = this.redis.multi(); multi.del(tokenKey); multi.sRem(this.userKey(data.userId), jti); multi.sRem(this.familyKey(data.tokenFamily), jti); await multi.exec(); } /** * Invalidates all tokens associated with a given token family. * * @param {string} tokenFamily - The identifier for the token family to invalidate. * @return {Promise<void>} A promise that resolves once the token family and related tokens are invalidated. */ async invalidateTokenFamily(tokenFamily) { const familyKey = this.familyKey(tokenFamily); const jtis = await this.redis.sMembers(familyKey); if (jtis.length === 0) return; const tokenDataList = await Promise.all( jtis.map((jti) => this.get(jti)) ); const multi = this.redis.multi(); for (let i = 0; i < jtis.length; i++) { const jti = jtis[i]; const data = tokenDataList[i]; if (data) { multi.del(this.tokenKey(jti)); multi.sRem(this.userKey(data.userId), jti); } } multi.del(familyKey); await multi.exec(); } /** * Invalidates all tokens associated with a specific user by their user ID. * This includes removing all token data and metadata stored in the Redis database. * * @param {string} userId - The unique identifier of the user for whom all tokens should be invalidated. * @return {Promise<void>} A promise that resolves when all tokens have been successfully invalidated. */ async invalidateAllForUser(userId) { const userKey = this.userKey(userId); const jtis = await this.redis.sMembers(userKey); if (jtis.length === 0) return; const tokenDataList = await Promise.all( jtis.map((jti) => this.get(jti)) ); const multi = this.redis.multi(); for (let i = 0; i < jtis.length; i++) { const jti = jtis[i]; const data = tokenDataList[i]; if (data) { multi.del(this.tokenKey(jti)); multi.sRem(this.familyKey(data.tokenFamily), jti); } } multi.del(userKey); await multi.exec(); } }; exports.HTTP_STATUS = HTTP_STATUS; exports.JwtAuthError = JwtAuthError; exports.JwtTokenManager = JwtTokenManager; exports.MemoryRefreshTokenStore = MemoryRefreshTokenStore; exports.MemoryTokenBlacklist = MemoryTokenBlacklist; exports.RedisRefreshTokenStore = RedisRefreshTokenStore; exports.RefreshTokenManager = RefreshTokenManager; exports.createAuthMiddleware = createAuthMiddleware; exports.generateTokenId = generateTokenId; exports.parseExpiry = parseExpiry; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map