Skip to content

fix(sso): stop bouncing an unmatched SAML response back to the IdP - #3239

Open
marevol wants to merge 1 commit into
masterfrom
fix/saml-session-less-callback
Open

fix(sso): stop bouncing an unmatched SAML response back to the IdP#3239
marevol wants to merge 1 commit into
masterfrom
fix/saml-session-less-callback

Conversation

@marevol

@marevol marevol commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

getLoginCredential() decided whether a request was an assertion consumer service callback by looking for SAML_STATE in the session:

final HttpSession session = request.getSession(false);
if (session != null) {
    final String requestId = (String) session.getAttribute(SAML_STATE);
    if (StringUtil.isNotBlank(requestId)) {
        ...
    }
}
// otherwise: build a new AuthnRequest and redirect to the IdP

That is the one piece of state that goes missing when the session cookie is not returned, so a callback without it was mistaken for a fresh visit and answered with a new AuthnRequest — which the IdP answers with another assertion, in the same state, forever.

The assertion arrives as a cross-site POST (SAML's HTTP-POST binding), and a SameSite=Lax cookie is not sent on one. Fess ships tomcat.sameSiteCookies = lax in tomcat_config.properties, applied to the Tomcat CookieProcessor in FessBoot; because the attribute is set explicitly, Chrome's two-minute "Lax + POST" grace period does not apply either — that only covers cookies with no SameSite attribute at all.

So on a default install, SAML SSO loops until the browser gives up with ERR_TOO_MANY_REDIRECTS, unless the deployment sets tomcat.sameSiteCookies = none.

This is the same failure #3215 identified and fixed for Entra ID. SAML cannot take the same route — #3215 switched Entra ID to response_mode=query so the callback becomes a top-level GET, but the HTTP-POST binding is not optional for a SAML assertion consumer service. The loop itself is broken instead.

Fix

Detect the callback without the session. A request is treated as a callback when it carries SAMLResponse. That does not depend on the cookie, so the callback is never mistaken for a fresh visit.

Fail once instead of bouncing. A callback with no matching AuthnRequest ID now logs a warning naming the likely cause and returns null, so SsoAction shows the SSO login error and goes to the login page — the same shape as EntraIdAuthenticator after #3215.

As a side effect, a plain visit to /sso/ while a login is in flight no longer consumes the pending AuthnRequest ID, so the assertion that follows is still matched instead of costing an extra round trip.

The required cookie setting is documented on the class. A companion fess-docs change covers the same ground in the SAML configuration guide.

Behaviour change worth calling out

An IdP-initiated (unsolicited) response is now rejected rather than answered with a fresh AuthnRequest.

It previously succeeded only via that extra round trip, and only where the session cookie survived — i.e. never under the shipped lax default. It is not documented (neither fess-docs nor this repo mentions IdP-initiated SSO or RelayState), not tested, and #3214 made Fess bind every response to an AuthnRequest ID it sent, so an unsolicited response has nothing to match against. Preserving it would mean carrying a marker through RelayState purely to allow one bounce, which is not worth the machinery for an undocumented accident. Raising this here in case that judgement should go the other way.

Tests

3 added to SamlAuthenticatorTest:

  • test_containsSamlResponse — absent / blank / present
  • test_getLoginCredential_unmatchedResponseFailsInsteadOfRedirecting — a SAMLResponse with no reachable session returns null and warns
  • test_getLoginCredential_requestWithoutResponseKeepsPendingRequestId — a plain visit leaves SAML_STATE intact
  • test_getLoginCredential_requestWithoutResponseStartsLogin — the normal entry path still issues an AuthnRequest

The two behavioural ones fail before this change: the callback returned an ActionResponseCredential (the redirect that caused the loop), and the pending ID was consumed.

org.codelibs.fess.sso package: Tests run: 166, Failures: 0, Errors: 0, Skipped: 0
mvn -o clean javadoc:jar: BUILD SUCCESS
formatter:format + license:format: no changes

`getLoginCredential()` decided whether a request was an assertion consumer
service callback by looking for `SAML_STATE` in the session. That is the one
piece of state that goes missing when the session cookie is not returned, so a
callback without it was mistaken for a fresh visit and answered with a new
AuthnRequest -- which the IdP answers with another assertion, in the same state,
forever.

The assertion arrives as a cross-site POST, and a `SameSite=Lax` cookie is not
sent on one. Fess ships `tomcat.sameSiteCookies = lax`, and because the
attribute is set explicitly the "Lax + POST" grace period does not apply either.
SAML SSO therefore loops on a default install unless the deployment sets
`tomcat.sameSiteCookies = none`.

This is the same failure #3215 fixed for Entra ID. SAML cannot take the same
route, because the HTTP-POST binding is not optional for the assertion consumer
service, so the loop itself is broken instead:

- a request is treated as a callback when it carries `SAMLResponse`, which does
  not depend on the session
- a callback with no matching AuthnRequest ID logs a warning naming the likely
  cause and returns null, so `SsoAction` shows the SSO login error once

A plain visit to `/sso/` while a login is in flight also no longer consumes the
pending AuthnRequest ID, so the assertion that follows is still matched.

Note that an IdP-initiated (unsolicited) response is now rejected rather than
answered with a fresh AuthnRequest. It previously succeeded only via that extra
round trip, and only where the session cookie survived; it is not a documented
or tested flow, and Fess binds every response to an AuthnRequest ID it sent, so
there is nothing for an unsolicited response to match against.

The required cookie setting is documented on the class.

Tests: 3 added to SamlAuthenticatorTest; the two behavioural ones fail before
this change (the callback returned an ActionResponseCredential, and the pending
ID was consumed). `org.codelibs.fess.sso` package: 166 tests, 0 failures.
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.

1 participant