barsa 2266167467 Enhance JWT handling and authentication flow
- Introduced support for previous JWT secrets in the environment configuration to facilitate key rotation.
- Refactored the JoseJwtService to manage multiple signing and verification keys, improving security during token validation.
- Updated the AuthTokenService to include family identifiers for refresh tokens, enhancing session management and security.
- Modified the PasswordWorkflowService and SignupWorkflowService to return session metadata instead of token strings, aligning with security best practices.
- Improved error handling and token revocation logic in the TokenBlacklistService and AuthTokenService to prevent replay attacks.
- Updated documentation to reflect changes in the authentication architecture and security model.
2025-12-12 15:29:58 +09:00

870 lines
29 KiB
TypeScript

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<AuthTokens> {
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<RefreshTokenPayload>(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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<StoredRefreshToken>;
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<StoredRefreshTokenFamily>;
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();
}
}