/** * TASK-0131 — OIDC RP flow (authorization-code + PKCE). * * Two endpoints, two steps: * 1. `authorize(orgId, req)` — build the IdP authorize URL. A `state` * (CSRF), `nonce` (replay) and `code_verifier` (PKCE) are generated and * stored in Redis (10 min TTL) under `sso:oidc:state:`. The * browser is redirected to the IdP's `authorization_endpoint` with * `response_type=code`, `code_challenge` (S256), `scope`, `nonce`. * 2. `callback(orgId, {code, state}, req, res)` — validate `state` * (present + not consumed), exchange `code` at the `token_endpoint` * (with `code_verifier`), fetch + cache the JWKS, verify the ID token * (RS256 + iss/aud/exp/nbf/nonce), resolve claims via `claimsMapping`, * then hand to the shared provisioning service (same match-or-create + * membership + session as SAML). * * Failures redirect to `/login?error=sso_oidc_` and audit * `sso.login_failed {reason}` (the same reasons the SAML ACS uses where they * overlap, plus the OIDC-specific token/claim ones). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request, Response } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { loadEnv } from "../../config/env.js"; import { OidcConfigSchema } from "./schemas.js"; import { SsoSecretsService } from "./sso-secrets.js"; import { SsoProvisioningService } from "./provisioning.service.js"; import { pkcePair, randomToken, resolveClaim, verifyIdToken, type Jwk } from "./oidc.js"; const STATE_TTL_SECONDS = 600; // 10 min (the OIDC flow window) const JWKS_TTL_SECONDS = 3600; // 1 h (JWKS rotation is rare) @Injectable() export class OidcService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService, @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService, @Inject(SsoProvisioningService) private readonly provisioning: SsoProvisioningService ) {} /** * The origin that SERVES the SSO protocol routes (the API) — used for the * OIDC `redirect_uri` (the IdP POSTs/redirects the code+state back to our * `/sso/oidc/:orgId/callback`, an API route). SSO_ORIGIN (API origin in * two-origin dev) or WEB_ORIGIN (single-origin nginx). */ private origin(): string { const env = loadEnv(); return (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, ""); } /** The WEB origin — the landing pages after sign-in (`/login`, `/w/default`). */ private webOrigin(): string { return loadEnv().WEB_ORIGIN.replace(/\/$/, ""); } /** Step 1: the authorize redirect URL (browser is sent here first). */ async authorize(orgId: string): Promise<{ url: string }> { const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: orgId } }); if (!provider || provider.type !== "OIDC") { throw new ApiError("NOT_FOUND", 404, "Organization has no OIDC SSO provider.", [{ reason: "no_sso_provider" }]); } const config = OidcConfigSchema.parse(provider.config); const state = randomToken(); const nonce = randomToken(); const { verifier, challenge } = pkcePair(); const redirectUri = config.redirectUri || `${this.origin()}/sso/oidc/${orgId}/callback`; const r = this.redis.getRedis(); await r.set( `sso:oidc:state:${state}`, JSON.stringify({ orgId, nonce, verifier, createdAt: Date.now() }), "EX", STATE_TTL_SECONDS ); const params = new URLSearchParams({ response_type: "code", client_id: config.clientId, redirect_uri: redirectUri, scope: config.scope, state, nonce, code_challenge: challenge, code_challenge_method: "S256" }); return { url: `${config.authorizationEndpoint}?${params.toString()}` }; } /** Step 2: the IdP redirects back with `code` + `state`. */ async callback( orgId: string, body: { code?: string; state?: string }, req: Request, res: Response ): Promise { const redirectTo = (error: string) => { const target = new URL(`${this.webOrigin()}/login`); target.searchParams.set("error", error); res.status(302).header("location", target.toString()).end(); }; const fail = async (reason: string): Promise => { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: orgId, metadata: { type: "oidc", reason } }); redirectTo(`sso_oidc_${reason}`); throw new ApiError("FORBIDDEN", 403, "OIDC sign-in failed.", [{ reason: `oidc_${reason}` }]); }; const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: orgId } }); if (!provider || provider.type !== "OIDC") { await fail("no_sso_provider"); return; } const config = OidcConfigSchema.parse(provider.config); // Validate state (CSRF + not-consumed). if (!body.state) { await fail("state_invalid"); return; } const stateKey = `sso:oidc:state:${body.state}`; let stateJson: string | null = null; try { stateJson = await this.redis.getRedis().get(stateKey); } catch { stateJson = null; } if (!stateJson) { await fail("state_invalid"); return; } let state: { orgId: string; nonce: string; verifier: string }; try { state = JSON.parse(stateJson) as { orgId: string; nonce: string; verifier: string }; } catch { await fail("state_invalid"); return; } if (state.orgId !== orgId) { await fail("state_invalid"); return; } // Consume the state (single-use → replay protection). try { await this.redis.getRedis().del(stateKey); } catch { /* already deleted by another callback; treat as replay */ await fail("state_replayed"); return; } if (!body.code) { await fail("missing_code"); return; } // Exchange the code for tokens (with the PKCE verifier). const clientSecret = (await this.secrets.getSecrets(orgId)).clientSecret ?? ""; let idToken: string; try { const form = new URLSearchParams({ grant_type: "authorization_code", code: body.code, redirect_uri: config.redirectUri || `${this.origin()}/sso/oidc/${orgId}/callback`, client_id: config.clientId, client_secret: clientSecret, code_verifier: state.verifier }); const tokenRes = await fetch(config.tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: form.toString() }); const tokenResponse = (await tokenRes.json()) as { id_token?: string }; if (!tokenResponse.id_token) throw new Error(`no id_token (status ${tokenRes.status})`); idToken = tokenResponse.id_token; } catch { await fail("token_exchange_failed"); return; } // JWKS (fetched + cached in Redis, 1 h TTL). const jwksUri = config.jwksUri || `${config.issuer}/.well-known/jwks.json`; let keys: Jwk[] = []; try { const cacheKey = `sso:oidc:jwks:${orgId}`; const cached = await this.redis.getRedis().get(cacheKey); if (cached) { keys = (JSON.parse(cached) as { keys: Jwk[] }).keys; } else { const jwksRes = await fetch(jwksUri, { headers: { accept: "application/json" } }); const jwksDoc = (await jwksRes.json()) as { keys: Jwk[] }; keys = jwksDoc.keys ?? []; await this.redis.getRedis().set(cacheKey, JSON.stringify({ keys }), "EX", JWKS_TTL_SECONDS); } } catch { await fail("invalid_id_token"); return; } // Verify the ID token (RS256 + claims). const verified = verifyIdToken(idToken, { keys }, { issuer: config.issuer, clientId: config.clientId, nonce: state.nonce }); if (!verified.ok) { await fail(verified.reason); return; } // Resolve claims via the configured mapping (dotted paths allowed). const mapping = config.claimsMapping; const emailRaw = mapping ? resolveClaim(verified.claims, mapping.email) : verified.claims.email; const subRaw = mapping ? resolveClaim(verified.claims, mapping.sub) : verified.claims.sub; const nameRaw = mapping ? resolveClaim(verified.claims, mapping.name) : verified.claims.name; const email = typeof emailRaw === "string" ? emailRaw : ""; const externalId = typeof subRaw === "string" ? subRaw : email; if (!email || !externalId) { await fail("missing_claims"); return; } try { await this.provisioning.provision({ req, res, organizationId: orgId, provider: { id: provider.id, type: "OIDC", name: provider.name, config: provider.config as Record }, externalId, email, displayName: typeof nameRaw === "string" ? nameRaw : null, claims: verified.claims }); } catch (err) { const detail = err instanceof ApiError ? (err.details?.[0]?.detail as string | undefined) : "provisioning_failed"; redirectTo(`sso_${detail ?? "provisioning_failed"}`); return; } // Landing is a WEB page (the app), so the WEB origin — not the API // origin that serves the callback itself. const target = this.webOrigin() + "/w/default"; res.status(302).header("location", target).end(); } }