/** * TASK-0131 — SSO provider config contracts (zod, single source of truth). * * `SsoProvider.config` (Prisma `SsoProvider.config Json`) holds the provider * settings for ONE protocol; the encrypted secrets (SAML X.509 certificate * PEM, OIDC client secret) live in `Organization.ssoConfig` as * AES-256-GCM ciphertext (TASK-0130 envelope via `packages/shared/crypto`), * NEVER in `config` plaintext — `assertNoPlaintextSecrets` guards that * invariant at save time (the schema test asserts no PEM in `config`). * * The admin controller and the SP/OIDC services both validate through these * schemas — the admin UI mirrors them client-side for live validation. */ import { X509Certificate } from "node:crypto"; import { z } from "zod"; const CERT_SIZE_CAP = 16 * 1024; // certs ≤ 16 KB in ssoConfig (no column — documented) /** A PEM block that parses as a valid X.509 certificate (≤ 16 KB). */ export const x509PemSchema = z .string() .refine((s) => { try { // node:crypto's X509Certificate THROWS on any malformed cert — the // strictest parser available without a third-party dep (the SP then // uses the SAME cert for signature verification via xml-crypto). new X509Certificate(s); return true; } catch { return false; } }, "certificate PEM does not parse as an X.509 certificate") .refine((s) => s.length <= CERT_SIZE_CAP, `certificate exceeds the ${CERT_SIZE_CAP} B ssoConfig cap`); /** SAML SP configuration (`SsoProvider.config` when `type === "SAML"`). */ export const SamlConfigSchema = z.object({ /** The SP's entity ID (defaults to `/saml/metadata/`). */ entityId: z.string().min(1).max(1024), /** The IdP single-sign-on URL (the browser is redirected here to start). */ ssoUrl: z.string().url().max(2048), /** * Where the IdP POSTs the `AuthnResponse` (our ACS). Defaults to * `/saml/acs`. */ acsUrl: z.string().url().max(2048), /** * Which slot in `Organization.ssoConfig` holds the IdP signing cert: * "current" or "previous" (cert rotation grace — the ACS accepts * signatures by EITHER; the ciphertexts are `certificateEncrypted` / * `previousCertificateEncrypted` on the org row). */ x509CertificateRef: z.enum(["current", "previous"]).default("current"), /** Which assertion attribute maps to the SAML `NameID` external id. */ nameIdAttribute: z.string().min(1).max(128).default("nameId"), /** Which assertion attribute carries the user's email (defaults to `mail`). */ emailAttribute: z.string().min(1).max(128).default("mail"), /** Assertion emails are treated as verified (spec: always true). */ emailIsVerified: z.literal(true).default(true), /** Extra claim→field map (forward-compatible; unused in v1 beyond email/name). */ attributeMapping: z.record(z.string(), z.string()).optional(), /** Optional IdP metadata URL (the admin "test connection" fetches it). */ metadataUrl: z.string().url().max(2048).optional(), /** * Optional IdP entity id. When set, the ACS also requires the assertion * `Issuer` to match it (defense in depth on top of the signature + cert * binding). Absent = issuer is not pinned (signature is the trust anchor). */ idpEntityId: z.string().min(1).max(1024).optional() }); export type SamlConfig = z.infer; /** OIDC client configuration (`SsoProvider.config` when `type === "OIDC"`). */ export const OidcConfigSchema = z.object({ issuer: z.string().url().max(2048), clientId: z.string().min(1).max(256), /** * The client secret. Reveal-once at config save: the admin UI POSTs the * plaintext ONCE, the API encrypts it (AES-GCM) into * `Organization.ssoConfig.clientSecretEncrypted` and never returns it * again (the read endpoint returns `clientSecretConfigured: boolean`). */ clientSecretEncrypted: z.literal(true).default(true), authorizationEndpoint: z.string().url().max(2048), tokenEndpoint: z.string().url().max(2048), /** JWKS URI (defaults to the issuer's `/.well-known/jwks.json`). */ jwksUri: z.string().url().max(2048).optional(), /** Our callback URL (defaults to `/sso/oidc/:orgId/callback`). */ redirectUri: z.string().url().max(2048), scope: z.string().default("openid email profile"), /** Claim→field mapping (nested paths allowed, e.g. `profile.email`). */ claimsMapping: z .object({ sub: z.string().default("sub"), email: z.string().default("email"), name: z.string().default("name"), emailVerified: z.string().default("email_verified") }) .optional(), attributeMapping: z.record(z.string(), z.string()).optional() }); export type OidcConfig = z.infer; /** Admin create input (the plaintext secret/cert arrives only on create/rotate). */ export const SsoProviderAdminInputSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("SAML"), name: z.string().min(1).max(128), config: SamlConfigSchema, /** The PEM itself — stored encrypted on the org row, never in config. */ certificate: x509PemSchema, /** Optional previous certificate for rotation grace (also encrypted). */ previousCertificate: x509PemSchema.optional() }), z.object({ type: z.literal("OIDC"), name: z.string().min(1).max(128), config: OidcConfigSchema, /** The plaintext client secret (reveal-once). */ clientSecret: z.string().min(8).max(1024) }) ]); export type SsoProviderAdminInput = z.infer; /** * Admin update input. `config` is a PARTIAL merge (zod strip-by-default on * the parsed object, then shallow-merged server-side over the stored config). * `certificate` / `clientSecret` are OPTIONAL rotation inputs (reveal-once * again); a SAML cert here ROTATES (current → previous). */ export const SsoProviderUpdateSchema = z .object({ name: z.string().min(1).max(128).optional(), config: z.record(z.string(), z.unknown()).optional(), certificate: x509PemSchema.optional(), previousCertificate: x509PemSchema.optional(), clientSecret: z.string().min(8).max(1024).optional() }) .refine((b) => b.name !== undefined || b.config !== undefined || b.certificate !== undefined || b.clientSecret !== undefined, { message: "no fields to update" }); export type SsoProviderUpdate = z.infer; /** Enforce toggle (mirror-writes `Organization.policy.ssoEnforced`). */ export const SsoEnforceSchema = z.object({ enforce: z.boolean() }); /** * Asserts a provider `config` Json carries no plaintext secrets (no PEM * block, no `client_secret`-shaped value). Called at save time so a future * refactor cannot accidentally regress the at-rest encryption invariant. */ export function assertNoPlaintextSecrets(config: unknown): void { const s = JSON.stringify(config); if (/-----BEGIN [A-Z ]+-----/.test(s)) { throw new Error("SsoProvider.config must not contain a plaintext certificate"); } if (/"client_secret"\s*:\s*"[^"]{8,}/.test(s)) { throw new Error("SsoProvider.config must not contain a plaintext client secret"); } }