fix(sso): bound the accounts kept in the shared Entra ID token cache - #3268
Merged
marevol merged 1 commit intoAug 22, 2026
Conversation
MSAL4J's TokenCache is five plain maps with no size bound and no expiry; removeAccount is the only thing that takes anything out of them, and an expired access token is filtered when it is read rather than swept. While the client application was rebuilt on every call there was nothing to accumulate, but #3227 shares one instance for the whole server so that silent refresh can work at all, and its cache now lives as long as the JVM. logout() prunes the account it is given, and that is the only trigger there is: there is no session listener, so a user who lets their session time out or just closes the browser keeps an access token, a refresh token and an ID token resident until the server restarts. A refresh token is good for up to 90 days. The growth is smaller than it first looks -- every cache key is composed from the home account id, environment, client id, realm and target, with no per-login component, so a user who logs in repeatedly overwrites their own entry and the bound is distinct accounts rather than logins. A large tenant still reaches a size worth capping. Accounts are tracked in access order, keyed by home account id, and the account that went longest without acquiring a token is evicted once maxCachedAccounts is passed. Access order rather than insertion order on purpose: a session alive for days keeps acquiring, and insertion order would evict exactly the account most likely to still be in use. The default of 10000 is out of reach for an ordinary deployment, and when it is reached the cost is one silent re-authentication -- a failed silent acquisition leaves refresh() returning true until the access token really expires, and the login that follows goes through an unexpired Entra ID session. removeAccount now frees the slot unconditionally and before the MSAL4J call, so a logout stops counting against the bound whether or not that call gets anywhere -- otherwise a server with a steady turnover would evict live sessions to make room for accounts that had already left. The eviction runs outside the monitor, because removeAccount joins on MSAL4J's future and holding the map across that would queue every other acquisition behind it.
marevol
force-pushed
the
fix/entraid-bound-msal-account-cache
branch
from
August 22, 2026 06:31
a6786f9 to
f4ca00d
Compare
marevol
changed the base branch from
master
to
test/spnego-basic-realm-library-parity
August 22, 2026 06:31
This was referenced Aug 22, 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.
Problem
MSAL4J's
TokenCacheis five plain maps with no size bound and no expiry.removeAccountis the only thing that takes anything out of them — theexpiresOncomparison is a read filter, not a sweep, so an expired access token is never removed, it just stops being served.While the client application was rebuilt on every call this was harmless: nothing survived the call. #3227 shares one instance for the whole server so that silent refresh can work at all — a fresh application starts with an empty cache and
acquireTokenSilentlythrowsNO_TOKEN_IN_CACHEon a miss — and that cache now lives as long as the JVM.logout()prunes the account it is given, and that is the only trigger there is. There is no session listener, so a user who lets their session time out, or just closes the browser, keeps an access token, a refresh token and an ID token resident until the server restarts. A refresh token is good for up to 90 days.The growth is smaller than it first looks. Every cache key is composed from the home account id, environment, credential type, client id, realm and target, with no per-login component — and the
extCacheKeyHashthat would add one is only non-blank for client-credential requests, which none of the three paths here use. So a user who logs in repeatedly overwrites their own entry: the bound is distinct accounts, not logins. A large tenant still reaches a size worth capping.Change
Accounts are tracked in a small access-ordered map keyed by home account id, and the account that went longest without acquiring a token is evicted once
maxCachedAccountsis passed, via the existingremoveAccountpath.Access order, not insertion order. A session that has been alive for days keeps acquiring; insertion order would evict exactly the account most likely to still be in use.
removeAccountfrees the slot unconditionally and before the MSAL4J call, so a logout stops counting against the bound whether or not that call gets anywhere. Otherwise a server with a steady turnover would evict live sessions to make room for accounts that had already left.The eviction runs outside the monitor, because
removeAccountjoins on MSAL4J's future and holding the map across that would queue every other acquisition behind it.Risk
The default of 10000 is out of reach for an ordinary deployment, so behaviour is unchanged for essentially everyone; it is exposed as a commented
<property>infess_sso++.xmlalongsidemaxGroupCacheSize, which has the same shape and the same default.When the bound is reached, the cost of evicting a live session is one silent re-authentication: a failed silent acquisition is caught and
refresh()still returnstrueuntil the access token really expires, and the login that follows goes through an unexpired Entra ID session.Targeted at 15.9.0 rather than 15.8.0 — this adds state to a path that was hardened repeatedly during the 15.8 cycle, and the condition it addresses is a slow accumulation rather than a fault.
Not done here
The complete fix is a session-backed token cache via
ITokenCacheAccessAspect, which is the pattern MSAL4J is designed for and which would let Tomcat's own session expiry do the eviction.deserialize()replaces the cache maps wholesale under the write lock, so that means one application per user session — a redesign ofgetClientApplication(), not a patch-release change.Verification
mvn testover the SSO, logout and admin surface → 236/236 (EntraIdAuthenticator*103, up from 98;EntraIdUserPermission*3,LogoutHandler*9,AdminGeneralAction*17,AdminSysteminfoAction*6,SamlAuthenticator*40,OpenIdConnectAuthenticator*26,SpnegoAuthenticator*32).Each new test was checked against a mutant:
keepsTheAccountThatIsStillAcquiringremoveAccountno longer frees the slotfreesTheSlotSoALogoutIsNotJustAnMsalCallevictsTheAccountThatWentLongestWithoutAToken