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 { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.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, OptionalAuth } from "../../decorators/public.decorator.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.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 { setAuthCookies, clearAuthCookies, buildSessionInfo, ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE, } from "./utils/auth-cookie.util.js"; // Import Zod schemas from domain import { signupRequestSchema, passwordResetRequestSchema, passwordResetSchema, setPasswordRequestSchema, linkWhmcsRequestSchema, changePasswordRequestSchema, accountStatusRequestSchema, ssoLinkRequestSchema, checkPasswordNeededRequestSchema, refreshTokenRequestSchema, checkPasswordNeededResponseSchema, linkWhmcsResponseSchema, } from "@customer-portal/domain/auth"; type CookieValue = string | undefined; type RequestWithCookies = Omit & { cookies?: Record; }; // Re-export for backward compatibility with tests export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE }; 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) {} @Controller("auth") export class AuthController { constructor( private authOrchestrator: AuthOrchestrator, private readonly jwtService: JoseJwtService ) {} private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void { FailedLoginThrottleGuard.applyRateLimitHeaders(req, res); } @Public() @Get("health-check") async healthCheck() { return this.authOrchestrator.healthCheck(); } @Public() @Post("account-status") async accountStatus(@Body() body: AccountStatusRequestDto) { return this.authOrchestrator.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.authOrchestrator.signup(signupData, req); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(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.authOrchestrator.login(req.user, req); setAuthCookies(res, result.tokens); this.applyAuthRateLimitHeaders(req, res); return { user: result.user, session: buildSessionInfo(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.authOrchestrator.logout(userId, token, req as Request); // Always clear cookies, even if session expired 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.authOrchestrator.refreshTokens(refreshToken, { deviceId: body.deviceId ?? undefined, userAgent: userAgent ?? undefined, }); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(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.authOrchestrator.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.authOrchestrator.setPassword(setPasswordData); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(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.authOrchestrator.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.authOrchestrator.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.authOrchestrator.resetPassword(body.token, body.password); // Clear auth cookies after password reset to force re-login 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.authOrchestrator.changePassword(req.user.id, body, req); setAuthCookies(res, result.tokens); return { user: result.user, session: buildSessionInfo(result.tokens) }; } /** * GET /auth/me - Check authentication status * * Uses @OptionalAuth: returns isAuthenticated: false if not logged in, * 401 only if session cookie is present but expired/invalid */ @OptionalAuth() @Get("me") getAuthStatus(@Req() req: Request & { user?: UserAuth }) { if (!req.user) { return { isAuthenticated: false }; } 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.authOrchestrator.createSsoLink(req.user.id, destination); } }