SSO — SAML 2.0 & OIDC (Phase 14)
TASK-0131. Enterprise single sign-on: each organization may configure ONE provider — SAML 2.0 (SP mode) or OIDC (authorization code + PKCE). A user who signs in through the IdP is provisioned just-in-time (or matched to an existing account by verified email) and lands in a normal rotating session with
authMethod: "sso". Conventions (error model, auth, IDs) are defined in authentication.md.
There are two surfaces:
-
Protocol routes — served by the API outside
/api/v1, because the IdP (or the user's browser mid-handshake) talks to them directly:Route Method Who calls it GET /saml/metadata/:orgSlugGET The IdP (federation) / admin tooling POST /saml/acsPOST (form) The IdP (browser form-POST of SAMLResponse+RelayState)GET /sso/start/:orgSlugGET The user's browser (public entry; dispatches per provider type) GET /sso/oidc/:orgId/authorizeGET The user's browser (→ 302 to the IdP) GET /sso/oidc/:orgId/callbackGET The IdP's redirect target (code + state) GET /api/v1/auth/sso/options?email=…GET The web login page (anonymous-safe discovery) -
Admin routes —
/api/v1, Bearer-token, org owner (the org-admin role gate lands with TASK-0133 and tightens these):Route Method Purpose GET /api/v1/orgs/:orgId/ssoGET Current provider (masked: config only — secrets never return) POST /api/v1/orgs/:orgId/ssoPOST Create provider (+ paste the SAML cert PEM / OIDC client secret) PATCH /api/v1/orgs/:orgId/ssoPATCH Update config; rotate cert (current → previous grace) / secret DELETE /api/v1/orgs/:orgId/ssoDELETE Remove provider + encrypted secrets (identities are kept) PATCH /api/v1/orgs/:orgId/sso/enforcePATCH `{ "enforce": true POST /api/v1/orgs/:orgId/sso/testPOST Probe the IdP (SAML metadata / OIDC discovery + JWKS)
The protocol routes are mounted by main.ts outside the global api/v1
prefix and whitelisted by AuthGuard — the assertion/ID-token signature IS
the authentication there; there is no user session yet.
Origins: SSO_ORIGIN vs WEB_ORIGIN
The protocol routes are BROWSER bindings, so their URLs must name the origin that actually serves them:
- Protocol URLs (SP entityID, ACS, metadata, OIDC
redirect_uri/authorize/callback,/sso/start) resolve toSSO_ORIGIN || WEB_ORIGIN. - Landing URLs (the
/login?error=…redirect on failure,/w/defaulton success) always resolve toWEB_ORIGIN.
Single-origin deployments (the reference nginx fronts web + api under one
host) need nothing. Two-origin deployments (web :3000, api :4000 —
the dev and E2E layout) must set SSO_ORIGIN to the API origin, or the
IdP's POST to the ACS 404s on the web origin. See
deployment guide §10.
SAML 2.0 (service provider)
Metadata
GET /saml/metadata/acme → 200 (application/xml)
<?xml version="1.0"?>
<EntityDescriptor entityID="https://api.example.com/saml/metadata/acme">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" …>
<AssertionConsumerService index="1" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://api.example.com/saml/acs"/>
…
entityID = <SSO_ORIGIN||WEB_ORIGIN>/saml/metadata/<orgSlug>; this string
is simultaneously the Audience every assertion must carry.
Start (SP-initiated)
GET /sso/start/acme → 302 (dispatches to the org's provider type)
GET /saml/start/acme → 302
Location: https://idp.example.com/sso?SAMLRequest=<base64 deflate AuthnRequest>
Assertion Consumer Service
POST /saml/acs (application/x-www-form-urlencoded)
SAMLResponse=<base64 AuthnResponse>&RelayState=<optional relative path>
Validation (all of these must pass, in order):
- Well-formed XML;
<!DOCTYPE/ external entities forbidden. - Exactly one
Assertion, signed (enveloped, exc-C14N, RSA-SHA256). - Certificate binding: the
KeyInfo X509Certificatemust match the org's current or previous (grace-window) configured cert — checked BEFORE the signature, so a foreign key can't be presented at all. Conditions.NotOnOrAfter(±60 s clock skew).Audienceincludes the SP entityID.Destination=== our ACS URL.Issuer(only if pinned viaconfig.idpEntityId).Status= Success.- NameID present (format from
config.nameIdAttribute, defaultnameId).
Then replay protection: the Response ID is single-use for 10 minutes
(Redis SET NX EX); a second use of the same ID → replayed.
Result:
- Failure →
302 <WEB_ORIGIN>/login?error=sso_<reason>&org=<slug>(reasons:malformed_xml,doctype_forbidden,missing_assertion,missing_signature,bad_signature,cert_mismatch,expired,audience_mismatch,destination_mismatch,issuer_mismatch,status_not_success,missing_nameid,replayed,unknown_org,no_sso_provider,sso_misconfigured, …) — and ansso.login_failed {reason}audit row. - Success → the refresh cookie is set (same rotating session as a password
login) and
302 <WEB_ORIGIN><RelayState|/w/default>. RelayState is open-redirect safe (relative paths only).
Admin config shape (SAML)
POST /api/v1/orgs/{id}/sso
{
"type": "SAML",
"name": "Acme Okta",
"certificate": "-----BEGIN CERTIFICATE-----\n…", // AES-256-GCM encrypted at rest
// "previousCertificate": "…", // optional explicit grace cert
"config": {
"entityId": "https://api.example.com/saml/metadata/acme",
"ssoUrl": "https://idp.example.com/sso",
"acsUrl": "https://api.example.com/saml/acs", // defaults to SSO_ORIGIN
"nameIdAttribute": "nameId", // default
"emailAttribute": "mail", // default
"metadataUrl": "https://idp.example.com/metadata", // optional; test-connection target
"idpEntityId": "https://idp.example.com" // optional; pins the Issuer
}
}
→ 201 { "id", "name", "type", "enforce", "config", "certificateConfigured": true, … }
PATCH rotates: sending a new certificate moves current → previous
(the validator accepts either, so an IdP key rollover has a grace window).
The cert is never returned — reads expose certificateConfigured only
(reveal-once).
Test connection
POST /api/v1/orgs/{id}/sso/test → 201 { "ok": true, "details": { … } }
SAML: fetches config.metadataUrl (if set) and checks for a usable IdP
descriptor (entityID + HTTP-Redirect SSO service + X509Certificate);
without a metadata URL it validates that the stored certificate parses and
entityId/ssoUrl are non-empty. OIDC: fetches
<issuer>/.well-known/openid-configuration, then the JWKS, and requires at
least one parseable RSA key.
OIDC (authorization code + PKCE)
GET /sso/oidc/{orgId}/authorize → 302
Location: https://idp.example.com/authorize?response_type=code
&client_id=cli_…&redirect_uri=https://api.example.com/sso/oidc/{orgId}/callback
&scope=openid%20email%20profile&state=<redis-bound>&nonce=<128-bit>
&code_challenge=<S256>&code_challenge_method=S256
state + nonce + the PKCE verifier live in Redis
(sso:oidc:state:<state>, TTL 10 min). The callback exchanges the code
(code_verifier + the org's encrypted client_secret) and validates the
id_token against the JWKS (cached 1 h; jwksUri or
<issuer>/.well-known/jwks.json):
- Header
algmust be RS256 with akidpresent in the JWKS (alg: none→unsupported_alg). - Signature valid.
iss=== the configured issuer;audincludes the client id (string or array);expvalid;nbfwithin 60 s skew;noncematches.- Claims:
sub(required),email,name(viaresolveClaim— flat or dotted paths).
Failures redirect to <WEB_ORIGIN>/login?error=sso_oidc_<reason> (no
&org= param — OIDC callbacks are per-org-id); the IdP access_token is
discarded, never stored. Success provisions exactly like SAML and lands on
/w/default.
Admin config shape (OIDC): config: { issuer, clientId, authorizationEndpoint, tokenEndpoint, jwksUri?, redirectUri, scope? } + clientSecret on
create/rotate (reveal-once, encrypted at rest).
Provisioning (shared by both protocols)
SsoIdentityhit (provider +externalId) → existing user.- Else verified-email match → link the identity to that user.
- Else JIT create (unless
policy.ssoJitProvisioning === false): new user, no password hash,emailVerification: VERIFIED. - Org membership:
OrganizationMemberatpolicy.ssoDefaultRole, capped atMEMBER(owner/admin must be granted by an org admin), and never demotes an existing role. The user is auto-joined to every non-personal workspace of the SSO org (workspace-scoped memberships; other orgs are untouched). - Session:
issueTokens(user, { authMethod: "sso" })— the same rotating refresh-cookie machinery as a password login.
Audit: sso.login.saml / sso.login.oidc (ip + user-agent) on success;
sso.login_failed { reason } on any rejection.
Enforce mode
PATCH /orgs/:id/sso/enforce { enforce: true } writes
SsoProvider.enforce and mirrors it into Organization.policy.ssoEnforced
(DELETE clears the mirror). While an org enforces SSO:
- Password login for any member of that org is refused before bcrypt
with
403 { reason: "sso_required", orgs: [slug, …] }(the SSO path stays open — this is also the recovery path, below). - Users with no enforced-org membership can still password-login.
- Lockout recovery (runbook): an owner whose IdP is unreachable signs in
via the SSO once the IdP is back (
/sso/start/<slug>), or an operator turns enforce off with a still-valid pre-enforcement session (PATCH …/sso/enforce { enforce: false }). There is deliberately no elevated "service account" credential — the enforcement state is data (SsoProvider.enforce+ the policy mirror), recoverable without a second trust path. Covered by the E2E enforce test (tests/e2e/specs/sso.spec.ts, "enforce mode…").
Discovery (login page)
GET /api/v1/auth/sso/options?email=jane@acme.com → 200
{ "orgs": [ { "slug": "acme", "ssoAvailable": true,
"startUrl": "https://api.example.com/sso/start/acme" } ] }
Anonymous-safe / minimal-info: it reveals only that SSO exists for orgs
the email already belongs to. A brand-new JIT email has no membership
yet, so discovery returns [] for it (anti-enumeration) — the org's SSO
link (/sso/start/<slug>) is the entry path for first-time users (documented
in the task file; see also the login page UI doc).