import { Controller, All, Req, Res, Get, Header, UseGuards, Request, Logger, HttpException, HttpStatus, } 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"; interface RequestWithSession { user?: AuthUser; session?: { id: string; token: string; expiresAt: Date; [key: string]: unknown; }; } @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: RequestWithSession): AuthSession { if (!req.user || !req.session) { // This should never happen after AuthGuard, but TypeScript needs the check throw new HttpException("User session not found", HttpStatus.UNAUTHORIZED); } 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); } 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) { 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"; } }