/** * TASK-0131 — OIDC RP endpoints (mounted OUTSIDE the `api/v1` prefix — the * authorization-code flow is browser-redirect, not a JSON API): * * GET /sso/oidc/:orgId/authorize — 302 → the IdP's authorize URL (state * + nonce + PKCE challenge). * GET /sso/oidc/:orgId/callback — the IdP redirects back with * `?code=&state=`. * * Both are PUBLIC (no user JWT) — the state/nonce/PKCE + the RS256 ID-token * signature are the authentication. The global AuthGuard whitelists the * `/sso/` prefix and `main.ts` excludes it from the api/v1 prefix. * * Result handling (browser context): failure → 302 to * `/login?error=sso_oidc_`; success → the refresh cookie is set by * `provision()` and the browser is redirected to `/w/default`. */ import { Controller, Get, Inject, Param, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import { loadEnv } from "../../config/env.js"; import { OidcService } from "./oidc.service.js"; @Controller("sso") export class OidcController { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(OidcService) private readonly oidc: OidcService ) {} /** * The origin that SERVES the SSO protocol routes (the API) — the start * dispatcher redirects to `/saml/start/` or `/sso/oidc/` (authorize), * both API routes. 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(/\/$/, ""); } /** GET /sso/oidc/:orgId/authorize — start the OIDC authorization-code flow. */ @Get("oidc/:orgId/authorize") async authorize(@Param("orgId") orgId: string, @Res() res: Response): Promise { const { url } = await this.oidc.authorize(orgId); res.status(302).header("location", url).end(); } /** GET /sso/oidc/:orgId/callback — the IdP redirects back with code + state. */ @Get("oidc/:orgId/callback") async callback( @Param("orgId") orgId: string, @Query() query: { code?: string; state?: string }, @Req() req: Request, @Res() res: Response ): Promise { await this.oidc.callback(orgId, { code: query.code, state: query.state }, req, res); } /** * GET /sso/start/:orgSlug — PUBLIC (no auth) slug-addressed SSO START * dispatcher. The login page learns only the org slug from the * minimal-info `GET /auth/sso/options?email=` (it never learns the * protocol), so it points the browser here and the SERVER does the * protocol-specific redirect: SAML → `/saml/start/:slug` (which builds * the AuthnRequest and 302s to the IdP); OIDC → `/sso/oidc/:orgId/authorize`. */ @Get("start/:orgSlug") async start(@Param("orgSlug") orgSlug: string, @Res() res: Response): Promise { const org = await this.prisma.organization.findUnique({ where: { slug: orgSlug }, select: { id: true, slug: true } }); if (!org) { res.status(404).json({ error: { code: "NOT_FOUND", message: "Unknown organization." } }); return; } const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: org.id }, select: { type: true } }); if (!provider) { res.status(404).json({ error: { code: "NOT_FOUND", message: "Organization has no SSO provider." } }); return; } const location = provider.type === "SAML" ? `${this.origin()}/saml/start/${org.slug}` : `${this.origin()}/sso/oidc/${org.id}/authorize`; res.status(302).header("location", location).end(); } }