import { Inject, Body, Controller, Get, HttpCode, Post, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; import { AuthService } from "./auth.service.js"; import { TwoFactorService } from "../org/two-factor.service.js"; import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js"; import { type LoginInput, type LogoutInput, type RefreshInput, type RegisterInput, type ResetPasswordConfirmInput, type ResetPasswordInput, LoginDto, LogoutDto, RefreshDto, RegisterDto, ResetPasswordConfirmDto, ResetPasswordDto, TwoFactorVerifyDto, type TwoFactorVerifyInput } from "./dto.js"; @Controller("auth") export class AuthController { constructor( @Inject(AuthService) private readonly auth: AuthService, @Inject(TwoFactorService) private readonly twoFactor: TwoFactorService ) {} @Post("register") register( @Body(ZodValidate(RegisterDto)) body: RegisterInput, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { return this.auth.register(body, { req, res }); } // login/refresh authenticate (they do not create a resource) → 200, not the // Nest @Post default of 201. The auth contract (tests/integration + e2e // utils/api.ts + global-setup) asserts 200 for both. register keeps 201 // (it creates the user/workspace). @HttpCode(200) @Post("login") login( @Body(ZodValidate(LoginDto)) body: LoginInput, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { return this.auth.login(body, { req, res }); } @HttpCode(200) @Post("refresh") refresh( @Body(ZodValidate(RefreshDto)) body: RefreshInput, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { return this.auth.refresh(body.refreshToken ?? this.auth.readRefreshCookie(req), { req, res }); } // TASK-0133 enforcement #1: complete a 2FA-challenged login (public — the // user is not authenticated yet; the pending step + TOTP code are the // credentials). Returns 200 with the session once the code verifies. @HttpCode(200) @Post("2fa/verify") verify2fa( @Body(ZodValidate(TwoFactorVerifyDto)) body: TwoFactorVerifyInput, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { return this.auth.complete2Fa(body, { req, res }); } // 2FA SELF-service (authenticated user): enroll (the secret is revealed // ONCE), status, disable. An org admin resetting a member's 2FA goes // through the admin console (members tab → suspend/reset), not here. @Get("2fa/status") async twoFactorStatus(@Req() req: Request) { return { enrolled: await this.twoFactor.isEnrolled(req.auth!.userId!) }; } @Post("2fa/enroll") twoFactorEnroll(@Req() req: Request) { return this.twoFactor.enroll(req.auth!.userId!, req); } @Post("2fa/disable") async twoFactorDisable(@Req() req: Request) { await this.twoFactor.disable(req.auth!.userId!, req); return { enrolled: false }; } @Post("logout") async logout( @Body(ZodValidate(LogoutDto)) body: LogoutInput, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { await this.auth.logout(this.auth.readRefreshCookie(req), body.sessionId, { req, res }); return { loggedOut: true }; } @Get("me") me(@Req() req: Request) { return this.auth.me(req.auth!.userId!); } /** * TASK-0131: ANONYMOUS SSO discovery for the login page (public — the * email is the only input, and the reply is minimal-info: org slug + * `ssoAvailable` + a protocol-agnostic start URL; no provider type, name, * entity id, or org id is revealed). Unknown/foreign emails → `{orgs:[]}` * (same empty shape, no enumeration signal). */ @Get("sso/options") ssoOptions(@Query("email") email: string | undefined) { return this.auth.ssoOptions(email ?? ""); } /** Always 200 — never reveals whether the email exists. */ @Post("reset-password") resetPassword(@Body(ZodValidate(ResetPasswordDto)) body: ResetPasswordInput) { return this.auth.resetPassword(body.email).then(() => ({ sent: true })); } @Post("reset-password/confirm") confirmReset( @Body(ZodValidate(ResetPasswordConfirmDto)) body: ResetPasswordConfirmInput, @Req() req: Request ) { return this.auth.resetPasswordConfirm(body, { req }); } }