Embedded auth runtime DCR does not bind upstream refresh tokens to the DCR client generation
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
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 asUpstreamClientIDor a stable DCR/config-generation fingerprint. - Populate it from the resolved upstream provider during callback storage.
- Validate it before refresh.
- Validate it before
maybeCarryForwardRefreshTokenreuses 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with pkg/authserver/server/handlers/callback.go and pkg/authserver/refresher.go, then inspect storage.UpstreamTokens and the provider refresh path. Reproduce the scope-change scenario and trace reauthorization through maybeCarryForwardRefreshToken. Done means upstream grants are bound to their client generation, mismatches avoid token-endpoint refreshes, stale grants receive a typed reauthorization result, and the listed migration and provider cases are tested.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, redis
- Domain
- authentication, backend, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100