You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 /authorizeCreatedAt: 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:
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:
Authorize handler mints a new session S2. The new JWT's tsid claim points at S2.
Callback completes. If the provider does not re-issue refresh_token (e.g., Google without prompt=consent — pkg/authserver/upstream/oidc.go:367-369 shows prompt=consent is optional, not default), the callback stores {AccessToken: new, RefreshToken: ""} at (S2, providerID).
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).
The user is forced to re-authorize. The cycle repeats.
Proposed fix
Two independent changes — neither alone is sufficient.
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.
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).
Start ToolHive with Google configured as an upstream OIDC provider.
As a user, complete the initial /authorize flow. Accept Google's consent screen.
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.
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.
Complete the flow. Google returns a new access token but norefresh_token (this is Google's documented behavior without prompt=consent).
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.
Wait for the new access token to expire (~1 hour with Google's default).
Trigger a downstream request that causes InProcessService.GetAccessTokens to run. Observe the log line:
omitting provider with unrefreshable expired token provider=google
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).
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
/authorizecall, 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_tokenon first consent or whenprompt=consentis 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 withErrNoRefreshToken.Root cause A — session-keyed storage
pkg/authserver/server/handlers/authorize.go:91generates a fresh session ID on every/authorize:pkg/authserver/server/handlers/callback.go:142stores upstream tokens keyed by that session. The session ID is then embedded as thetsidclaim in the issued JWT (pkg/authserver/server/session/session.go:120-123), and downstream read paths look up bytsid/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 onUpstreamTokens(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-142storesidpTokens.RefreshTokenverbatim, with no preservation of a previously-stored refresh token:pkg/authserver/refresher.go:73-75already 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-136then returnsErrNoRefreshToken:Failure mode
refresh_token, are stored under sessionS1with key(S1, providerID)./authorizecall is made (any reason — including the compound effect of Non-expiring upstream tokens incorrectly marked as 1h-expiring #5046 causing spurious refresh/reauth cycles).S2. The new JWT'stsidclaim points atS2.refresh_token(e.g., Google withoutprompt=consent—pkg/authserver/upstream/oidc.go:367-369showsprompt=consentis optional, not default), the callback stores{AccessToken: new, RefreshToken: ""}at(S2, providerID).(S2, providerID), findsRefreshToken == "", and returnsErrNoRefreshToken. The provider is then omitted from downstream responses with the log lineomitting provider with unrefreshable expired token(pkg/auth/upstreamtoken/service.go:114).Proposed fix
Two independent changes — neither alone is sufficient.
Key upstream tokens by
UserID, not session ID.(user_id, provider_id) → tokensindex in both memory and Redis storage backends.pkg/auth/token.go:1189-1196and friends) to resolvetsid → user_idvia the session store, then read upstream tokens byuser_id.UserIDis currently only metadata.Preserve prior refresh token in the callback. Before calling
StoreUpstreamTokens, load any existing record for(user_id, provider_id)and, ifidpTokens.RefreshToken == "", carry forward the existingRefreshToken. Mirror the pattern already used atpkg/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=consentset).Start ToolHive with Google configured as an upstream OIDC provider.
As a user, complete the initial
/authorizeflow. Accept Google's consent screen.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 theupstream_tokens:<session_id>:googlekey.Trigger a second
/authorizefor the same Google account, without the initial session being invalidated. This can be done by:Complete the flow. Google returns a new access token but no
refresh_token(this is Google's documented behavior withoutprompt=consent).Inspect the stored tokens under the new session's key:
RefreshTokenis 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'stsid.Wait for the new access token to expire (~1 hour with Google's default).
Trigger a downstream request that causes
InProcessService.GetAccessTokensto run. Observe the log line: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 showsprompt=consentis optional (pkg/authserver/upstream/oidc.go:367-369).