@smertins27/jwt-auth-manager
Version:
Modernes JWT-Management mit Access & Refresh Token Rotation
620 lines (613 loc) • 23.7 kB
JavaScript
import { SignJWT, jwtVerify } from 'jose';
import { randomBytes } from '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 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 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 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 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 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 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 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();
}
};
export { HTTP_STATUS, JwtAuthError, JwtTokenManager, MemoryRefreshTokenStore, MemoryTokenBlacklist, RedisRefreshTokenStore, RefreshTokenManager, createAuthMiddleware, generateTokenId, parseExpiry };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map