SAML 2.0#
Requirements: APP-018, FED-014, SAML-001..SAML-010, TEST-051 (docs/plan.md
§7, §11, §18.3, §26.2, §29). Code: internal/saml (XML parsing, bindings,
signatures, encryption, metadata, signing keys), internal/saml/idp
(identity provider), internal/federation/saml.go and saml_response.go
(broker), internal/httpapi/saml.go (routes). Tests:
TestSAMLIdentityProvider and TestSAMLKeyRollover
(tests/e2e/saml_test.go), TestSAMLBroker
(tests/e2e/saml_broker_test.go), and the unit tests in
internal/saml/saml_test.go. OpenAPI tag SAML. Migration
0060_saml.sql.
ProudAuth implements SAML 2.0 in two separate roles (SAML-001), each with its own metadata and its own trusted counterparties:
| Role | What it does | Managed at | Counterparty trust |
|---|---|---|---|
| Identity provider | The realm signs users in to registered SAML applications (service providers) | /api/v1/realms/{realm}/saml/applications | Per application: entity ID, exactly registered assertion consumer service (ACS) URLs, SP signing certificates |
| Broker (service provider) | Users sign in through an enterprise SAML identity provider, a SAML connection (a federation connection of kind saml) | /api/v1/realms/{realm}/saml/connections | Per connection: the IdP's entity ID and its trusted signing certificates |
Status. Both roles are tested against the test SP and test IdP in this
repository (written on etree, goxmldsig and the Go standard library,
independently of internal/saml) and against one third-party product,
Keycloak 26.8.0, as service provider and as identity provider
(scripts/interop-keycloak.sh, the profiles in
interoperability.md).
No other product (Shibboleth, SimpleSAMLphp, Okta, Microsoft Entra ID,
ADFS, ...) has been tested, and no SAML conformance suite has been run
(interoperability.md).
Unsupported features are listed in Not supported; none of
them is advertised in metadata.
Enabling SAML#
Both roles are off by default. The saml configuration section
(configuration.md):
| Key | Default | Effect |
|---|---|---|
saml.enabled | false | Turns on both roles and their APIs. The broker also needs federation.enabled (default true) |
saml.max_message_bytes | 262144 | Largest decoded (and, for HTTP-Redirect, inflated) message and largest uploaded metadata document; 16384–4194304 |
saml.max_clock_skew | 5m | Upper bound for the clock_skew_seconds of applications and connections; 0–30m |
saml.key_prepublish | 48h | How long the next signing key must be published in metadata before it can be activated without force |
saml.retired_key_publication | 168h | How long a replaced signing key stays published in metadata |
While saml.enabled is false, every SAML route of an existing realm
answers 404 feature_disabled and nothing SAML-related is advertised. OpenID
Connect discovery never mentions SAML.
HTTP-POST messages (SSO, single logout and the broker's ACS) are also
bounded by limits.max_body_bytes (256 KiB by default, roughly 180 KiB of
XML once the browser's form encoding and base64 are removed); a larger body
gets 413 payload_too_large. Raise both limits together if an identity
provider sends unusually large responses.
Endpoints#
I is the realm's issuer, {server.public_origin}/realms/{realm}; R is
/api/v1/realms/{realm}.
| Route | Purpose | Trust class |
|---|---|---|
GET I/saml/metadata | Identity provider metadata | Public metadata |
GET I/saml/sso | SSO, HTTP-Redirect binding | Public protocol initiation |
POST I/saml/sso | SSO, HTTP-POST binding (continues with a 303 to I/saml/sso/continue?request=...) | Public protocol initiation |
GET I/saml/sso/continue | Starts a recorded request (?request=) or completes it (?code&state, ?error&state) | Transaction-bound (browser binding cookie) |
GET I/saml/idp-initiated?sp=<entity ID> | IdP-initiated (unsolicited) SSO, only for applications that opted in | Public protocol initiation |
GET, POST I/saml/slo | SP-initiated single logout | Public protocol initiation (signed request required) |
GET I/saml/sp/{connection} | The broker's SP metadata for one connection (also its default SP entity ID) | Public metadata |
POST I/saml/acs/{connection} | The broker's assertion consumer service (HTTP-POST) | Transaction-bound |
GET, POST R/saml/applications; GET, PUT, DELETE R/saml/applications/{id} | SAML applications (realm.clients.read, realm.clients.write) | Administration |
GET, POST R/saml/connections; GET, PUT, DELETE R/saml/connections/{id} | SAML connections (realm.identity_providers.read, realm.identity_providers.write) | Administration |
GET R/saml/signing-keys; POST R/saml/signing-keys; POST R/saml/signing-keys/{id}/activate | The realm's SAML signing keys (realm.keys.read, realm.keys.rotate) | Administration |
Administration routes need an administration token from an MFA session,
like every realm administration route. PUT needs If-Match with the
ETag of the last GET (428 without it, 412 when stale); POST
answers 201 with an ETag, DELETE 204.
Cookies: pa_saml binds a SAML sign-in to the browser that started it
(random secret, HttpOnly, always SameSite=Lax, path
/realms/{realm}/saml/, lifetime tokens.transaction_ttl); pa_tx and
pa_sid are the same cookies as for OAuth sign-ins.
Signing keys and rollover#
SAML-002. SAML has its own signing keys, separate from the JWT signing keys
(keys.md): SAML counterparties pin X.509
certificates rather than fetching JWKs, cache metadata for long periods,
need RSA whatever algorithm the realm uses for tokens, and rotate on their
own schedule. One key set per realm serves both roles (identity provider
responses and the broker's AuthnRequests).
- Keys are RSA 3072-bit with a self-signed SHA-256 certificate valid for 5
years. The validity period does not establish trust; the published
metadata does. Private keys are encrypted with the signing-wrap key
(
keys.signing_wrap_keys, or the root-derived key; associated datasaml-signing-key:<id>), like token signing keys. The first key is created on first use. - States:
pending(the next key: published in metadata, never signs) →active→retiring(still published untilpublish_until, the activation time plussaml.retired_key_publication) →retired(by the worker's retention task).
Rollover runbook:
POST R/saml/signing-keyspublishes the next key (409while one is already pending). Auditsaml.signing_key.prepared.- Wait at least
saml.key_prepublish(48 hours by default) so service providers and identity providers refresh the metadata. A counterparty that pins a single certificate by hand must add the new one now. POST R/saml/signing-keys/{id}/activate. Beforesaml.key_prepublishhas passed this answers409, unless the body is{"force": true, "reason": "..."}with a reason of at least 10 characters; a forced activation is audited in categoryemergency(for a compromised key). Auditsaml.signing_key.activated.- The previous key leaves the metadata after
saml.retired_key_publication.
The metadata of both roles always lists the active key, the pending key and
retiring keys still inside their publication window. Retired keys stay in
saml_signing_keys with state retired; they are not deleted.
Identity provider metadata#
GET I/saml/metadata (application/samlmetadata+xml,
Cache-Control: public, max-age=300) is unsigned and lists the realm's
signing certificates, the four NameID formats, and HTTP-Redirect and
HTTP-POST endpoints for SSO and single logout. It always says
WantAuthnRequestsSigned="false": whether requests must be signed is
decided per application (require_signed_requests). Give service providers
this URL or a copy of the document, and refresh their copy during a
key rollover.
Identity provider: SAML applications#
APP-018.
Registering an application#
POST R/saml/applications (realm.clients.write). The body may include
metadata_xml: the service provider's metadata as uploaded text, an
EntityDescriptor with an SPSSODescriptor (aggregates such as
EntitiesDescriptor are refused). Metadata URLs are never fetched.
Metadata fills the fields not given explicitly: entity ID, HTTP-POST ACS
endpoints (other bindings are ignored), logout endpoints, signing
certificates, and require_signed_requests from AuthnRequestsSigned. An
encryption key in the metadata is used only when encrypt_assertions is
true.
POST /api/v1/realms/main/saml/applications
{
"name": "Wiki",
"entity_id": "https://wiki.example.com/saml/metadata",
"assertion_consumer_services": [
{"url": "https://wiki.example.com/saml/acs", "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"}
],
"signing_certificates": ["MIID..."],
"name_id_format": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
"attributes": [
{"name": "mail", "source": "email"},
{"name": "displayName", "source": "name"}
]
}| Member | Rules |
|---|---|
entity_id | Absolute URI (URL or URN), at most 1024 characters, unique in the realm (409) |
assertion_consumer_services | 1 to 10 endpoints, HTTP-POST only (the Web SSO profile forbids HTTP-Redirect for responses; Artifact is not supported). The exact-redirect rules of APP-004 apply: at most 2048 bytes, https (plain http only to localhost or a loopback address, and only when the application's environment is not production), no fragment, userinfo, wildcards, path traversal or encoded separators, and no localhost at all for a production application. Each endpoint is {binding, url, index, is_default}; each index is unique (assigned in order when all are 0); one is the default (the first when none is marked) |
single_logout_services | 0 to 4 endpoints, HTTP-Redirect or HTTP-POST, with an optional response_url; requires a signing certificate |
signing_certificates | PEM or base64 DER; RSA of at least 2048 bits; not expired; at most 4 (for overlap during rollover; duplicates are dropped). They verify AuthnRequests and LogoutRequests |
require_signed_requests | Default true when certificates are registered. AuthnRequestsSigned="true" in uploaded metadata can only turn it on |
name_id_format | The full URN: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent (default), urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress, urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified or urn:oasis:names:tc:SAML:2.0:nameid-format:transient; short names are refused with 422 (NameID formats) |
attributes | At most 50 mappings {name, name_format, friendly_name, source, value} (Attribute mapping) |
sign_response, sign_assertion | Both default to true; at least one must be true |
signature_algorithm | http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 (default), #rsa-sha384 or #rsa-sha512; anything else, including SHA-1, is 422 |
encrypt_assertions, encryption_certificate, data_encryption_algorithm, key_transport_algorithm | Encrypted assertions |
allow_idp_initiated, idp_initiated_relay_state | Default false; the relay state (at most 1024 bytes) only together with the flag (IdP-initiated SSO) |
clock_skew_seconds | Default 120 (0 also means 120); at most saml.max_clock_skew. With saml.max_clock_skew below 2m, every application must set a value explicitly |
assertion_lifetime_seconds | Default 300; 60 to 1800 |
additional_audiences | At most 5 extra Audience values |
authn_context_map | At most 20 entries "<AuthnContextClassRef>": "urn:proudauth:acr:..." that extend or override the built-in mapping (Authentication context) |
| Client members | name, description, project_id, environment (fixed after creation), first_party, consent_mode, consent_terms_version, require_mfa, max_session_age_seconds, allow_remember_me, login_ui_url, organization_id |
The response describes the application with its backing client_ref and
client_id, its state, the parsed certificates, and identity_provider
(entity_id, metadata_url, sso_url, slo_url): the values to configure
at the service provider.
Changes to certificates, attributes, the entity ID and the IdP-initiated
settings are audited (saml.application.created, .updated, .deleted,
category application, with the fingerprints of certificates added and
removed).
The backing client#
Each SAML application is stored in saml_service_providers and backed by
a client record (clients.protocol = "saml"; confidential,
private_key_jwt with no key, grant authorization_code, one redirect URI
I/saml/sso/continue). The backing client is what lets the existing sign-in
machinery apply unchanged: login methods, MFA policy and step-up, consent
and administrative consent pre-authorization, organization policy, SSO
session reuse and max_session_age_seconds, client sessions, the client
lifecycle and audit (client_id).
The backing client is not an OAuth client: /oauth/authorize answers
invalid_client for it, OAuth client authentication (token, introspection,
revocation, PAR, device authorization) always fails for it, and
PUT /clients/{id} answers 409 ("change it through /saml/applications").
GET /clients lists it with "protocol": "saml".
Lifecycle: suspend an application by suspending its client
(/clients/{client}/... lifecycle routes). DELETE R/saml/applications/{id}
deletes the application (its pending requests and logout records with it)
and decommissions the client, revoking its grants. The entity ID can then
be registered again; the persistent NameIDs of the old registration are not
reused.
SP-initiated SSO#
- Receive (
GETorPOST I/saml/sso). Until the ACS is validated, every refusal is a local400problem, never a message to an unregistered URL. The checks, in order, with the problem'sreason:- decoding and parsing: base64, DEFLATE bounded by
saml.max_message_bytes, XML safety →saml_invalid_request; - the root must be
samlp:AuthnRequest, and itsIssuera registered, active application →saml_unknown_service_provider; - signature: for HTTP-Redirect, the detached signature over the raw query
octets; for HTTP-POST, the enveloped signature (verified with
goxmldsig; only the verified element is read). A signature that is present is always checked when the application has certificates, and an invalid one is never ignored; an unsigned request when signatures are required →saml_signature. Only RSA-SHA256, -SHA384 and -SHA512; Version="2.0", a validID, theIssuerof the verified element,IssueInstantno later than now plus the skew and no earlier than 10 minutes plus the skew ago;Destination, when present, equal toI/saml/sso; required on signed HTTP-POST requests;- ACS:
AssertionConsumerServiceURLexactly registered, orAssertionConsumerServiceIndexregistered, or the default; both given, or aProtocolBindingother than HTTP-POST →saml_unregistered_acs; - each request ID is accepted once per application →
saml_request_replay.
- decoding and parsing: base64, DEFLATE bounded by
- From here on, a request the identity provider cannot honor is
answered with a signed SAML error response to the validated ACS
(top-level status
RequesterorResponder, with a second-level code): aSubjectin the request →RequestUnsupported; aNameIDPolicyFormatthat is neitherunspecifiednor the application's format, or a foreignSPNameQualifier→InvalidNameIDPolicy; aRequestedAuthnContextwith only unknown class references, anAuthnContextDeclRef, an unknown comparison, orbetterthan the strongest local level →NoAuthnContext;IsPassivetogether withForceAuthn→NoPassive. - The accepted request is recorded (
saml_idp_requests) and bound to the browser with thepa_samlsecret. HTTP-Redirect continues at once; HTTP-POST continues with a303toGET I/saml/sso/continue?request=...(a cross-sitePOSTcarries noSameSite=Laxcookies; theGETcarries the SSO session cookie). - Start. An authentication transaction is created for the backing
client (browser mode) with the continuation endpoint as redirect URI, a
PKCE verifier held by the server, a random
state, scopes that describe the released data (openidplusemail,profileorphoneaccording to the attribute mapping; this is what consent shows), the application's organization as the requested organization,ForceAuthnasprompt=login, and the requested assurance as ACR values (step-up). The SSO session is reused when policy allows (SES-014,max_session_age_seconds).- If the transaction completes silently, the assertion is delivered at
once (a
200form, step 6). IsPassiveand not silently completable →NoPassiveerror response; the transaction is cancelled.- Otherwise
302to the login interface (the application'slogin_ui_url, else the realm's) withtransaction_id, settingpa_tx. Without a login interface the answer is503. - The emergency control for
authorization_code(SEC-020), a suspended client or an inactive realm →RequestDenied.
- If the transaction completes silently, the assertion is delivered at
once (a
- Complete (
GET I/saml/sso/continue?code&state). The login interface sends the browser to the transaction'sredirect_to. The request is consumed once and only with the matchingpa_samlcookie (another browser, or a replay →400 transaction_invalid, reasonsaml_request_invalid). The internal code is redeemed with the PKCE verifier and must belong to the request's transaction, the backing client and the continuation URI. Then: the grant and session must be active and the account able to authenticate; for an application bound to an organization the user must be an active member (otherwiseRequestDenied); the session's ACR must meet the requested level (otherwiseNoAuthnContext); the NameID is derived (no verified email foremailAddress→InvalidNameIDPolicy).?error=from a cancelled or failed transaction becomesRequestDenied(access_denied),NoPassive(login, interaction or consent required) orAuthnFailed. - Delivery is an auto-submitting HTML form (
SAMLResponse,RelayState) to the registered ACS, with a per-response nonce inContent-Security-Policy: default-src 'none'; script-src 'nonce-...'; frame-ancestors 'none'; base-uri 'none', a Continue button for browsers without scripts,Cache-Control: no-storeandReferrer-Policy: no-referrer.form-actionis not restricted because some browsers apply it to the redirects the SP makes after its ACS; the form's action is always a registered ACS.
Response contents#
A samlp:Response (Destination = the ACS, InResponseTo unless
unsolicited, Issuer = the realm's issuer, Status) with one
saml:Assertion:
Issuer;Subjectwith theNameID(withNameQualifierandSPNameQualifierfor persistent and transient formats) and a bearerSubjectConfirmation(Recipient= the ACS,InResponseTo,NotOnOrAfter= now plus the assertion lifetime);Conditions(NotBefore= now minus the skew,NotOnOrAfter= now plus the lifetime) with oneAudienceRestriction(the entity ID andadditional_audiences);AuthnStatement(AuthnInstant= the authentication time,SessionIndexper session and application,SessionNotOnOrAfter= the session's absolute expiry,AuthnContextClassRef);AttributeStatementwith the mapped attributes (noxsi:type).
Signatures follow sign_assertion and sign_response: enveloped, exclusive
canonicalization, the signature placed after Issuer, the X.509
certificate in KeyInfo. Error responses are always signed. Each assertion
is audited as saml.assertion.issued (category token: entity ID, ACS,
NameID format, authentication context, session, grant).
NameID formats#
SAML-006.
| Format | Value |
|---|---|
...:2.0:nameid-format:persistent (default) | An opaque sps_... value: an HMAC-SHA256 under the deployment's pairwise-subject purpose key over the sector saml:<application id> and the account ID, truncated to 20 bytes and base64url-encoded. It is derived once and stored in pairwise_subjects, so it stays stable across root-key rotation; it differs for every application (no correlation between applications) and is deleted with the account |
...:1.1:nameid-format:emailAddress | The account's verified email address only (the primary one first); none → InvalidNameIDPolicy |
...:1.1:nameid-format:unspecified | The account's immutable ID (the same value as the OIDC public sub) |
...:2.0:nameid-format:transient | A new random value for each assertion |
Attribute mapping#
SAML-006. Each mapping releases one attribute: name (sent as the SAML
attribute name), optional name_format (default uri when the name
contains :, otherwise basic) and friendly_name, and a source:
email (verified email only), email_verified (true when a verified
email is released), name, given_name, family_name,
preferred_username (the username), locale, zoneinfo, phone_number
(verified phone only), user_id, subject (the NameID value),
organization_id (the application's organization), or static with
value. Empty values are not released.
Protected names (compared case-insensitively) cannot carry static or user-editable values:
- email names (
email,mail,emailaddress,http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress,urn:oid:0.9.2342.19200300.100.1.3) accept only sourceemail; - identifier names (
uid,sub,nameid,upn,eppn,user_id,userid, the WS-Federationnameidentifierandupnclaim URIs, Azure'sobjectidentifier,urn:oid:0.9.2342.19200300.100.1.1, and the eduPerson principal, targeted and unique IDs) accept onlyuser_id,subject,emailorpreferred_username.
Authentication context#
The requested AuthnContextClassRef values map to local assurance levels:
| Class references | Local level |
|---|---|
unspecified, Password, PasswordProtectedTransport | Single factor (rank 1) |
MobileTwoFactorContract, TimeSyncToken, http://schemas.microsoft.com/claims/multipleauthn, https://refeds.org/profile/mfa, urn:proudauth:acr:mfa | MFA (rank 3) |
urn:proudauth:acr:phishing_resistant | Phishing-resistant (rank 4) |
urn:proudauth:acr:single_factor, urn:proudauth:acr:federated | Rank 1 |
authn_context_map adds class references or overrides these. Comparison
exact, minimum, maximum (or none) requires the lowest requested rank;
better requires one above the highest. Ranks 3 and 4 become the
transaction's ACR requirement, so the user is stepped up or asked to
enroll a factor. The assertion states the strongest requested reference the
session meets; otherwise the mapped reference for the session's ACR;
otherwise urn:proudauth:acr:mfa or urn:proudauth:acr:phishing_resistant,
PasswordProtectedTransport for a password-only session, and
unspecified for anything else.
Consent and organizations#
Third-party applications (first_party: false, consent_mode: required)
ask for consent through the normal transaction, with the scopes described
in step 4 above. Consents are stored against the backing client and can be
revoked like any other; administrative pre-authorization applies.
organization_id binds the application to one organization: that
organization's policies (MFA, login methods, SSO-mandatory, the rule for
connections of other organizations) apply to the sign-in, and only its
active members receive assertions. Choosing an organization per request is
not supported.
Encrypted assertions#
Outbound only. With encrypt_assertions: true and an
encryption_certificate (or the encryption key from the SP's metadata), the
signed assertion is encrypted (sign, then encrypt) into a
saml:EncryptedAssertion:
| Member | Values |
|---|---|
data_encryption_algorithm | http://www.w3.org/2009/xmlenc11#aes256-gcm (default) or #aes128-gcm (IV, ciphertext and tag) |
key_transport_algorithm | http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p (default; its SHA-1 digest is used only inside OAEP padding, not for collision resistance) or http://www.w3.org/2009/xmlenc11#rsa-oaep (SHA-256 digest, MGF1 with SHA-256) |
The SP's certificate is placed in the EncryptedKey's KeyInfo. AES-CBC
and RSA PKCS#1 v1.5 key transport are refused (422, SAML-009). An
encryption certificate without encrypt_assertions is refused. A service
provider that supports only CBC cannot receive encrypted assertions; send it
signed assertions over TLS instead.
IdP-initiated SSO#
SAML-007. GET I/saml/idp-initiated?sp=... takes the application's entity
ID or its ID (an unknown one is 400, reason
saml_unknown_service_provider). It is disabled by default: 403 (reason
saml_unsolicited_disabled). When an application sets
allow_idp_initiated: true:
- the response always goes to the application's default ACS;
RelayStateis only the registeredidp_initiated_relay_state; aRelayStatequery parameter is ignored, so the endpoint is not a redirector;- the response has no
InResponseTo, and the sign-in is still a real sign-in bound to the browser (pa_saml) and audited.
Residual risk: unsolicited SSO allows login CSRF into the user's own account at the service provider. Enable it only for applications that need to be launched from a portal.
Single logout#
SAML-008. A declared subset:
- Supported: SP-initiated
LogoutRequestover HTTP-Redirect or HTTP-POST, always signed with a registered certificate of the issuing application (an unsigned request gets400even whenrequire_signed_requestsisfalse).Destination, when present, must beI/saml/slo;IssueInstantwithin the same window as for SSO (no later than now plus the skew, no earlier than 10 minutes plus the skew ago);NotOnOrAfteris honored. - The
NameID(with its format, by default the application's) and theSessionIndexvalues select the sessions for which the application received assertions; withoutSessionIndex, every session of that subject with that application. Each selected session is revoked (grants, refresh tokens, opaque tokens; OIDC clients with back-channel logout receive logout tokens as for any revocation). The browser'spa_sidcookie is cleared when its own session ended. - The answer is a signed
LogoutResponseto the application's first registered logout endpoint (itsresponse_urlwhen set) in that endpoint's binding (HTTP-Redirect: signed query; HTTP-POST: enveloped signature in an auto-submitting form), with statusSuccessand the second-level statusPartialLogoutwhen other SAML applications, or OIDC clients without back-channel logout, took part in the session: they are not notified. - Audit
saml.logout.completed(sessions ended, partial).
Not supported: IdP-initiated logout (sending LogoutRequests to service
providers, front-channel propagation), so incoming LogoutResponses are
refused with 400; SOAP back-channel logout. Logging out through the OIDC
/oidc/logout endpoint does not notify SAML service providers.
Broker: SAML connections#
FED-014.
Creating a connection#
POST R/saml/connections (realm.identity_providers.write) creates an
identity_providers row with kind and adapter saml and issuer = the
IdP's entity ID; the SAML settings are stored in saml_connections. The
shared federation settings apply unchanged (headless-authentication.md):
alias, linking_policy, jit_provisioning, jit_default_role_id
(organization-scoped and assignable by the administrator),
organization_id (binding to an organization and the rule for connections
of other organizations, FED-006), trust_upstream_mfa,
allowed_email_domains, claim_mapping (keys are SAML attribute names;
defaults displayName, givenName, sn), the state (active,
disabled; emergency controls work), SSO-mandatory organizations
(sso_provider_id may name a SAML connection), identifier-based SSO
routing, self-service linking (POST /me/linked-identities) and unlinking.
| Member | Rules |
|---|---|
metadata_xml | The IdP's metadata as uploaded text (an IDPSSODescriptor). Never fetched. Fills the entity ID, the HTTP-Redirect SSO endpoint, the signing certificates and sign_authn_requests (from WantAuthnRequestsSigned) when they are not given |
alias, display_name | As for other connections; alias cannot change (422), display_name at most 100 characters |
idp_entity_id | The IdP's entity ID; cannot change while identities are linked (409) |
sso_url | The IdP's HTTP-Redirect SSO endpoint, at most 2048 characters, no userinfo or fragment; https (http only outside production); the residency policy applies |
signing_certificates | 1 to 4 trusted certificates; RSA of at least 2048 bits; not expired |
sp_entity_id | Default I/saml/sp/{alias} |
sign_authn_requests | Default true; WantAuthnRequestsSigned in uploaded metadata can only turn it on |
require_signed_assertions | Refuse responses where only the Response is signed |
name_id_format | The format requested in NameIDPolicy |
subject_attribute | Attribute (at most 256 characters, no whitespace) that carries the stable subject instead of the NameID; required with a transient NameID |
email_attribute | Attribute (at most 256 characters, no whitespace) that carries the email address (otherwise an emailAddress NameID) |
trust_email_verified | Treat the IdP's email as verified (SAML has no verification flag). Required, together with allowed_email_domains, for linking policy trusted_verified_email |
trusted_authn_contexts | Up to 10 AuthnContextClassRef URIs (at most 256 characters each) accepted as upstream MFA; required with trust_upstream_mfa (FED-009) |
requested_authn_contexts | Up to 10 URIs, sent as RequestedAuthnContext (comparison minimum) |
force_authn | Send ForceAuthn |
clock_skew_seconds | Default 120 (0 also means 120); at most saml.max_clock_skew |
The linking_policy defaults to verify_existing. A disabled connection's
ACS answers 404 feature_disabled. The response shows the settings under
saml (with parsed certificates) and, under service_provider, what the
IdP's administrator registers: entity_id, acs_url and metadata_url. SAML connections are listed by GET /identity-providers (kind saml) and
offered in transactions like other connections. PUT /identity-providers/{id}
answers 409 for them and POST /identity-providers refuses
"adapter": "saml": change them through R/saml/connections.
DELETE /identity-providers/{id} works with the usual rule (no linked
identities). POST /identity-providers/{id}/test returns offline diagnostics
(valid certificates, those expiring within 30 days, expired ones and ones
not valid yet); nothing is fetched. Only realm administrators manage SAML connections. Audit
federation.provider.created and .updated with the certificate
fingerprints (signing_certificates, signing_certificates_added,
signing_certificates_removed).
Sign-in flow#
POST R/auth/transactions/{id}/federation/start {"provider": "<alias>"}returnsexternal_redirect: the IdP's SSO URL with an HTTP-RedirectAuthnRequest(a 160-bit randomID,Destination,AssertionConsumerServiceURL=I/saml/acs/{alias},ProtocolBindingHTTP-POST,Issuer= the SP entity ID,NameIDPolicywithAllowCreate="true", optionalRequestedAuthnContextandForceAuthn), signed with RSA-SHA256 and the realm's SAML key unlesssign_authn_requestsisfalse, and aRelayStatethat is a 256-bit bearer secret. Only its digest is stored (saml_broker_requests, valid for 10 minutes).- The IdP posts
SAMLResponseandRelayStateto the ACS. - Local refusals (
400 invalid_proof, auditsaml.response.rejectedwith the reason; no transaction is touched and the pending request stays usable): unparseable or unsafe XML, not aResponse, unsolicited (noInResponseTo), orInResponseToandRelayStatenot matching an unconsumed, unexpired request of this connection (this also refuses a replay of the same response). - Then the request is consumed and the response
validated. A failure is reported to the
transaction as
upstream_invalid_response(upstream_deniedfor a non-success status) and audited with the precise reason. - Identity: the subject is the
subject_attributevalue or the NameID (a transient NameID without a subject attribute →transient_subject), at most 255 characters; the issuer is the IdP's entity ID. The email comes fromemail_attributeor anemailAddressNameID and is verified only withtrust_email_verified. Upstream MFA counts only withtrust_upstream_mfaand anAuthnContextClassReflisted intrusted_authn_contexts. - The identity enters the shared federation pipeline: existing link,
trusted-email linking, linking with proof of control of the local
account (FED-005), JIT provisioning with organization membership and
default role, the registration approval queue, account state checks.
The browser is sent with a
303to the login interface withtransaction_id(federation_erroron failure), or JSON is returned for realms without a login interface. The login completes only with the transaction's continuation, so a response posted from another browser cannot finish someone else's login.
Response validation#
SAML-003. All of these must hold:
- status
Success; exactly oneAssertion, a direct child of theResponse, and no otherAssertionanywhere (assertion_count); noEncryptedAssertion(encrypted_assertion_unsupported); - signatures: a signed
Response, a signedAssertion, or both, each verified against the connection's trusted certificates (any of them, for overlap). A signature that is present must verify; neither signed →unsigned. With only theResponsesigned, the assertion is read from the verified response (refused withrequire_signed_assertions). Every value is read from verified elements, which defeats signature wrapping; - algorithms: RSA-SHA256, -SHA384 or -SHA512, digests SHA-256, -384 or -512,
the enveloped-signature transform plus one canonicalization (exclusive or
inclusive 1.0 or 1.1, never the
WithCommentsvariants); SHA-1 →weak_algorithm; - exactly one
Signaturereferences the element, as its direct child, with exactly oneReferenceto#<ID>; Response:Destination= the ACS,InResponseTo= the request,Issuer(if present) = the IdP's entity ID,IssueInstantwithin [now − 10 minutes − skew, now + skew];Assertion: version 2.0, a validID, theIssueInstantwindow,Issuer= the IdP's entity ID (entity format only); exactly one directNameIDof at most 1024 characters (subjectotherwise; anEncryptedIDorBaseIDis never read); a bearerSubjectConfirmationwithRecipient= the ACS,InResponseTo= the request,NotOnOrAfterlater than now − skew andNotBefore(if present) no later than now + skew;Conditionspresent:NotBefore≤ now + skew,NotOnOrAfter> now − skew, at least oneAudienceRestriction, and every restriction contains the SP entity ID;OneTimeUseandProxyRestrictionare accepted, any other condition →unknown_condition;- at least one
AuthnStatement, whoseSessionNotOnOrAfterhas not passed; - replay: the assertion ID is recorded per connection
(
saml_assertion_ids) until it expires; a second use →assertion_replay; - attributes: a value longer than 1024 characters (
attribute_value) refuses the response.Attributeelements that repeat aNameare one multi-valued attribute (identity providers such as Keycloak send one element per value, for example oneRoleelement per role), except the connection'ssubject_attributeandemail_attribute: one of those appearing twice is ambiguous and refuses the response (duplicate_attribute). A multi-valued attribute never fills a single-valued field (subject, email, theclaim_mappingprofile fields). Attributes beyond the first 100 names, and attributes whose name is empty or longer than 256 characters, are dropped silently.
SP metadata#
GET I/saml/sp/{alias} returns the broker's metadata for one connection:
entityID, AuthnRequestsSigned, WantAssertionsSigned="true", the realm's
SAML signing certificates (the pending key included), the requested
NameIDFormat and the HTTP-POST ACS. It has no SingleLogoutService and
no encryption key. Cacheable for 300 seconds.
Rollover of the IdP's certificate#
Update the connection with both the current and the next certificate (both are trusted), then remove the old one after the IdP has switched. An expired certificate verifies nothing and cannot be uploaded.
XML processing#
SAML-004, TEST-051. Every inbound message and every uploaded metadata
document goes through saml.Parse before a tree is built (a streaming pass
with Go's encoding/xml in strict mode):
- size at most
saml.max_message_bytesafter base64 decoding; HTTP-Redirect DEFLATE is inflated through a limited reader, so a compression bomb stops at the limit; the HTTP body is bounded as well; - UTF-8 only (other declared encodings are refused);
- no
DOCTYPEor any other markup declaration: without a DTD there are no external or internal entities, so XXE and entity expansion are impossible; unknown entity references are refused; - no comments (this defeats NameID truncation through comments) and no processing instructions other than the XML declaration;
- depth at most 32, at most 4096 elements and 64 attributes per element, no duplicate attributes, exactly one root element;
- no duplicate
IDvalues anywhere in the document; - text values are the concatenation of all character data, and an element with child elements where text is expected is malformed;
- elements are matched by namespace URI, never by prefix.
Signatures are verified with github.com/russellhaering/goxmldsig v1.6.1
(no canonicalization or verification code of ProudAuth's own), with the
pinning rules above; the element consumed is the one goxmldsig returns,
parsed again from the digested canonical bytes. There is no SHA-1
compatibility switch and no per-counterparty algorithm exception
(SAML-009).
Errors and reason codes#
| Where | Status and code | reason or SAML status |
|---|---|---|
| IdP SSO, before the ACS is validated | 400 invalid_request | saml_invalid_request, saml_unknown_service_provider, saml_signature, saml_unregistered_acs, saml_request_replay |
| IdP continuation | 400 transaction_invalid | saml_request_invalid |
| IdP-initiated SSO not enabled | 403 forbidden | saml_unsolicited_disabled |
| No login interface configured | 503 unavailable | — |
| IdP error responses (sent to the ACS) | Requester | RequestUnsupported, InvalidNameIDPolicy (also when no verified email exists for an emailAddress NameID) |
Responder | NoPassive, NoAuthnContext, RequestDenied, AuthnFailed | |
| Single logout | 400 invalid_request | saml_logout_unsupported, saml_logout_response_unexpected (a LogoutResponse over HTTP-Redirect, or in the SAMLRequest field; one posted as SAMLResponse gets 400 invalid_request without a reason), saml_unknown_service_provider, saml_signature, saml_invalid_request |
| Broker ACS, not correlated to a request | 400 invalid_proof | Audit reasons decode, unsafe_xml, too_large, duplicate_id, malformed, not_a_response, unsolicited, unknown_request, expired_request |
| Broker ACS, correlated | Transaction notice / federation_error | upstream_invalid_response, upstream_denied (and the linking outcomes). Audit reasons: version, status, status_missing, encrypted_assertion_unsupported, assertion_count, response_signature:<x> and assertion_signature:<x> (weak_algorithm, ambiguous, malformed, invalid), unsigned, destination, in_response_to, issuer, issue_instant, assertion_malformed, subject, subject_confirmation, conditions, not_yet_valid, expired, audience, unknown_condition, authn_statement, upstream_session_expired, duplicate_attribute, attribute_value, subject_attribute, transient_subject, assertion_replay |
| Administration | 422 validation_failed with the field; 409 for conflicts (entity ID or alias in use, linked identities exist, a pending key exists, activation too early, the client is managed by a SAML application) |
Audit events#
| Event | Category |
|---|---|
saml.request.rejected | security |
saml.assertion.issued | token |
saml.logout.completed | security |
saml.response.rejected | security |
saml.application.created, .updated, .deleted | application |
saml.signing_key.created, .prepared, .activated | configuration (emergency for a forced activation) |
The existing federation.*, authentication.*, consent.*,
session.revoked and client.* events are recorded as for OIDC sign-ins.
Find SAML events with GET /audit-events?type_prefix=saml.
(monitoring.md).
Stored data and upgrade note#
Migration 0060_saml.sql is expand-only: it adds clients.protocol
(oidc by default, or saml) and the tables saml_signing_keys,
saml_service_providers, saml_idp_requests, saml_idp_sessions,
saml_connections, saml_broker_requests and saml_assertion_ids
(data-inventory.md). The worker's
retention.cleanup task (every 5 minutes) deletes identity provider
requests and session participation records 1 hour after they expired or
ended, broker requests 1 hour after they expired and replay records when
they expire, and retires signing keys after their publication window.
The configuration document (configuration export and import) does not
include SAML applications, SAML connections or SAML signing keys, and leaves
the backing clients of SAML applications out; recreate them through the
SAML API. Older binaries in a mixed-version window have no SAML
routes; enable saml.enabled after the rollout.
Not supported#
None of these is advertised in metadata:
- Both roles: Artifact binding, SOAP, attribute queries, ECP, proxying
and
Scoping(IDPListis ignored),AuthnContextDeclRef, a requestedSubject, metadata signing, fetching metadata from a URL or refreshing it automatically, SHA-1 or any other algorithm exception. - Identity provider: HTTP-Redirect or Artifact ACS endpoints (HTTP-POST
only); IdP-initiated logout and front-channel logout propagation; SOAP
logout; notifying SAML service providers from
/oidc/logout; choosing an organization per request. Persistent NameIDs belong to an application registration: deleting and recreating an application changes them. - Broker: encrypted assertions and encrypted NameIDs (refused; the SP
metadata publishes no encryption key); sending
AuthnRequests with the HTTP-POST binding (only HTTP-Redirect SSO endpoints are used); IdP-initiated (unsolicited) responses, which are always refused; upstream single logout (logout is local only, and OIDC logout reports the upstream logout asunsupportedfor SAML connections); signatures whoseKeyInfocarries only a key value or key name (the X.509 certificate must be inKeyInfo, orKeyInfoomitted); management of SAML connections by organization administrators. - SAML connections stay listed and offered when
saml.enabledis later switched off; sign-ins through them then fail withfeature_disabled. Disable the connections first. goxmldsigchecks certificate validity dates, so an expired trusted certificate stops working (deliberate).
Testing and interoperability status#
SAML-010 asks for tests against multiple independent IdP and SP implementations; Keycloak 26.8.0 is the first third-party one.
TestSAMLIdentityProvider: both bindings, session reuse,ForceAuthn,IsPassive, request validation (SHA-1, wrong key, unregistered ACS, unknown SP, wrong destination, stale request, replay, DTD/XXE),NameIDPolicy, browser binding, the IdP-initiated policy, backing client guards, registration rules, organization binding and consent, MFA step-up throughRequestedAuthnContext, unknown contexts, encrypted assertions with both key transports, and signed single logout withPartialLogoutand cookie clearing.TestSAMLKeyRollover: the pending key in metadata, the activation gate, the forced-activation reason, the publication window of the retiring key.TestSAMLBroker: signedAuthnRequest, SP metadata, JIT provisioning with organization membership, response and assertion-ID replay, unsigned responses, an untrusted key, SHA-1, wrong audience, destination and recipient,InResponseTomismatch, expired and future assertions, skew tolerance, response-only signatures, transient NameIDs, error statuses, five signature-wrapping shapes (an unsigned assertion before or after the signed one, a signed assertion hidden inExtensions, an assertion swapped under a signed response, an injectedEncryptedAssertion), unsolicited responses, a wrongRelayState, DTD/XXE, duplicate IDs, a comment in the NameID, oversized messages, certificate overlap and removal,require_signed_assertions, repeated attribute elements (merged, except a repeated email attribute), trusted email linking, self-service linking, administration guards and diagnostics.- The test SP and IdP are written directly on
etree,goxmldsigand the Go standard library, independently ofinternal/saml, so messages, signatures, DEFLATE and encryption are checked against a second implementation. They are still written by the same project. - Keycloak 26.8.0 (
scripts/interop-keycloak.sh,make interop-keycloak, weekly in theinterop.ymlworkflow), in both roles, each side configured from the other's metadata: as service provider of a SAML application (signed HTTP-RedirectAuthnRequest, response and assertion signed over HTTP-POST, persistent NameID, attributes, an encrypted assertion, signed single logout over HTTP-Redirect) and as identity provider of a SAML connection (signed HTTP-RedirectAuthnRequest, signed response and assertion, persistent NameID, email attribute, repeatedRoleattributes, just-in-time provisioning), with a tampered and a replayedAuthnRequestand SAML response refused. That run found the repeated-attribute refusal fixed above. Settings that matter on the Keycloak side and what is not covered: interoperability.md. - Not tested (QA-004): other products (for example Shibboleth, SimpleSAMLphp, Okta, Microsoft Entra ID, ADFS) and other Keycloak releases. Treat compatibility with them as unverified and test it in your environment.
Threat analysis: threat-models/saml.md.