import { Controller, Get, HttpCode, HttpStatus, Post, Req, Res, UnauthorizedException, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import { Public } from '../decorators/public.decorator'; import { AuthService } from '../auth.service'; import { MfaRequiredResponse } from '../auth.controller'; import { AccessTokenDto } from '../dto/access-token.dto'; import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle'; import { setRefreshTokenCookie } from '../util/refresh-token-cookie'; import { SsoService } from './sso.service'; const PKCE_COOKIE = 'oidcPkceVerifier'; const PKCE_COOKIE_PATH = '/api/v1/auth/sso'; const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000; // Scoped to /complete only - the completion endpoint is the only place that // ever needs to read it, and it's never sent on the wider /auth/sso/* path. const SSO_CODE_COOKIE = 'oidcSsoCode'; const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete'; const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000; interface PkceCookiePayload { codeVerifier: string; state: string; } @ApiTags('auth') @Controller('auth/sso') export class SsoController { constructor( private readonly ssoService: SsoService, private readonly authService: AuthService, ) {} private frontendLoginUrl(): string { return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`; } private cookieOptions(path: string, maxAge: number) { return { httpOnly: true, secure: process.env.COOKIE_SECURE !== 'false', sameSite: 'lax' as const, path, maxAge, }; } @Public() @Get('status') status(): { enabled: boolean } { return { enabled: this.ssoService.isConfigured() }; } @Public() @Get('login') async login(@Res() res: Response): Promise { const { redirectUrl, codeVerifier, state } = await this.ssoService.buildAuthorizationRequest(); const payload: PkceCookiePayload = { codeVerifier, state }; res.cookie( PKCE_COOKIE, JSON.stringify(payload), this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS), ); res.redirect(redirectUrl); } @Public() @Get('callback') async callback(@Req() req: Request, @Res() res: Response): Promise { const rawCookie = (req.cookies as Record | undefined)?.[ PKCE_COOKIE ]; res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0)); const pkce = this.parsePkceCookie(rawCookie); if (!pkce) { res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`); return; } // Built from the configured redirect URI, not from the Host header a // client sends; only the query (code, state, iss) comes from the request. const callbackUrl = new URL(process.env.OIDC_REDIRECT_URI as string); callbackUrl.search = new URL(req.originalUrl, 'http://placeholder').search; const result = await this.ssoService.handleCallback( callbackUrl, pkce.codeVerifier, pkce.state, ); if (result.status === 'error') { res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`); return; } // The one-time completion code never touches the URL (history, server // logs, Referer headers) - it travels only in an httpOnly cookie scoped // to the /complete endpoint, which the frontend calls right after. res.cookie( SSO_CODE_COOKIE, result.ssoCode, this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS), ); res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`); } @Public() @Throttle(SENSITIVE_ACTION_THROTTLE) @Post('complete') @HttpCode(HttpStatus.OK) async complete( @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { const ssoCode = (req.cookies as Record | undefined)?.[ SSO_CODE_COOKIE ]; res.clearCookie( SSO_CODE_COOKIE, this.cookieOptions(SSO_CODE_COOKIE_PATH, 0), ); if (!ssoCode) { throw new UnauthorizedException( 'SSO-Anmeldung abgelaufen, bitte erneut versuchen', ); } const { userId } = this.ssoService.verifySsoCode(ssoCode); const result = await this.authService.issueTokensForSsoUser(userId); if (result.mfaRequired) { return { mfaRequired: true, mfaToken: result.mfaToken }; } setRefreshTokenCookie(res, result.refreshToken); return { accessToken: result.accessToken }; } private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null { if (!raw) { return null; } try { const parsed = JSON.parse(raw) as Partial; if ( typeof parsed.codeVerifier !== 'string' || typeof parsed.state !== 'string' ) { return null; } return { codeVerifier: parsed.codeVerifier, state: parsed.state }; } catch { return null; } } }