SAML sign-in
SAML logs a browser user in through a corporate identity provider — Okta, ADFS, Entra ID,
Keycloak — using the SAML 2.0 SP-initiated web SSO flow with the HTTP-POST binding. It then
issues a TesseraQL browser session: the same tesseraql_sid cookie that password and OIDC login
produce, so every auth: browser route is satisfied identically however the user signed in (see
authentication.md).
Once enabled, the login page shows Sign in with SAML, linking to
GET /_tesseraql/saml/login. The page the user originally requested rides along as RelayState,
and is always sanitized to a same-origin path after login, so it cannot become an open
redirect.
Like OIDC, SAML travels with the runtime: set tesseraql.saml.enabled: true and the SP routes
install themselves. Nothing to add to a classpath — configuration is what turns it on. All SAML
processing is JDK-only; there is no third-party SAML or XML security dependency.
Configuration
Section titled “Configuration”A complete SP configuration:
tesseraql: saml: enabled: true sp: audience: https://app.example.com/saml # SP entity id (required) acsUrl: https://app.example.com/_tesseraql/saml/acs nameIdFormat: urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress signingKey: saml/sp-key.pem # optional; signs redirect messages idp: metadata: saml/idp-metadata.xml # pinned signing key source (or publicKey) ssoUrl: https://idp.example.com/sso # IdP single sign-on URL sloUrl: https://idp.example.com/slo # IdP single logout URL (optional) attributes: loginId: uid displayName: displayName email: mail roles: roles groups: memberOf tenant: tenantId link: enabled: true # resolve/authorize via local identity contracts (else IdP-asserted) provision: false # JIT-provision an unknown user the first time they sign in allowIdpInitiated: false # accept unsolicited (IdP-initiated) responses requireSignedLogout: true # inbound single-logout requests must be signed clockSkew: 5m # allowed skew for the assertion's time conditions| Key | Required | Meaning |
|---|---|---|
sp.audience |
yes | The SP entity id. Every assertion’s audience restriction must include it. |
sp.acsUrl |
recommended | The public URL of the Assertion Consumer Service (…/_tesseraql/saml/acs). Enables the recipient check, the Sign in with SAML login route, and the SP metadata endpoint; without it those are off — and lint says so (TQL-SEC-4092). |
sp.nameIdFormat |
no | The NameID format advertised in SP metadata (default unspecified). |
sp.signingKey |
no | Path to an RSA private key (PKCS#8, PEM or DER) used to sign outgoing HTTP-Redirect messages. Without it, redirects are sent unsigned. |
idp.metadata |
one of these two | Path to the IdP’s SAML metadata XML; the signing certificate is extracted from its IDPSSODescriptor (a use="signing" key, else one with no use). |
idp.publicKey |
one of these two | Path to the IdP signing key directly: an X.509 certificate (PEM or DER) or a bare public key (PEM PUBLIC KEY or DER). |
idp.ssoUrl |
for SP-initiated login | Where GET /_tesseraql/saml/login redirects the browser with its AuthnRequest. |
idp.sloUrl |
for single logout | Where logout sends the LogoutRequest; also enables the inbound single-logout endpoint. |
attributes.* |
no | Assertion-attribute names mapped onto the principal (see below). |
attributes.map.<name> |
no | Declared attribute capture: assertion attribute <name> re-syncs into the store attribute the value names, at every linked login (see below). |
link.enabled |
no | Resolve the principal from the local identity store instead of the assertion (default false). |
link.provision |
no | With linking on, create a local user on first federated sign-in (default false). |
link.subjectAttribute |
no | The assertion attribute holding the immutable identity-link subject; default is the persistent NameID. |
allowIdpInitiated |
no | Accept responses that answer no pending request (default false). |
requireSignedLogout |
no | Reject unsigned inbound logout requests (default true). |
clockSkew |
no | Allowed clock skew for the assertion’s time-bound conditions, as a duration string such as 5m or 30s (default 5m). |
Key paths are files relative to the app home, read at startup. idp.metadata takes a
file path or an https:// URL fetched at boot: the metadata pins the IdP signing key, so
a URL is held to the egress discipline — the host must be in
tesseraql.http.outbound.allowedHosts (TQL-BATCH-5305, deny by default) and plain http://
is refused off loopback (TQL-SEC-4087). A successful fetch caches to
work/saml/idp-metadata.xml; if the IdP’s endpoint is down at a later boot, the cached copy
serves with a warning, so an IdP outage never bricks the app. A Studio wizard (SAML SP,
under the IAM Admin wizards) generates this config block.
Metadata exchange
Section titled “Metadata exchange”When sp.acsUrl is set, the SP publishes its own metadata at GET /_tesseraql/saml/metadata
(application/samlmetadata+xml, unauthenticated): an EntityDescriptor advertising the entity id,
the HTTP-POST Assertion Consumer Service, and the NameID format, declaring
WantAssertionsSigned="true". Point the IdP at that URL, or register the two values manually:
- Entity id / audience —
tesseraql.saml.sp.audience - ACS URL (HTTP-POST) —
https://<your-app>/_tesseraql/saml/acs
In the other direction, download the IdP’s metadata XML into the app (idp.metadata) — or, if the
IdP hands you only a certificate, use idp.publicKey — and copy its SSO/SLO URLs into
idp.ssoUrl / idp.sloUrl.
Attribute mapping and user linking
Section titled “Attribute mapping and user linking”The assertion’s subject NameID becomes the principal subject, and — unless
attributes.loginId names an attribute — also the login id. Each attributes.* entry names the
SAML Attribute whose value fills the corresponding principal field: display name, email, tenant,
and the (possibly multi-valued) roles and groups. Unmapped fields stay empty; the full attribute
set is available as principal claims either way.
Where roles come from is the link switch:
- Linking off (default): the principal is built from the assertion — IdP-asserted roles and groups drive authorization directly.
- Linking on: the principal is resolved against the local identity store, so authorization
uses locally managed roles and permissions rather than whatever the IdP asserts. The resolution
key is an immutable identity link (
tql_user_identities): the persistent NameID — or the attributelink.subjectAttributenames — links to the local user on first sign-in, and every later sign-in resolves through the link. Login id, display name and email are mutable, re-synced profile fields, so a login rename or mail-domain migration at the IdP moves the same account — roles, grants and all — instead of provisioning a duplicate. A federated user with no local account is rejected — unlesslink.provision: true, which creates an ACTIVE local user (with an opaque internal id) on first sign-in. With linking on, thesamlcoverage kind tracks that the identity contracts the login path runs are exercised by contract test cases (see testing.md).
With linking on, attributes.map is the declared attribute capture: each entry re-syncs one
assertion attribute into a store attribute at
every login — set when the assertion carries it, deleted when it stops — written before the
principal resolves, so the same sign-in’s assignment rules already see the fresh value. Unmapped
attributes stay discarded; capture is declared, not promiscuous.
attributes: email: email map: department: department # assertion attribute → store attribute title: jobTitleLogout
Section titled “Logout”GET /_tesseraql/saml/logout invalidates the local session and clears the cookie; when
idp.sloUrl is configured it then redirects to the IdP with a LogoutRequest carrying the
federated NameID and session index, so the IdP can end its own session too. When the IdP initiates
logout, it calls GET /_tesseraql/saml/slo (available when idp.sloUrl is set): the request’s
redirect signature is verified against the pinned IdP key (mandatory unless
requireSignedLogout: false), the local session ends, and a LogoutResponse redirect answers the
IdP.
Security posture
Section titled “Security posture”- Pinned IdP key. Signatures verify only against the configured key; any
KeyInfoembedded in the message is ignored, so an attacker cannot present a self-chosen certificate. - Response validation. XML parsing disallows DTDs and external entities; the response status
must be Success; signature verification runs with JDK secure validation (weak algorithms
rejected, transforms restricted); and the consumed assertion must lie inside the signed element —
an assertion smuggled outside the signed subtree (XML signature wrapping) is never trusted. The
audience restriction must include
sp.audience, and whensp.acsUrlis set the subject confirmation’s recipient must match it. Leavingsp.acsUrlunset is supported (an IdP-initiated-only deployment has no ACS URL to advertise) but turns that recipient check off: the assertion is then accepted regardless of which service provider it was addressed to, so one captured at another SP of the same IdP replays here. Lint raisesTQL-SEC-4092for it — the control is optional, but never silently absent. - Replay protection. A database-backed guard (tables
tql_saml_requestandtql_saml_seen_assertion, created automatically) makes each AuthnRequest id single-use: the response’sInResponseTomust consume a pending request exactly once, and the RelayState recorded at issue time pins the round-tripped value against tampering. Each assertion id is accepted at most once until itsNotOnOrAfter. Because the state is in the shared database, replays are rejected on any node of a multi-node deployment. - Clock skew. Time-bound conditions (
NotBefore,NotOnOrAfter, subject confirmation expiry) allow five minutes of skew by default;tesseraql.saml.clockSkew(a duration string such as30s) tightens or relaxes it. - IdP-initiated SSO is off by default. An unsolicited response — one answering no pending
request — is rejected unless
allowIdpInitiated: true. Leave it off unless your IdP portal starts logins itself. - Generic failures. A validation failure answers
401with a generic body; assertion contents are never echoed back or logged. - SSO-only mode. Hide the password form with
tesseraql.console.login.password.enabled: false, leaving SAML as the only sign-in; the account page then tells users their credentials are managed by the identity provider, and password change is unavailable (TQL-ACCOUNT-4803).
Troubleshooting
Section titled “Troubleshooting”SP failures return generic JSON — 401 {"error": "SAML authentication failed"} for a validation
failure, 400 {"error": "Invalid SAML request"} for a malformed one — with no SAML-specific
TQL-* runtime codes. The usual causes:
- Every login fails with 401. The pinned key does not match what the IdP signs with — most
often a rotated IdP certificate. Refresh
idp.metadata(oridp.publicKey). Also check that the IdP sends the exactsp.audienceas the assertion audience and the exactsp.acsUrlas the recipient — a scheme, host, or trailing-slash mismatch fails validation. - 401 only for IdP-portal logins. Unsolicited responses are rejected by default; set
allowIdpInitiated: trueif users start from the IdP’s app portal. - 401 after sitting on the IdP login page. Pending AuthnRequests expire after ten minutes; the returning response no longer matches a pending request. Signing in again succeeds.
- 401 for one particular user, others fine. With
link.enabled: trueand provisioning off, a federated login with no matching local account is rejected — create the local user (or enablelink.provision). - No “Sign in with SAML” on the login page.
enabledis nottrue, orsp.acsUrl/idp.ssoUrlis missing (the login route only installs when both are known). A plugin allowlist (tesseraql.plugins.allowlist) must includesamlif one is declared — the allowlist gates extensions that arrive on the classpath as well as ones that arrive as plugin jars.
Related pages
Section titled “Related pages”- authentication.md — the principal model, browser sessions, the login page, and OIDC (the other browser SSO method).
- account.md — what signed-in SSO users see on the account surface.
- credential-lifecycle.md — password reset, invitations, and TOTP for the local realm; SSO-managed credentials live at the IdP instead.