Skip to content

upstreamtoken: ID token can go permanently stale because refresh is keyed on access-token expiry #6237

Description

@tgrunnagle

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:

  1. Login: access token (1h) + ID token (1h) stored.
  2. t+1h: access token expires, refresh succeeds, response has no id_token.
  3. The expired ID token is carried forward. Access token is fresh; session continues.
  4. 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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions