import { Injectable, Inject, UnauthorizedException, ServiceUnavailableException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { User } from "@customer-portal/domain/customer"; import { UsersService } from "@bff/modules/users/users.service"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; export interface RefreshTokenPayload { userId: string; tokenId: string; deviceId?: string; userAgent?: string; type: "refresh"; } interface StoredRefreshToken { familyId: string; userId: string; valid: boolean; } interface StoredRefreshTokenFamily { userId: string; tokenHash: string; deviceId?: string; userAgent?: string; createdAt?: string; } @Injectable() export class AuthTokenService { private readonly ACCESS_TOKEN_EXPIRY = "15m"; // Short-lived access tokens private readonly REFRESH_TOKEN_EXPIRY = "7d"; // Longer-lived refresh tokens private readonly REFRESH_TOKEN_FAMILY_PREFIX = "refresh_family:"; private readonly REFRESH_TOKEN_PREFIX = "refresh_token:"; private readonly REFRESH_USER_SET_PREFIX = "refresh_user:"; private readonly allowRedisFailOpen: boolean; private readonly requireRedisForTokens: boolean; private readonly maintenanceMode: boolean; private readonly maintenanceMessage: string; constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, @Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject(Logger) private readonly logger: Logger, private readonly usersService: UsersService ) { this.allowRedisFailOpen = this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true"; this.requireRedisForTokens = this.configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true"; this.maintenanceMode = this.configService.get("AUTH_MAINTENANCE_MODE", "false") === "true"; this.maintenanceMessage = this.configService.get( "AUTH_MAINTENANCE_MESSAGE", "Authentication service is temporarily unavailable for maintenance. Please try again later." ); } /** * Check if authentication service is available */ private checkServiceAvailability(): void { if (this.maintenanceMode) { this.logger.warn("Authentication service in maintenance mode", { maintenanceMessage: this.maintenanceMessage, }); throw new ServiceUnavailableException(this.maintenanceMessage); } if (this.requireRedisForTokens && this.redis.status !== "ready") { this.logger.error("Redis required for token operations but not available", { redisStatus: this.redis.status, requireRedisForTokens: this.requireRedisForTokens, }); throw new ServiceUnavailableException("Authentication service temporarily unavailable"); } } /** * Generate a new token pair with refresh token rotation */ async generateTokenPair( user: { id: string; email: string; role?: string; }, deviceInfo?: { deviceId?: string; userAgent?: string; } ): Promise { this.checkServiceAvailability(); const tokenId = this.generateTokenId(); const familyId = this.generateTokenId(); // Create access token payload const accessPayload = { sub: user.id, email: user.email, role: user.role || "user", tokenId, type: "access", }; // Create refresh token payload const refreshPayload: RefreshTokenPayload = { userId: user.id, tokenId: familyId, deviceId: deviceInfo?.deviceId, userAgent: deviceInfo?.userAgent, type: "refresh", }; // Generate tokens const accessToken = this.jwtService.sign(accessPayload, { expiresIn: this.ACCESS_TOKEN_EXPIRY, }); const refreshToken = this.jwtService.sign(refreshPayload, { expiresIn: this.REFRESH_TOKEN_EXPIRY, }); // Store refresh token family in Redis const refreshTokenHash = this.hashToken(refreshToken); const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); if (this.redis.status === "ready") { try { await this.storeRefreshTokenInRedis( user.id, familyId, refreshTokenHash, deviceInfo, refreshExpirySeconds ); } catch (error) { this.logger.error("Failed to store refresh token in Redis", { error: error instanceof Error ? error.message : String(error), userId: user.id, }); // If Redis is required, fail the operation if (this.requireRedisForTokens) { throw new ServiceUnavailableException("Authentication service temporarily unavailable"); } } } else { if (this.requireRedisForTokens) { this.logger.error("Redis required but not ready for token issuance", { status: this.redis.status, }); throw new ServiceUnavailableException("Authentication service temporarily unavailable"); } this.logger.warn("Redis not ready for token issuance; issuing non-rotating tokens", { status: this.redis.status, }); } const accessExpiresAt = new Date( Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) ).toISOString(); const refreshExpiresAt = new Date( Date.now() + this.parseExpiryToMs(this.REFRESH_TOKEN_EXPIRY) ).toISOString(); this.logger.debug("Generated new token pair", { userId: user.id, tokenId, familyId }); return { accessToken, refreshToken, expiresAt: accessExpiresAt, refreshExpiresAt, tokenType: "Bearer", }; } /** * Refresh access token using refresh token rotation */ async refreshTokens( refreshToken: string, deviceInfo?: { deviceId?: string; userAgent?: string; } ): Promise<{ tokens: AuthTokens; user: User }> { if (!refreshToken) { throw new UnauthorizedException("Invalid refresh token"); } this.checkServiceAvailability(); if (!this.allowRedisFailOpen && this.redis.status !== "ready") { this.logger.error("Redis unavailable for token refresh", { redisStatus: this.redis.status, }); throw new ServiceUnavailableException("Token refresh temporarily unavailable"); } try { // Verify refresh token const payload = this.jwtService.verify(refreshToken); if (payload.type !== "refresh") { this.logger.warn("Token presented to refresh endpoint is not a refresh token", { tokenId: payload.tokenId, }); throw new UnauthorizedException("Invalid refresh token"); } const refreshTokenHash = this.hashToken(refreshToken); // Check if refresh token exists and is valid let storedToken: string | null; try { storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); } catch (error) { this.logger.error("Redis error during token refresh", { error: error instanceof Error ? error.message : String(error), }); throw new UnauthorizedException("Token validation temporarily unavailable"); } if (!storedToken) { this.logger.warn("Refresh token not found or expired", { tokenHash: refreshTokenHash.slice(0, 8), }); throw new UnauthorizedException("Invalid refresh token"); } const tokenRecord = this.parseRefreshTokenRecord(storedToken); if (!tokenRecord) { this.logger.warn("Stored refresh token payload was invalid JSON", { tokenHash: refreshTokenHash.slice(0, 8), }); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); throw new UnauthorizedException("Invalid refresh token"); } if (!tokenRecord.valid) { this.logger.warn("Refresh token marked as invalid", { tokenHash: refreshTokenHash.slice(0, 8), }); // Invalidate entire token family on reuse attempt await this.invalidateTokenFamily(tokenRecord.familyId); throw new UnauthorizedException("Invalid refresh token"); } // Get user info from database (using internal method to get role) const prismaUser = await this.usersService.findByIdInternal(payload.userId); if (!prismaUser) { this.logger.warn("User not found during token refresh", { userId: payload.userId }); throw new UnauthorizedException("User not found"); } // Convert to the format expected by generateTokenPair const user = { id: prismaUser.id, email: prismaUser.email, role: prismaUser.role || "USER", }; // Invalidate current refresh token await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); // Generate new token pair const newTokenPair = await this.generateTokenPair(user, deviceInfo); const userProfile = mapPrismaUserToDomain(prismaUser); this.logger.debug("Refreshed token pair", { userId: payload.userId }); return { tokens: newTokenPair, user: userProfile, }; } catch (error) { this.logger.error("Token refresh failed", { error: error instanceof Error ? error.message : String(error), }); // Always fail closed when Redis is not available for token operations // This prevents refresh token replay attacks and maintains security if (this.redis.status !== "ready") { this.logger.error("Redis unavailable for token refresh - failing closed for security", { redisStatus: this.redis.status, allowRedisFailOpen: this.allowRedisFailOpen, securityReason: "refresh_token_rotation_requires_redis", }); throw new ServiceUnavailableException("Token refresh temporarily unavailable"); } throw new UnauthorizedException("Invalid refresh token"); } } /** * Revoke a specific refresh token */ async revokeRefreshToken(refreshToken: string): Promise { try { const refreshTokenHash = this.hashToken(refreshToken); const storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); if (storedToken) { const tokenRecord = this.parseRefreshTokenRecord(storedToken); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); if (tokenRecord) { await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${tokenRecord.familyId}`); } this.logger.debug("Revoked refresh token", { tokenHash: refreshTokenHash.slice(0, 8) }); } } catch (error) { this.logger.error("Failed to revoke refresh token", { error: error instanceof Error ? error.message : String(error), }); } } /** * Store refresh token in Redis with per-user token set management */ private async storeRefreshTokenInRedis( userId: string, familyId: string, refreshTokenHash: string, deviceInfo?: { deviceId?: string; userAgent?: string }, refreshExpirySeconds?: number ): Promise { const expiry = refreshExpirySeconds || this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`; const pipeline = this.redis.pipeline(); // Store token family metadata pipeline.setex( `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`, expiry, JSON.stringify({ userId, tokenHash: refreshTokenHash, deviceId: deviceInfo?.deviceId, userAgent: deviceInfo?.userAgent, createdAt: new Date().toISOString(), }) ); // Store token validation data pipeline.setex( `${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`, expiry, JSON.stringify({ familyId, userId, valid: true, }) ); // Add to user's token set for per-user management pipeline.sadd(userFamilySetKey, familyId); pipeline.expire(userFamilySetKey, expiry); // Enforce maximum tokens per user (optional limit) const maxTokensPerUser = 10; // Configurable limit pipeline.scard(userFamilySetKey); const results = await pipeline.exec(); // Check if user has too many tokens and clean up oldest ones const cardResult = results?.[results.length - 1]; if ( cardResult && Array.isArray(cardResult) && cardResult[1] && typeof cardResult[1] === "number" ) { const tokenCount = cardResult[1]; if (tokenCount > maxTokensPerUser) { await this.cleanupExcessUserTokens(userId, maxTokensPerUser); } } } /** * Clean up excess tokens for a user, keeping only the most recent ones */ private async cleanupExcessUserTokens(userId: string, maxTokens: number): Promise { try { const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`; const familyIds = await this.redis.smembers(userFamilySetKey); if (familyIds.length <= maxTokens) { return; } // Get creation times for all families const familiesWithTimes: Array<{ familyId: string; createdAt: Date }> = []; for (const familyId of familyIds) { const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); if (familyData) { const family = this.parseRefreshTokenFamilyRecord(familyData); if (family?.createdAt) { familiesWithTimes.push({ familyId, createdAt: new Date(family.createdAt), }); } } } // Sort by creation time (oldest first) and remove excess familiesWithTimes.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); const tokensToRemove = familiesWithTimes.slice(0, familiesWithTimes.length - maxTokens); for (const { familyId } of tokensToRemove) { await this.invalidateTokenFamily(familyId); await this.redis.srem(userFamilySetKey, familyId); } this.logger.debug("Cleaned up excess user tokens", { userId, removedCount: tokensToRemove.length, remainingCount: maxTokens, }); } catch (error) { this.logger.error("Failed to cleanup excess user tokens", { error: error instanceof Error ? error.message : String(error), userId, }); } } /** * Get all active refresh token families for a user */ async getUserRefreshTokenFamilies( userId: string ): Promise< Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }> > { try { const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`; const familyIds = await this.redis.smembers(userFamilySetKey); const families: Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string; }> = []; for (const familyId of familyIds) { const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); if (familyData) { const family = this.parseRefreshTokenFamilyRecord(familyData); if (family) { families.push({ familyId, deviceId: family.deviceId, userAgent: family.userAgent, createdAt: family.createdAt, }); } } } return families.sort((a, b) => { const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0; const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return timeB - timeA; // Most recent first }); } catch (error) { this.logger.error("Failed to get user refresh token families", { error: error instanceof Error ? error.message : String(error), userId, }); return []; } } /** * Revoke all refresh tokens for a user (optimized with per-user sets) */ async revokeAllUserTokens(userId: string): Promise { try { const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`; const familyIds = await this.redis.smembers(userFamilySetKey); if (familyIds.length === 0) { this.logger.debug("No tokens found for user", { userId }); return; } const pipeline = this.redis.pipeline(); // Get all family data first to find token hashes const familyDataPromises = familyIds.map(familyId => this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`) ); const familyDataResults = await Promise.all(familyDataPromises); // Delete all tokens and families for (let i = 0; i < familyIds.length; i++) { const familyId = familyIds[i]; const familyData = familyDataResults[i]; // Delete family record pipeline.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); // Delete token record if we can parse the family data if (familyData) { const family = this.parseRefreshTokenFamilyRecord(familyData); if (family?.tokenHash) { pipeline.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); } } } // Delete the user's token set pipeline.del(userFamilySetKey); await pipeline.exec(); this.logger.debug("Revoked all tokens for user", { userId, tokenCount: familyIds.length, }); } catch (error) { this.logger.error("Failed to revoke all user tokens", { error: error instanceof Error ? error.message : String(error), userId, }); // Fallback to the old scan method if the optimized approach fails await this.revokeAllUserTokensFallback(userId); } } /** * Fallback method for revoking all user tokens using scan */ private async revokeAllUserTokensFallback(userId: string): Promise { try { let cursor = "0"; const pattern = `${this.REFRESH_TOKEN_FAMILY_PREFIX}*`; do { const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100); cursor = nextCursor; if (keys && keys.length) { for (const key of keys) { const data = await this.redis.get(key); if (!data) continue; const family = this.parseRefreshTokenFamilyRecord(data); if (family && family.userId === userId) { await this.redis.del(key); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); } } } } while (cursor !== "0"); this.logger.debug("Revoked all tokens for user (fallback method)", { userId }); } catch (error) { this.logger.error("Failed to revoke all user tokens (fallback)", { error: error instanceof Error ? error.message : String(error), userId, }); } } private async invalidateTokenFamily(familyId: string): Promise { try { const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); if (familyData) { const family = this.parseRefreshTokenFamilyRecord(familyData); const pipeline = this.redis.pipeline(); pipeline.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); if (family) { pipeline.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); // Remove from user's token set const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${family.userId}`; pipeline.srem(userFamilySetKey, familyId); await pipeline.exec(); this.logger.warn("Invalidated token family due to security concern", { familyId: familyId.slice(0, 8), userId: family.userId, }); } else { await pipeline.exec(); } } } catch (error) { this.logger.error("Failed to invalidate token family", { error: error instanceof Error ? error.message : String(error), }); } } private generateTokenId(): string { return randomBytes(32).toString("hex"); } private hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } private parseRefreshTokenRecord(value: string): StoredRefreshToken | null { try { const parsed = JSON.parse(value) as Partial; if ( parsed && typeof parsed === "object" && typeof parsed.familyId === "string" && typeof parsed.userId === "string" && typeof parsed.valid === "boolean" ) { return { familyId: parsed.familyId, userId: parsed.userId, valid: parsed.valid, }; } } catch (error) { this.logger.warn("Failed to parse refresh token record", { error: error instanceof Error ? error.message : String(error), }); } return null; } private parseRefreshTokenFamilyRecord(value: string): StoredRefreshTokenFamily | null { try { const parsed = JSON.parse(value) as Partial; if ( parsed && typeof parsed === "object" && typeof parsed.userId === "string" && typeof parsed.tokenHash === "string" ) { return { userId: parsed.userId, tokenHash: parsed.tokenHash, deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined, userAgent: typeof parsed.userAgent === "string" ? parsed.userAgent : undefined, createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : undefined, }; } } catch (error) { this.logger.warn("Failed to parse refresh token family record", { error: error instanceof Error ? error.message : String(error), }); } return null; } private parseExpiryToMs(expiry: string): number { const unit = expiry.slice(-1); const value = parseInt(expiry.slice(0, -1)); switch (unit) { case "s": return value * 1000; case "m": return value * 60 * 1000; case "h": return value * 60 * 60 * 1000; case "d": return value * 24 * 60 * 60 * 1000; default: return 15 * 60 * 1000; // Default 15 minutes } } private parseExpiryToSeconds(expiry: string): number { return Math.floor(this.parseExpiryToMs(expiry) / 1000); } private calculateExpiryDate(expiresIn: string | number): string { const now = new Date(); if (typeof expiresIn === "number") { return new Date(now.getTime() + expiresIn * 1000).toISOString(); } return new Date(now.getTime() + this.parseExpiryToMs(expiresIn)).toISOString(); } }