Spaces:
Sleeping
Sleeping
| import { Controller, Get, Query, Req, Res } from '@nestjs/common'; | |
| import type { Request, Response } from 'express'; | |
| import { OidcService } from './oidc.service'; | |
| import { cookieOptions } from '../../services/cookie'; | |
| const OIDC_STATE_COOKIE = 'trek_oidc_state'; | |
| /** | |
| * /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE). | |
| * | |
| * Byte-identical to 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. | |
| */ | |
| ('api/auth/oidc') | |
| export class OidcController { | |
| constructor(private readonly oidc: OidcService) {} | |
| ('login') | |
| async login(() req: Request, () res: Response): Promise<void> { | |
| 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://') && process.env.NODE_ENV?.toLowerCase() === 'production') { | |
| 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 redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; | |
| const inviteToken = req.query.invite as string | undefined; | |
| const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken); | |
| // Bind the state to THIS browser. The callback requires a matching cookie, | |
| // so an attacker-initiated login (whose callback URL carries a valid state | |
| // from the shared server map) cannot be replayed in a victim's browser to | |
| // log them into the attacker's account (OIDC login CSRF / session fixation). | |
| res.cookie(OIDC_STATE_COOKIE, state, { ...cookieOptions(false, req), maxAge: 10 * 60 * 1000 }); | |
| const params = new URLSearchParams({ | |
| response_type: 'code', | |
| client_id: config.clientId, | |
| redirect_uri: redirectUri, | |
| scope: process.env.OIDC_SCOPE || 'openid email profile', | |
| state, | |
| code_challenge: codeChallenge, | |
| code_challenge_method: 'S256', | |
| }); | |
| res.redirect(`${doc.authorization_endpoint}?${params}`); | |
| } catch (err: unknown) { | |
| console.error('[OIDC] Login error:', err instanceof Error ? err.message : err); | |
| res.status(500).json({ error: 'OIDC login failed' }); | |
| } | |
| } | |
| ('callback') | |
| async callback( | |
| ('code') code: string | undefined, | |
| ('state') state: string | undefined, | |
| ('error') oidcError: string | undefined, | |
| () req: Request, | |
| () res: Response, | |
| ): Promise<void> { | |
| 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<string, string> | 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://') && process.env.NODE_ENV?.toLowerCase() === 'production') { | |
| 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, | |
| (doc.issuer ?? '').replace(/\/+$/, '') || config.issuer, | |
| ); | |
| if (idVerify.ok !== true) { | |
| const reason = 'error' in idVerify ? idVerify.error : 'unknown'; | |
| console.error('[OIDC] id_token verification failed:', reason); | |
| return f('/login?oidc_error=id_token_invalid'); | |
| } | |
| const userInfo = await this.oidc.getUserInfo(doc.userinfo_endpoint, tokenData.access_token); | |
| if (!userInfo.email) return f('/login?oidc_error=no_email'); | |
| const tokenSub = idVerify.claims.sub; | |
| if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) { | |
| console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login'); | |
| return f('/login?oidc_error=subject_mismatch'); | |
| } | |
| const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken); | |
| if ('error' in result) return f('/login?oidc_error=' + result.error); | |
| this.oidc.touchLastLogin(result.user.id); | |
| const jwtToken = this.oidc.generateToken(result.user); | |
| const authCode = this.oidc.createAuthCode(jwtToken); | |
| return f('/login?oidc_code=' + authCode); | |
| } catch (err: unknown) { | |
| console.error('[OIDC] Callback error:', err); | |
| return f('/login?oidc_error=server_error'); | |
| } | |
| } | |
| ('exchange') | |
| exchange(('code') code: string | undefined, () req: Request, () res: Response): void { | |
| if (!code) { | |
| res.status(400).json({ error: 'Code required' }); | |
| return; | |
| } | |
| const result = this.oidc.consumeAuthCode(code); | |
| if ('error' in result) { | |
| res.status(400).json({ error: result.error }); | |
| return; | |
| } | |
| this.oidc.setAuthCookie(res, result.token, req); | |
| res.json({ token: result.token }); | |
| } | |
| } | |