Skip to content

Upstream refresh tokens orphaned on re-authorization #5047

@lorr1

Description

@lorr1

Summary

Upstream refresh tokens are orphaned when a user re-authorizes. Two independent mechanisms cause this: (1) storage is keyed by an ephemeral session ID that is freshly generated on every /authorize call, and (2) the callback stores the new IDP response verbatim, overwriting a previously-stored refresh token with the empty string when the provider does not re-issue one.

The second mechanism is most visible with providers that only return refresh_token on first consent or when prompt=consent is explicitly passed (Google OAuth 2.0 is a prominent example). After any re-authorization, the stored refresh token is effectively lost, and subsequent refresh attempts fail with ErrNoRefreshToken.

Root cause A — session-keyed storage

pkg/authserver/server/handlers/authorize.go:91 generates a fresh session ID on every /authorize:

pending := &storage.PendingAuthorization{
    ...
    SessionID: rand.Text(),   // ← fresh random on every /authorize
    CreatedAt: time.Now(),
}

pkg/authserver/server/handlers/callback.go:142 stores upstream tokens keyed by that session. The session ID is then embedded as the tsid claim in the issued JWT (pkg/authserver/server/session/session.go:120-123), and downstream read paths look up by tsid/session ID (pkg/auth/token.go:1189-1196, pkg/authserver/storage/types.go:222-240).

UserID (the internal ToolHive user subject) is stored as a field on UpstreamTokens (callback.go:138) but no read path uses it as a lookup key. It is only used for cleanup/metadata.

Consequence: any re-authorization mints a new session; the old session's tokens become unreachable through the normal read path.

Root cause B — callback clobbers prior refresh token

pkg/authserver/server/handlers/callback.go:131-142 stores idpTokens.RefreshToken verbatim, with no preservation of a previously-stored refresh token:

storageTokens := &storage.UpstreamTokens{
    ProviderID:   providerID,
    AccessToken:  idpTokens.AccessToken,
    RefreshToken: idpTokens.RefreshToken,   // empty if provider didn't re-issue
    ...
}
if err := h.storage.StoreUpstreamTokens(ctx, sessionID, providerID, storageTokens); err != nil {

pkg/authserver/refresher.go:73-75 already has the correct pattern for the refresh path (preserve prior refresh token when provider omits a new one). The callback path does not mirror this.

Even if root cause A is fixed (e.g., switching to UserID-keyed storage), root cause B will still overwrite a good refresh token with the empty string on every re-auth where the provider does not re-issue a refresh token.

pkg/auth/upstreamtoken/service.go:135-136 then returns ErrNoRefreshToken:

if expired.RefreshToken == "" {
    return nil, ErrNoRefreshToken
}

Failure mode

  1. Initial consent completes. Upstream tokens, including a refresh_token, are stored under session S1 with key (S1, providerID).
  2. Some time later, a new /authorize call is made (any reason — including the compound effect of Non-expiring upstream tokens incorrectly marked as 1h-expiring #5046 causing spurious refresh/reauth cycles).
  3. Authorize handler mints a new session S2. The new JWT's tsid claim points at S2.
  4. Callback completes. If the provider does not re-issue refresh_token (e.g., Google without prompt=consentpkg/authserver/upstream/oidc.go:367-369 shows prompt=consent is optional, not default), the callback stores {AccessToken: new, RefreshToken: ""} at (S2, providerID).
  5. When the new access token expires, the refresher reads from (S2, providerID), finds RefreshToken == "", and returns ErrNoRefreshToken. The provider is then omitted from downstream responses with the log line omitting provider with unrefreshable expired token (pkg/auth/upstreamtoken/service.go:114).
  6. The user is forced to re-authorize. The cycle repeats.

Proposed fix

Two independent changes — neither alone is sufficient.

  1. Key upstream tokens by UserID, not session ID.

    • Add a (user_id, provider_id) → tokens index in both memory and Redis storage backends.
    • Update the downstream read path (pkg/auth/token.go:1189-1196 and friends) to resolve tsid → user_id via the session store, then read upstream tokens by user_id.
    • This is net-new index work; UserID is currently only metadata.
  2. Preserve prior refresh token in the callback. Before calling StoreUpstreamTokens, load any existing record for (user_id, provider_id) and, if idpTokens.RefreshToken == "", carry forward the existing RefreshToken. Mirror the pattern already used at pkg/authserver/refresher.go:73-75.

Related

Compounds with #5046. The fake 1h expiry described there triggers the re-authorization cascade that exposes this bug.

Reproduction

Assumes a Google OAuth client (OIDC) is configured as a ToolHive upstream with the default config (no prompt=consent set).

  1. Start ToolHive with Google configured as an upstream OIDC provider.

  2. As a user, complete the initial /authorize flow. Accept Google's consent screen.

  3. Verify the stored upstream tokens include a non-empty RefreshToken. For the in-memory backend this can be inspected via the authserver's storage; for Redis, read the upstream_tokens:<session_id>:google key.

  4. Trigger a second /authorize for the same Google account, without the initial session being invalidated. This can be done by:

    • Calling the OAuth endpoint again from a fresh client registration, or
    • Signing out of the ToolHive client and signing back in, or
    • Any path that causes the client to restart the authorization flow.
  5. Complete the flow. Google returns a new access token but no refresh_token (this is Google's documented behavior without prompt=consent).

  6. Inspect the stored tokens under the new session's key: RefreshToken is now "". The previous session's tokens (with the good refresh token) still exist under the old session key but are unreachable via the new JWT's tsid.

  7. Wait for the new access token to expire (~1 hour with Google's default).

  8. Trigger a downstream request that causes InProcessService.GetAccessTokens to run. Observe the log line:

    omitting provider with unrefreshable expired token provider=google
    
  9. The Google upstream is now omitted from downstream responses until the user re-authorizes — which will reproduce the same failure.

Caveats

The claim "Google does not re-issue refresh tokens without prompt=consent" reflects Google's documented OAuth 2.0 behavior. The repo only shows prompt=consent is optional (pkg/authserver/upstream/oidc.go:367-369).

Metadata

Metadata

Assignees

Labels

No fields configured for Bug 🐞.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions