Keyboard shortcuts

Press โ† or โ†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sovereign, high-performance data protection

SAML 2.0 Single Sign-On

The Authentication Verifier can act as a SAML 2.0 Service Provider (SP): users of a realm sign in at the organisation's identity provider (IdP) โ€” Microsoft Entra ID, Okta, ADFS, Keycloak, Shibboleth, โ€ฆ โ€” and come back with the usual _ea_ session cookie. Nothing changes for the API servers that validate sessions: a SAML session is a session like any other, with as_as (auth scheme) set to "sa".


Table of Contents


What is supported

SupportedNot supported
SP-initiated Web Browser SSO (SAML Profiles ยง4.1)IdP-initiated (unsolicited) responses
AuthnRequest over HTTP-Redirect, signed with RSA-SHA256HTTP-POST or Artifact binding for requests
Response over HTTP-POSTArtifact binding for responses
Signed assertion, signed response, or bothEncrypted assertions
RSA or ECDSA signatures with SHA-256/384/512SHA-1 signatures or digests
IdP metadata pasted by an administratorFetching metadata from a URL
One IdP per realmFederations / multi-IdP discovery
Local logout (DELETE /sessions)SAML Single Logout

SAML is a login method for the users of a realm. It is never an administrator credential: admins keep signing in with the methods of the _ realm, which refuses SAML settings.


Architecture

flowchart LR
    subgraph user["End user"]
        B["Web browser"]
    end

    subgraph app["Your application"]
        APP["Web application<br/>(origin listed in allowed_return_origins)"]
        API["API server"]
    end

    subgraph idp["Identity provider"]
        IDP["SAML 2.0 IdP<br/>Entra ID, Okta, ADFS, Keycloak, Shibboleth"]
        DIR[("User directory<br/>AD, LDAP")]
        MFA["MFA factors"]
    end

    subgraph verifier["Authentication Verifier deployment"]
        EA["Authentication Verifier<br/>SAML 2.0 Service Provider<br/>/saml/{realm_id}/login, /acs, /metadata"]
        KEY["SP signing key and certificate<br/>(PEM files of saml_sp_params)"]
        DB[("Auth database<br/>SQLite, PostgreSQL, MySQL<br/>realms and saml_params")]
        SS[("Session store<br/>SQLite, PostgreSQL, MySQL, Redis<br/>sessions, pending SAML requests, replay cache")]
    end

    ADM["Realm administrator<br/>admin UI or admin API"]
    IDPADM["IdP administrator"]

    B -- "1. opens the application" --> APP
    B -- "2. login redirect and ACS form post" --> EA
    B -- "3. signs in" --> IDP
    IDP --> DIR
    IDP --> MFA
    B -- "4. API calls with the _ea_ cookie" --> API
    API -- "5. validates the session (/sessions)" --> EA
    EA --> DB
    EA --> SS
    KEY -.-> EA
    ADM -. "pastes IdP metadata, sets SAML realm settings" .-> EA
    EA -. "SP metadata" .-> IDPADM
    IDPADM -. "registers the SP" .-> IDP

The Authentication Verifier and the IdP never talk to each other directly: every SAML message travels through the browser (front channel), and trust is set up once, out of band, by exchanging metadata. The application and the API server only ever see the _ea_ session.


Prerequisites

  1. A server built with the saml feature. SAML statically links the xmlsec and libxml2 C libraries, so it is off by default. Either use a prebuilt SAML build or build it yourself:

    • Prebuilt: the release publishes a SAML variant next to the standard one โ€” packages whose file name ends in -saml (for example auth-verifier_<version>-1_amd64-saml.deb) and the Docker image ghcr.io/cosmian/auth-verifier-saml:<version>, a separate package from the standard ghcr.io/cosmian/auth-verifier. It is a static build only and installs under the same package name as the standard server, so install one or the other.
    • From source with Nix: nix-build -A auth-verifier-static-saml
    • From source with cargo, inside the Nix shell:
    nix-shell --run "cargo build --release --features auth_verifier/saml"
    

    A server built without the feature refuses realms carrying SAML settings (HTTP 400) and refuses to start if [saml_sp_params] is configured.

  2. HTTPS. The ACS URL must be https://, and the login cookie that protects against login CSRF is Secure.

  3. An SP signing key in the server configuration โ€” Step 1. Without it the /saml routes don't exist and realms can't enable SAML.


Step 1 โ€” Create the SP signing key

One RSA key signs the AuthnRequests of every SAML realm, and its certificate is published in the SP metadata that IdPs import. It is server configuration: it never goes through the database or the admin API.

openssl req -x509 -newkey rsa:3072 -sha256 -days 1095 -nodes \
    -subj "/CN=auth.example.com SAML SP" \
    -keyout saml-sp.key.pem -out saml-sp.cert.pem
chmod 600 saml-sp.key.pem
[saml_sp_params]
saml_rsa_private_key = "/etc/cosmian/saml-sp.key.pem"
saml_certificate     = "/etc/cosmian/saml-sp.cert.pem"

The server checks the pair at startup and stops if a file is unreadable, the key isn't RSA or is shorter than 2048 bits, or the certificate doesn't match the key. A self-signed certificate is fine: IdPs pin the certificate from the metadata rather than validating a chain. See Server configuration.


Step 2 โ€” Configure the realm

In the admin UI, open the realm (Realms โ†’ Edit) and tick SAML 2.0 (single sign-on). The same settings can be sent through the API as auth_params.saml_params on POST /admins/realms or PUT /admins/realms/{realm_id}:

{
  "saml_params": {
    "metadata_xml": "<md:EntityDescriptor entityID=\"https://idp.example.com/โ€ฆ\">โ€ฆ</md:EntityDescriptor>",
    "sp_entity_id": "https://auth.example.com/saml/my-service",
    "sp_acs_url": "https://auth.example.com/saml/my-service/acs",
    "subject_attribute": "email",
    "normalize_subject_case": true,
    "role_attribute": "groups",
    "attribute_claim_map": { "department": "dept" },
    "allowed_return_origins": ["https://app.example.com"],
    "default_return_url": "https://app.example.com/"
  }
}
FieldRequiredDescription
metadata_xmlYesThe IdP's SAML 2.0 metadata (one EntityDescriptor, at most 256 KiB).
sp_entity_idYesThis server's entity ID for the realm, given to the IdP. Conventionally https://<server>/saml/<realm_id>.
sp_acs_urlYeshttps://<server>/saml/<realm_id>/acs โ€” the public URL browsers post the IdP's response to.
subject_attributeNoAttribute whose single value becomes the user name. Unset: the NameID is used. Required when the IdP issues transient NameIDs.
normalize_subject_caseNoLowercase the user name (default false).
role_attributeNoAttribute whose values become the session roles.
attribute_claim_mapNoAttribute name โ†’ extra-claim name. Only listed attributes reach the session.
allowed_return_originsYeshttps://host[:port] origins a login may return to.
default_return_urlYesWhere a login returns when no return_to is given; must be under an allowed origin.

The IdP fields (idp_entity_id, idp_sso_url, idp_signing_certificates, idp_nameid_format) are derived by the server from metadata_xml on every save, and any values sent for them are ignored. The metadata is refused if it isn't a single SAML 2.0 EntityDescriptor, has expired (validUntil), has no HTTPS HTTP-Redirect SingleSignOnService, or has no signing certificate. Every refusal is an HTTP 400 naming the field (saml_params.<field>: <reason>); the admin UI shows it next to that field.

The admin UI previews the parsed metadata โ€” IdP entity ID, sign-in URL, signing certificates with their expiry โ€” before you save.


Step 3 โ€” Register the realm at the IdP

The IdP administrator needs this server's SP details. Either give them the SP metadata:

https://auth.example.com/saml/my-service/metadata

(also available from the realm drawer's Download SP metadata button, or AuthClient::get_saml_metadata), or enter by hand:

IdP settingValue
Entity ID / Identifier / Audiencesp_entity_id
ACS URL / Reply URL (HTTP-POST)sp_acs_url
Request signing certificateThe [saml_sp_params] certificate
SignAssertion, response, or both
Attributes to releaseThose named in subject_attribute, role_attribute and attribute_claim_map

Then paste the IdP's metadata into the realm (Step 2).


Step 4 โ€” Sign users in from your application

Send the browser to the realm's login endpoint, with the page to come back to:

https://auth.example.com/saml/my-service/login?return_to=https://app.example.com/dashboard

After signing in at the IdP, the browser returns to return_to (or default_return_url) holding the _ea_ session cookie. return_to must be an https URL under one of allowed_return_origins; anything else is refused with HTTP 400 rather than silently replaced, so a misconfigured link is noticed.

Your API validates the session exactly as for any other method โ€” see Session management.


Login flow

sequenceDiagram
    autonumber
    participant B as Browser
    participant EA as Authentication Verifier
    participant DB as Auth database
    participant RS as SAML request store
    participant SS as Session store
    participant IdP as Identity Provider
    participant APP as Application and API

    B->>EA: GET /saml/{realm_id}/login?return_to=URL
    note over EA: per-IP rate limit (same as /login)
    EA->>DB: get realm
    DB-->>EA: realm and saml_params
    note over EA: 400 if the realm does not use SAML<br/>return_to must be https under allowed_return_origins (else 400)<br/>absent return_to uses default_return_url
    EA->>RS: store pending request (request ID, realm, return URL, expires in 10 min)
    note over EA: AuthnRequest ID = request ID<br/>signed RSA-SHA256 with the SP key (HTTP-Redirect)
    EA-->>B: 302 Location: IdP SSO URL with SAMLRequest, RelayState = request ID, SigAlg, Signature<br/>Set-Cookie: _ea_saml = request ID (Secure, HttpOnly, SameSite=None, Path=/)

    rect rgba(128, 128, 128, 0.1)
        note over B,IdP: at the IdP, outside the Authentication Verifier
        B->>IdP: GET SSO URL
        note over IdP: checks the AuthnRequest signature<br/>authenticates the user (password, MFA)
        IdP-->>B: auto-submitting HTML form
    end

    B->>EA: POST /saml/{realm_id}/acs with SAMLResponse and RelayState<br/>Cookie: _ea_saml = request ID
    note over EA: _ea_saml cookie must equal RelayState (else 401)
    EA->>DB: get realm
    DB-->>EA: realm and saml_params
    EA->>RS: take pending request (RelayState, realm), single use
    RS-->>EA: pending request with return URL (none: 401)
    note over EA: base64-decode SAMLResponse<br/>verify signature against the IdP certificates (xmlsec)<br/>check algorithms, Issuer, InResponseTo, Audience,<br/>Recipient, Destination, validity window, AuthnStatement, Status
    EA->>RS: record assertion ID in this realm (replay cache)
    RS-->>EA: new (already seen: 401)
    note over EA: map identity: subject, roles, extra claims<br/>issue session token (as_as = sa, as_rid = realm)
    EA->>SS: upsert session
    EA-->>B: 200 HTML page continuing to the return URL<br/>Set-Cookie: _ea_ session (SameSite=Strict), _ea_saml cleared
    B->>APP: GET return URL with the _ea_ cookie
    APP->>EA: validate the session (/sessions)
    EA-->>APP: session data

The pending requests and the replay cache live in the session store's database (SQLite, PostgreSQL, MySQL or Redis) and are purged on the stale-session collector's interval. The /saml routes share the per-IP rate limit of /login.

On the SQL backends SAML opens its own connection pool with the session store's max_connections / min_connections, so plan for twice that many connections to that database. With auto_init_schema = false, create the two SAML tables yourself: saml_pending_request (request_id primary key, realm_id, return_url, created_at, expires_at) and saml_seen_assertion (primary key realm_id + assertion_id, or assertion_id_sha256 BINARY(32) on MySQL, and expires_at), each indexed on expires_at. The CREATE TABLE statements in server/src/saml/impls/ are the reference. Until they exist, SAML logins fail and the periodic cleanup logs errors.

The ACS answers with a small page instead of a redirect: the SameSite=Strict session cookie may be withheld on a navigation that directly follows the IdP's cross-site POST, and continuing from a page of this server makes it a same-site navigation.

SAML sessions don't go through this server's TOTP step: multi-factor authentication is the IdP's responsibility.


Identity mapping

Session fieldSource
sub (user name)subject_attribute's value โ€” exactly one non-empty value is required โ€” or else the <NameID>. A transient NameID is refused unless subject_attribute is set, as it changes on every login. Lowercased when normalize_subject_case is set.
rolesThe deduplicated values of role_attribute. None when it is unset or not sent, which policies treat as no access.
extra claimsFor each attribute_claim_map entry the IdP sent: a string, or an array for several values. At most 4 KiB in total; claim names this server sets itself (sub, roles, as_rid, โ€ฆ) are refused when saving.
as_as"sa"
as_ridThe realm ID

Roles come straight from the IdP: this server does not filter them against its role list.

A SAML user is a different identity from a user of another scheme with the same name: a session's identity is the realm, the scheme (as_as) and sub together. Applications must key users on all three, never on sub alone.


Security checks

A response is accepted only if all of these hold; otherwise the ACS answers 401 SAML error: <reason> and logs an auth.login.failure event.

CheckRule
Browser bindingThe _ea_saml cookie set by /login equals RelayState (prevents login CSRF: a response obtained elsewhere can't be posted into someone else's browser).
SolicitedInResponseTo names a pending request of this realm, which is consumed (single use, 10-minute lifetime).
SignatureThe assertion, or the response containing it, is signed by one of the IdP certificates from the metadata. Unsigned content is ignored (XML signature-wrapping defence).
AlgorithmsRSA or ECDSA with SHA-256/384/512 only, for both signature and digest.
IssuerEquals the IdP entity ID.
AudienceAn AudienceRestriction is present and every one of them names sp_entity_id.
Recipient / DestinationEqual sp_acs_url.
TimeNotBefore / NotOnOrAfter hold with 3 minutes of clock skew, and the assertion was issued at most 5 minutes ago.
AuthenticationThe assertion has an AuthnStatement and a bearer SubjectConfirmation.
StatusThe response status is Success.
ReplayThe assertion ID hasn't been used before (remembered for as long as it could be accepted).

Certificate and key rotation

IdP signing certificate. When the IdP rolls its certificate, paste its new metadata into the realm. During a planned rollover, metadata listing both certificates keeps logins working across the switch. The admin UI shows each certificate's expiry in the preview and flags a realm card when a certificate expires within 30 days.

SP signing key. Replace the files named in [saml_sp_params] and restart the server, then have every IdP import the new metadata. IdPs that verify request signatures reject requests signed with the new key until they do.


Troubleshooting

Rejections are logged with the reason:

WARN event="auth.login.failure" realm=my-service auth_scheme="saml" SAML login rejected: โ€ฆ
SymptomLikely cause
404 on /saml/โ€ฆThe server was built without saml, or [saml_sp_params] isn't set.
400 realm 'โ€ฆ' does not exist or does not use SAMLWrong realm in the URL, or SAML isn't enabled for it.
400 return_to is not under an allowed return originAdd the origin to allowed_return_origins.
401 this sign-in was not started from this browserThe browser didn't send back the _ea_saml cookie: the login was started in another browser or profile, a newer sign-in was started in another tab of the same browser (only the latest one can complete), or the server isn't reached over HTTPS. Start again from /saml/{realm}/login.
401 unknown or expired sign-in requestMore than 10 minutes at the IdP, the response was already used, or it answers another realm.
401 โ€ฆ must be signed / signature errorsThe IdP signs with a certificate missing from the pasted metadata, or signs neither the assertion nor the response. Refresh the metadata.
401 โ€ฆ AudienceRequirement / not addressed to this service providerThe IdP's entity ID / audience setting differs from sp_entity_id.
401 โ€ฆ Recipient / destinationThe IdP's ACS / reply URL differs from sp_acs_url (watch for http vs https and trailing slashes).
401 โ€ฆ expired / not valid untilClock drift above 3 minutes between the IdP and this server โ€” check NTP.
401 the NameID is transient โ€ฆSet subject_attribute, or configure a persistent NameID at the IdP.
401 subject attribute 'โ€ฆ' is missingThe IdP doesn't release that attribute to this SP.