AUTH4D-148 Enterprise single sign-on (OIDC and SAML 2.0 connections)

A project can connect its customers’ own identity providers (IdPs) and let their staff sign in on the hosted login with their organisation’s account. A connection is either an OpenID Connect relying party, reusing the AUTH4D-146 (repository file docs/api-spec/auth4d-146.md) code exchange and ID token checks, or a SAML 2.0 service provider (Web Browser SSO profile). Users reach a connection by the domain of the work email address they type; the address itself is never sent to the API, only its domain.

Connections are per project (tenant). Every query is scoped by tenant, Durable Object keys include the tenant, and a connection of one project can never be used by another. Device sign-in, guest upgrades and native games do not offer single sign-on.

Data model (migration 0026_enterprise_sso.sql)

  • sso_connections: (tenant_id, connection_id) primary key; protocol oidc or saml; name; enabled. OIDC: issuer, authorization, token and JWKS endpoints, client ID, token endpoint authentication method and the client secret as an AUTH4D-146 credential envelope (purpose sso:<connectionId>:client_secret). SAML: IdP entity ID, single sign-on URL, the IdP signing certificate (public, base64 DER) with its SHA-256 fingerprint and expiry. A CHECK keeps each protocol’s columns complete and the other protocol’s empty.
  • sso_connection_domains: (tenant_id, domain) primary key and a (tenant_id, connection_id) foreign key with ON DELETE CASCADE. Within a project a domain routes to at most one connection.
  • identities is rebuilt to allow provider sso. The subject is <connectionId>:<subject>, where the subject is the OIDC sub or the SAML NameID (at most 200 characters), so the same subject at two connections is two identities. Accounts are never merged by email.

Connection IDs are ssc_ and 32 lower-case hex characters.

Management API

Routes live under /management/tenants/{tenantId}, with the AUTH4D-157 access rules: a management token (401), the path (404), the per-actor budget (429), live project membership (403) and, for writes, an active project (409). Every member can read; owners and admins can change. Refused changes are audited as denied. Responses are no-store.

  • GET /management/tenants/{tenantId}/sso-connections returns { connections: SsoConnection[], canManage } (at most 10 connections).
  • POST /management/tenants/{tenantId}/sso-connections creates one connection (201).
  • GET /management/tenants/{tenantId}/sso-connections/{connectionId} returns { connection }.
  • PATCH /management/tenants/{tenantId}/sso-connections/{connectionId} changes any subset (200).
  • DELETE /management/tenants/{tenantId}/sso-connections/{connectionId} with body {} (204).

Create body (CreateSsoConnectionRequestSchema):

{
  "protocol": "saml",
  "name": "Example Corp",
  "enabled": true,
  "domains": ["example.com"],
  "saml": {
    "idpEntityId": "https://idp.example.com/saml",
    "ssoUrl": "https://idp.example.com/saml/sso",
    "certificate": "<PEM X.509 certificate>"
  }
}

or, for OpenID Connect, "protocol": "oidc" with "oidc": { "issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri", "clientId", "clientSecret", "tokenAuthMethod" }. tokenAuthMethod is client_secret_basic (default; id and secret form-encoded, then HTTP Basic, as RFC 6749 §2.3.1 says) or client_secret_post.

PATCH takes name, enabled, domains (replaces the list), and oidc or saml with any of their fields; it must match the connection’s protocol. A new OIDC clientId needs clientSecret again. A new certificate replaces the old one at once.

Rules the API applies on top of the shapes:

  • Every provider URL (OIDC authorization, token and JWKS endpoints, SAML SSO URL) and the OIDC issuer follow the AUTH4D-157 webhook URL rules: https:// on the default port, a public DNS name (no IP literals, localhost, single-label or special-use names), no credentials or fragment, never an auth4.dev host. A SAML SSO URL may not already carry SAMLRequest or RelayState. Errors: 400 invalid_provider_url with the field and a reason code.
  • The certificate must be an X.509 certificate (PEM, or its base64 body) with an RSA key of at least 2048 bits: 400 invalid_certificate. Only its key is used; its validity dates are shown, not enforced, because IdPs commonly pin self-signed certificates.
  • Domains are lower-cased DNS names, up to 20 per connection. A domain already routed to another connection of the project: 409 domain_in_use. Domain routing does not prove who owns a domain.
  • At most 10 connections per project: 409 sso_connection_limit.
  • Storing a client secret needs the deployment’s encryption key ring: 503 not_ready otherwise.

SsoConnection (SsoConnectionSchema) carries the settings above, never the client secret, plus the values to enter at the IdP: for OIDC oidc.redirectUri ({AUTH_ORIGIN}/t/{tenantId}/sso/oidc/{connectionId}/callback); for SAML saml.spEntityId and saml.metadataUrl (both {AUTH_ORIGIN}/t/{tenantId}/sso/saml/{connectionId}/metadata) and saml.acsUrl ({AUTH_ORIGIN}/t/{tenantId}/sso/saml/{connectionId}/acs), and the certificate’s certificateFingerprint and certificateNotAfter.

Audit events (tenant audit_events; metadata never holds secrets, certificates, subjects or addresses):

Event Metadata
management.sso_connection.created protocol, status, domain_count, actor_role
management.sso_connection.updated protocol, status, changed (field names), domain_count
management.sso_connection.deleted protocol, actor_role
authentication.sso.succeeded protocol; actor is the signed-in user, target the connection
authentication.sso.denied protocol, reason (a fixed code such as audience, assertion_replayed, signature_digest_mismatch, invalid_id_token)

Deleting a connection keeps the accounts of users who signed in through it; they can no longer use it. Adding the connection again creates new identities, because the connection ID is part of the subject.

Hosted login

GET /t/{tenantId}/login/context reports login_methods.sso: true when the project has an enabled connection with at least one domain, for browser and server applications outside guest-linking flows. The hosted login then offers “Continue with single sign-on”, asks for a work email address and navigates to the start route with only its domain. Errors return to /login with error sso_not_configured, access_denied or temporarily_unavailable; the page shows fixed wording.

Sign-in routes

  • GET /t/{tenantId}/sso/start?client_id={clientId}&browser_flow_id={flowId}&domain={domain}, or &connection_id={connectionId} instead of domain (exactly one of the two).

    Requires the browser flow cookie of an openid authorization flow of this client, as the AUTH4D-146 start route does; guest-linking flows and native clients are refused (403 or 400). An unknown, malformed or unrouted domain, or a disabled connection, or a connection without domains, returns to the hosted login with error=sso_not_configured. Otherwise the per-request secrets are stored as one-time Durable Object state keyed by the hash of a new 32-byte state value, for at most the flow’s remaining ten minutes, and the browser is redirected (302, no-store, Referrer-Policy: no-referrer):

    • OIDC: to the connection’s authorization endpoint with response_type=code, client_id, redirect_uri, scope=openid email profile, state, nonce and an S256 code_challenge. The endpoint’s own query parameters are kept; one that already sets an OAuth parameter is refused.
    • SAML: to the SSO URL with an unsigned AuthnRequest (HTTP-Redirect binding: DEFLATE, base64, SAMLRequest) and RelayState set to the state value. The request has a fresh ID (_ and 40 hex characters), Destination, AssertionConsumerServiceURL (HTTP-POST), the SP entity ID as Issuer, and NameIDPolicy unspecified with AllowCreate="true".
  • GET /t/{tenantId}/sso/oidc/{connectionId}/callback

    Takes the one-time state (so every callback URL works once) and checks the tenant, connection, age and flow cookie (403 otherwise). A provider error returns to the hosted login. The code is exchanged with the stored client secret; the ID token must verify against the connection’s JWKS with RS256 or ES256, with iss exactly the configured issuer, aud the client ID (and azp the client ID when there are several audiences), exp, iat, sub and the nonce. Provider responses, codes and tokens never reach errors or logs.

  • POST /t/{tenantId}/sso/saml/{connectionId}/acs

    The IdP’s HTTP-POST binding: a cross-site form POST (SAMLResponse, RelayState) from any origin, without the SameSite=Lax flow cookie and without CORS headers. A missing RelayState (IdP-initiated sign-in) or one that matches no live state for this tenant and connection is refused (400). The Response is validated here (below); the assertion ID enters a Durable Object replay cache until the assertion’s NotOnOrAfter plus two minutes, and a second use is refused. Only the outcome (the subject and email, or an error code) is held for two minutes, and the browser is sent (303) to the GET form. Nothing is created yet.

  • GET /t/{tenantId}/sso/saml/{connectionId}/acs?state={state}&resume=1

    Takes the held outcome (single use) and checks the flow cookie (403 otherwise) before any user is created or proof issued.

  • GET /t/{tenantId}/sso/saml/{connectionId}/metadata

    Public SP metadata (application/samlmetadata+xml): EntityDescriptor with the SP entity ID, AuthnRequestsSigned="false", WantAssertionsSigned="true", NameID formats persistent and emailAddress, and the ACS (HTTP-POST). Served for disabled connections too, so the IdP can be set up first; 404 for unknown connections and OIDC connections.

On success both protocols find or create the user (provider sso, subject <connectionId>:<subject>; a disabled user is refused), store a five-minute one-time identity proof and return to /login with proof_id and proof_purpose=sso_login. POST /oauth/authorize/continue accepts sso_login like the other social proofs, and the Flows post_login method is sso. The email is stored lower-cased and counts as verified only when its domain is one of the connection’s domains and (for OIDC) the provider did not send email_verified: false.

SAML Response validation

The parser (packages/provider-saml/src/xml.ts) refuses document types (so no DTD, internal or external entity, or entity expansion), entity references other than the five predefined ones, processing instructions, comments anywhere, undeclared prefixes, duplicate attributes and anything after the root, and caps size (128 KB of text, 60 KB of base64), depth (24), elements (2,000) and attributes (32 per element). Line endings and attribute values are normalised as XML 1.0 requires.

A Response is accepted only when:

  1. the root is samlp:Response version 2.0 and every ID attribute in the document is unique;
  2. there is no EncryptedAssertion, exactly one saml:Assertion, and it is a direct child of the Response; any ds:Signature sits directly on the Response or on that Assertion;
  3. InResponseTo is the AuthnRequest’s ID, Destination (when present) is the ACS URL, the Response Issuer (when present) is the IdP entity ID, and the status is Success (otherwise access_denied);
  4. the Assertion, or the whole Response, carries an enveloped signature, and every signature present verifies: a single Reference whose URI is # plus the signed element’s own ID, transforms exactly enveloped-signature then Exclusive C14N (an InclusiveNamespaces PrefixList is honoured), Exclusive C14N for SignedInfo, rsa-sha256 or rsa-sha512 with a SHA-256 or SHA-512 digest (SHA-1 is refused), checked against the connection’s pinned certificate only (KeyInfo is ignored);
  5. the Assertion is version 2.0, was issued no more than ten minutes ago (and not in the future), and its Issuer is the IdP entity ID;
  6. the Subject has a NameID (not EncryptedID or BaseID), not transient, of 1 to 200 characters, and a bearer SubjectConfirmationData with Recipient the ACS URL, InResponseTo the request ID and a future NotOnOrAfter;
  7. Conditions are present, inside NotBefore/NotOnOrAfter, every AudienceRestriction names the SP entity ID, and no condition other than AudienceRestriction, OneTimeUse and ProxyRestriction appears;
  8. there is an AuthnStatement, and no SessionNotOnOrAfter has passed.

All time checks allow two minutes of clock difference. Values are read from the same element objects that were verified. The email comes from an emailAddress NameID or the first value of an attribute named email, mail, emailaddress, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress or urn:oid:0.9.2342.19200300.100.1.3.

Not supported: IdP-initiated sign-in, encrypted assertions, signed AuthnRequests, the artifact and POST bindings for requests, single logout, and attribute mapping beyond the email.

Console

The Sign-in methods screen has an “Enterprise single sign-on” section: a list of connections with the values to enter at the IdP (copy fields), the certificate fingerprint and expiry (with a notice within 30 days of expiry), and add, edit, enable/disable and delete for owners and admins. The BFF proxies /api/management/tenants/{tenantId}/sso-connections and /api/management/tenants/{tenantId}/sso-connections/{connectionId} only.

Source: docs/api-spec/auth4d-148.md