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;protocoloidcorsaml;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 (purposesso:<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 withON DELETE CASCADE. Within a project a domain routes to at most one connection.identitiesis rebuilt to allow providersso. The subject is<connectionId>:<subject>, where the subject is the OIDCsubor 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-connectionsreturns{ connections: SsoConnection[], canManage }(at most 10 connections).POST /management/tenants/{tenantId}/sso-connectionscreates 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 carrySAMLRequestorRelayState. Errors: 400invalid_provider_urlwith 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_readyotherwise.
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 ofdomain(exactly one of the two).Requires the browser flow cookie of an
openidauthorization 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 witherror=sso_not_configured. Otherwise the per-request secrets are stored as one-time Durable Object state keyed by the hash of a new 32-bytestatevalue, 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,nonceand an S256code_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) andRelayStateset to the state value. The request has a freshID(_and 40 hex characters),Destination,AssertionConsumerServiceURL(HTTP-POST), the SP entity ID asIssuer, andNameIDPolicyunspecifiedwithAllowCreate="true".
- OIDC: to the connection’s authorization endpoint with
-
GET /t/{tenantId}/sso/oidc/{connectionId}/callbackTakes the one-time state (so every callback URL works once) and checks the tenant, connection, age and flow cookie (403 otherwise). A provider
errorreturns to the hosted login. The code is exchanged with the stored client secret; the ID token must verify against the connection’s JWKS withRS256orES256, withissexactly the configured issuer,audthe client ID (andazpthe client ID when there are several audiences),exp,iat,suband thenonce. Provider responses, codes and tokens never reach errors or logs. -
POST /t/{tenantId}/sso/saml/{connectionId}/acsThe 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 missingRelayState(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’sNotOnOrAfterplus 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=1Takes 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}/metadataPublic SP metadata (
application/samlmetadata+xml):EntityDescriptorwith the SP entity ID,AuthnRequestsSigned="false",WantAssertionsSigned="true", NameID formatspersistentandemailAddress, 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:
- the root is
samlp:Responseversion 2.0 and everyIDattribute in the document is unique; - there is no
EncryptedAssertion, exactly onesaml:Assertion, and it is a direct child of the Response; anyds:Signaturesits directly on the Response or on that Assertion; InResponseTois the AuthnRequest’s ID,Destination(when present) is the ACS URL, the ResponseIssuer(when present) is the IdP entity ID, and the status isSuccess(otherwiseaccess_denied);- the Assertion, or the whole Response, carries an enveloped signature, and every signature present
verifies: a single
ReferencewhoseURIis#plus the signed element’s ownID, transforms exactly enveloped-signature then Exclusive C14N (anInclusiveNamespacesPrefixListis honoured), Exclusive C14N forSignedInfo,rsa-sha256orrsa-sha512with a SHA-256 or SHA-512 digest (SHA-1 is refused), checked against the connection’s pinned certificate only (KeyInfois ignored); - the Assertion is version 2.0, was issued no more than ten minutes ago (and not in the future),
and its
Issueris the IdP entity ID; - the Subject has a
NameID(notEncryptedIDorBaseID), nottransient, of 1 to 200 characters, and abearerSubjectConfirmationDatawithRecipientthe ACS URL,InResponseTothe request ID and a futureNotOnOrAfter; Conditionsare present, insideNotBefore/NotOnOrAfter, everyAudienceRestrictionnames the SP entity ID, and no condition other thanAudienceRestriction,OneTimeUseandProxyRestrictionappears;- there is an
AuthnStatement, and noSessionNotOnOrAfterhas 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