import { Controller, Post, Body, UseGuards, UseInterceptors, Get, Req, HttpCode, UsePipes, Res, } from "@nestjs/common"; import type { Request, Response } from "express"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js"; import { LocalAuthGuard } from "./guards/local-auth.guard.js"; import { FailedLoginThrottleGuard, type RequestWithRateLimit, } from "./guards/failed-login-throttle.guard.js"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js"; import { Public } from "../../decorators/public.decorator.js"; import { ZodValidationPipe } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; import { JoseJwtService } from "../../infra/token/jose-jwt.service.js"; import type { UserAuth } from "@customer-portal/domain/customer"; import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js"; // Import Zod schemas from domain import { signupRequestSchema, passwordResetRequestSchema, passwordResetSchema, setPasswordRequestSchema, linkWhmcsRequestSchema, changePasswordRequestSchema, validateSignupRequestSchema, accountStatusRequestSchema, ssoLinkRequestSchema, checkPasswordNeededRequestSchema, refreshTokenRequestSchema, checkPasswordNeededResponseSchema, linkWhmcsResponseSchema, type SignupRequest, type PasswordResetRequest, type ResetPasswordRequest, type SetPasswordRequest, type LinkWhmcsRequest, type ChangePasswordRequest, type ValidateSignupRequest, type AccountStatusRequest, type SsoLinkRequest, type CheckPasswordNeededRequest, type RefreshTokenRequest, type AuthTokens, } from "@customer-portal/domain/auth"; type CookieValue = string | undefined; type RequestWithCookies = Omit & { cookies?: Record; }; const calculateCookieMaxAge = (isoTimestamp: string): number => { const expiresAt = Date.parse(isoTimestamp); if (Number.isNaN(expiresAt)) { return 0; } return Math.max(0, expiresAt - Date.now()); }; const ACCESS_COOKIE_PATH = "/api"; const REFRESH_COOKIE_PATH = "/api/auth/refresh"; @Controller("auth") export class AuthController { constructor( private authFacade: AuthFacade, private readonly jwtService: JoseJwtService ) {} private setAuthCookies(res: Response, tokens: AuthTokens): void { const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt); const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt); res.setSecureCookie("access_token", tokens.accessToken, { maxAge: accessMaxAge, path: ACCESS_COOKIE_PATH, }); res.setSecureCookie("refresh_token", tokens.refreshToken, { maxAge: refreshMaxAge, path: REFRESH_COOKIE_PATH, }); } private clearAuthCookies(res: Response): void { // Clear current cookie paths res.setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH }); res.setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH }); // Backward-compat: clear legacy cookies that were set on `/` res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" }); res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" }); } private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void { FailedLoginThrottleGuard.applyRateLimitHeaders(req, res); } @Public() @Post("validate-signup") @UseGuards(RateLimitGuard, SalesforceReadThrottleGuard) @RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP @UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) async validateSignup(@Body() validateData: ValidateSignupRequest, @Req() req: Request) { return this.authFacade.validateSignup(validateData, req); } @Public() @Get("health-check") async healthCheck() { return this.authFacade.healthCheck(); } @Public() @Post("signup-preflight") @UseGuards(RateLimitGuard, SalesforceReadThrottleGuard) @RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP @UsePipes(new ZodValidationPipe(signupRequestSchema)) @HttpCode(200) async signupPreflight(@Body() signupData: SignupRequest) { return this.authFacade.signupPreflight(signupData); } @Public() @Post("account-status") @UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) async accountStatus(@Body() body: AccountStatusRequest) { return this.authFacade.getAccountStatus(body.email); } @Public() @Post("signup") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation) @UsePipes(new ZodValidationPipe(signupRequestSchema)) async signup( @Body() signupData: SignupRequest, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.signup(signupData, req); this.setAuthCookies(res, result.tokens); return result; } @Public() @UseGuards(LocalAuthGuard, FailedLoginThrottleGuard) @UseInterceptors(LoginResultInterceptor) @Post("login") async login( @Req() req: RequestWithUser & RequestWithRateLimit, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.login(req.user, req); this.setAuthCookies(res, result.tokens); this.applyAuthRateLimitHeaders(req, res); return result; } @Public() @Post("logout") async logout( @Req() req: RequestWithCookies & { user?: { id: string } }, @Res({ passthrough: true }) res: Response ) { const token = extractAccessTokenFromRequest(req); let userId = req.user?.id; if (!userId && token) { const payload = await this.jwtService.verifyAllowExpired<{ sub?: string }>(token); if (payload?.sub) { userId = payload.sub; } } await this.authFacade.logout(userId, token, req as Request); // Always clear cookies, even if session expired this.clearAuthCookies(res); return { message: "Logout successful" }; } @Public() @Post("refresh") @UseGuards(RateLimitGuard) @RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP @UsePipes(new ZodValidationPipe(refreshTokenRequestSchema)) async refreshToken( @Body() body: RefreshTokenRequest, @Req() req: RequestWithCookies, @Res({ passthrough: true }) res: Response ) { const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; const rawUserAgent = req.headers["user-agent"]; const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const result = await this.authFacade.refreshTokens(refreshToken, { deviceId: body.deviceId, userAgent, }); this.setAuthCookies(res, result.tokens); return result; } @Public() @Post("link-whmcs") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { const result = await this.authFacade.linkWhmcsUser(linkData); return linkWhmcsResponseSchema.parse(result); } @Public() @Post("set-password") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard) @UsePipes(new ZodValidationPipe(setPasswordRequestSchema)) async setPassword( @Body() setPasswordData: SetPasswordRequest, @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.setPassword(setPasswordData); this.setAuthCookies(res, result.tokens); return result; } @Public() @Post("check-password-needed") @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @HttpCode(200) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) { const response = await this.authFacade.checkPasswordNeeded(data.email); return checkPasswordNeededResponseSchema.parse(response); } @Public() @Post("request-password-reset") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) async requestPasswordReset(@Body() body: PasswordResetRequest, @Req() req: Request) { await this.authFacade.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } @Public() @Post("reset-password") @HttpCode(200) @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetSchema)) async resetPassword( @Body() body: ResetPasswordRequest, @Res({ passthrough: true }) res: Response ) { await this.authFacade.resetPassword(body.token, body.password); // Clear auth cookies after password reset to force re-login this.clearAuthCookies(res); return { message: "Password reset successful" }; } @Post("change-password") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes @UsePipes(new ZodValidationPipe(changePasswordRequestSchema)) async changePassword( @Req() req: Request & { user: { id: string } }, @Body() body: ChangePasswordRequest, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.changePassword(req.user.id, body, req); this.setAuthCookies(res, result.tokens); return result; } @Get("me") getAuthStatus(@Req() req: Request & { user: UserAuth }) { return { isAuthenticated: true, user: req.user }; } @Post("sso-link") @UsePipes(new ZodValidationPipe(ssoLinkRequestSchema)) async createSsoLink( @Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkRequest ) { const destination = body?.destination; return this.authFacade.createSsoLink(req.user.id, destination); } }