refactor: enhance WHMCS HTTP client error logging and streamline auth module

- Simplified error logging in WhmcsHttpClientService by removing action parameters from log messages for clarity.
- Introduced RequestWithCookies type to improve type safety in auth controllers and utilities.
- Refactored ValidatedUser interface to remove export, aligning with internal usage.
- Removed deprecated auth error constants and streamlined error handling in various auth services.
- Consolidated token handling logic by utilizing a shared hashToken utility across token services.
- Removed unused and redundant methods in token services, improving code maintainability.
This commit is contained in:
barsa 2026-03-04 10:17:04 +09:00
parent f6c0812061
commit 239de6f20a
26 changed files with 86 additions and 880 deletions

View File

@ -59,8 +59,7 @@ export class WhmcsHttpClientService {
params: redactForLogs(params),
responseTime: Date.now() - startTime,
},
"WHMCS HTTP request failed [%s]",
action
"WHMCS HTTP request failed"
);
throw error;
@ -146,8 +145,7 @@ export class WhmcsHttpClientService {
statusText: response.statusText,
snippet,
},
"WHMCS non-OK response body snippet [%s]",
action
"WHMCS non-OK response body snippet"
);
}
}
@ -255,8 +253,7 @@ export class WhmcsHttpClientService {
parseError: extractErrorMessage(parseError),
params: redactForLogs(params),
},
"Invalid JSON response from WHMCS API [%s]",
action
"Invalid JSON response from WHMCS API"
);
throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
}
@ -272,8 +269,7 @@ export class WhmcsHttpClientService {
: { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params),
},
"WHMCS API returned invalid response structure [%s]",
action
"WHMCS API returned invalid response structure"
);
throw new WhmcsOperationException("Invalid response structure from WHMCS API");
}
@ -291,12 +287,12 @@ export class WhmcsHttpClientService {
// classifies severity and the business layer logs the final outcome.
this.logger.debug(
{
action,
errorMessage,
errorCode,
params: redactForLogs(params),
},
"WHMCS API returned error [%s]",
action
"WHMCS API returned error"
);
// Return error response for the orchestrator to handle with proper exception types

View File

@ -10,7 +10,7 @@ import { UsersService } from "@bff/modules/users/application/users.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface ValidatedUser {
interface ValidatedUser {
id: string;
email: string;
role: string;

View File

@ -14,8 +14,6 @@ import type { User as PrismaUser } from "@prisma/client";
import type { Request } from "express";
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
import { AuthTokenService } from "../infra/token/token.service.js";
import { AuthLoginService } from "./auth-login.service.js";
/**
* Auth Orchestrator
*
@ -36,18 +34,9 @@ export class AuthOrchestrator {
private readonly auditService: AuditService,
private readonly tokenBlacklistService: TokenBlacklistService,
private readonly tokenService: AuthTokenService,
private readonly loginService: AuthLoginService,
@Inject(Logger) private readonly logger: Logger
) {}
async validateUser(
email: string,
password: string,
request?: Request
): Promise<{ id: string; email: string; role: string } | null> {
return this.loginService.validateUser(email, password, request);
}
/**
* Complete login after credential validation (and OTP verification if required)
* Generates tokens and updates user state
@ -171,29 +160,11 @@ export class AuthOrchestrator {
* Create SSO link to WHMCS for general access
*/
async createSsoLink(userId: string, destination?: string): Promise<SsoLinkResponse> {
try {
this.logger.log("Creating SSO link request");
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
const ssoDestination = "sso:custom_redirect";
const ssoRedirectPath = sanitizeWhmcsRedirectPath(destination);
const result = await this.whmcsSsoService.createSsoToken(
whmcsClientId,
ssoDestination,
ssoRedirectPath
);
this.logger.log("SSO link created successfully");
return result;
} catch (error) {
this.logger.error("SSO link creation failed", {
errorType: error instanceof Error ? error.constructor.name : "Unknown",
message: extractErrorMessage(error),
});
throw error;
}
return this.whmcsSsoService.createSsoToken(whmcsClientId, ssoDestination, ssoRedirectPath);
}
async refreshTokens(

View File

@ -2,6 +2,14 @@ import type { User, UserAuth } from "@customer-portal/domain/customer";
import type { Request } from "express";
import type { AuthTokens } from "@customer-portal/domain/auth";
/**
* Express Request with typed cookies.
* Used across auth controllers and cookie utilities.
*/
export type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, string>;
};
export type RequestWithUser = Request & { user: User };
/**

View File

@ -1,115 +0,0 @@
/**
* Centralized Authentication Error Catalog
*
* Provides consistent error codes and messages across the auth module.
* Use these constants instead of hardcoding error messages.
*/
export const AUTH_ERRORS = {
// Token errors
TOKEN_INVALID: {
code: "TOKEN_INVALID",
message: "Invalid token",
},
TOKEN_EXPIRED: {
code: "TOKEN_EXPIRED",
message: "Token has expired",
},
TOKEN_REVOKED: {
code: "TOKEN_REVOKED",
message: "Token has been revoked",
},
TOKEN_MISSING: {
code: "TOKEN_MISSING",
message: "Missing token",
},
TOKEN_INVALID_TYPE: {
code: "TOKEN_INVALID_TYPE",
message: "Invalid access token",
},
TOKEN_INVALID_PAYLOAD: {
code: "TOKEN_INVALID_PAYLOAD",
message: "Invalid token payload",
},
TOKEN_MISSING_EXPIRATION: {
code: "TOKEN_MISSING_EXPIRATION",
message: "Token missing expiration claim",
},
TOKEN_EXPIRING_SOON: {
code: "TOKEN_EXPIRING_SOON",
message: "Token expired or expiring soon",
},
TOKEN_SUBJECT_MISMATCH: {
code: "TOKEN_SUBJECT_MISMATCH",
message: "Token subject does not match user record",
},
// User errors
USER_NOT_FOUND: {
code: "USER_NOT_FOUND",
message: "User not found",
},
ACCOUNT_EXISTS: {
code: "ACCOUNT_EXISTS",
message: "Account already exists",
},
INVALID_CREDENTIALS: {
code: "INVALID_CREDENTIALS",
message: "Invalid credentials",
},
EMAIL_NOT_VERIFIED: {
code: "EMAIL_NOT_VERIFIED",
message: "Email not verified",
},
ACCOUNT_DISABLED: {
code: "ACCOUNT_DISABLED",
message: "Account has been disabled",
},
// Password errors
PASSWORD_TOO_WEAK: {
code: "PASSWORD_TOO_WEAK",
message: "Password does not meet requirements",
},
PASSWORD_MISMATCH: {
code: "PASSWORD_MISMATCH",
message: "Current password is incorrect",
},
PASSWORD_RESET_EXPIRED: {
code: "PASSWORD_RESET_EXPIRED",
message: "Password reset token has expired",
},
PASSWORD_RESET_INVALID: {
code: "PASSWORD_RESET_INVALID",
message: "Invalid password reset token",
},
// Session errors
SESSION_EXPIRED: {
code: "SESSION_EXPIRED",
message: "Session has expired",
},
SESSION_INVALID: {
code: "SESSION_INVALID",
message: "Invalid session",
},
// Request context errors
INVALID_REQUEST_CONTEXT: {
code: "INVALID_REQUEST_CONTEXT",
message: "Invalid request context",
},
// Service errors
SERVICE_UNAVAILABLE: {
code: "SERVICE_UNAVAILABLE",
message: "Authentication temporarily unavailable",
},
RATE_LIMITED: {
code: "RATE_LIMITED",
message: "Too many requests. Please try again later.",
},
} as const;
export type AuthErrorCode = keyof typeof AUTH_ERRORS;
export type AuthError = (typeof AUTH_ERRORS)[AuthErrorCode];

View File

@ -91,7 +91,7 @@ export class LoginSessionService {
* @param sessionToken - The session token (UUID)
* @returns Session data if found and valid, null otherwise
*/
async get(sessionToken: string): Promise<LoginSessionData | null> {
private async get(sessionToken: string): Promise<LoginSessionData | null> {
const sessionData = await this.cache.get<LoginSessionData>(this.buildKey(sessionToken));
if (!sessionData) {

View File

@ -173,25 +173,6 @@ export class GetStartedSessionService {
this.logger.debug({ sessionId: sessionToken }, "Get-started session invalidated");
}
/**
* Validate that a session exists and email is verified
*/
async validateVerifiedSession(sessionToken: string): Promise<GetStartedSession | null> {
const session = await this.get(sessionToken);
if (!session) {
this.logger.warn({ sessionId: sessionToken }, "Session not found");
return null;
}
if (!session.emailVerified) {
this.logger.warn({ sessionId: sessionToken }, "Session email not verified");
return null;
}
return session;
}
// ============================================================================
// Guest Handoff Token Methods
// ============================================================================

View File

@ -136,16 +136,6 @@ export class OtpService {
return result;
}
/**
* Check if an OTP exists for the given email (without consuming attempts)
*/
async exists(email: string): Promise<boolean> {
const normalizedEmail = this.normalizeEmail(email);
const key = this.buildKey(normalizedEmail);
const otpData = await this.cache.get<OtpData>(key);
return otpData !== null;
}
/**
* Delete any existing OTP for the given email
*/

View File

@ -9,7 +9,6 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import type { Request } from "express";
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import { createHash } from "crypto";
import type { Redis } from "ioredis";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import {
@ -29,10 +28,6 @@ export interface RateLimitOutcome {
export class AuthRateLimitService {
private readonly loginLimiter: RateLimiterRedis;
private readonly refreshLimiter: RateLimiterRedis;
private readonly signupLimiter: RateLimiterRedis;
private readonly passwordResetLimiter: RateLimiterRedis;
private readonly loginCaptchaThreshold: number;
@ -47,29 +42,21 @@ export class AuthRateLimitService {
const loginLimit = this.configService.get<number>("LOGIN_RATE_LIMIT_LIMIT", 20);
const loginTtlMs = this.configService.get<number>("LOGIN_RATE_LIMIT_TTL", 300000);
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
const passwordResetTtlMs = this.configService.get<number>(
"PASSWORD_RESET_RATE_LIMIT_TTL",
900000
);
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
this.loginCaptchaThreshold = this.configService.get<number>("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 5);
this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true";
this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs);
this.signupLimiter = this.createLimiter("auth-signup", signupLimit, signupTtlMs);
this.passwordResetLimiter = this.createLimiter(
"auth-password-reset",
passwordResetLimit,
passwordResetTtlMs
);
this.refreshLimiter = this.createLimiter("auth-refresh", refreshLimit, refreshTtlMs);
}
async consumeLoginAttempt(request: Request): Promise<RateLimitOutcome> {
@ -86,31 +73,12 @@ export class AuthRateLimitService {
await this.deleteKey(this.loginLimiter, key, "login");
}
async consumeSignupAttempt(request: Request): Promise<RateLimitOutcome> {
const key = this.buildKey("signup", request);
const outcome = await this.consume(this.signupLimiter, key, "signup");
return { ...outcome, needsCaptcha: false };
}
async consumePasswordReset(request: Request): Promise<RateLimitOutcome> {
const key = this.buildKey("password-reset", request);
const outcome = await this.consume(this.passwordResetLimiter, key, "password-reset");
return { ...outcome, needsCaptcha: false };
}
async consumeRefreshAttempt(request: Request, refreshToken: string): Promise<RateLimitOutcome> {
const tokenHash = this.hashToken(refreshToken);
const key = this.buildKey("refresh", request, tokenHash);
return this.consume(this.refreshLimiter, key, "refresh");
}
getCaptchaHeaderValue(needsCaptcha: boolean): string {
if (needsCaptcha || this.captchaAlwaysOn) {
return "required";
}
return "optional";
}
private requiresCaptcha(consumedPoints: number): boolean {
if (this.loginCaptchaThreshold <= 0) {
return false;
@ -208,8 +176,4 @@ export class AuthRateLimitService {
const timeMessage = remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
return `Too many ${context} attempts. Try again in ${timeMessage}.`;
}
private hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
}

View File

@ -5,6 +5,7 @@
*/
export type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
export { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
export { AuthTokenService } from "./token.service.js";
export { TokenGeneratorService } from "./token-generator.service.js";
export { TokenRefreshService } from "./token-refresh.service.js";

View File

@ -145,31 +145,6 @@ export class PasswordResetTokenService {
return { userId: payload.sub };
}
/**
* Invalidate a password reset token without using it
*
* Useful for cleanup or when a user requests a new token
*
* @param tokenId - The token ID to invalidate
*/
async invalidate(tokenId: string): Promise<void> {
await this.cache.del(this.buildKey(tokenId));
this.logger.log({ tokenId }, "Password reset token invalidated");
}
/**
* Invalidate all password reset tokens for a user
*
* Useful when password is changed through another method
*
* @param userId - The user ID to invalidate tokens for
*/
async invalidateAllForUser(userId: string): Promise<void> {
// Use pattern-based deletion
await this.cache.delPattern(`${this.REDIS_PREFIX}*`);
this.logger.log({ userId }, "All password reset tokens invalidated for user");
}
/**
* Build Redis key for token storage
*/

View File

@ -1,8 +1,8 @@
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createHash } from "crypto";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { hashToken } from "../../utils/hash-token.util.js";
import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
import { JoseJwtService } from "./jose-jwt.service.js";
@ -108,10 +108,6 @@ export class TokenBlacklistService {
}
private buildBlacklistKey(token: string): string {
return `blacklist:${this.hashToken(token)}`;
}
private hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
return `blacklist:${hashToken(token)}`;
}
}

View File

@ -6,10 +6,13 @@ import {
} from "@nestjs/common";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import { randomBytes } from "crypto";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserRole } from "@customer-portal/domain/customer";
import { parseJwtExpiry, parseJwtExpiryMs } from "../../utils/jwt-expiry.util.js";
import { hashToken } from "../../utils/hash-token.util.js";
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
import { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
@ -22,9 +25,6 @@ const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailabl
*/
@Injectable()
export class TokenGeneratorService {
readonly ACCESS_TOKEN_EXPIRY = "15m";
readonly REFRESH_TOKEN_EXPIRY = "7d";
constructor(
private readonly jwtService: JoseJwtService,
private readonly storage: TokenStorageService,
@ -77,12 +77,12 @@ export class TokenGeneratorService {
type: "refresh",
};
const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const accessToken = await this.jwtService.sign(accessPayload, ACCESS_TOKEN_EXPIRY);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
const refreshExpirySeconds = parseJwtExpiry(REFRESH_TOKEN_EXPIRY);
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
const refreshTokenHash = this.hashToken(refreshToken);
const refreshTokenHash = hashToken(refreshToken);
const refreshAbsoluteExpiresAt = new Date(
Date.now() + refreshExpirySeconds * 1000
).toISOString();
@ -112,7 +112,7 @@ export class TokenGeneratorService {
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
Date.now() + parseJwtExpiryMs(ACCESS_TOKEN_EXPIRY)
).toISOString();
const refreshExpiresAt = refreshAbsoluteExpiresAt;
@ -161,9 +161,9 @@ export class TokenGeneratorService {
type: "refresh",
};
const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const newAccessToken = await this.jwtService.sign(accessPayload, ACCESS_TOKEN_EXPIRY);
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
const newRefreshTokenHash = this.hashToken(newRefreshToken);
const newRefreshTokenHash = hashToken(newRefreshToken);
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
}
@ -171,30 +171,4 @@ export class TokenGeneratorService {
generateTokenId(): string {
return randomBytes(32).toString("hex");
}
hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1);
const value = Number.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;
}
}
parseExpiryToSeconds(expiry: string): number {
return Math.floor(this.parseExpiryToMs(expiry) / 1000);
}
}

View File

@ -1,452 +0,0 @@
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Redis } from "ioredis";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface MigrationStats {
totalKeysScanned: number;
familiesFound: number;
familiesMigrated: number;
tokensFound: number;
tokensMigrated: number;
orphanedTokens: number;
errors: number;
duration: number;
}
interface ParsedFamily {
userId: string;
}
interface ParsedToken {
userId: string;
familyId: string;
}
@Injectable()
export class TokenMigrationService {
private readonly REFRESH_TOKEN_FAMILY_PREFIX = "refresh_family:";
private readonly REFRESH_TOKEN_PREFIX = "refresh_token:";
private readonly REFRESH_USER_SET_PREFIX = "refresh_user:";
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Migrate existing refresh tokens to the new per-user token set structure
*/
async migrateExistingTokens(dryRun = true): Promise<MigrationStats> {
const startTime = Date.now();
const stats: MigrationStats = {
totalKeysScanned: 0,
familiesFound: 0,
familiesMigrated: 0,
tokensFound: 0,
tokensMigrated: 0,
orphanedTokens: 0,
errors: 0,
duration: 0,
};
this.logger.log("Starting token migration", { dryRun });
if (this.redis.status !== "ready") {
throw new ServiceUnavailableException("Redis is not ready for migration");
}
try {
// First, scan for all refresh token families
await this.migrateFamilies(stats, dryRun);
// Then, scan for orphaned tokens (tokens without families)
await this.migrateOrphanedTokens(stats, dryRun);
stats.duration = Date.now() - startTime;
this.logger.log("Token migration completed", {
dryRun,
stats,
});
return stats;
} catch (error) {
stats.duration = Date.now() - startTime;
stats.errors++;
this.logger.error("Token migration failed", {
error: extractErrorMessage(error),
stats,
});
throw error;
}
}
/**
* Migrate refresh token families to per-user sets
*/
private async migrateFamilies(stats: MigrationStats, dryRun: boolean): Promise<void> {
let cursor = "0";
const pattern = `${this.REFRESH_TOKEN_FAMILY_PREFIX}*`;
do {
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = nextCursor;
stats.totalKeysScanned += keys.length;
if (keys && keys.length > 0) {
// eslint-disable-next-line no-await-in-loop -- Batch processing within SCAN loop
const results = await Promise.allSettled(
keys.map(async key => this.migrateSingleFamily(key, stats, dryRun))
);
this.countSettledErrors(results, stats, "Failed to migrate family");
}
} while (cursor !== "0");
}
/**
* Migrate a single refresh token family
*/
private async migrateSingleFamily(
familyKey: string,
stats: MigrationStats,
dryRun: boolean
): Promise<void> {
const familyData = await this.redis.get(familyKey);
if (!familyData) {
return;
}
stats.familiesFound++;
const family = this.parseFamilyData(familyKey, familyData, stats);
if (!family) {
return;
}
const familyId = familyKey.replace(this.REFRESH_TOKEN_FAMILY_PREFIX, "");
const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${family.userId}`;
// Check if this family is already in the user's set
const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, familyId);
if (isAlreadyMigrated) {
this.logger.debug("Family already migrated", { familyKey, userId: family.userId });
return;
}
if (!dryRun) {
// Add family to user's token set
const pipeline = this.redis.pipeline();
pipeline.sadd(userFamilySetKey, familyId);
// Set expiration on the user set (use the same TTL as the family)
const ttl = await this.redis.ttl(familyKey);
if (ttl > 0) {
pipeline.expire(userFamilySetKey, ttl);
}
await pipeline.exec();
}
stats.familiesMigrated++;
this.logger.debug("Migrated family to user set", {
familyKey,
userId: family.userId,
dryRun,
});
}
/**
* Migrate orphaned tokens (tokens without corresponding families)
*/
private async migrateOrphanedTokens(stats: MigrationStats, dryRun: boolean): Promise<void> {
let cursor = "0";
const pattern = `${this.REFRESH_TOKEN_PREFIX}*`;
do {
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = nextCursor;
stats.totalKeysScanned += keys.length;
if (keys && keys.length > 0) {
// eslint-disable-next-line no-await-in-loop -- Batch processing within SCAN loop
const results = await Promise.allSettled(
keys.map(async key => this.migrateSingleToken(key, stats, dryRun))
);
this.countSettledErrors(results, stats, "Failed to migrate token");
}
} while (cursor !== "0");
}
/**
* Migrate a single refresh token
*/
private async migrateSingleToken(
tokenKey: string,
stats: MigrationStats,
dryRun: boolean
): Promise<void> {
const tokenData = await this.redis.get(tokenKey);
if (!tokenData) {
return;
}
stats.tokensFound++;
const token = this.parseTokenData(tokenKey, tokenData, stats);
if (!token) {
return;
}
// Check if the corresponding family exists
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`;
const familyExists = await this.redis.exists(familyKey);
if (!familyExists) {
stats.orphanedTokens++;
this.logger.warn("Found orphaned token (no corresponding family)", {
tokenKey,
familyId: token.familyId,
userId: token.userId,
});
if (!dryRun) {
// Remove orphaned token
await this.redis.del(tokenKey);
this.logger.debug("Removed orphaned token", { tokenKey });
}
return;
}
// Check if this token's family is already in the user's set
const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${token.userId}`;
const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, token.familyId);
if (isAlreadyMigrated) {
stats.tokensMigrated++;
return;
}
if (!dryRun) {
// Add family to user's token set
const pipeline = this.redis.pipeline();
pipeline.sadd(userFamilySetKey, token.familyId);
// Set expiration on the user set (use the same TTL as the token)
const ttl = await this.redis.ttl(tokenKey);
if (ttl > 0) {
pipeline.expire(userFamilySetKey, ttl);
}
await pipeline.exec();
}
stats.tokensMigrated++;
this.logger.debug("Migrated token family to user set", {
tokenKey,
familyId: token.familyId,
userId: token.userId,
dryRun,
});
}
/**
* Clean up orphaned tokens and expired families
*/
async cleanupOrphanedTokens(dryRun = true): Promise<{ removed: number; errors: number }> {
const stats = { removed: 0, errors: 0 };
this.logger.log("Starting orphaned token cleanup", { dryRun });
let cursor = "0";
const pattern = `${this.REFRESH_TOKEN_PREFIX}*`;
do {
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = nextCursor;
if (keys && keys.length > 0) {
// eslint-disable-next-line no-await-in-loop -- Batch processing within SCAN loop
const results = await Promise.allSettled(
keys.map(async key => this.cleanupSingleToken(key, stats, dryRun))
);
this.countSettledErrors(results, stats, "Failed to cleanup token");
}
} while (cursor !== "0");
this.logger.log("Orphaned token cleanup completed", { dryRun, stats });
return stats;
}
/**
* Clean up a single orphaned token
*/
private async cleanupSingleToken(
key: string,
stats: { removed: number; errors: number },
dryRun: boolean
): Promise<void> {
const tokenData = await this.redis.get(key);
if (!tokenData) {
return;
}
const token = this.parseTokenData(key, tokenData, stats);
if (!token) {
return;
}
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`;
const familyExists = await this.redis.exists(familyKey);
if (familyExists) {
return;
}
if (!dryRun) {
await this.redis.del(key);
}
stats.removed++;
this.logger.debug("Removed orphaned token", {
tokenKey: key,
familyId: token.familyId,
dryRun,
});
}
/**
* Get migration status and statistics
*/
async getMigrationStatus(): Promise<{
totalFamilies: number;
totalTokens: number;
migratedUsers: number;
orphanedTokens: number;
needsMigration: boolean;
}> {
const [totalFamilies, totalTokens, migratedUsers] = await Promise.all([
this.countKeysByPattern(`${this.REFRESH_TOKEN_FAMILY_PREFIX}*`),
this.countKeysByPattern(`${this.REFRESH_TOKEN_PREFIX}*`),
this.countKeysByPattern(`${this.REFRESH_USER_SET_PREFIX}*`),
]);
return {
totalFamilies,
totalTokens,
migratedUsers,
orphanedTokens: 0,
needsMigration: totalFamilies > 0 && migratedUsers === 0,
};
}
/**
* Count keys matching a pattern using Redis SCAN
*/
private async countKeysByPattern(pattern: string): Promise<number> {
let count = 0;
let cursor = "0";
do {
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = nextCursor;
count += keys.length;
} while (cursor !== "0");
return count;
}
private parseFamilyData(
familyKey: string,
raw: string,
tracker: { errors: number }
): ParsedFamily | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
tracker.errors++;
this.logger.error("Failed to parse family data", {
familyKey,
error: extractErrorMessage(error),
});
return null;
}
if (!parsed || typeof parsed !== "object") {
this.logger.warn("Invalid family structure, skipping", { familyKey });
return null;
}
const record = parsed as Record<string, unknown>;
const userId = record["userId"];
if (typeof userId !== "string" || userId.length === 0) {
this.logger.warn("Invalid family structure, skipping", { familyKey });
return null;
}
return { userId };
}
private parseTokenData(
tokenKey: string,
raw: string,
tracker: { errors: number }
): ParsedToken | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
tracker.errors++;
this.logger.error("Failed to parse token data", {
tokenKey,
error: extractErrorMessage(error),
});
return null;
}
if (!parsed || typeof parsed !== "object") {
this.logger.warn("Invalid token structure, skipping", { tokenKey });
return null;
}
const record = parsed as Record<string, unknown>;
const userId = record["userId"];
const familyId = record["familyId"];
if (typeof userId !== "string" || typeof familyId !== "string") {
this.logger.warn("Invalid token structure, skipping", { tokenKey });
return null;
}
return { userId, familyId };
}
/**
* Count and log errors from Promise.allSettled results
*/
private countSettledErrors(
results: PromiseSettledResult<void>[],
stats: { errors: number },
message: string
): void {
for (const result of results) {
if (result.status === "rejected") {
stats.errors++;
this.logger.error(message, {
error: extractErrorMessage(result.reason),
});
}
}
}
}

View File

@ -11,7 +11,10 @@ import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { parseJwtExpiry, parseJwtExpiryMs } from "../../utils/jwt-expiry.util.js";
import { hashToken } from "../../utils/hash-token.util.js";
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
import { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
@ -87,7 +90,7 @@ export class TokenRefreshService {
): Promise<ValidatedTokenContext> {
const payload = await this.verifyRefreshTokenPayload(refreshToken);
const familyId = this.extractFamilyId(payload);
const refreshTokenHash = this.generator.hashToken(refreshToken);
const refreshTokenHash = hashToken(refreshToken);
await this.validateStoredToken(refreshTokenHash, familyId, payload);
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
@ -235,7 +238,7 @@ export class TokenRefreshService {
const tokenTtl = await this.storage.getTtl(tokenKey);
return typeof tokenTtl === "number" && tokenTtl > 0
? tokenTtl
: this.generator.parseExpiryToSeconds(this.generator.REFRESH_TOKEN_EXPIRY);
: parseJwtExpiry(REFRESH_TOKEN_EXPIRY);
}
private async performTokenRotation(
@ -283,7 +286,7 @@ export class TokenRefreshService {
}
const accessExpiresAt = new Date(
Date.now() + this.generator.parseExpiryToMs(this.generator.ACCESS_TOKEN_EXPIRY)
Date.now() + parseJwtExpiryMs(ACCESS_TOKEN_EXPIRY)
).toISOString();
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });

View File

@ -1,6 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { createHash } from "crypto";
import { hashToken } from "../../utils/hash-token.util.js";
import { TokenStorageService } from "./token-storage.service.js";
/**
@ -23,7 +23,7 @@ export class TokenRevocationService {
*/
async revokeRefreshToken(refreshToken: string): Promise<void> {
try {
const tokenHash = this.hashToken(refreshToken);
const tokenHash = hashToken(refreshToken);
const tokenRecord = await this.storage.getTokenByHash(tokenHash);
if (tokenRecord) {
@ -212,8 +212,4 @@ export class TokenRevocationService {
return [];
}
}
private hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
}

View File

@ -27,16 +27,6 @@ export interface StoreRefreshTokenParams {
absoluteExpiresAt?: string;
}
export interface UpdateFamilyParams {
familyId: string;
userId: string;
newTokenHash: string;
deviceInfo?: DeviceInfo;
createdAt: string;
absoluteExpiresAt: string;
ttlSeconds: number;
}
export interface AtomicTokenRotationParams {
oldTokenHash: string;
newTokenHash: string;
@ -223,19 +213,6 @@ export class TokenStorageService {
return { token, family };
}
/**
* Mark a token as invalid (used during rotation)
*/
async markTokenInvalid(
tokenHash: string,
familyId: string,
userId: string,
ttlSeconds: number
): Promise<void> {
const key = `${this.REFRESH_TOKEN_PREFIX}${tokenHash}`;
await this.redis.setex(key, ttlSeconds, JSON.stringify({ familyId, userId, valid: false }));
}
/**
* Atomically validate and rotate a refresh token.
* Uses Redis Lua script to prevent race conditions during concurrent refresh requests.
@ -338,35 +315,6 @@ export class TokenStorageService {
return { success: false, error: result[1] };
}
/**
* Update token family with new token
*/
async updateFamily(params: UpdateFamilyParams): Promise<void> {
const { familyId, userId, newTokenHash, deviceInfo, createdAt, absoluteExpiresAt, ttlSeconds } =
params;
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
const tokenKey = `${this.REFRESH_TOKEN_PREFIX}${newTokenHash}`;
const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`;
const pipeline = this.redis.pipeline();
pipeline.setex(
familyKey,
ttlSeconds,
JSON.stringify({
userId,
tokenHash: newTokenHash,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
createdAt,
absoluteExpiresAt,
})
);
pipeline.setex(tokenKey, ttlSeconds, JSON.stringify({ familyId, userId, valid: true }));
pipeline.sadd(userFamilySetKey, familyId);
pipeline.expire(userFamilySetKey, ttlSeconds);
await pipeline.exec();
}
/**
* Delete a token by hash
*/

View File

@ -84,28 +84,9 @@ export class AuthTokenService {
}
/**
* Revoke a specific refresh token
*/
async revokeRefreshToken(refreshToken: string): Promise<void> {
return this.revocation.revokeRefreshToken(refreshToken);
}
/**
* Get all active refresh token families for a user
*/
async getUserRefreshTokenFamilies(userId: string): Promise<
Array<{
familyId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
createdAt?: string | undefined;
}>
> {
return this.revocation.getUserRefreshTokenFamilies(userId);
}
/**
* Revoke all refresh tokens for a user
* Revoke all refresh tokens for a user.
* Intentionally skips `checkServiceAvailability()` logout must succeed
* even during maintenance windows so users are never locked into a session.
*/
async revokeAllUserTokens(userId: string): Promise<void> {
return this.revocation.revokeAllUserTokens(userId);

View File

@ -38,26 +38,32 @@ export interface CreateWhmcsClientResult {
*/
@Injectable()
export class CreateWhmcsClientStep {
private readonly customerNumberFieldId: string | undefined;
private readonly dobFieldId: string | undefined;
private readonly genderFieldId: string | undefined;
private readonly nationalityFieldId: string | undefined;
constructor(
private readonly whmcsClientService: WhmcsClientService,
private readonly configService: ConfigService,
configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
) {
this.customerNumberFieldId = configService.get<string>("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
this.dobFieldId = configService.get<string>("WHMCS_DOB_FIELD_ID");
this.genderFieldId = configService.get<string>("WHMCS_GENDER_FIELD_ID");
this.nationalityFieldId = configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
}
async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> {
const customerNumberFieldId = this.configService.get<string>("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfieldsMap: Record<string, string> = {};
if (customerNumberFieldId && params.customerNumber) {
customfieldsMap[customerNumberFieldId] = params.customerNumber;
if (this.customerNumberFieldId && params.customerNumber) {
customfieldsMap[this.customerNumberFieldId] = params.customerNumber;
}
if (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth;
if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender;
if (nationalityFieldId && params.nationality)
customfieldsMap[nationalityFieldId] = params.nationality;
if (this.dobFieldId && params.dateOfBirth)
customfieldsMap[this.dobFieldId] = params.dateOfBirth;
if (this.genderFieldId && params.gender) customfieldsMap[this.genderFieldId] = params.gender;
if (this.nationalityFieldId && params.nationality)
customfieldsMap[this.nationalityFieldId] = params.nationality;
this.logger.log(
{
@ -102,9 +108,7 @@ export class CreateWhmcsClientStep {
},
"Failed to create WHMCS client"
);
throw new BadRequestException(
`Failed to create billing account: ${extractErrorMessage(whmcsError)}`
);
throw new BadRequestException("Failed to create billing account");
}
}

View File

@ -22,7 +22,7 @@ import {
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import type { RequestWithUser, RequestWithCookies } from "@bff/modules/auth/auth.types.js";
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js";
@ -59,11 +59,6 @@ import {
loginVerifyOtpRequestSchema,
} from "@customer-portal/domain/auth";
type CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
};
// Re-export for backward compatibility with tests
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };

View File

@ -1,12 +1,12 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js";
import { AuthLoginService } from "@bff/modules/auth/application/auth-login.service.js";
import { ErrorCode } from "@customer-portal/domain/common";
@Injectable()
export class LocalAuthGuard implements CanActivate {
constructor(private readonly authOrchestrator: AuthOrchestrator) {}
constructor(private readonly loginService: AuthLoginService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
@ -22,7 +22,7 @@ export class LocalAuthGuard implements CanActivate {
});
}
const user = await this.authOrchestrator.validateUser(email, password, request);
const user = await this.loginService.validateUser(email, password, request);
if (!user) {
throw new UnauthorizedException({
message: "Invalid credentials",

View File

@ -1,4 +1,5 @@
import type { Request, Response } from "express";
import type { Response } from "express";
import type { RequestWithCookies } from "@bff/modules/auth/auth.types.js";
import { getSecureCookie } from "./secure-cookie.util.js";
/**
@ -67,13 +68,6 @@ export function clearTrustedDeviceCookie(res: Response): void {
}
}
/**
* Request type with cookies
*/
type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, string | undefined>;
};
/**
* Get the trusted device token from the request cookies
*

View File

@ -1 +0,0 @@
export * from "./http/auth.controller.js";

View File

@ -8,7 +8,6 @@ import { TokenRevocationService } from "../infra/token/token-revocation.service.
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
import { AuthTokenService } from "../infra/token/token.service.js";
import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js";
import { TokenMigrationService } from "../infra/token/token-migration.service.js";
@Module({
imports: [UsersModule],
@ -21,15 +20,7 @@ import { TokenMigrationService } from "../infra/token/token-migration.service.js
TokenBlacklistService,
AuthTokenService,
PasswordResetTokenService,
TokenMigrationService,
],
exports: [
JoseJwtService,
AuthTokenService,
TokenBlacklistService,
TokenRefreshService,
PasswordResetTokenService,
TokenMigrationService,
],
exports: [JoseJwtService, AuthTokenService, TokenBlacklistService, PasswordResetTokenService],
})
export class TokensModule {}

View File

@ -0,0 +1,9 @@
import { createHash } from "crypto";
/**
* SHA-256 hash a token string to a hex digest.
* Used consistently for refresh token hashing and blacklist keys.
*/
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

View File

@ -51,12 +51,9 @@ export const parseJwtExpiry = (expiresIn: string | number | undefined | null): n
}
};
export const calculateExpiryDate = (
expiresIn: string | number | undefined | null,
referenceDate: number = Date.now()
): string => {
const seconds = parseJwtExpiry(expiresIn);
return new Date(referenceDate + seconds * 1000).toISOString();
};
export const JWT_EXPIRY_DEFAULT_SECONDS = DEFAULT_JWT_EXPIRY_SECONDS;
/**
* Parse a JWT expiry configuration string into milliseconds.
* Convenience wrapper over `parseJwtExpiry`.
*/
export const parseJwtExpiryMs = (expiresIn: string | number | undefined | null): number =>
parseJwtExpiry(expiresIn) * 1000;