Skip to content

fix(sso): bound the accounts kept in the shared Entra ID token cache - #3268

Merged
marevol merged 1 commit into
test/spnego-basic-realm-library-parityfrom
fix/entraid-bound-msal-account-cache
Aug 22, 2026
Merged

fix(sso): bound the accounts kept in the shared Entra ID token cache#3268
marevol merged 1 commit into
test/spnego-basic-realm-library-parityfrom
fix/entraid-bound-msal-account-cache

Conversation

@marevol

@marevol marevol commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

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 — the expiresOn comparison 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 acquireTokenSilently throws NO_TOKEN_IN_CACHE on 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 extCacheKeyHash that 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 maxCachedAccounts is passed, via the existing removeAccount path.

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.

removeAccount 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.

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> in fess_sso++.xml alongside maxGroupCacheSize, 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 returns true until 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 of getClientApplication(), not a patch-release change.

Verification

mvn test over 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:

mutation reddens
insertion order instead of access order keepsTheAccountThatIsStillAcquiring
removeAccount no longer frees the slot freesTheSlotSoALogoutIsNotJustAnMsalCall
eviction loop removed evictsTheAccountThatWentLongestWithoutAToken

@marevol marevol added this to the 15.9.0 milestone Aug 11, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant