import { Body, Controller, Get, HttpException, Put, Query, Req, Res, UseGuards } from '@nestjs/common'; import type { Request, Response } from 'express'; import { oidcLoginQuerySchema } from '@trek/shared'; import { readEnv } from '../../app-config'; import { OidcService, OIDC_STATE_TTL_MS, OIDC_AUTH_CODE_TTL_MS } from './oidc.service'; import { cookieOptions } from '../common/cookie'; import { AdminGuard } from '../auth/admin.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { User } from '../../types'; import { AuditService } from '../audit/audit.service'; import { getClientIp } from '../audit/client-ip'; import { AdminOidcUpdateDto } from '../admin/admin.dto'; import { Public } from '../auth/public.decorator'; import { ManagedForbidden } from '../common/managed'; const OIDC_STATE_COOKIE = 'trek_oidc_state'; const OIDC_EXCHANGE_COOKIE = 'trek_oidc_exchange'; /** * /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE). * * Carried over from the legacy Express route (server/src/routes/oidc.ts): * unauthenticated, the sso-disabled / not-configured / HTTPS-issuer guards, the * strict id_token + userinfo.sub cross-check, all the frontend redirect error * codes, and the auth-code → cookie hand-off on /exchange. Uses @Res directly * because the flow mixes provider redirects with JSON error bodies. * * Two cookies bind the flow to one browser, and both are single use: the state * cookie set at /login is required at /callback, and the binding cookie set at * /callback is required at /exchange. Neither the provider state nor the auth * code is a credential on its own — both travel in URLs. */ @Public('the OIDC handshake happens before a TREK session exists; the provider state is the credential') @Controller('api/auth/oidc') export class OidcController { constructor( private readonly oidc: OidcService, private readonly audit: AuditService, ) {} @Get('login') async login(@Req() req: Request, @Res() res: Response): Promise { if (!this.oidc.oidcLoginEnabled()) { res.status(403).json({ error: 'SSO login is disabled.' }); return; } const config = this.oidc.getOidcConfig(); if (!config) { res.status(400).json({ error: 'OIDC not configured' }); return; } if (config.issuer && !config.issuer.startsWith('https://') && readEnv().app.isProduction) { res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' }); return; } try { const doc = await this.oidc.discover(config.issuer, config.discoveryUrl); const appUrl = this.oidc.getAppUrl(); if (!appUrl) { res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' }); return; } // `(? { const f = (p: string) => res.redirect(this.oidc.frontendUrl(p)); // The state cookie is single-use — clear it regardless of the outcome. const boundState = (req.cookies as Record | undefined)?.[OIDC_STATE_COOKIE]; res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req)); if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled'); if (oidcError) { console.error('[OIDC] Provider error:', oidcError); return f('/login?oidc_error=' + encodeURIComponent(oidcError)); } if (!code || !state) return f('/login?oidc_error=missing_params'); // Require the callback to come from the browser that started the flow. if (!boundState || boundState !== state) return f('/login?oidc_error=invalid_state'); const pending = this.oidc.consumeState(state); if (!pending) return f('/login?oidc_error=invalid_state'); const config = this.oidc.getOidcConfig(); if (!config) return f('/login?oidc_error=not_configured'); if (config.issuer && !config.issuer.startsWith('https://') && readEnv().app.isProduction) { return f('/login?oidc_error=issuer_not_https'); } try { const doc = await this.oidc.discover(config.issuer, config.discoveryUrl); const tokenData = await this.oidc.exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier); if (!tokenData._ok || !tokenData.access_token) { console.error('[OIDC] Token exchange failed: status', tokenData._status); return f('/login?oidc_error=token_failed'); } if (!tokenData.id_token) { console.error('[OIDC] Token response missing id_token — refusing login'); return f('/login?oidc_error=no_id_token'); } const idVerify = await this.oidc.verifyIdToken( tokenData.id_token, doc, config.clientId, // Same trim as oidc.service.ts, and the same reason for the lookbehind — this one // runs over an issuer read out of a fetched discovery document. (doc.issuer ?? '').replace(/(? | undefined)?.[OIDC_EXCHANGE_COOKIE]; res.clearCookie(OIDC_EXCHANGE_COOKIE, cookieOptions(true, req)); if (!code) { res.status(400).json({ error: 'Code required' }); return; } const result = this.oidc.consumeAuthCode(code, binding); if ('error' in result) { res.status(400).json({ error: result.error }); return; } this.oidc.setAuthCookie(res, result.token, req, result.remember); res.json({ token: result.token }); } } /** * /api/admin/oidc — the SSO configuration. * * Two routes that sat on AdminController with their SQL in AdminService, for a domain * that already had a module. They read and write the same five app_settings keys the * login flow next door consumes, so they belong here; the lockout guard that refuses * to remove the config while password login is off lives with them in OidcService. * * The path, the {error,status} envelope and the audit action are unchanged. */ @Controller('api/admin/oidc') @UseGuards(JwtAuthGuard, AdminGuard) export class AdminOidcController { constructor( private readonly oidc: OidcService, private readonly audit: AuditService, ) {} // Both halves, not just the write: reading back the issuer, client id and the // discovery URL tells a caller how the identity of this install is wired, and // on a managed install that wiring is not theirs. @ManagedForbidden('the identity provider is part of what the operator runs') @Get() get() { return this.oidc.getOidcSettings(); } @ManagedForbidden('an instance-supplied issuer could assert any address as verified') @Put() update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) { const result = this.oidc.updateOidcSettings(body); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } // Only whether an issuer was set, never the value: the details column is readable // by every admin and the issuer identifies the customer's IdP tenant. this.audit.writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer }, }); return { success: true }; } }