fix(sso): recover an Entra ID session whose access token expired - #3255
Open
marevol wants to merge 1 commit into
Open
fix(sso): recover an Entra ID session whose access token expired#3255marevol wants to merge 1 commit into
marevol wants to merge 1 commit into
Conversation
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.
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.
Problem
refresh()gave up the moment the access token was past its expiry:FessBaseAction.godHandPrologueis the only caller ofFessUser.refresh()in the whole tree —refresh()is declared on Fess's ownorg.codelibs.fess.entity.FessUser, so LastaFlute cannot call it — and it assigns the result to a local that is only debug-logged. Sofalsenever 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 intervalEntraIdAuthenticatoruses 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
truestill returnstrue, and every path that used to returnfalsestill returnsfalse. Only "expired, then successfully renewed" changes. That is expressed asreturn !expiredat 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:
refreshTokenSilentlycatchesExceptionitself and returnsnull, so the dominant failure mode never reached thecatchclause at all and fell through toreturn truewith no logging at any level. Raising only thecatchwould 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
Each new test was checked against the unfixed class first (restored from
HEAD) and fails there with the symptom it describes:attemptsARenewalWhenTheTokenHasExpired—an expired access token must not be given up on without asking MSAL4J ==> expected: <1> but was: <0>recoversASessionWhoseTokenExpired—expected: <true> but was: <false>holdsOffAFailingRenewalUntilTheThrottleLapses—expected: <1> but was: <0>doesNotStampedeWhenConcurrentRequestsFindAnExpiredToken— the latch timed out because the winner never entered the acquisitionBecause 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 thecatchalone givesexpected: <1> but was: <2>. All mutations were reverted.The WARN was confirmed to render in
target/logs/fess.log, whereorg.codelibslogs:Silent authentication returned no usable access token for ... expired=true. Next attempt in 60 seconds.mvn formatter:formatandlicense:formatreport no changes.Follow-ups not in this PR
EntraIdAuthenticator.refreshTokenSilentlyswallows every exception intonulland 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.godHandProloguestill discards the boolean. Acting onfalseis a behaviour change that needs its own decision, and this PR deliberately does not make it.