upstreamtoken: ID token can go permanently stale because refresh is keyed on access-token expiry
@tgrunnagle is already working on this.
Since Aug 10, 2026.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
Summary
upstreamtoken.Service refreshes an upstream provider's tokens based on access-token expiry only, and carries the original, possibly expired ID token forward when the refresh response omits id_token. A consumer that treats the ID token's own exp as 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.go gates refresh on tokens.IsExpired(time.Now()), which reads tokens.ExpiresAt — the access token's lifetime:
if !tokens.ExpiresAt.IsZero() && tokens.IsExpired(time.Now()) {
return s.refreshOrFail(ctx, sessionID, providerName, tokens)
}
and the same test in the batch path. The ID token's own exp is never consulted.
2. An omitted id_token on refresh falls back to the original.
idToken := refreshed.IDToken
if idToken == "" {
idToken = expired.IDToken
}
The comment correctly notes OIDC Core 1.0 §12.2 permits — but does not require — a new id_token on 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_token on refresh, the sequence is:
- Login: access token (1h) + ID token (1h) stored.
- t+1h: access token expires, refresh succeeds, response has no
id_token. - The expired ID token is carried forward. Access token is fresh; session continues.
- Every subsequent read returns a fresh access token and an ID token that is now hours or days past
exp.
A consumer that validates exp on 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 RefreshToken is empty (for example, an operator-narrowed scope set that drops offline_access), refreshOrFail fails 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.IDToken has no freshness contract, so every consumer has to guess.
Suggested directions
Roughly in order of preference:
- Give
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. - Make staleness visible instead of silent. Return the stale ID token with an explicit signal (a
IDTokenExpiresAtfield, or a distinguishable sentinel) so a consumer can decide, and so a re-auth can be triggered rather than inferred. - Drop the fallback and return an empty
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
- OIDC Core 1.0 §12.2 (Successful Refresh Response) —
id_tokenis optional on refresh. - OIDC Core 1.0 §3.1.3.7 (ID Token Validation) — step 9 requires the current time be before
exp.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.