Repository navigation
Support SessionIndex and NameID Format in logout requests - #112
Merged
Merged
Conversation
Some IdPs, notably Auth0, issue several sessions for a single NameID and so
cannot tell which session a LogoutRequest refers to when the request carries
only a NameID. They reject it:
No active session(s) found matching LogoutRequest
SAML Profiles 4.4.4.1 makes SessionIndex the element that scopes a
LogoutRequest to a session. Two gaps stopped us from using it:
- Assertion->map never read AuthnStatement, so the SessionIndex the IdP sent
at login was discarded at parse time.
- build-logout-obj had no way to emit one.
Parse it into the assertion map as :session-index, and accept an optional
:session-index when building a LogoutRequest.
A hardcoded SessionIndex was tried once before and broke Azure AD
(metabase/metabase#41600), because a fabricated value matches no session.
Only an index the IdP itself issued is ever sent, and when there is none the
element is omitted entirely so the request stays byte-identical to before.
The existing idp-logout-redirect-response-test, which asserts on the exact
encoded request, is deliberately left unchanged to guard that.
An IdP matches a LogoutRequest on its NameID, and some compare the Format literally rather than treating a missing Format as "unspecified". Accept an optional :name-id-format so an SP can echo back the Format the IdP used in its login assertion. Omitted or blank leaves the NameID without a Format attribute, so the request is byte-identical to one built before this change.
ranquild
previously approved these changes
Sep 14, 2026
Test-Browser-e2e has failed on every run with an etaoin timeout waiting for the
post-login page. Driving the same flow by hand against the live containers shows
what the timeout was hiding: Okta's sign-in page answers every credential submit
with
This developer org has been deactivated. If you need access or have
questions, please contact your org admin.
so the browser never leaves the sign-in page and the wait eventually gives up.
The free org behind these tests (dev-08548225.okta.com) is gone; no amount of
rerunning brings it back.
Drop :okta from the provider list so the suite exercises the IdPs that still
work. The Keycloak leg runs a full SAML login and single logout round trip
against the containerised IdP and passes, which is what this suite is for. The
Entra tenant still reaches its sign-in form, so that test is left alone.
Re-add :okta once a live org is provisioned.
Replaces the deactivated dev-08548225 org, whose sign-in page answered every
credential submit with "This developer org has been deactivated", with a new
Integrator Free Plan org. Updates the SSO and SLO endpoints, the SAML issuer,
and the IdP signing certificate.
The new org runs Okta's Identity Engine sign-in widget, which asks for the
username and password on two separate screens and names the field `identifier`
rather than `username`. Extract a sign-in! helper that picks the right form:
Keycloak still serves the classic single-page form. The test user also needs its
own password, since Okta's default policy rejects the all-lowercase one.
Both legs now run a full SAML login and single logout round trip:
Ran 1 tests containing 6 assertions.
0 failures, 0 errors.
Org setup worth recording, since none of it is discoverable from the code: the
app needs Single Logout enabled with the SP signing certificate uploaded, and
mbcitest needs an app sign-in policy of "Password" - the default "Any two
factors" policy sends the browser to an Okta Verify enrollment screen that a
headless test cannot complete.
ranquild
approved these changes
Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Some IdPs — notably Auth0 — issue several sessions for a single
NameIDand reject aLogoutRequestthat does not say which session to end:SAML Profiles §4.4.4.1 makes
SessionIndexthe element that scopes aLogoutRequestto asession. Two gaps stopped us from using it:
Assertion->mapnever readAuthnStatement, so theSessionIndexthe IdP sent at login wasdiscarded at parse time.
build-logout-objhad no way to emit one.Reported downstream as metabase/metabase#77837.
What
Assertion->mapnow returns:session-index, read from the assertion's firstAuthnStatement.nilwhen the IdP sends none.idp-logout-redirect-response/logout-redirect-locationaccept an optional:session-index. Existing positional arities are unchanged and pass no index.Note the asymmetry in the OpenSAML API this mirrors: on login
SessionIndexis an attribute(
AuthnStatement.getSessionIndex()→ String); on logout it is an element appended toLogoutRequest.getSessionIndexes().The Azure AD constraint
A hardcoded
SessionIndexwas tried once before (b680a8e) and broke Azure AD(metabase/metabase#41600), because a fabricated value matches no session. So:
Only an index the IdP itself issued is ever sent. With none, the element is omitted entirely —
not empty, not a placeholder — leaving the request byte-identical to before.
idp-logout-redirect-response-test, which asserts on the exact deflated+base64 encoded request,is deliberately left unmodified. It passing is the regression guard.
How this was validated
Each step below answers a specific "how do you know?", in order of increasing realism.
1. The XML is shaped correctly, and omission is truly byte-identical.
Built both variants and compared serialized output with the clock pinned (the default
IssueInstantisnow, so an unpinned comparison is meaningless — the first attempt at thischeck was wrong for exactly that reason):
<saml2p:SessionIndex>in thesaml2pprotocol namespace, positioned afterNameID, per §4.4.4.1niland blank → output identical to the pre-change path, to the byte2. Parsing works on real assertions. Every fixture in
test/saml20_clj/test/*.xmlalreadycarried
SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93", so the exact-map assertionsin
response_test.cljnow pin it. No new fixtures needed.3. The tests actually catch the regression they claim to. Mutation check: forcing the element
to always emit (
when trueinstead of the non-blank guard) fails 5 assertions — the new testand the pre-existing byte-exact legacy tests. Reverted, green again. Without this, "the legacy
test still passes" would be an untested claim about a test.
4. A real IdP accepts it. Ran Keycloak in Docker (
docker-compose.yml) and POSTed two signedLogoutRequests to its live SLO endpoint — one with aSessionIndex, one without. Keycloakreturned
StatusCode: Successfor both. Keycloak never had this bug, so this does not provethe fix fixes anything; it proves the change does not break an IdP that already worked, which is
the #41600 failure class.
5. Real Auth0, end to end. Downstream in metabase/metabase#77837 a Metabase instance pointed at
a live Auth0 tenant now completes a full browser login/logout: Auth0 replies
StatusCode: Successwith
InResponseTomatching the request, and the session is deleted.That took a second change in this PR. Sending the correct
SessionIndexwas not enough — Auth0 stillanswered
No active session(s) found matching LogoutRequest, because an IdP matches a LogoutRequeston NameID + SessionIndex, and Metabase was sending a user's email where Auth0's NameID is an
opaque
auth0|<id>. Hence:name-id-format, so an SP can echo back theFormatfrom the loginassertion alongside the right NameID value.
A caution, since an earlier revision of this description got it wrong: do not verify against Auth0
with curl. It returns
Successto an anonymous LogoutRequest unconditionally — including for afabricated SessionIndex and for a nonexistent user. Only a real browser session distinguishes
success from failure.
Suite: 42 tests / 197 assertions, 0 failures.
Release
VERSION.txt→ 4.3.0. Merging to master auto-deploys to Clojars, which unblocks the Metabase PR.