import { Controller, Post, Body, UseGuards, UseInterceptors, Get, Req, HttpCode, 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 { createZodDto, ZodResponse } 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, } from "@customer-portal/domain/auth"; type CookieValue = string | undefined; type RequestWithCookies = Omit & { cookies?: Record; }; class ValidateSignupRequestDto extends createZodDto(validateSignupRequestSchema) {} class SignupRequestDto extends createZodDto(signupRequestSchema) {} class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {} class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {} class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {} class SetPasswordRequestDto extends createZodDto(setPasswordRequestSchema) {} class CheckPasswordNeededRequestDto extends createZodDto(checkPasswordNeededRequestSchema) {} class PasswordResetRequestDto extends createZodDto(passwordResetRequestSchema) {} class ResetPasswordRequestDto extends createZodDto(passwordResetSchema) {} class ChangePasswordRequestDto extends createZodDto(changePasswordRequestSchema) {} class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {} class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {} class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {} 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"; const TOKEN_TYPE = "Bearer" as const; @Controller("auth") export class AuthController { constructor( private authFacade: AuthFacade, private readonly jwtService: JoseJwtService ) {} private setAuthCookies( res: Response, tokens: { accessToken: string; refreshToken: string; expiresAt: string; refreshExpiresAt: string; } ): 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 toSession(tokens: { expiresAt: string; refreshExpiresAt: string }) { return { expiresAt: tokens.expiresAt, refreshExpiresAt: tokens.refreshExpiresAt, tokenType: TOKEN_TYPE, }; } 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 async validateSignup(@Body() validateData: ValidateSignupRequestDto, @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 @HttpCode(200) async signupPreflight(@Body() signupData: SignupRequestDto) { return this.authFacade.signupPreflight(signupData); } @Public() @Post("account-status") async accountStatus(@Body() body: AccountStatusRequestDto) { 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) async signup( @Body() signupData: SignupRequestDto, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.signup(signupData, req); this.setAuthCookies(res, result.tokens); return { user: result.user, session: this.toSession(result.tokens) }; } @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 { user: result.user, session: this.toSession(result.tokens) }; } @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 async refreshToken( @Body() body: RefreshTokenRequestDto, @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 { user: result.user, session: this.toSession(result.tokens) }; } @Public() @Post("migrate") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto }) async migrateAccount(@Body() linkData: LinkWhmcsRequestDto) { const result = await this.authFacade.linkWhmcsUser(linkData); return result; } @Public() @Post("set-password") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard) async setPassword( @Body() setPasswordData: SetPasswordRequestDto, @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.setPassword(setPasswordData); this.setAuthCookies(res, result.tokens); return { user: result.user, session: this.toSession(result.tokens) }; } @Public() @Post("check-password-needed") @HttpCode(200) @ZodResponse({ status: 200, description: "Check if password is needed", type: CheckPasswordNeededResponseDto, }) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) { const response = await this.authFacade.checkPasswordNeeded(data.email); return response; } @Public() @Post("request-password-reset") @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.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) async resetPassword( @Body() body: ResetPasswordRequestDto, @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 async changePassword( @Req() req: Request & { user: { id: string } }, @Body() body: ChangePasswordRequestDto, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.changePassword(req.user.id, body, req); this.setAuthCookies(res, result.tokens); return { user: result.user, session: this.toSession(result.tokens) }; } @Get("me") getAuthStatus(@Req() req: Request & { user: UserAuth }) { return { isAuthenticated: true, user: req.user }; } @Post("sso-link") async createSsoLink( @Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkRequestDto ) { const destination = body?.destination; return this.authFacade.createSsoLink(req.user.id, destination); } }