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), params: redactForLogs(params),
responseTime: Date.now() - startTime, responseTime: Date.now() - startTime,
}, },
"WHMCS HTTP request failed [%s]", "WHMCS HTTP request failed"
action
); );
throw error; throw error;
@ -146,8 +145,7 @@ export class WhmcsHttpClientService {
statusText: response.statusText, statusText: response.statusText,
snippet, snippet,
}, },
"WHMCS non-OK response body snippet [%s]", "WHMCS non-OK response body snippet"
action
); );
} }
} }
@ -255,8 +253,7 @@ export class WhmcsHttpClientService {
parseError: extractErrorMessage(parseError), parseError: extractErrorMessage(parseError),
params: redactForLogs(params), params: redactForLogs(params),
}, },
"Invalid JSON response from WHMCS API [%s]", "Invalid JSON response from WHMCS API"
action
); );
throw new WhmcsOperationException("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) }), : { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params), params: redactForLogs(params),
}, },
"WHMCS API returned invalid response structure [%s]", "WHMCS API returned invalid response structure"
action
); );
throw new WhmcsOperationException("Invalid response structure from WHMCS API"); 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. // classifies severity and the business layer logs the final outcome.
this.logger.debug( this.logger.debug(
{ {
action,
errorMessage, errorMessage,
errorCode, errorCode,
params: redactForLogs(params), params: redactForLogs(params),
}, },
"WHMCS API returned error [%s]", "WHMCS API returned error"
action
); );
// Return error response for the orchestrator to handle with proper exception types // 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 { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface ValidatedUser { interface ValidatedUser {
id: string; id: string;
email: string; email: string;
role: string; role: string;

View File

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

View File

@ -2,6 +2,14 @@ import type { User, UserAuth } from "@customer-portal/domain/customer";
import type { Request } from "express"; import type { Request } from "express";
import type { AuthTokens } from "@customer-portal/domain/auth"; 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 }; 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) * @param sessionToken - The session token (UUID)
* @returns Session data if found and valid, null otherwise * @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)); const sessionData = await this.cache.get<LoginSessionData>(this.buildKey(sessionToken));
if (!sessionData) { if (!sessionData) {

View File

@ -173,25 +173,6 @@ export class GetStartedSessionService {
this.logger.debug({ sessionId: sessionToken }, "Get-started session invalidated"); 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 // Guest Handoff Token Methods
// ============================================================================ // ============================================================================

View File

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

View File

@ -145,31 +145,6 @@ export class PasswordResetTokenService {
return { userId: payload.sub }; 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 * Build Redis key for token storage
*/ */

View File

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

View File

@ -6,10 +6,13 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto"; import { randomBytes } from "crypto";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserRole } from "@customer-portal/domain/customer"; 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 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 { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js"; import { TokenStorageService } from "./token-storage.service.js";
@ -22,9 +25,6 @@ const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailabl
*/ */
@Injectable() @Injectable()
export class TokenGeneratorService { export class TokenGeneratorService {
readonly ACCESS_TOKEN_EXPIRY = "15m";
readonly REFRESH_TOKEN_EXPIRY = "7d";
constructor( constructor(
private readonly jwtService: JoseJwtService, private readonly jwtService: JoseJwtService,
private readonly storage: TokenStorageService, private readonly storage: TokenStorageService,
@ -77,12 +77,12 @@ export class TokenGeneratorService {
type: "refresh", 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 refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
const refreshTokenHash = this.hashToken(refreshToken); const refreshTokenHash = hashToken(refreshToken);
const refreshAbsoluteExpiresAt = new Date( const refreshAbsoluteExpiresAt = new Date(
Date.now() + refreshExpirySeconds * 1000 Date.now() + refreshExpirySeconds * 1000
).toISOString(); ).toISOString();
@ -112,7 +112,7 @@ export class TokenGeneratorService {
} }
const accessExpiresAt = new Date( const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) Date.now() + parseJwtExpiryMs(ACCESS_TOKEN_EXPIRY)
).toISOString(); ).toISOString();
const refreshExpiresAt = refreshAbsoluteExpiresAt; const refreshExpiresAt = refreshAbsoluteExpiresAt;
@ -161,9 +161,9 @@ export class TokenGeneratorService {
type: "refresh", 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 newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
const newRefreshTokenHash = this.hashToken(newRefreshToken); const newRefreshTokenHash = hashToken(newRefreshToken);
return { newAccessToken, newRefreshToken, newRefreshTokenHash }; return { newAccessToken, newRefreshToken, newRefreshTokenHash };
} }
@ -171,30 +171,4 @@ export class TokenGeneratorService {
generateTokenId(): string { generateTokenId(): string {
return randomBytes(32).toString("hex"); 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 type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js"; import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.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 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 { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js"; import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js"; import { TokenRevocationService } from "./token-revocation.service.js";
@ -87,7 +90,7 @@ export class TokenRefreshService {
): Promise<ValidatedTokenContext> { ): Promise<ValidatedTokenContext> {
const payload = await this.verifyRefreshTokenPayload(refreshToken); const payload = await this.verifyRefreshTokenPayload(refreshToken);
const familyId = this.extractFamilyId(payload); const familyId = this.extractFamilyId(payload);
const refreshTokenHash = this.generator.hashToken(refreshToken); const refreshTokenHash = hashToken(refreshToken);
await this.validateStoredToken(refreshTokenHash, familyId, payload); await this.validateStoredToken(refreshTokenHash, familyId, payload);
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime( const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
@ -235,7 +238,7 @@ export class TokenRefreshService {
const tokenTtl = await this.storage.getTtl(tokenKey); const tokenTtl = await this.storage.getTtl(tokenKey);
return typeof tokenTtl === "number" && tokenTtl > 0 return typeof tokenTtl === "number" && tokenTtl > 0
? tokenTtl ? tokenTtl
: this.generator.parseExpiryToSeconds(this.generator.REFRESH_TOKEN_EXPIRY); : parseJwtExpiry(REFRESH_TOKEN_EXPIRY);
} }
private async performTokenRotation( private async performTokenRotation(
@ -283,7 +286,7 @@ export class TokenRefreshService {
} }
const accessExpiresAt = new Date( const accessExpiresAt = new Date(
Date.now() + this.generator.parseExpiryToMs(this.generator.ACCESS_TOKEN_EXPIRY) Date.now() + parseJwtExpiryMs(ACCESS_TOKEN_EXPIRY)
).toISOString(); ).toISOString();
this.logger.debug("Refreshed token pair", { userId: context.payload.userId }); this.logger.debug("Refreshed token pair", { userId: context.payload.userId });

View File

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

View File

@ -27,16 +27,6 @@ export interface StoreRefreshTokenParams {
absoluteExpiresAt?: string; absoluteExpiresAt?: string;
} }
export interface UpdateFamilyParams {
familyId: string;
userId: string;
newTokenHash: string;
deviceInfo?: DeviceInfo;
createdAt: string;
absoluteExpiresAt: string;
ttlSeconds: number;
}
export interface AtomicTokenRotationParams { export interface AtomicTokenRotationParams {
oldTokenHash: string; oldTokenHash: string;
newTokenHash: string; newTokenHash: string;
@ -223,19 +213,6 @@ export class TokenStorageService {
return { token, family }; 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. * Atomically validate and rotate a refresh token.
* Uses Redis Lua script to prevent race conditions during concurrent refresh requests. * 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] }; 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 * Delete a token by hash
*/ */

View File

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

View File

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

View File

@ -22,7 +22,7 @@ import {
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
import { Public, OptionalAuth } from "../../decorators/public.decorator.js"; import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
import { createZodDto, ZodResponse } from "nestjs-zod"; 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 { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js"; import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js"; import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js";
@ -59,11 +59,6 @@ import {
loginVerifyOtpRequestSchema, loginVerifyOtpRequestSchema,
} from "@customer-portal/domain/auth"; } 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 // Re-export for backward compatibility with tests
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE }; export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };

View File

@ -1,12 +1,12 @@
import { Injectable, UnauthorizedException } from "@nestjs/common"; import { Injectable, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common"; import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express"; 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"; import { ErrorCode } from "@customer-portal/domain/common";
@Injectable() @Injectable()
export class LocalAuthGuard implements CanActivate { export class LocalAuthGuard implements CanActivate {
constructor(private readonly authOrchestrator: AuthOrchestrator) {} constructor(private readonly loginService: AuthLoginService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>(); 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) { if (!user) {
throw new UnauthorizedException({ throw new UnauthorizedException({
message: "Invalid credentials", 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"; 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 * 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 { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
import { AuthTokenService } from "../infra/token/token.service.js"; import { AuthTokenService } from "../infra/token/token.service.js";
import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js"; import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js";
import { TokenMigrationService } from "../infra/token/token-migration.service.js";
@Module({ @Module({
imports: [UsersModule], imports: [UsersModule],
@ -21,15 +20,7 @@ import { TokenMigrationService } from "../infra/token/token-migration.service.js
TokenBlacklistService, TokenBlacklistService,
AuthTokenService, AuthTokenService,
PasswordResetTokenService, PasswordResetTokenService,
TokenMigrationService,
],
exports: [
JoseJwtService,
AuthTokenService,
TokenBlacklistService,
TokenRefreshService,
PasswordResetTokenService,
TokenMigrationService,
], ],
exports: [JoseJwtService, AuthTokenService, TokenBlacklistService, PasswordResetTokenService],
}) })
export class TokensModule {} 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, * Parse a JWT expiry configuration string into milliseconds.
referenceDate: number = Date.now() * Convenience wrapper over `parseJwtExpiry`.
): string => { */
const seconds = parseJwtExpiry(expiresIn); export const parseJwtExpiryMs = (expiresIn: string | number | undefined | null): number =>
return new Date(referenceDate + seconds * 1000).toISOString(); parseJwtExpiry(expiresIn) * 1000;
};
export const JWT_EXPIRY_DEFAULT_SECONDS = DEFAULT_JWT_EXPIRY_SECONDS;