import { Injectable, Inject, UnauthorizedException, ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; import type { JWTPayload } from "jose"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { User, UserRole } from "@customer-portal/domain/customer"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { JoseJwtService } from "./jose-jwt.service.js"; export interface RefreshTokenPayload extends JWTPayload { userId: string; /** * Refresh token family identifier (stable across rotations). * Present on newly issued tokens; legacy tokens used `tokenId` for this value. */ familyId?: string; /** * Refresh token identifier (unique per token). Used for replay/reuse detection. * For legacy tokens, this was equal to the family id. */ 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; /** * Absolute refresh-session expiration timestamp (ISO). * Used to avoid indefinitely extending sessions on refresh (RFC 9700 guidance). */ absoluteExpiresAt?: 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: JoseJwtService, private readonly configService: ConfigService, @Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject(Logger) private readonly logger: Logger, private readonly usersFacade: UsersFacade ) { 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?: UserRole; }, deviceInfo?: { deviceId?: string; userAgent?: string; } ): Promise { this.checkServiceAvailability(); const accessTokenId = this.generateTokenId(); const refreshFamilyId = this.generateTokenId(); const refreshTokenId = this.generateTokenId(); // Create access token payload const accessPayload = { sub: user.id, email: user.email, role: user.role || "USER", tokenId: accessTokenId, type: "access", }; // Create refresh token payload const refreshPayload: RefreshTokenPayload = { userId: user.id, familyId: refreshFamilyId, tokenId: refreshTokenId, deviceId: deviceInfo?.deviceId, userAgent: deviceInfo?.userAgent, type: "refresh", }; // Generate tokens const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY); const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds); // Store refresh token family in Redis const refreshTokenHash = this.hashToken(refreshToken); const refreshAbsoluteExpiresAt = new Date( Date.now() + refreshExpirySeconds * 1000 ).toISOString(); if (this.redis.status === "ready") { try { await this.storeRefreshTokenInRedis( user.id, refreshFamilyId, refreshTokenHash, deviceInfo, refreshExpirySeconds, refreshAbsoluteExpiresAt ); } 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 = refreshAbsoluteExpiresAt; this.logger.debug("Generated new token pair", { userId: user.id, accessTokenId, refreshFamilyId, refreshTokenId, }); 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 = await 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"); } if (!payload.userId || typeof payload.userId !== "string") { throw new UnauthorizedException("Invalid refresh token"); } if (!payload.tokenId || typeof payload.tokenId !== "string") { throw new UnauthorizedException("Invalid refresh token"); } const familyId = typeof payload.familyId === "string" && payload.familyId.length > 0 ? payload.familyId : payload.tokenId; // legacy tokens used tokenId as family id const refreshTokenHash = this.hashToken(refreshToken); const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`; const tokenKey = `${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`; // Check if refresh token exists and is valid let storedToken: string | null; let familyData: string | null; try { const results = await Promise.all([this.redis.get(tokenKey), this.redis.get(familyKey)]); storedToken = results[0]; familyData = results[1]; } 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), }); // Best-effort: treat this as a replay/reuse signal and revoke the family. await this.invalidateTokenFamily(familyId); 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(tokenKey); throw new UnauthorizedException("Invalid refresh token"); } if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) { this.logger.warn("Refresh token record mismatch", { tokenHash: refreshTokenHash.slice(0, 8), }); await this.invalidateTokenFamily(tokenRecord.familyId); 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"); } const family = familyData ? this.parseRefreshTokenFamilyRecord(familyData) : null; if (family && family.tokenHash !== refreshTokenHash) { // Token record says it's valid, but it's not the family's current token hash: // treat this as reuse or out-of-order rotation and revoke family. this.logger.warn("Refresh token does not match current family token", { familyId: familyId.slice(0, 8), tokenHash: refreshTokenHash.slice(0, 8), }); await this.invalidateTokenFamily(familyId); throw new UnauthorizedException("Invalid refresh token"); } // Determine remaining lifetime for this refresh session (absolute, not sliding). let remainingSeconds: number | null = null; let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt; if (absoluteExpiresAt) { const absMs = Date.parse(absoluteExpiresAt); if (!Number.isNaN(absMs)) { remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000)); } else { absoluteExpiresAt = undefined; } } if (remainingSeconds === null) { const ttl = await this.redis.ttl(familyKey); if (typeof ttl === "number" && ttl > 0) { remainingSeconds = ttl; } else { const tokenTtl = await this.redis.ttl(tokenKey); remainingSeconds = typeof tokenTtl === "number" && tokenTtl > 0 ? tokenTtl : this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); } absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString(); } if (!remainingSeconds || remainingSeconds <= 0) { await this.invalidateTokenFamily(familyId); throw new UnauthorizedException("Invalid refresh token"); } // Get user info from database (using internal method to get role) const user = await this.usersFacade.findByIdInternal(payload.userId); if (!user) { 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 userProfile = mapPrismaUserToDomain(user); // Mark current refresh token as invalid (keep it for the remaining TTL so reuse is detectable). await this.redis.setex( tokenKey, remainingSeconds, JSON.stringify({ familyId: tokenRecord.familyId, userId: tokenRecord.userId, valid: false, }) ); // Generate new token pair (keep family id, do not extend absolute lifetime). const accessTokenId = this.generateTokenId(); const refreshTokenId = this.generateTokenId(); const accessPayload = { sub: user.id, email: user.email, role: user.role || "USER", tokenId: accessTokenId, type: "access", }; const newRefreshPayload: RefreshTokenPayload = { userId: user.id, familyId, tokenId: refreshTokenId, deviceId: deviceInfo?.deviceId, userAgent: deviceInfo?.userAgent, type: "refresh", }; const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY); const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds); const newRefreshTokenHash = this.hashToken(newRefreshToken); const createdAt = family?.createdAt ?? new Date().toISOString(); const refreshExpiresAt = absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString(); const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${user.id}`; const pipeline = this.redis.pipeline(); pipeline.setex( familyKey, remainingSeconds, JSON.stringify({ userId: user.id, tokenHash: newRefreshTokenHash, deviceId: deviceInfo?.deviceId, userAgent: deviceInfo?.userAgent, createdAt, absoluteExpiresAt: refreshExpiresAt, }) ); pipeline.setex( `${this.REFRESH_TOKEN_PREFIX}${newRefreshTokenHash}`, remainingSeconds, JSON.stringify({ familyId, userId: user.id, valid: true, }) ); pipeline.sadd(userFamilySetKey, familyId); pipeline.expire(userFamilySetKey, remainingSeconds); await pipeline.exec(); const accessExpiresAt = new Date( Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) ).toISOString(); this.logger.debug("Refreshed token pair", { userId: payload.userId }); return { tokens: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresAt: accessExpiresAt, refreshExpiresAt, tokenType: "Bearer", }, 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, absoluteExpiresAt?: string ): Promise { const expiry = refreshExpirySeconds || this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`; const absolute = absoluteExpiresAt ?? new Date(Date.now() + expiry * 1000).toISOString(); 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(), absoluteExpiresAt: absolute, }) ); // 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, absoluteExpiresAt: typeof parsed.absoluteExpiresAt === "string" ? parsed.absoluteExpiresAt : 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(); } }