- 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.
870 lines
29 KiB
TypeScript
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();
|
|
}
|
|
}
|