import { Controller, Get, Query, Req, Res } from '@nestjs/common'; import type { Response } from 'express'; import { AppConfig } from '../config/app-config.service'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { RateLimit } from '../rate-limit/rate-limit.guard'; import { AuthedRequest, Public, setSessionCookie } from './auth.guard'; import { OidcService } from './oidc.service'; import { sessionAbsoluteMs } from './sessions.service'; /** Carries state+nonce+PKCE verifier across the IdP round-trip — signed * (purpose-derived key), HttpOnly, Lax so the top-level callback * navigation still sends it, and 10 minutes short-lived. */ const OIDC_STATE_COOKIE = 'dt_oidc'; /** * OIDC endpoints (issue #214, ADR 0021). Browser-navigation shaped: `login` * and `link` answer 302 to the IdP, the callback lands back here and * redirects into the SPA — errors become `/login?error=` so the SPA * can translate them. */ @AuthenticatedOnly() @Controller('auth/oidc') export class OidcController { constructor( private readonly oidc: OidcService, private readonly config: AppConfig, ) {} private stateCookie(response: Response, value: string): void { response.cookie(OIDC_STATE_COOKIE, value, { httpOnly: true, sameSite: 'lax', secure: this.config.env.NODE_ENV === 'production', maxAge: 10 * 60 * 1000, path: '/', }); } @Public() @Get('login') @RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 }) async login(@Res() response: Response): Promise { this.oidc.assertEnabled(); const { url, stateToken } = await this.oidc.beginLogin(); this.stateCookie(response, stateToken); response.redirect(url); } /** The deliberate account-linking flow (ADR 0021 §2): only a logged-in * user attaches an IdP identity to their own account. */ @Get('link') @RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 }) async link(@Req() request: AuthedRequest, @Res() response: Response): Promise { this.oidc.assertEnabled(); const { url, stateToken } = await this.oidc.beginLogin(request.user!.id); this.stateCookie(response, stateToken); response.redirect(url); } @Public() @Get('callback') @RateLimit({ scope: 'oidc-callback', limit: 30, windowSeconds: 60 }) async callback( @Query('code') code: string | undefined, @Query('state') state: string | undefined, @Query('error') idpError: string | undefined, @Req() request: AuthedRequest, @Res() response: Response, ): Promise { this.oidc.assertEnabled(); const base = this.config.env.APP_BASE_URL; response.clearCookie(OIDC_STATE_COOKIE, { path: '/' }); const stateToken = (request.cookies as Record | undefined)?.[OIDC_STATE_COOKIE]; if (idpError || !code || !state || !stateToken) { response.redirect(`${base}/login?error=oidc_cancelled`); return; } try { const result = await this.oidc.completeLogin( code, state, stateToken, request.headers['user-agent'], ); if (result.linked) { response.redirect(`${base}/settings?oidc=linked`); return; } setSessionCookie( response, result.sessionToken!, this.config.env.NODE_ENV === 'production', sessionAbsoluteMs(this.config.env), ); response.redirect(`${base}/`); } catch (error) { const code_ = typeof (error as { response?: { code?: string } })?.response?.code === 'string' ? (error as { response: { code: string } }).response.code : 'oidc_failed'; response.redirect(`${base}/login?error=${encodeURIComponent(code_)}`); } } }