import { Controller, Post, Body, UseGuards, UseInterceptors, Get, Req, HttpCode, Res, UnauthorizedException, } 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 { JoseJwtService } from "../../infra/token/jose-jwt.service.js"; import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-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"; import { setAuthCookies, clearAuthCookies, buildSessionInfo, ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE, } from "./utils/auth-cookie.util.js"; import { setTrustedDeviceCookie, clearTrustedDeviceCookie, getTrustedDeviceToken, } from "./utils/trusted-device-cookie.util.js"; import { getDevAuthConfig } from "@bff/core/config/auth-dev.config.js"; import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.service.js"; // Import Zod schemas from domain import { passwordResetRequestSchema, passwordResetSchema, setPasswordRequestSchema, changePasswordRequestSchema, accountStatusRequestSchema, ssoLinkRequestSchema, checkPasswordNeededRequestSchema, refreshTokenRequestSchema, checkPasswordNeededResponseSchema, loginVerifyOtpRequestSchema, } 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 AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {} class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {} 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 CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {} class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {} @Controller("auth") export class AuthController { constructor( private authOrchestrator: AuthOrchestrator, private readonly jwtService: JoseJwtService, private readonly loginOtpWorkflow: LoginOtpWorkflowService, private readonly trustedDeviceService: TrustedDeviceService ) {} 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); } /** * POST /auth/login - Initiate login with credentials * * After valid credential check: * 1. Check if device is trusted (has valid trusted device cookie for this user) * 2. If trusted, skip OTP and complete login directly * 3. Otherwise, generate OTP and send to user's email * 4. Return session token for OTP verification * 5. User must call /auth/login/verify-otp to complete login */ @Public() @UseGuards(LocalAuthGuard, FailedLoginThrottleGuard) @UseInterceptors(LoginResultInterceptor) @Post("login") async login( @Req() req: RequestWithUser & RequestWithRateLimit & RequestWithCookies, @Res({ passthrough: true }) res: Response ) { this.applyAuthRateLimitHeaders(req, res); // In dev mode with SKIP_OTP=true, skip OTP and complete login directly if (getDevAuthConfig().skipOtp) { const loginResult = await this.authOrchestrator.completeLogin( { id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" }, req ); setAuthCookies(res, loginResult.tokens); return { user: loginResult.user, session: buildSessionInfo(loginResult.tokens), }; } // Check if this is a trusted device for the authenticated user const trustedDeviceToken = getTrustedDeviceToken(req); const trustedDeviceResult = await this.trustedDeviceService.validateTrustedDevice( trustedDeviceToken, req.user.id ); // If device is trusted for this user, skip OTP and complete login if (trustedDeviceResult.valid) { const loginResult = await this.authOrchestrator.completeLogin( { id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" }, req ); setAuthCookies(res, loginResult.tokens); return { user: loginResult.user, session: buildSessionInfo(loginResult.tokens), }; } // Credentials validated by LocalAuthGuard - now initiate OTP const fingerprint = getRequestFingerprint(req); const otpResult = await this.loginOtpWorkflow.initiateOtp( { id: req.user.id, email: req.user.email, role: req.user.role ?? "USER", }, fingerprint ); // Return OTP required response - no tokens issued yet return { requiresOtp: true, sessionToken: otpResult.sessionToken, maskedEmail: otpResult.maskedEmail, expiresAt: otpResult.expiresAt, }; } /** * POST /auth/login/verify-otp - Complete login with OTP verification * * Verifies the OTP code and issues auth tokens on success. * If rememberDevice is true, sets a trusted device cookie to skip OTP on future logins. */ @Public() @Post("login/verify-otp") @UseGuards(RateLimitGuard) @RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP @HttpCode(200) async verifyLoginOtp( @Body() body: LoginVerifyOtpRequestDto, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { const fingerprint = getRequestFingerprint(req); const result = await this.loginOtpWorkflow.verifyOtp(body.sessionToken, body.code, fingerprint); if (!result.success) { throw new UnauthorizedException(result.error); } // OTP verified - complete login with token generation const loginResult = await this.authOrchestrator.completeLogin( { id: result.userId, email: result.email, role: result.role }, req ); setAuthCookies(res, loginResult.tokens); // If user wants to remember this device, create and set trusted device cookie if (body.rememberDevice) { const rawUserAgent = req.headers["user-agent"]; const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const trustedDeviceToken = await this.trustedDeviceService.createTrustedDevice( result.userId, userAgent ); setTrustedDeviceCookie(res, trustedDeviceToken, this.trustedDeviceService.getTtlMs()); } return { user: loginResult.user, session: buildSessionInfo(loginResult.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; } } // Revoke trusted device for this user if they have one const trustedDeviceToken = getTrustedDeviceToken(req); if (trustedDeviceToken && userId) { // Validate to get device ID, then revoke const validation = await this.trustedDeviceService.validateTrustedDevice( trustedDeviceToken, userId ); if (validation.deviceId) { await this.trustedDeviceService.revokeDevice(validation.deviceId); } } await this.authOrchestrator.logout(userId, token, req as Request); // Always clear cookies, even if session expired clearAuthCookies(res); clearTrustedDeviceCookie(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("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); } }