Skip to content

fix(sso): recover an Entra ID session whose access token expired - #3255

Open
marevol wants to merge 1 commit into
masterfrom
fix/entraid-expired-token-refresh
Open

fix(sso): recover an Entra ID session whose access token expired#3255
marevol wants to merge 1 commit into
masterfrom
fix/entraid-expired-token-refresh

Conversation

@marevol

@marevol marevol commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

refresh() gave up the moment the access token was past its expiry:

if (tokenExpiryTime < currentTime) {
    return false;
}

FessBaseAction.godHandPrologue is the only caller of FessUser.refresh() in the whole tree — refresh() is declared on Fess's own org.codelibs.fess.entity.FessUser, so LastaFlute cannot call it — and it assigns the result to a local that is only debug-logged. So false never logged anyone out. The session simply kept a dead access token, and because every later request took the same early exit, no further silent acquisition was ever attempted and the user's group memberships stopped being re-read for the rest of the session.

That early exit is byte-identical to 15.7, so it is not itself new. What changed is how easy it is to reach. 15.7 ran a silent acquisition on every request, so any request before expiry renewed the token. Since #3243 one is attempted only inside the last five minutes, which is the right fix for the per-request Graph call it removed, but it means a user idle across that margin now lands in the dead state 15.7 kept them out of.

Change

An expired access token goes through the acquisition instead of straight out. MSAL4J's silent flow spends the cached refresh token, which outlives the access token by hours, so the session is usually recoverable.

Attempting it unconditionally would put the per-request round trip straight back, because several failures are permanent: a revoked refresh token, a disabled account, and an account a logout() on another session evicted from the shared MSAL4J cache. One failure therefore holds the next attempt off for a minute — the same interval EntraIdAuthenticator uses for a throttled Microsoft Graph, and short enough that a failure early in the five-minute margin still leaves four more attempts before the token actually expires.

The return value is deliberately conservative: every path that used to return true still returns true, and every path that used to return false still returns false. Only "expired, then successfully renewed" changes. That is expressed as return !expired at the three exits, so the CAS loser and the post-failure exit report what this thread can actually vouch for rather than what the winner might achieve.

A silent result whose own expiry is already past is treated as a failure, so no path can leave the session holding a dead token without also recording the backoff.

Logging

Both failure paths now log at WARN. This is the anti-pattern #3218 removed from getLoginCredential, where a failure was invisible unless debug logging happened to be on.

It is worth being precise about which path mattered: refreshTokenSilently catches Exception itself and returns null, so the dominant failure mode never reached the catch clause at all and fell through to return true with no logging at any level. Raising only the catch would have left the real failure invisible, so both are raised. The backoff is what keeps a permanently failing session to one line per minute instead of one per request.

One intended side effect: an in-margin acquisition failure now also logs, where it previously logged nothing. expired= in the message distinguishes the two.

Verification

Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 -- EntraIdUserPermissionTest (3 before)
Tests run: 94, Failures: 0, Errors: 0, Skipped: 0 -- sso.entraid

Each new test was checked against the unfixed class first (restored from HEAD) and fails there with the symptom it describes:

  • attemptsARenewalWhenTheTokenHasExpiredan expired access token must not be given up on without asking MSAL4J ==> expected: <1> but was: <0>
  • recoversASessionWhoseTokenExpiredexpected: <true> but was: <false>
  • holdsOffAFailingRenewalUntilTheThrottleLapsesexpected: <1> but was: <0>
  • doesNotStampedeWhenConcurrentRequestsFindAnExpiredToken — the latch timed out because the winner never entered the acquisition

Because the unfixed code never attempts a renewal at all, those failures alone would not prove the throttle and the CAS guard are load-bearing, so each was also mutation-tested against the fixed code: disabling the throttle check gives expected: <1> but was: <3>; disabling the CAS guard fails the stampede test; removing the throttle from the catch alone gives expected: <1> but was: <2>. All mutations were reverted.

The WARN was confirmed to render in target/logs/fess.log, where org.codelibs logs: Silent authentication returned no usable access token for ... expired=true. Next attempt in 60 seconds.

mvn formatter:format and license:format report no changes.

Follow-ups not in this PR

  • EntraIdAuthenticator.refreshTokenSilently swallows every exception into null and a DEBUG line, which is why the new WARN can only say "no usable token" and never why — revoked, network, or timeout. Letting it throw, or logging the MSAL error code there, would make this message actionable.
  • godHandPrologue still discards the boolean. Acting on false is a behaviour change that needs its own decision, and this PR deliberately does not make it.

refresh() gave up the moment the access token was past its expiry, and
FessBaseAction.godHandPrologue -- its only caller -- discards the
boolean, so nothing logged the user out. The session simply kept a dead
token, and because every later request took the same early exit, no
further silent acquisition was ever attempted and the group
memberships stopped being re-read for the rest of the session.

The early exit is byte-identical to 15.7, so it is not itself new. What
changed is how easy it is to reach: 15.7 ran a silent acquisition on
every request, so any request before expiry renewed the token, while
15.8 only attempts one inside the last five minutes. A user idle across
that margin now lands in the dead state 15.7 kept them out of.

An expired token now goes through the acquisition instead. MSAL4J's
silent flow spends the cached refresh token, which outlives the access
token by hours, so the session is usually recoverable.

Attempting it unconditionally would put back the per-request round trip
REFRESH_MARGIN was introduced to remove, because a revoked refresh
token, a disabled account and an account evicted from the shared MSAL4J
cache by a logout elsewhere all fail permanently. One failure therefore
holds the next attempt off for a minute, matching the backoff the
authenticator applies to a throttled Microsoft Graph. Every pre-existing
scenario returns exactly what it returned before; only "expired, then
renewed" changes.

Both failure paths now log at WARN rather than DEBUG or not at all.
refreshTokenSilently answers null instead of throwing, so the dominant
failure never reached the catch clause and was previously invisible at
any log level. The backoff is what keeps a permanently failing session
to one line per minute instead of one per request.
@marevol marevol added this to the 15.9.0 milestone Aug 11, 2026
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