/** * TASK-0131 — SAML 2.0 SP endpoints (mounted OUTSIDE the `api/v1` prefix — * the SAML binding is browser-POST, not a JSON API): * * GET /saml/metadata/:orgSlug — SP metadata (entity id, ACS, * WantAssertionsSigned="true"). * POST /saml/acs — the IdP posts `SAMLResponse` (+ * `RelayState`) here after sign-in. * * Both are PUBLIC (no user JWT) — the assertion signature + cert binding IS * the authentication; the global AuthGuard whitelists the `/saml/` prefix * (see auth.guard.ts) and `main.ts` excludes it from the api/v1 prefix. * * ACS result handling (browser context): * - validation/provisioning failure → 302 to `/login?error=sso_` * (the reason from the validator's rejection matrix, e.g. * `sso_bad_signature`, `sso_expired`, `sso_replayed`) — the login page * renders the error state; the failure is ALSO audited * (`sso.login_failed {reason}`). * - success → the refresh cookie is set here (the same rotating session * as a password login) and the browser is redirected to the RelayState * (open-redirect safe: relative paths only) or `/w/default`. */ import { Body, Controller, Get, Inject, Param, Post, Req, Res } 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 { SamlConfigSchema } from "./schemas.js"; import { buildSamlStartRedirect, validateAuthnResponse } from "./saml.js"; import { SsoSecretsService } from "./sso-secrets.js"; import { SsoProvisioningService } from "./provisioning.service.js"; const REPLAY_TTL_SECONDS = 600; // 10 min (the ACS replay window) @Controller("saml") export class SamlController { 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 PUBLIC ORIGIN THAT SERVES THE SSO PROTOCOL ROUTES (the API). The * SP metadata, the start redirect, and the ACS are all hit by the IdP / * the browser at THIS origin — in two-origin dev (web :3000, api :4000) * that is the API origin (SSO_ORIGIN), not the web origin. Single-origin * (reference nginx) leaves SSO_ORIGIN empty → falls back to WEB_ORIGIN. */ private origin(): string { const env = loadEnv(); return (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, ""); } /** The WEB origin — the final landing pages (`/login`, `/w/default`). */ private webOrigin(): string { return loadEnv().WEB_ORIGIN.replace(/\/$/, ""); } /** SP entity id for an org (stable, slug-derived — the Audience value). */ private spEntityId(orgSlug: string): string { return `${this.origin()}/saml/metadata/${orgSlug}`; } /** * GET /saml/start/:orgSlug — the ANONYMOUS SAML start (slug-addressed, so * the login page's minimal-info `/auth/sso/options?email=` reply — which * reveals only the org slug, never the protocol — can point the browser * here and let the server do the protocol-specific 302). SP-initiated * (HTTP-Redirect binding): build an AuthnRequest, base64url-encode it into * `SAMLRequest`, redirect to the IdP's `ssoUrl`. The ACS is STATELESS * (no stored-request correlation; the signature + cert binding is the * trust anchor), so the E2E mock IdP auto-approves and POSTs the * `AuthnResponse` directly. */ @Get("start/:orgSlug") async start(@Param("orgSlug") orgSlug: string, @Res() res: Response): Promise { const org = await this.prisma.organization.findUnique({ where: { slug: orgSlug } }); 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 } }); if (!provider || provider.type !== "SAML") { res .status(404) .json({ error: { code: "NOT_FOUND", message: "Organization has no SAML SSO provider." } }); return; } const config = SamlConfigSchema.parse(provider.config); const location = buildSamlStartRedirect({ ssoUrl: config.ssoUrl, spEntityId: this.spEntityId(org.slug), acsUrl: `${this.origin()}/saml/acs` }); res.status(302).header("location", location).end(); } /** GET /saml/metadata/:orgSlug — the SP metadata document. */ @Get("metadata/:orgSlug") async metadata(@Param("orgSlug") orgSlug: string, @Res() res: Response) { const org = await this.prisma.organization.findUnique({ where: { slug: orgSlug } }); if (!org) throw ApiError.notFound("Unknown organization."); const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: org.id } }); if (!provider || provider.type !== "SAML") { throw new ApiError( "NOT_FOUND", 404, "Organization has no SAML SSO provider.", [{ reason: "no_sso_provider" }] ); } const entityId = this.spEntityId(orgSlug); const acsUrl = `${this.origin()}/saml/acs`; const xml = ` `; res.setHeader("content-type", "application/xml"); res.setHeader("cache-control", "no-store"); res.status(200).send(xml); } /** * POST /saml/acs — the Assertion Consumer Service. The IdP POSTs a * form-urlencoded body: `SAMLResponse` (base64 AuthnResponse) + optional * `RelayState`. */ @Post("acs") async acs( @Body() body: { SAMLResponse?: string; RelayState?: string }, @Req() req: Request, @Res() res: Response ): Promise { const redirectTo = (error: string, orgSlug?: string) => { const target = new URL(`${this.webOrigin()}/login`); target.searchParams.set("error", error); if (orgSlug) target.searchParams.set("org", orgSlug); res.status(302).header("location", target.toString()).end(); }; const samlResponse = body?.SAMLResponse; if (!samlResponse) { redirectTo("sso_missing_response"); return; } let xml: string; try { xml = Buffer.from(samlResponse, "base64").toString("utf8"); } catch { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: "saml", metadata: { reason: "malformed_base64" } }); redirectTo("sso_malformed_response"); return; } // Identify the org: from the Audience (SP entity id) — the metadata URL // embeds the slug. (A response addressed to a DIFFERENT audience fails // the audience check below after the org is resolved from it.) const audienceMatch = /([^<]+)<\/saml:Audience>/.exec(xml); const slugFromAudience = audienceMatch ? audienceMatch[1].match(/saml\/metadata\/([^"<>?\s]+)$/) : null; const org = slugFromAudience ? await this.prisma.organization.findUnique({ where: { slug: slugFromAudience[1] } }) : null; if (!org) { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: "saml", metadata: { reason: "no_sso_provider" } }); redirectTo("sso_unknown_org"); return; } const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: org.id } }); if (!provider || provider.type !== "SAML") { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: org.slug, metadata: { reason: "no_sso_provider" } }); redirectTo("sso_unknown_org", org.slug); return; } const config = SamlConfigSchema.parse(provider.config); const secrets = await this.secrets.getSecrets(org.id); const certificates = [secrets.certificate, secrets.previousCertificate].filter( (c): c is string => Boolean(c) ); if (!certificates.length) { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: org.slug, metadata: { reason: "no_certificate_configured" } }); redirectTo("sso_misconfigured", org.slug); return; } const result = validateAuthnResponse(xml, { entityId: this.spEntityId(org.slug), acsUrl: `${this.origin()}/saml/acs`, issuer: config.idpEntityId, certificates, nameIdAttribute: config.nameIdAttribute, emailAttribute: config.emailAttribute }); if (!result.ok) { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: org.slug, metadata: { reason: result.reason, detail: result.detail ?? null } }); redirectTo(`sso_${result.reason}`, org.slug); return; } // Replay protection (AFTER the checks above — a forged assertion must // not consume a replay slot): the Response ID is single-use for 10 min. const replayKey = `sso:replay:${result.data.id}`; let seen: string | null = null; try { seen = await this.redis.getRedis().set(replayKey, "1", "EX", REPLAY_TTL_SECONDS, "NX"); } catch { // Redis down: fail OPEN for availability but log (the 10-min window // is a replay bound, not the primary trust anchor — the signature is). await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: org.slug, metadata: { reason: "replay_store_unavailable" } }); } if (seen === null) { await this.audit.audit(req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: org.slug, metadata: { reason: "replayed", responseId: result.data.id } }); redirectTo("sso_replayed", org.slug); return; } // Provision + session (audit `sso.login.saml` happens inside). try { await this.provisioning.provision({ req, res, organizationId: org.id, provider: { id: provider.id, type: "SAML", name: provider.name, config: provider.config as Record }, externalId: result.data.nameId, email: result.data.email ?? result.data.nameId, displayName: result.data.attributes.displayName ?? result.data.attributes.name ?? null, claims: result.data.attributes }); } catch (err) { // provision() throws ApiError.forbidden with the specific reason in // details[0].detail (and audits sso.login_failed itself). const detail = err instanceof ApiError ? (err.details?.[0]?.detail as string | undefined) : "provisioning_failed"; redirectTo(`sso_${detail ?? "provisioning_failed"}`, org.slug); return; } // Success: redirect to the RelayState (open-redirect safe) or home. // The target is a WEB page (the app), so the WEB origin — not the API // origin that serves the ACS itself. const relay = body?.RelayState; const target = relay && relay.startsWith("/") && !relay.startsWith("//") ? relay : "/w/default"; res.status(302).header("location", this.webOrigin() + target).end(); } }