import { Controller, All, Req, Res, Get, Header, UseGuards, Request, Logger, HttpException, HttpStatus, UnauthorizedException, } from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; import type { Request as ExpressRequest, Response as ExpressResponse } from "express"; import type { AuthUser, AuthSession, AuthConfigResponse } from "@mosaic/shared"; import { AuthService } from "./auth.service"; import { AuthGuard } from "./guards/auth.guard"; import { CurrentUser } from "./decorators/current-user.decorator"; import { SkipCsrf } from "../common/decorators/skip-csrf.decorator"; import type { AuthenticatedRequest } from "./types/better-auth-request.interface"; @Controller("auth") export class AuthController { private readonly logger = new Logger(AuthController.name); constructor(private readonly authService: AuthService) {} /** * Get current session * Returns user and session data for authenticated user */ @Get("session") @UseGuards(AuthGuard) getSession(@Request() req: AuthenticatedRequest): AuthSession { // Defense-in-depth: AuthGuard should guarantee these, but if someone adds // a route with AuthenticatedRequest and forgets @UseGuards(AuthGuard), // TypeScript types won't help at runtime. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!req.user || !req.session) { throw new UnauthorizedException("Missing authentication context"); } return { user: req.user, session: { id: req.session.id, token: req.session.token, expiresAt: req.session.expiresAt, }, }; } /** * Get current user profile * Returns basic user information */ @Get("profile") @UseGuards(AuthGuard) getProfile(@CurrentUser() user: AuthUser): AuthUser { // Return only defined properties to maintain type safety const profile: AuthUser = { id: user.id, email: user.email, name: user.name, }; if (user.image !== undefined) { profile.image = user.image; } if (user.emailVerified !== undefined) { profile.emailVerified = user.emailVerified; } if (user.workspaceId !== undefined) { profile.workspaceId = user.workspaceId; } if (user.currentWorkspaceId !== undefined) { profile.currentWorkspaceId = user.currentWorkspaceId; } if (user.workspaceRole !== undefined) { profile.workspaceRole = user.workspaceRole; } return profile; } /** * Get available authentication providers. * Public endpoint (no auth guard) so the frontend can discover login options * before the user is authenticated. */ @Get("config") @Header("Cache-Control", "public, max-age=300") async getConfig(): Promise { return this.authService.getAuthConfig(); } /** * Handle all other auth routes (sign-in, sign-up, sign-out, etc.) * Delegates to BetterAuth * * Rate limit: "strict" tier (10 req/min) - More restrictive than normal routes * to prevent brute-force attacks on auth endpoints * * Security note: This catch-all route bypasses standard guards that other routes have. * Rate limiting and logging are applied to mitigate abuse (SEC-API-10). */ @All("*") // BetterAuth handles CSRF internally (Fetch Metadata + SameSite=Lax cookies). // @SkipCsrf avoids double-protection conflicts. // See: https://www.better-auth.com/docs/reference/security @SkipCsrf() @Throttle({ strict: { limit: 10, ttl: 60000 } }) async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise { // Extract client IP for logging const clientIp = this.getClientIp(req); // Log auth catch-all hits for monitoring and debugging this.logger.debug(`Auth catch-all: ${req.method} ${req.url} from ${clientIp}`); const handler = this.authService.getNodeHandler(); try { await handler(req, res); // BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling if (res.statusCode >= 500) { this.logger.error( `BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` + ` — check container stdout for '# SERVER_ERROR' details` ); } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; this.logger.error( `BetterAuth handler error: ${req.method} ${req.url} from ${clientIp} - ${message}`, stack ); if (!res.headersSent) { const mappedError = this.mapToHttpException(error); if (mappedError) { throw mappedError; } throw new HttpException( "Unable to complete authentication. Please try again in a moment.", HttpStatus.INTERNAL_SERVER_ERROR ); } this.logger.error( `Headers already sent for failed auth request ${req.method} ${req.url} — client may have received partial response` ); } } /** * Extract client IP from request, handling proxies */ private getClientIp(req: ExpressRequest): string { // Check X-Forwarded-For header (for reverse proxy setups) const forwardedFor = req.headers["x-forwarded-for"]; if (forwardedFor) { const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; return ips?.split(",")[0]?.trim() ?? "unknown"; } // Fall back to direct IP return req.ip ?? req.socket.remoteAddress ?? "unknown"; } /** * Preserve known HTTP errors from BetterAuth/better-call instead of converting * every failure into a generic 500. */ private mapToHttpException(error: unknown): HttpException | null { if (error instanceof HttpException) { return error; } if (!error || typeof error !== "object") { return null; } const statusCode = "statusCode" in error ? error.statusCode : undefined; if (!this.isHttpStatus(statusCode)) { return null; } const responseBody = "body" in error && error.body !== undefined ? error.body : undefined; if ( responseBody !== undefined && responseBody !== null && (typeof responseBody === "string" || typeof responseBody === "object") ) { return new HttpException(responseBody, statusCode); } const message = "message" in error && typeof error.message === "string" && error.message.length > 0 ? error.message : "Authentication request failed"; return new HttpException(message, statusCode); } private isHttpStatus(value: unknown): value is number { if (typeof value !== "number" || !Number.isInteger(value)) { return false; } return value >= 400 && value <= 599; } }