Comprehensive refactoring across 70 files (net -298 lines) improving type safety, error handling, and code organization: - Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas - Tighten Record<string, unknown> to bounded union types where possible - Replace throw new Error with domain-specific exceptions (OrderException, FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.) - Split AuthTokenService (625 lines) into TokenGeneratorService and TokenRefreshService with thin orchestrator - Deduplicate FreebitClientService with shared makeRequest() method - Add typed interfaces to WHMCS facade, order service, and fulfillment mapper - Externalize hardcoded config values to ConfigService with env fallbacks - Consolidate duplicate billing cycle enums into shared billingCycleSchema - Standardize logger usage (nestjs-pino @Inject(Logger) everywhere) - Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { Redis } from "ioredis";
|
|
import { Logger } from "nestjs-pino";
|
|
import type { JWTPayload } from "jose";
|
|
import type { AuthTokens } from "@customer-portal/domain/auth";
|
|
import type { UserAuth, UserRole } from "@customer-portal/domain/customer";
|
|
import { TokenGeneratorService } from "./token-generator.service.js";
|
|
import { TokenRefreshService } from "./token-refresh.service.js";
|
|
import { TokenRevocationService } from "./token-revocation.service.js";
|
|
|
|
export interface RefreshTokenPayload extends JWTPayload {
|
|
userId: string;
|
|
/**
|
|
* Refresh token family identifier (stable across rotations).
|
|
* Present on newly issued tokens; legacy tokens used `tokenId` for this value.
|
|
*/
|
|
familyId?: string | undefined;
|
|
/**
|
|
* Refresh token identifier (unique per token). Used for replay/reuse detection.
|
|
* For legacy tokens, this was equal to the family id.
|
|
*/
|
|
tokenId: string;
|
|
deviceId?: string | undefined;
|
|
userAgent?: string | undefined;
|
|
type: "refresh";
|
|
}
|
|
|
|
export interface DeviceInfo {
|
|
deviceId?: string | undefined;
|
|
userAgent?: string | undefined;
|
|
}
|
|
|
|
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
|
|
|
|
/**
|
|
* Auth Token Service
|
|
*
|
|
* Thin orchestrator that delegates to focused services:
|
|
* - TokenGeneratorService: token creation
|
|
* - TokenRefreshService: refresh + rotation logic
|
|
* - TokenRevocationService: token revocation
|
|
*
|
|
* Preserves the existing public API so consumers don't need changes.
|
|
*/
|
|
@Injectable()
|
|
export class AuthTokenService {
|
|
private readonly requireRedisForTokens: boolean;
|
|
private readonly maintenanceMode: boolean;
|
|
private readonly maintenanceMessage: string;
|
|
|
|
constructor(
|
|
private readonly generator: TokenGeneratorService,
|
|
private readonly refreshService: TokenRefreshService,
|
|
private readonly revocation: TokenRevocationService,
|
|
configService: ConfigService,
|
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {
|
|
this.requireRedisForTokens =
|
|
configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
|
|
this.maintenanceMode = configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
|
|
this.maintenanceMessage = configService.get(
|
|
"AUTH_MAINTENANCE_MESSAGE",
|
|
"Authentication service is temporarily unavailable for maintenance. Please try again later."
|
|
);
|
|
}
|
|
|
|
private checkServiceAvailability(): void {
|
|
if (this.maintenanceMode) {
|
|
this.logger.warn("Authentication service in maintenance mode", {
|
|
maintenanceMessage: this.maintenanceMessage,
|
|
});
|
|
throw new ServiceUnavailableException(this.maintenanceMessage);
|
|
}
|
|
|
|
if (this.requireRedisForTokens && this.redis.status !== "ready") {
|
|
this.logger.error("Redis required for token operations but not available", {
|
|
redisStatus: this.redis.status,
|
|
requireRedisForTokens: this.requireRedisForTokens,
|
|
});
|
|
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a new token pair with refresh token rotation
|
|
*/
|
|
async generateTokenPair(
|
|
user: { id: string; email: string; role?: UserRole },
|
|
deviceInfo?: DeviceInfo
|
|
): Promise<AuthTokens> {
|
|
this.checkServiceAvailability();
|
|
return this.generator.generateTokenPair(user, deviceInfo);
|
|
}
|
|
|
|
/**
|
|
* Refresh access token using refresh token rotation
|
|
*/
|
|
async refreshTokens(
|
|
refreshToken: string,
|
|
deviceInfo?: DeviceInfo
|
|
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
|
|
this.checkServiceAvailability();
|
|
return this.refreshService.refreshTokens(refreshToken, deviceInfo);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async revokeAllUserTokens(userId: string): Promise<void> {
|
|
return this.revocation.revokeAllUserTokens(userId);
|
|
}
|
|
}
|