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
IDToken a freshness contract. Refresh when either token is near expiry, by parsing the ID token's exp at store time and tracking it alongside ExpiresAt. This makes the field mean what consumers assume.
- Make staleness visible instead of silent. Return the stale ID token with an explicit signal (a
IDTokenExpiresAt field, 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
IDToken once 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_token is optional on refresh.
- OIDC Core 1.0 §3.1.3.7 (ID Token Validation) — step 9 requires the current time be before
exp.
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.