refactor: streamline authentication workflows and remove deprecated services

- Removed WhmcsLinkWorkflowService and integrated its functionality into new AccountCreationWorkflowService and AccountMigrationWorkflowService.
- Introduced OtpEmailService for sending OTP emails, enhancing email handling in LoginOtpWorkflowService and VerificationWorkflowService.
- Replaced NewCustomerSignupWorkflowService and SfCompletionWorkflowService with AccountCreationWorkflowService, consolidating account creation logic.
- Updated GetStartedCoordinator to utilize new workflow services, improving clarity and maintainability of the authentication process.
- Enhanced error handling and logging across workflows to provide better feedback during account creation and migration.
This commit is contained in:
barsa 2026-03-03 18:34:04 +09:00
parent 451d58d436
commit f6c0812061
26 changed files with 424 additions and 684 deletions

View File

@ -122,26 +122,33 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
const timeout = this.configService.get<number>("WHMCS_QUEUE_TIMEOUT_MS", 30000);
this.logger.log("WHMCS Request Queue initialized", {
concurrency,
rateLimit: `${intervalCap} requests/minute (${(intervalCap / 60).toFixed(1)} RPS)`,
timeout: `${timeout / 1000} seconds`,
});
this.logger.log(
{
concurrency,
rateLimit: `${intervalCap} requests/minute (${(intervalCap / 60).toFixed(1)} RPS)`,
timeout: `${timeout / 1000} seconds`,
},
"WHMCS Request Queue initialized"
);
}
async onModuleDestroy() {
this.logger.log("Shutting down WHMCS Request Queue", {
pendingRequests: this.queue?.pending ?? 0,
queueSize: this.queue?.size ?? 0,
});
this.logger.log(
{
pendingRequests: this.queue?.pending ?? 0,
queueSize: this.queue?.size ?? 0,
},
"Shutting down WHMCS Request Queue"
);
// Wait for pending requests to complete (with timeout)
try {
await this.queue?.onIdle();
} catch (error) {
this.logger.warn("Some WHMCS requests may not have completed during shutdown", {
error: error instanceof Error ? error.message : String(error),
});
this.logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"Some WHMCS requests may not have completed during shutdown"
);
}
}
@ -156,12 +163,15 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
this.metrics.totalRequests++;
this.updateQueueMetrics();
this.logger.debug("Queueing WHMCS request", {
requestId,
queueSize: queue.size,
pending: queue.pending,
priority: options.priority || 0,
});
this.logger.debug(
{
requestId,
queueSize: queue.size,
pending: queue.pending,
priority: options.priority || 0,
},
"Queueing WHMCS request"
);
try {
const result = (await queue.add(
@ -179,12 +189,15 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
this.metrics.completedRequests++;
this.metrics.lastRequestTime = new Date();
this.logger.debug("WHMCS request completed", {
requestId,
waitTime,
executionTime,
totalTime: Date.now() - startTime,
});
this.logger.debug(
{
requestId,
waitTime,
executionTime,
totalTime: Date.now() - startTime,
},
"WHMCS request completed"
);
return response;
} catch (error) {
@ -193,7 +206,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
this.metrics.failedRequests++;
this.metrics.lastErrorTime = new Date();
this.logger.warn(
// Log at debug — the business layer (discovery, workflow) decides final severity.
// Queue metrics (waitTime, executionTime) are useful for performance debugging
// but shouldn't pollute warn logs for expected failures like "Client Not Found".
this.logger.debug(
{
requestId,
waitTime,
@ -279,10 +295,13 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
return;
}
this.logger.warn("Clearing WHMCS request queue", {
queueSize: queue.size,
pendingRequests: queue.pending,
});
this.logger.warn(
{
queueSize: queue.size,
pendingRequests: queue.pending,
},
"Clearing WHMCS request queue"
);
queue.clear();
await queue.onIdle();

View File

@ -66,12 +66,12 @@ export class WhmcsErrorHandlerService {
const message = extractErrorMessage(error);
if (this.isTimeoutError(error)) {
this.logger.warn("WHMCS request timeout", { error: message });
this.logger.warn({ error: message }, "WHMCS request timeout");
throw new WhmcsTimeoutError(message, error);
}
if (this.isNetworkError(error)) {
this.logger.warn("WHMCS network error", { error: message });
this.logger.warn({ error: message }, "WHMCS network error");
throw new WhmcsNetworkError(message, error);
}
@ -82,11 +82,16 @@ export class WhmcsErrorHandlerService {
// Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error")
const httpStatusError = this.parseHttpStatusError(message);
if (httpStatusError) {
this.logger.error("WHMCS HTTP status error", {
upstreamStatus: httpStatusError.status,
upstreamStatusText: httpStatusError.statusText,
originalError: message,
});
// WARN not ERROR — these are external system issues (config, permissions, availability),
// not bugs in our code. 401/403 during discovery is expected for some WHMCS setups.
this.logger.warn(
{
upstreamStatus: httpStatusError.status,
upstreamStatusText: httpStatusError.statusText,
originalError: message,
},
"WHMCS HTTP status error"
);
// Map upstream HTTP status to appropriate domain error
const mapped = this.mapHttpStatusToDomainError(httpStatusError.status);
@ -108,11 +113,14 @@ export class WhmcsErrorHandlerService {
}
// Log unhandled errors for debugging
this.logger.error("WHMCS unhandled request error", {
error: message,
errorType: error instanceof Error ? error.constructor.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
});
this.logger.error(
{
error: message,
errorType: error instanceof Error ? error.constructor.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
},
"WHMCS unhandled request error"
);
// Wrap unknown errors with context
throw new WhmcsApiError(

View File

@ -49,12 +49,19 @@ export class WhmcsHttpClientService {
this.stats.failedRequests++;
this.stats.lastErrorTime = new Date();
this.logger.warn(`WHMCS HTTP request failed [${action}]`, {
error: extractErrorMessage(error),
action,
params: redactForLogs(params),
responseTime: Date.now() - startTime,
});
// Log at debug — the error handler and business layer decide the final severity.
// Logging at warn/error here duplicates upstream logs for expected outcomes
// (e.g. "Client Not Found" during discovery, HTTP 403 on GetUsers).
this.logger.debug(
{
error: extractErrorMessage(error),
action,
params: redactForLogs(params),
responseTime: Date.now() - startTime,
},
"WHMCS HTTP request failed [%s]",
action
);
throw error;
}
@ -132,12 +139,16 @@ export class WhmcsHttpClientService {
if (process.env["NODE_ENV"] !== "production") {
const snippet = responseText?.slice(0, 300);
if (snippet) {
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
action,
status: response.status,
statusText: response.statusText,
snippet,
});
this.logger.debug(
{
action,
status: response.status,
statusText: response.statusText,
snippet,
},
"WHMCS non-OK response body snippet [%s]",
action
);
}
}
@ -236,26 +247,34 @@ export class WhmcsHttpClientService {
parsedResponse = JSON.parse(responseText);
} catch (parseError) {
const isProd = process.env["NODE_ENV"] === "production";
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
...(isProd
? { responseTextLength: responseText.length }
: { responseText: responseText.slice(0, 500) }),
parseError: extractErrorMessage(parseError),
params: redactForLogs(params),
});
this.logger.error(
{
...(isProd
? { responseTextLength: responseText.length }
: { responseText: responseText.slice(0, 500) }),
parseError: extractErrorMessage(parseError),
params: redactForLogs(params),
},
"Invalid JSON response from WHMCS API [%s]",
action
);
throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
}
// Validate basic response structure
if (!this.isWhmcsResponse(parsedResponse)) {
const isProd = process.env["NODE_ENV"] === "production";
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
responseType: typeof parsedResponse,
...(isProd
? { responseTextLength: responseText.length }
: { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params),
});
this.logger.error(
{
responseType: typeof parsedResponse,
...(isProd
? { responseTextLength: responseText.length }
: { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params),
},
"WHMCS API returned invalid response structure [%s]",
action
);
throw new WhmcsOperationException("Invalid response structure from WHMCS API");
}
@ -267,16 +286,17 @@ export class WhmcsHttpClientService {
const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
const errorCode = this.toDisplayString(errorcode, "unknown");
// Many WHMCS "result=error" responses are expected business outcomes (e.g. invalid credentials).
// Log as warning (not error) to avoid spamming error logs; the unified exception filter will
// still emit the request-level log based on the mapped error code.
this.logger.warn(
// Many WHMCS "result=error" responses are expected business outcomes (e.g. "Client Not Found"
// during discovery, invalid credentials during login). Log at debug — the error handler
// classifies severity and the business layer logs the final outcome.
this.logger.debug(
{
errorMessage,
errorCode,
params: redactForLogs(params),
},
`WHMCS API returned error [${action}]`
"WHMCS API returned error [%s]",
action
);
// Return error response for the orchestrator to handle with proper exception types

View File

@ -31,7 +31,7 @@ export class WhmcsAccountDiscoveryService {
// 1. Try to find client ID by email from cache
const cachedClientId = await this.cacheService.getClientIdByEmail(email);
if (cachedClientId) {
this.logger.debug(`Cache hit for email-to-id: ${email} -> ${cachedClientId}`);
this.logger.debug({ email, cachedClientId }, "Cache hit for email-to-id lookup");
// If we have ID, fetch the full client data (which has its own cache)
return this.getClientDetailsById(cachedClientId);
}
@ -54,7 +54,7 @@ export class WhmcsAccountDiscoveryService {
this.cacheService.setClientIdByEmail(email, client.id),
]);
this.logger.log(`Discovered client by email: ${email}`);
this.logger.log({ email, clientId: client.id }, "Discovered client by email");
return client;
} catch (error) {
// Handle "Not Found" specifically — this is expected for discovery
@ -108,22 +108,26 @@ export class WhmcsAccountDiscoveryService {
// Get the first associated client (users can belong to multiple clients)
const clientAssociation = exactMatch.clients?.[0];
if (!clientAssociation) {
this.logger.warn(`User ${exactMatch.id} found but has no associated clients`);
this.logger.warn(
{ userId: exactMatch.id, email },
"User found but has no associated clients"
);
return null;
}
this.logger.log(
`Discovered user by email: ${email} (user: ${exactMatch.id}, client: ${clientAssociation.id})`
{ email, userId: exactMatch.id, clientId: clientAssociation.id },
"Discovered user by email"
);
return {
userId: Number(exactMatch.id),
clientId: Number(clientAssociation.id),
};
} catch (error) {
// Sub-account lookup is best-effort — many WHMCS setups don't expose GetUsers.
// Log and return null rather than blocking the flow. The primary client lookup
// (findClientByEmail) is the authority; this is supplementary.
this.logger.warn(
// Sub-account lookup is best-effort — many WHMCS setups don't expose GetUsers
// (commonly returns HTTP 403). Log at debug to avoid polluting warn logs on every
// signup. The primary client lookup (findClientByEmail) is the authority.
this.logger.debug(
{ email, error: extractErrorMessage(error) },
"User sub-account lookup unavailable — skipping"
);

View File

@ -7,7 +7,7 @@ Authentication and authorization for the Customer Portal.
```
auth/
├── application/ # Orchestration layer
│ ├── auth.facade.ts # Main entry point for auth operations
│ ├── auth-orchestrator.service.ts # Main entry point for auth operations
│ ├── auth-login.service.ts
│ └── auth-health.service.ts
├── decorators/
@ -72,7 +72,7 @@ LocalAuthGuard (validates credentials)
FailedLoginThrottleGuard (rate limiting)
AuthFacade.login()
AuthOrchestrator.login()
├─► Generate token pair
├─► Set httpOnly cookies

View File

@ -8,30 +8,22 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino";
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util.js";
import {
type SetPasswordRequest,
type ChangePasswordRequest,
type SsoLinkResponse,
} from "@customer-portal/domain/auth";
import { type SsoLinkResponse } from "@customer-portal/domain/auth";
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 { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js";
import { PasswordWorkflowService } from "../infra/workflows/password-workflow.service.js";
// mapPrismaUserToDomain removed - usersService.update now returns profile directly
import { AuthHealthService } from "./auth-health.service.js";
import { AuthLoginService } from "./auth-login.service.js";
/**
* Auth Orchestrator
*
* Application layer orchestrator that coordinates authentication operations.
* Delegates to specialized services for specific functionality:
* - AuthHealthService: Health checks
* - AuthLoginService: Login validation
* - PasswordWorkflowService: Password operations
* Application layer orchestrator that coordinates authentication operations
* requiring multiple services: login completion, logout, SSO, account status.
*
* Password and health operations are handled by their own services,
* injected directly by the controller.
*/
@Injectable()
export class AuthOrchestrator {
@ -43,53 +35,25 @@ export class AuthOrchestrator {
private readonly salesforceService: SalesforceFacade,
private readonly auditService: AuditService,
private readonly tokenBlacklistService: TokenBlacklistService,
private readonly passwordWorkflow: PasswordWorkflowService,
private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService,
private readonly healthService: AuthHealthService,
private readonly loginService: AuthLoginService,
@Inject(Logger) private readonly logger: Logger
) {}
async healthCheck() {
return this.healthService.check();
}
/**
* Original login method - validates credentials and completes login
* Used by LocalAuthGuard flow
*/
async login(
user: {
id: string;
email: string;
role?: string;
passwordHash?: string | null;
failedLoginAttempts?: number | null;
lockedUntil?: Date | null;
},
async validateUser(
email: string,
password: string,
request?: Request
) {
if (request) {
await this.authRateLimitService.clearLoginAttempts(request);
}
return this.completeLogin(
{ id: user.id, email: user.email, role: user.role ?? "USER" },
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
*
* @param user - Validated user info (id, email, role)
* @param request - Express request for audit logging
*/
async completeLogin(user: { id: string; email: string; role: string }, request?: Request) {
// Update last login time and reset failed attempts
// usersService.update returns the updated profile, avoiding a second DB fetch
const profile = await this.usersService.update(user.id, {
lastLoginAt: new Date(),
failedLoginAttempts: 0,
@ -125,22 +89,6 @@ export class AuthOrchestrator {
};
}
async checkPasswordNeeded(email: string) {
return this.passwordWorkflow.checkPasswordNeeded(email);
}
async setPassword(setPasswordData: SetPasswordRequest) {
return this.passwordWorkflow.setPassword(setPasswordData.email, setPasswordData.password);
}
async validateUser(
email: string,
password: string,
request?: Request
): Promise<{ id: string; email: string; role: string } | null> {
return this.loginService.validateUser(email, password, request);
}
async logout(userId?: string, token?: string, request?: Request): Promise<void> {
if (token) {
await this.tokenBlacklistService.blacklistToken(token);
@ -162,61 +110,6 @@ export class AuthOrchestrator {
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true);
}
/**
* 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;
}
}
private async updateAccountLastSignIn(userId: string): Promise<void> {
try {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
return;
}
await this.salesforceService.updateAccountPortalFields(mapping.sfAccountId, {
lastSignedInAt: new Date(),
});
} catch (error) {
this.logger.debug("Failed to update Salesforce last sign-in", {
userId,
error: extractErrorMessage(error),
});
}
}
async requestPasswordReset(email: string, request?: Request): Promise<void> {
await this.passwordWorkflow.requestPasswordReset(email, request);
}
async resetPassword(token: string, newPassword: string) {
return this.passwordWorkflow.resetPassword(token, newPassword);
}
async getAccountStatus(email: string) {
const normalized = email?.toLowerCase().trim();
if (!normalized || !normalized.includes("@")) {
@ -274,8 +167,33 @@ export class AuthOrchestrator {
};
}
async changePassword(userId: string, data: ChangePasswordRequest, request?: Request) {
return this.passwordWorkflow.changePassword(userId, data, request);
/**
* 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;
}
}
async refreshTokens(
@ -292,4 +210,22 @@ export class AuthOrchestrator {
tokens,
};
}
private async updateAccountLastSignIn(userId: string): Promise<void> {
try {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
return;
}
await this.salesforceService.updateAccountPortalFields(mapping.sfAccountId, {
lastSignedInAt: new Date(),
});
} catch (error) {
this.logger.debug("Failed to update Salesforce last sign-in", {
userId,
error: extractErrorMessage(error),
});
}
}
}

View File

@ -6,8 +6,6 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
import { AddressModule } from "@bff/modules/address/address.module.js";
import { TokensModule } from "../tokens/tokens.module.js";
import { OtpModule } from "../otp/otp.module.js";
// Coordinator
import { GetStartedCoordinator } from "../infra/workflows/get-started-coordinator.service.js";
// Workflow services
import { VerificationWorkflowService } from "../infra/workflows/verification-workflow.service.js";
import { GuestEligibilityWorkflowService } from "../infra/workflows/guest-eligibility-workflow.service.js";
@ -21,8 +19,6 @@ import {
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
CreateEligibilityCaseStep,
PortalUserCreationService,
WhmcsCleanupService,
} from "../infra/workflows/steps/index.js";
// Controller
import { GetStartedController } from "../presentation/http/get-started.controller.js";
@ -39,8 +35,6 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
],
controllers: [GetStartedController],
providers: [
// Coordinator
GetStartedCoordinator,
// Workflow services
VerificationWorkflowService,
GuestEligibilityWorkflowService,
@ -53,9 +47,6 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
UpdateSalesforceFlagsStep,
GenerateAuthResultStep,
CreateEligibilityCaseStep,
PortalUserCreationService,
WhmcsCleanupService,
],
exports: [GetStartedCoordinator],
})
export class GetStartedModule {}

View File

@ -4,6 +4,7 @@
* Provides JWT token management, storage, and revocation capabilities.
*/
export type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
export { AuthTokenService } from "./token.service.js";
export { TokenGeneratorService } from "./token-generator.service.js";
export { TokenRefreshService } from "./token-refresh.service.js";

View File

@ -7,26 +7,12 @@ import {
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserRole } from "@customer-portal/domain/customer";
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
interface RefreshTokenPayload extends JWTPayload {
userId: string;
familyId?: string | undefined;
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/**

View File

@ -7,30 +7,16 @@ import {
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 } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
import { JoseJwtService } from "./jose-jwt.service.js";
interface RefreshTokenPayload extends JWTPayload {
userId: string;
familyId?: string | undefined;
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";

View File

@ -1,6 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import type { DeviceInfo } from "./token.types.js";
export interface StoredRefreshToken {
familyId: string;
@ -17,11 +18,6 @@ export interface StoredRefreshTokenFamily {
absoluteExpiresAt?: string | undefined;
}
export interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
export interface StoreRefreshTokenParams {
userId: string;
familyId: string;

View File

@ -2,35 +2,13 @@ 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 type { DeviceInfo } from "./token.types.js";
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";
/**

View File

@ -0,0 +1,23 @@
import type { JWTPayload } from "jose";
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;
}

View File

@ -355,21 +355,11 @@ export class AccountCreationWorkflowService {
throw portalError;
}
// Step 5: Update SF Flags (DEGRADABLE)
await safeOperation(
async () =>
this.sfFlagsStep.execute({
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
}),
{
criticality: OperationCriticality.OPTIONAL,
fallback: undefined,
context: "SF flags update",
logger: this.logger,
metadata: { email },
}
);
// Step 5: Update SF Flags (DEGRADABLE — step handles its own errors)
await this.sfFlagsStep.execute({
sfAccountId: sfResult.sfAccountId,
whmcsClientId: whmcsResult.whmcsClientId,
});
// Step 6: Generate Auth Result
const auditSource = withEligibility

View File

@ -151,19 +151,12 @@ export class AccountMigrationWorkflowService {
sfAccountId: sfAccount.id,
});
// Update Salesforce portal flags (DEGRADABLE)
try {
await this.sfFlagsStep.execute({
sfAccountId: sfAccount.id,
whmcsClientId,
source: PORTAL_SOURCE_MIGRATED,
});
} catch (flagsError) {
this.logger.warn(
{ error: extractErrorMessage(flagsError), email },
"SF flags update failed (non-critical, continuing)"
);
}
// Update Salesforce portal flags (DEGRADABLE — step handles its own errors)
await this.sfFlagsStep.execute({
sfAccountId: sfAccount.id,
whmcsClientId,
source: PORTAL_SOURCE_MIGRATED,
});
// Generate auth result
const authResult = await this.authResultStep.execute({

View File

@ -1,77 +0,0 @@
import { Injectable } from "@nestjs/common";
import type {
SendVerificationCodeRequest,
SendVerificationCodeResponse,
VerifyCodeRequest,
VerifyCodeResponse,
GuestEligibilityRequest,
GuestEligibilityResponse,
CompleteAccountRequest,
SignupWithEligibilityRequest,
MigrateWhmcsAccountRequest,
} from "@customer-portal/domain/get-started";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { VerificationWorkflowService } from "./verification-workflow.service.js";
import { GuestEligibilityWorkflowService } from "./guest-eligibility-workflow.service.js";
import { AccountCreationWorkflowService } from "./account-creation-workflow.service.js";
import { AccountMigrationWorkflowService } from "./account-migration-workflow.service.js";
/**
* Get Started Coordinator
*
* Thin routing layer that delegates to focused workflow services.
* Method signatures match the previous god class so the controller
* requires minimal changes.
*/
@Injectable()
export class GetStartedCoordinator {
constructor(
private readonly verification: VerificationWorkflowService,
private readonly guestEligibility: GuestEligibilityWorkflowService,
private readonly accountCreation: AccountCreationWorkflowService,
private readonly accountMigration: AccountMigrationWorkflowService
) {}
async sendVerificationCode(
request: SendVerificationCodeRequest,
fingerprint?: string
): Promise<SendVerificationCodeResponse> {
return this.verification.sendCode(request, fingerprint);
}
async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise<VerifyCodeResponse> {
return this.verification.verifyCode(request, fingerprint);
}
async guestEligibilityCheck(
request: GuestEligibilityRequest,
fingerprint?: string
): Promise<GuestEligibilityResponse> {
return this.guestEligibility.execute(request, fingerprint);
}
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
return this.accountCreation.execute(request) as Promise<AuthResultInternal>;
}
async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{
success: boolean;
message?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}> {
return this.accountCreation.execute(request, { withEligibility: true }) as Promise<{
success: boolean;
message?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}>;
}
async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise<AuthResultInternal> {
return this.accountMigration.execute(request);
}
}

View File

@ -1,7 +1,5 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PortalUserCreationService } from "./portal-user-creation.service.js";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -19,30 +17,69 @@ export interface CreatePortalUserResult {
/**
* Step: Create a portal user with ID mapping.
*
* Delegates to PortalUserCreationService for the Prisma transaction
* that creates both the User row and the IdMapping row atomically.
* Creates both the User row and the IdMapping row atomically
* in a single Prisma transaction.
*
* Rollback deletes the user and associated ID mapping.
*/
@Injectable()
export class CreatePortalUserStep {
constructor(
private readonly portalUserCreation: PortalUserCreationService,
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
async execute(params: CreatePortalUserParams): Promise<CreatePortalUserResult> {
const { userId } = await this.portalUserCreation.createUserWithMapping({
email: params.email,
passwordHash: params.passwordHash,
whmcsClientId: params.whmcsClientId,
sfAccountId: params.sfAccountId,
});
try {
const result = await this.prisma.$transaction(async tx => {
const created = await tx.user.create({
data: {
email: params.email,
passwordHash: params.passwordHash,
emailVerified: true,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
},
select: { id: true, email: true },
});
this.logger.log({ userId, email: params.email }, "Portal user created with ID mapping");
await tx.idMapping.create({
data: {
userId: created.id,
whmcsClientId: params.whmcsClientId,
sfAccountId: params.sfAccountId,
},
});
return { userId };
return { userId: created.id };
});
this.logger.log(
{
userId: result.userId,
email: params.email,
whmcsClientId: params.whmcsClientId,
sfAccountId: params.sfAccountId,
},
"Portal user created with ID mapping"
);
return result;
} catch (dbError) {
this.logger.error(
{
whmcsClientId: params.whmcsClientId,
email: params.email,
error: extractErrorMessage(dbError),
},
"Database transaction failed"
);
throw new BadRequestException(
`Failed to create user account: ${extractErrorMessage(dbError)}`
);
}
}
async rollback(userId: string): Promise<void> {

View File

@ -1,6 +1,9 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { WhmcsCleanupService, type WhmcsCreatedClient } from "./whmcs-cleanup.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers";
export interface CreateWhmcsClientParams {
email: string;
@ -30,37 +33,79 @@ export interface CreateWhmcsClientResult {
/**
* Step: Create a WHMCS billing client.
*
* Delegates to WhmcsCleanupService for the actual API call.
* Calls WhmcsClientService directly with custom-field mapping.
* Rollback marks the created client as inactive for manual cleanup.
*/
@Injectable()
export class CreateWhmcsClientStep {
constructor(
private readonly signupWhmcs: WhmcsCleanupService,
private readonly whmcsClientService: WhmcsClientService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> {
const result: WhmcsCreatedClient = await this.signupWhmcs.createClient({
firstName: params.firstName,
lastName: params.lastName,
email: params.email,
password: params.password,
phone: params.phone,
address: params.address,
customerNumber: params.customerNumber,
...(params.company != null && { company: params.company }),
...(params.dateOfBirth != null && { dateOfBirth: params.dateOfBirth }),
...(params.gender != null && { gender: params.gender }),
...(params.nationality != null && { nationality: params.nationality }),
});
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 (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth;
if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender;
if (nationalityFieldId && params.nationality)
customfieldsMap[nationalityFieldId] = params.nationality;
this.logger.log(
{ email: params.email, whmcsClientId: result.clientId },
"WHMCS client created"
{
email: params.email,
firstName: params.firstName,
lastName: params.lastName,
sfNumber: params.customerNumber,
},
"Creating WHMCS client"
);
return { whmcsClientId: result.clientId };
try {
const whmcsClient = await this.whmcsClientService.addClient({
firstname: params.firstName,
lastname: params.lastName,
email: params.email,
companyname: params.company || "",
phonenumber: params.phone,
address1: params.address.address1,
address2: params.address.address2 ?? "",
city: params.address.city,
state: params.address.state,
postcode: params.address.postcode,
country: params.address.country,
password2: params.password,
customfields: serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
});
this.logger.log(
{ whmcsClientId: whmcsClient.clientId, email: params.email },
"WHMCS client created successfully"
);
return { whmcsClientId: whmcsClient.clientId };
} catch (whmcsError) {
this.logger.error(
{
error: extractErrorMessage(whmcsError),
email: params.email,
firstName: params.firstName,
lastName: params.lastName,
},
"Failed to create WHMCS client"
);
throw new BadRequestException(
`Failed to create billing account: ${extractErrorMessage(whmcsError)}`
);
}
}
async rollback(whmcsClientId: number, email?: string): Promise<void> {
@ -68,6 +113,26 @@ export class CreateWhmcsClientStep {
{ whmcsClientId, email },
"Rolling back WHMCS client creation — marking for cleanup"
);
await this.signupWhmcs.markClientForCleanup(whmcsClientId, email ?? "unknown");
try {
await this.whmcsClientService.updateClient(whmcsClientId, {
status: "Inactive",
});
this.logger.warn(
{ whmcsClientId, email, action: "marked_for_cleanup" },
"Marked orphaned WHMCS client for manual cleanup"
);
} catch (cleanupError) {
this.logger.error(
{
whmcsClientId,
email,
cleanupError: extractErrorMessage(cleanupError),
recommendation: "Manual cleanup required in WHMCS admin",
},
"Failed to mark orphaned WHMCS client for cleanup"
);
}
}
}

View File

@ -25,7 +25,3 @@ export type {
CreateEligibilityCaseAddress,
CreateEligibilityCaseResult,
} from "./create-eligibility-case.step.js";
export { PortalUserCreationService } from "./portal-user-creation.service.js";
export type { UserCreationParams, CreatedUserResult } from "./portal-user-creation.service.js";
export { WhmcsCleanupService } from "./whmcs-cleanup.service.js";

View File

@ -1,82 +0,0 @@
/**
* Service responsible for creating portal users and ID mappings during signup
*/
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsCleanupService } from "./whmcs-cleanup.service.js";
export interface UserCreationParams {
email: string;
passwordHash: string;
whmcsClientId: number;
sfAccountId: string;
}
export interface CreatedUserResult {
userId: string;
}
@Injectable()
export class PortalUserCreationService {
constructor(
private readonly prisma: PrismaService,
private readonly whmcsService: WhmcsCleanupService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Create user and ID mapping in a single transaction
* Compensates by marking WHMCS client for cleanup on failure
*/
async createUserWithMapping(params: UserCreationParams): Promise<CreatedUserResult> {
try {
const result = await this.prisma.$transaction(async tx => {
const created = await tx.user.create({
data: {
email: params.email,
passwordHash: params.passwordHash,
emailVerified: false,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
},
select: { id: true, email: true },
});
await tx.idMapping.create({
data: {
userId: created.id,
whmcsClientId: params.whmcsClientId,
sfAccountId: params.sfAccountId,
},
});
return { userId: created.id };
});
this.logger.debug("Created user with mapping", {
userId: result.userId,
email: params.email,
whmcsClientId: params.whmcsClientId,
sfAccountId: params.sfAccountId,
});
return result;
} catch (dbError) {
this.logger.error("Database transaction failed, cleaning up WHMCS client", {
whmcsClientId: params.whmcsClientId,
email: params.email,
error: extractErrorMessage(dbError),
});
// Compensation: Mark the orphaned WHMCS client for cleanup
await this.whmcsService.markClientForCleanup(params.whmcsClientId, params.email);
throw new BadRequestException(
`Failed to create user account: ${extractErrorMessage(dbError)}`
);
}
}
}

View File

@ -1,135 +0,0 @@
/**
* Service responsible for WHMCS client creation and cleanup during signup.
*
* Used by CreateWhmcsClientStep for client creation and by
* PortalUserCreationService for compensation (marking orphaned clients).
*/
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers";
export interface WhmcsClientCreationParams {
firstName: string;
lastName: string;
email: string;
password: string;
company?: string;
phone: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
customerNumber: string | null;
dateOfBirth?: string | null;
gender?: string | null;
nationality?: string | null;
}
export interface WhmcsCreatedClient {
clientId: number;
}
@Injectable()
export class WhmcsCleanupService {
constructor(
private readonly whmcsClientService: WhmcsClientService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Create a new WHMCS client for signup
*/
async createClient(params: WhmcsClientCreationParams): Promise<WhmcsCreatedClient> {
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 (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth;
if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender;
if (nationalityFieldId && params.nationality)
customfieldsMap[nationalityFieldId] = params.nationality;
this.logger.log("Creating WHMCS client", {
email: params.email,
firstName: params.firstName,
lastName: params.lastName,
sfNumber: params.customerNumber,
});
try {
const whmcsClient = await this.whmcsClientService.addClient({
firstname: params.firstName,
lastname: params.lastName,
email: params.email,
companyname: params.company || "",
phonenumber: params.phone,
address1: params.address.address1,
address2: params.address.address2 ?? "",
city: params.address.city,
state: params.address.state,
postcode: params.address.postcode,
country: params.address.country,
password2: params.password,
customfields: serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
});
this.logger.log("WHMCS client created successfully", {
clientId: whmcsClient.clientId,
email: params.email,
});
return { clientId: whmcsClient.clientId };
} catch (whmcsError) {
this.logger.error(
{
error: extractErrorMessage(whmcsError),
email: params.email,
firstName: params.firstName,
lastName: params.lastName,
},
"Failed to create WHMCS client"
);
throw new BadRequestException(
`Failed to create billing account: ${extractErrorMessage(whmcsError)}`
);
}
}
/**
* Mark orphaned WHMCS client for cleanup
* Called when database transaction fails after WHMCS client creation
*/
async markClientForCleanup(clientId: number, email: string): Promise<void> {
try {
await this.whmcsClientService.updateClient(clientId, {
status: "Inactive",
});
this.logger.warn("Marked orphaned WHMCS client for manual cleanup", {
whmcsClientId: clientId,
email,
action: "marked_for_cleanup",
});
} catch (cleanupError) {
this.logger.error("Failed to mark orphaned WHMCS client for cleanup", {
whmcsClientId: clientId,
email,
cleanupError: extractErrorMessage(cleanupError),
recommendation: "Manual cleanup required in WHMCS admin",
});
}
}
}

View File

@ -13,6 +13,7 @@ import {
import type { Request, Response } from "express";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js";
import { AuthHealthService } from "@bff/modules/auth/application/auth-health.service.js";
import { LocalAuthGuard } from "./guards/local-auth.guard.js";
import {
FailedLoginThrottleGuard,
@ -24,6 +25,7 @@ import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } 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";
import type { UserAuth } from "@customer-portal/domain/customer";
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
import { getRequestFingerprint } from "@bff/core/http/request-context.util.js";
@ -82,7 +84,9 @@ export class AuthController {
private authOrchestrator: AuthOrchestrator,
private readonly jwtService: JoseJwtService,
private readonly loginOtpWorkflow: LoginOtpWorkflowService,
private readonly trustedDeviceService: TrustedDeviceService
private readonly trustedDeviceService: TrustedDeviceService,
private readonly passwordWorkflow: PasswordWorkflowService,
private readonly healthService: AuthHealthService
) {}
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
@ -92,7 +96,7 @@ export class AuthController {
@Public()
@Get("health-check")
async healthCheck() {
return this.authOrchestrator.healthCheck();
return this.healthService.check();
}
@Public()
@ -288,7 +292,10 @@ export class AuthController {
@Req() _req: Request,
@Res({ passthrough: true }) res: Response
) {
const result = await this.authOrchestrator.setPassword(setPasswordData);
const result = await this.passwordWorkflow.setPassword(
setPasswordData.email,
setPasswordData.password
);
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@ -302,8 +309,7 @@ export class AuthController {
type: CheckPasswordNeededResponseDto,
})
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) {
const response = await this.authOrchestrator.checkPasswordNeeded(data.email);
return response;
return this.passwordWorkflow.checkPasswordNeeded(data.email);
}
@Public()
@ -311,7 +317,7 @@ export class AuthController {
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
await this.authOrchestrator.requestPasswordReset(body.email, req);
await this.passwordWorkflow.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" };
}
@ -324,7 +330,7 @@ export class AuthController {
@Body() body: ResetPasswordRequestDto,
@Res({ passthrough: true }) res: Response
) {
await this.authOrchestrator.resetPassword(body.token, body.password);
await this.passwordWorkflow.resetPassword(body.token, body.password);
// Clear auth cookies after password reset to force re-login
clearAuthCookies(res);
@ -339,7 +345,7 @@ export class AuthController {
@Body() body: ChangePasswordRequestDto,
@Res({ passthrough: true }) res: Response
) {
const result = await this.authOrchestrator.changePassword(req.user.id, body, req);
const result = await this.passwordWorkflow.changePassword(req.user.id, body, req);
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}

View File

@ -24,8 +24,12 @@ import {
} from "@customer-portal/domain/get-started";
import type { User } from "@customer-portal/domain/customer";
import { GetStartedCoordinator } from "../../infra/workflows/get-started-coordinator.service.js";
import { VerificationWorkflowService } from "../../infra/workflows/verification-workflow.service.js";
import { AccountCreationWorkflowService } from "../../infra/workflows/account-creation-workflow.service.js";
import { AccountMigrationWorkflowService } from "../../infra/workflows/account-migration-workflow.service.js";
import { GuestEligibilityWorkflowService } from "../../infra/workflows/guest-eligibility-workflow.service.js";
import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js";
import type { AuthResultInternal } from "../../auth.types.js";
// DTO classes using Zod schemas
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
@ -58,7 +62,12 @@ interface AuthSuccessResponse {
*/
@Controller("auth/get-started")
export class GetStartedController {
constructor(private readonly workflow: GetStartedCoordinator) {}
constructor(
private readonly verification: VerificationWorkflowService,
private readonly accountCreation: AccountCreationWorkflowService,
private readonly accountMigration: AccountMigrationWorkflowService,
private readonly guestEligibility: GuestEligibilityWorkflowService
) {}
/**
* Send OTP verification code to email
@ -73,7 +82,7 @@ export class GetStartedController {
@Req() req: Request
): Promise<SendVerificationCodeResponseDto> {
const fingerprint = getRateLimitFingerprint(req);
return this.workflow.sendVerificationCode(body, fingerprint);
return this.verification.sendCode(body, fingerprint);
}
/**
@ -89,7 +98,7 @@ export class GetStartedController {
@Req() req: Request
): Promise<VerifyCodeResponseDto> {
const fingerprint = getRateLimitFingerprint(req);
return this.workflow.verifyCode(body, fingerprint);
return this.verification.verifyCode(body, fingerprint);
}
/**
@ -108,7 +117,7 @@ export class GetStartedController {
@Req() req: Request
): Promise<GuestEligibilityResponseDto> {
const fingerprint = getRateLimitFingerprint(req);
return this.workflow.guestEligibilityCheck(body, fingerprint);
return this.guestEligibility.execute(body, fingerprint);
}
/**
@ -126,7 +135,7 @@ export class GetStartedController {
@Body() body: CompleteAccountRequestDto,
@Res({ passthrough: true }) res: Response
): Promise<AuthSuccessResponse> {
const result = await this.workflow.completeAccount(body);
const result = (await this.accountCreation.execute(body)) as AuthResultInternal;
setAuthCookies(res, result.tokens);
@ -155,7 +164,12 @@ export class GetStartedController {
| SignupWithEligibilityResponseDto
| (AuthSuccessResponse & { success: true; eligibilityRequestId?: string })
> {
const result = await this.workflow.signupWithEligibility(body);
const result = (await this.accountCreation.execute(body, { withEligibility: true })) as {
success: boolean;
message?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
};
if (!result.success || !result.authResult) {
return {
@ -190,7 +204,7 @@ export class GetStartedController {
@Body() body: MigrateWhmcsAccountRequestDto,
@Res({ passthrough: true }) res: Response
): Promise<AuthSuccessResponse> {
const result = await this.workflow.migrateWhmcsAccount(body);
const result = await this.accountMigration.execute(body);
setAuthCookies(res, result.tokens);

View File

@ -1,4 +1,5 @@
import type { Response, CookieOptions } from "express";
import type { Response } from "express";
import { getSecureCookie } from "./secure-cookie.util.js";
/**
* Auth tokens structure for cookie setting
@ -19,24 +20,6 @@ export interface SessionInfo {
tokenType: "Bearer";
}
/**
* Custom setSecureCookie function signature
* This is added by our security middleware
*/
type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
/**
* Get setSecureCookie function from response if available
* Returns null if the custom helper is not present
*/
function getSecureCookie(res: Response): SetSecureCookieFn | null {
const maybeSecure = res as Response & { setSecureCookie?: unknown };
if (typeof maybeSecure.setSecureCookie === "function") {
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
}
return null;
}
// Cookie paths - access token needs broader access, refresh token only for refresh endpoint
export const ACCESS_COOKIE_PATH = "/api";
export const REFRESH_COOKIE_PATH = "/api/auth/refresh";

View File

@ -0,0 +1,19 @@
import type { Response, CookieOptions } from "express";
/**
* Custom setSecureCookie function signature.
* This is added by our security middleware.
*/
export type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
/**
* Get setSecureCookie function from response if available.
* Returns null if the custom helper is not present.
*/
export function getSecureCookie(res: Response): SetSecureCookieFn | null {
const maybeSecure = res as Response & { setSecureCookie?: unknown };
if (typeof maybeSecure.setSecureCookie === "function") {
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
}
return null;
}

View File

@ -1,4 +1,5 @@
import type { Request, Response, CookieOptions } from "express";
import type { Request, Response } from "express";
import { getSecureCookie } from "./secure-cookie.util.js";
/**
* Cookie name for trusted device token
@ -10,24 +11,6 @@ export const TRUSTED_DEVICE_COOKIE_NAME = "trusted_device";
*/
export const TRUSTED_DEVICE_COOKIE_PATH = "/api/auth";
/**
* Custom setSecureCookie function signature
* This is added by our security middleware
*/
type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
/**
* Get setSecureCookie function from response if available
* Returns null if the custom helper is not present
*/
function getSecureCookie(res: Response): SetSecureCookieFn | null {
const maybeSecure = res as Response & { setSecureCookie?: unknown };
if (typeof maybeSecure.setSecureCookie === "function") {
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
}
return null;
}
/**
* Set the trusted device cookie on the response
*