1/** 2 * TASK-0131 — SSO provider config contracts (zod, single source of truth). 3 * 4 * `SsoProvider.config` (Prisma `SsoProvider.config Json`) holds the provider 5 * settings for ONE protocol; the encrypted secrets (SAML X.509 certificate 6 * PEM, OIDC client secret) live in `Organization.ssoConfig` as 7 * AES-256-GCM ciphertext (TASK-0130 envelope via `packages/shared/crypto`), 8 * NEVER in `config` plaintext — `assertNoPlaintextSecrets` guards that 9 * invariant at save time (the schema test asserts no PEM in `config`). 10 * 11 * The admin controller and the SP/OIDC services both validate through these 12 * schemas — the admin UI mirrors them client-side for live validation. 13 */ 14import { X509Certificate } from "node:crypto"; 15import { z } from "zod"; 16 17const CERT_SIZE_CAP = 16 * 1024; // certs ≤ 16 KB in ssoConfig (no column — documented) 18 19/** A PEM block that parses as a valid X.509 certificate (≤ 16 KB). */ 20export const x509PemSchema = z 21 .string() 22 .refine((s) => { 23 try { 24 // node:crypto's X509Certificate THROWS on any malformed cert — the 25 // strictest parser available without a third-party dep (the SP then 26 // uses the SAME cert for signature verification via xml-crypto). 27 new X509Certificate(s); 28 return true; 29 } catch { 30 return false; 31 } 32 }, "certificate PEM does not parse as an X.509 certificate") 33 .refine((s) => s.length <= CERT_SIZE_CAP, `certificate exceeds the ${CERT_SIZE_CAP} B ssoConfig cap`); 34 35/** SAML SP configuration (`SsoProvider.config` when `type === "SAML"`). */ 36export const SamlConfigSchema = z.object({ 37 /** The SP's entity ID (defaults to `<origin>/saml/metadata/<orgSlug>`). */ 38 entityId: z.string().min(1).max(1024), 39 /** The IdP single-sign-on URL (the browser is redirected here to start). */ 40 ssoUrl: z.string().url().max(2048), 41 /** 42 * Where the IdP POSTs the `AuthnResponse` (our ACS). Defaults to 43 * `<origin>/saml/acs`. 44 */ 45 acsUrl: z.string().url().max(2048), 46 /** 47 * Which slot in `Organization.ssoConfig` holds the IdP signing cert: 48 * "current" or "previous" (cert rotation grace — the ACS accepts 49 * signatures by EITHER; the ciphertexts are `certificateEncrypted` / 50 * `previousCertificateEncrypted` on the org row). 51 */ 52 x509CertificateRef: z.enum(["current", "previous"]).default("current"), 53 /** Which assertion attribute maps to the SAML `NameID` external id. */ 54 nameIdAttribute: z.string().min(1).max(128).default("nameId"), 55 /** Which assertion attribute carries the user's email (defaults to `mail`). */ 56 emailAttribute: z.string().min(1).max(128).default("mail"), 57 /** Assertion emails are treated as verified (spec: always true). */ 58 emailIsVerified: z.literal(true).default(true), 59 /** Extra claim→field map (forward-compatible; unused in v1 beyond email/name). */ 60 attributeMapping: z.record(z.string(), z.string()).optional(), 61 /** Optional IdP metadata URL (the admin "test connection" fetches it). */ 62 metadataUrl: z.string().url().max(2048).optional(), 63 /** 64 * Optional IdP entity id. When set, the ACS also requires the assertion 65 * `Issuer` to match it (defense in depth on top of the signature + cert 66 * binding). Absent = issuer is not pinned (signature is the trust anchor). 67 */ 68 idpEntityId: z.string().min(1).max(1024).optional() 69}); 70export type SamlConfig = z.infer<typeof SamlConfigSchema>; 71 72/** OIDC client configuration (`SsoProvider.config` when `type === "OIDC"`). */ 73export const OidcConfigSchema = z.object({ 74 issuer: z.string().url().max(2048), 75 clientId: z.string().min(1).max(256), 76 /** 77 * The client secret. Reveal-once at config save: the admin UI POSTs the 78 * plaintext ONCE, the API encrypts it (AES-GCM) into 79 * `Organization.ssoConfig.clientSecretEncrypted` and never returns it 80 * again (the read endpoint returns `clientSecretConfigured: boolean`). 81 */ 82 clientSecretEncrypted: z.literal(true).default(true), 83 authorizationEndpoint: z.string().url().max(2048), 84 tokenEndpoint: z.string().url().max(2048), 85 /** JWKS URI (defaults to the issuer's `/.well-known/jwks.json`). */ 86 jwksUri: z.string().url().max(2048).optional(), 87 /** Our callback URL (defaults to `<origin>/sso/oidc/:orgId/callback`). */ 88 redirectUri: z.string().url().max(2048), 89 scope: z.string().default("openid email profile"), 90 /** Claim→field mapping (nested paths allowed, e.g. `profile.email`). */ 91 claimsMapping: z 92 .object({ 93 sub: z.string().default("sub"), 94 email: z.string().default("email"), 95 name: z.string().default("name"), 96 emailVerified: z.string().default("email_verified") 97 }) 98 .optional(), 99 attributeMapping: z.record(z.string(), z.string()).optional() 100}); 101export type OidcConfig = z.infer<typeof OidcConfigSchema>; 102 103/** Admin create input (the plaintext secret/cert arrives only on create/rotate). */ 104export const SsoProviderAdminInputSchema = z.discriminatedUnion("type", [ 105 z.object({ 106 type: z.literal("SAML"), 107 name: z.string().min(1).max(128), 108 config: SamlConfigSchema, 109 /** The PEM itself — stored encrypted on the org row, never in config. */ 110 certificate: x509PemSchema, 111 /** Optional previous certificate for rotation grace (also encrypted). */ 112 previousCertificate: x509PemSchema.optional() 113 }), 114 z.object({ 115 type: z.literal("OIDC"), 116 name: z.string().min(1).max(128), 117 config: OidcConfigSchema, 118 /** The plaintext client secret (reveal-once). */ 119 clientSecret: z.string().min(8).max(1024) 120 }) 121]); 122export type SsoProviderAdminInput = z.infer<typeof SsoProviderAdminInputSchema>; 123 124/** 125 * Admin update input. `config` is a PARTIAL merge (zod strip-by-default on 126 * the parsed object, then shallow-merged server-side over the stored config). 127 * `certificate` / `clientSecret` are OPTIONAL rotation inputs (reveal-once 128 * again); a SAML cert here ROTATES (current → previous). 129 */ 130export const SsoProviderUpdateSchema = z 131 .object({ 132 name: z.string().min(1).max(128).optional(), 133 config: z.record(z.string(), z.unknown()).optional(), 134 certificate: x509PemSchema.optional(), 135 previousCertificate: x509PemSchema.optional(), 136 clientSecret: z.string().min(8).max(1024).optional() 137 }) 138 .refine((b) => b.name !== undefined || b.config !== undefined || b.certificate !== undefined || b.clientSecret !== undefined, { 139 message: "no fields to update" 140 }); 141export type SsoProviderUpdate = z.infer<typeof SsoProviderUpdateSchema>; 142 143/** Enforce toggle (mirror-writes `Organization.policy.ssoEnforced`). */ 144export const SsoEnforceSchema = z.object({ enforce: z.boolean() }); 145 146/** 147 * Asserts a provider `config` Json carries no plaintext secrets (no PEM 148 * block, no `client_secret`-shaped value). Called at save time so a future 149 * refactor cannot accidentally regress the at-rest encryption invariant. 150 */ 151export function assertNoPlaintextSecrets(config: unknown): void { 152 const s = JSON.stringify(config); 153 if (/-----BEGIN [A-Z ]+-----/.test(s)) { 154 throw new Error("SsoProvider.config must not contain a plaintext certificate"); 155 } 156 if (/"client_secret"\s*:\s*"[^"]{8,}/.test(s)) { 157 throw new Error("SsoProvider.config must not contain a plaintext client secret"); 158 } 159} 160