Repository navigation
upstreamtoken: ID token can go permanently stale because refresh is keyed on access-token expiry #6237
Description
Activity
- addedneeds-triageIssue needs initial triage by a maintainerIssue needs initial triage by a maintainer
on Aug 7, 2026 - addedbugSomething isn't workingSomething isn't workingand removedneeds-triageIssue needs initial triage by a maintainerIssue needs initial triage by a maintainer
on Aug 10, 2026 A second shape of this bug: it also hits providers that DO rotate the ID token
The summary above covers the case where the provider omits
id_tokenon refresh. There is a second case with the same root cause (refresh is keyed only on access-token expiry). It affects providers that rotate the ID token correctly, as long as their ID token expires before their access token.Microsoft Entra ID is the concrete example. Per Microsoft's token-lifetime documentation, ID tokens have a fixed lifetime of about 60 minutes, while access tokens get a randomised lifetime of 60 to 90 minutes. Entra v2 does return a new
id_tokenon arefresh_tokengrant whenopenidis in scope, and ToolHive handles that part correctly at v0.51.4:- The refresh request sends the configured scopes explicitly, and the OIDC provider always includes
openid(oauth2.go#L829-L858,oidc.go#L260-L268). - The refreshed ID token is validated (
oidc.go#L585-L623) and persisted (refresher.go#L197). - The old ID token is only kept when the response omits one (
refresher.go#L217-L221,service.go#L184-L195).
The gap is in when refresh runs.
GetValidTokens(service.go#L71) andGetAllUpstreamCredentials(service.go#L113-L122) refresh only whenExpiresAt, which is the access token's expiry, has passed. With Entra, each cycle goes like this:- t=0: login stores an access token (expires somewhere in t+60..90m) and an ID token (expires at t+60m).
- t+60m to t+60..90m: the ID token has expired, but the access token has not, so nothing refreshes.
Identity.UpstreamIDTokenscarries an expired ID token for up to about 30 minutes. - Once the access token expires, refresh runs, Entra returns a fresh ID token, and the consumer recovers until the next cycle.
Any consumer that needs a currently valid ID token fails closed throughout step 2 of every cycle. One example is a consumer that evaluates the ID token's claims as an authorization input and treats
expas its staleness signal. This is a recurring outage window, not a permanent one. It affects any provider whose ID-token lifetime is shorter than its access-token lifetime, which includes some Okta configurations as well as Entra.Proposal: an opt-in ID-token staleness trigger
This is direction 1 from the issue body, made opt-in:
- Add an opt-in setting to
InProcessService, either a constructor option or a per-provider config flag. The name is open; something likeRefreshOnExpiredIDToken. - When the setting is enabled, the stored row has a non-empty
IDTokenwhoseexphas passed (minus a small skew), and a refresh token exists, refresh exactly as for an expired access token. This applies in bothGetValidTokensandGetAllUpstreamCredentialsthroughrefreshOrFail, so the existing singleflight/CAS path is reused. Parsingexponce at store time and keeping it onUpstreamTokens(for exampleIDTokenExpiresAt) avoids re-parsing the JWT on every read. - Why opt-in: the
Identity.UpstreamIDTokensdoc comment (identity.go#L157-L176) and the comment atservice.go#L137-L145deliberately treat ID-token claims as login-time facts that claim-readers should not expire. The in-tree consumers keep that contract. An opt-in trigger serves consumers that need a fresh token without changing it for anyone else, and adds no extra refresh traffic by default. - When the provider omits
id_tokenon refresh: keep today's carry-forward. The consumer still sees an expired ID token and fails closed, so this proposal does not mask the omitted-id_tokencase in the issue body. Directions 2 and 3 remain the way to make that case visible. With the trigger enabled, the refresh-on-every-read loop this could cause should be bounded. For example, skip the ID-token trigger after a refresh that returned noid_tokenuntil the access token itself next expires. - When there is no refresh token: do not trigger. Return the stored row unchanged, as today.
Acceptance criteria
- Unit test (
pkg/auth/upstreamtoken): with the option enabled, a stored row with a valid access token and an expired ID token triggers exactly one refresh through the refresher, and the returnedUpstreamCredential.IDTokenis the rotated one. Cover bothGetValidTokensandGetAllUpstreamCredentials. - Unit test: with the option enabled and no refresh token, no refresh is attempted and the stored (expired) ID token is returned.
- Unit test: with the option enabled and a refresh response that omits
id_token, the old ID token is carried forward (consumer sees it stale), and a subsequent read before access-token expiry does not refresh again. - Unit test: with the option disabled (the default), a row with a valid access token and an expired ID token triggers no refresh, so existing behaviour is unchanged.
- The
Identity.UpstreamIDTokensdoc comment states that, with the option enabled, the ID token is refreshed when expired, and keeps the omitted-id_tokencaveat.
- The refresh request sends the configured scopes explicitly, and the OIDC provider always includes
Summary
upstreamtoken.Servicerefreshes an upstream provider's tokens based on access-token expiry only, and carries the original, possibly expired ID token forward when the refresh response omitsid_token. A consumer that treats the ID token's ownexpas meaningful will therefore see a permanently stale subject token for the rest of the session, with no path to recover.Detail
Two behaviours combine.
1. Refresh is triggered by access-token expiry only.
pkg/auth/upstreamtoken/service.gogates refresh ontokens.IsExpired(time.Now()), which readstokens.ExpiresAt— the access token's lifetime:and the same test in the batch path. The ID token's own
expis never consulted.2. An omitted
id_tokenon refresh falls back to the original.The comment correctly notes OIDC Core 1.0 §12.2 permits — but does not require — a new
id_tokenon refresh, and frames the fallback as defense-in-depth so the caller never sees an empty subject token. That is reasonable in isolation.Why the combination is a problem
ID tokens are typically much shorter-lived than refresh tokens. Against a provider that omits
id_tokenon refresh, the sequence is:id_token.exp.A consumer that validates
expon the ID token — the correct thing to do for a token it is about to derive identity or claims from — must reject it. Because the access token keeps refreshing successfully, the provider never enters a failed state, so the auth chain regards the leg as healthy and does not re-prompt. The user cannot self-heal by reconnecting.A related shape: when
RefreshTokenis empty (for example, an operator-narrowed scope set that dropsoffline_access),refreshOrFailfails and the provider lands in a failed state, so the credential is absent rather than stale. That case is at least visible.Impact
Any downstream consumer that validates ID-token freshness. Concretely, this was found while adding a consumer in the Stacklok enterprise distribution that validates the platform IdP's ID token (
exp/iss/aud/iat/nbf) before using its claims for an authorization decision. The stale-token state makes that consumer fail closed indefinitely for an otherwise healthy session.We are not asking for a behaviour change to suit that consumer specifically — the underlying issue is that
UpstreamCredential.IDTokenhas no freshness contract, so every consumer has to guess.Suggested directions
Roughly in order of preference:
IDTokena freshness contract. Refresh when either token is near expiry, by parsing the ID token'sexpat store time and tracking it alongsideExpiresAt. This makes the field mean what consumers assume.IDTokenExpiresAtfield, or a distinguishable sentinel) so a consumer can decide, and so a re-auth can be triggered rather than inferred.IDTokenonce the original has expired. Simplest, but it moves the failure to consumers that currently rely on the carry-forward — hence third.Happy to send a PR for whichever direction maintainers prefer.
References
id_tokenis optional on refresh.exp.