Summary
The embedded auth server persists upstream provider refresh tokens without recording which upstream DCR client generated them. If a DCR-sensitive provider setting changes (most visibly scopes), ToolHive resolves a new DCR client but keeps existing user refresh tokens. Later refreshes present those old tokens with the current client, and the provider rejects them with invalid_grant / client_id mismatch.
This leaves every grant from the previous client generation latent-broken until its access token expires.
Verified on ToolHive v0.44.0. I also checked v0.46.0 and current main: storage.UpstreamTokens still has no upstream-client binding field, and upstreamTokenRefresher still refreshes through the currently resolved provider client.
Reproduction
- Configure an embedded-auth OAuth2 upstream using runtime DCR:
upstreamProviders:
- name: example
type: oauth2
oauth2Config:
dcrConfig:
registrationEndpoint: https://provider.example/register
scopes: [read]
- Authorize a user and retain the upstream refresh token.
- Change scopes to
[read, write] and restart/reconcile the proxy.
- The DCR cache key changes because it includes
ScopesHash, so ToolHive registers a new upstream OAuth client.
- Let the user's old access token expire and make a request.
- ToolHive calls the provider token endpoint with the old refresh token and the new client ID. Providers that bind refresh tokens to clients reject it.
Observed provider error:
upstream token refresh failed: token request failed: invalid_grant - client_id mismatch
Root cause
pkg/authserver/server/handlers/callback.go stores:
storageTokens := &storage.UpstreamTokens{
// ...
ClientID: pending.ClientID,
}
That ClientID is the downstream client (for example LibreChat), not the upstream DCR client used by BaseOAuth2Provider for the authorization-code exchange.
pkg/authserver/refresher.go later selects the provider by ProviderID and calls:
provider.RefreshTokens(ctx, expired.RefreshToken, expired.UpstreamSubject)
The provider contains the current DCR-resolved oauth2.Config.ClientID. Nothing verifies that it matches the client that minted expired.RefreshToken.
There is a related reauthorization hazard: maybeCarryForwardRefreshToken can copy the latest prior refresh token into a newly authorized session when the provider omits a new refresh token, without checking its upstream DCR generation. That can reintroduce an obsolete refresh token immediately after a successful reconnect.
Fleet evidence
One deployment accumulated three upstream client generations for the same provider:
- original statically configured DCR client: 92 sessions
- first runtime-DCR client (
read): 28 sessions
- current runtime-DCR client (
read + write): 79 sessions
The provider's refresh tokens are JWTs containing the public client_id claim, so classification is exact: 120 stale sessions versus 79 current sessions. Every observed client_id mismatch belonged to one of the stale generations.
The DCR client IDs themselves are long-lived; this was caused by config generations, not client expiration.
Other providers may issue opaque refresh tokens, making the same drift impossible to classify after the fact.
Expected behavior
An upstream grant should be bound to the exact upstream client/config generation that obtained it. Before refresh, ToolHive should either:
- refresh with that same upstream client while it remains registered, or
- detect a generation mismatch, delete/invalidate the stale provider grant, and return a typed reauthorization-required error without calling the token endpoint.
Proposed fix
- Add an upstream binding field to
UpstreamTokens, such as UpstreamClientID or a stable DCR/config-generation fingerprint.
- Populate it from the resolved upstream provider during callback storage.
- Validate it before refresh.
- Validate it before
maybeCarryForwardRefreshToken reuses a prior refresh token.
- On mismatch, remove the stale provider row and return a typed reauthorization-required result rather than retrying
invalid_grant.
- Add provider/user-scoped revocation or migration APIs so operators do not need to manipulate Redis storage directly.
- Add tests for:
- scope changes
- static-client to runtime-DCR migration
- redirect URI / registration endpoint changes
- reauthorization where the provider omits a new refresh token
- opaque refresh-token providers
Operational impact
The failure is delayed until access-token expiry, so a rollout can look healthy for hours or days while every pre-change user grant is already doomed. Operators need a controlled reconnect migration every time a DCR-sensitive field changes until the binding is represented in storage.
Summary
The embedded auth server persists upstream provider refresh tokens without recording which upstream DCR client generated them. If a DCR-sensitive provider setting changes (most visibly
scopes), ToolHive resolves a new DCR client but keeps existing user refresh tokens. Later refreshes present those old tokens with the current client, and the provider rejects them withinvalid_grant/client_id mismatch.This leaves every grant from the previous client generation latent-broken until its access token expires.
Verified on ToolHive v0.44.0. I also checked v0.46.0 and current
main:storage.UpstreamTokensstill has no upstream-client binding field, andupstreamTokenRefresherstill refreshes through the currently resolved provider client.Reproduction
[read, write]and restart/reconcile the proxy.ScopesHash, so ToolHive registers a new upstream OAuth client.Observed provider error:
Root cause
pkg/authserver/server/handlers/callback.gostores:That
ClientIDis the downstream client (for example LibreChat), not the upstream DCR client used byBaseOAuth2Providerfor the authorization-code exchange.pkg/authserver/refresher.golater selects the provider byProviderIDand calls:The provider contains the current DCR-resolved
oauth2.Config.ClientID. Nothing verifies that it matches the client that mintedexpired.RefreshToken.There is a related reauthorization hazard:
maybeCarryForwardRefreshTokencan copy the latest prior refresh token into a newly authorized session when the provider omits a new refresh token, without checking its upstream DCR generation. That can reintroduce an obsolete refresh token immediately after a successful reconnect.Fleet evidence
One deployment accumulated three upstream client generations for the same provider:
read): 28 sessionsread + write): 79 sessionsThe provider's refresh tokens are JWTs containing the public
client_idclaim, so classification is exact: 120 stale sessions versus 79 current sessions. Every observedclient_id mismatchbelonged to one of the stale generations.The DCR client IDs themselves are long-lived; this was caused by config generations, not client expiration.
Other providers may issue opaque refresh tokens, making the same drift impossible to classify after the fact.
Expected behavior
An upstream grant should be bound to the exact upstream client/config generation that obtained it. Before refresh, ToolHive should either:
Proposed fix
UpstreamTokens, such asUpstreamClientIDor a stable DCR/config-generation fingerprint.maybeCarryForwardRefreshTokenreuses a prior refresh token.invalid_grant.Operational impact
The failure is delayed until access-token expiry, so a rollout can look healthy for hours or days while every pre-change user grant is already doomed. Operators need a controlled reconnect migration every time a DCR-sensitive field changes until the binding is represented in storage.