Skip to content

Support SessionIndex and NameID Format in logout requests - #112

Merged
escherize merged 4 commits into
masterfrom
saml-session-index
Sep 15, 2026
Merged

escherize merged 4 commits into
masterfrom
saml-session-index

Conversation

@escherize

@escherize escherize commented Sep 14, 2026 •

Copy link
Copy Markdown

Why

Some IdPs — notably Auth0 — issue several sessions for a single NameID and reject a
LogoutRequest that does not say which session to end:

client_error: 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.

Reported downstream as metabase/metabase#77837.

What

  • Parse — Assertion->map now returns :session-index, read from the assertion's first
    AuthnStatement. nil when the IdP sends none.
  • Emit — idp-logout-redirect-response / logout-redirect-location accept an optional
    :session-index. Existing positional arities are unchanged and pass no index.

Note the asymmetry in the OpenSAML API this mirrors: on login SessionIndex is an attribute
(AuthnStatement.getSessionIndex() → String); on logout it is an element appended to
LogoutRequest.getSessionIndexes().

The Azure AD constraint

A hardcoded SessionIndex was 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
IssueInstant is now, so an unpinned comparison is meaningless — the first attempt at this
check was wrong for exactly that reason):

  • with an index → <saml2p:SessionIndex> in the saml2p protocol namespace, positioned after
    NameID, per §4.4.4.1
  • nil and blank → output identical to the pre-change path, to the byte

2. Parsing works on real assertions. Every fixture in test/saml20_clj/test/*.xml already
carried SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93", so the exact-map assertions
in response_test.clj now 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 true instead of the non-blank guard) fails 5 assertions — the new test
and 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 signed
LogoutRequests to its live SLO endpoint — one with a SessionIndex, one without. Keycloak
returned StatusCode: Success for both. Keycloak never had this bug, so this does not prove
the 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: Success
with InResponseTo matching the request, and the session is deleted.

That took a second change in this PR. Sending the correct SessionIndex was not enough — Auth0 still
answered No active session(s) found matching LogoutRequest, because an IdP matches a LogoutRequest
on 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 the Format from the login
assertion 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 Success to an anonymous LogoutRequest unconditionally — including for a
fabricated 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.

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.
@escherize escherize changed the title Support SessionIndex in login assertions and logout requests Support SessionIndex and NameID Format in logout requests Sep 14, 2026
ranquild
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.
@escherize
escherize merged commit d649833 into master Sep 15, 2026
11 checks passed
@escherize
escherize deleted the saml-session-index branch September 15, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants