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:
parent
f6c0812061
commit
239de6f20a
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 };
|
||||
|
||||
/**
|
||||
|
||||
@ -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];
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./http/auth.controller.js";
|
||||
@ -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 {}
|
||||
|
||||
9
apps/bff/src/modules/auth/utils/hash-token.util.ts
Normal file
9
apps/bff/src/modules/auth/utils/hash-token.util.ts
Normal 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");
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user