auth: bind refreshing token source to a background context, not the request ctx (#988)

## Problem

`AuthorizationCodeHandler.exchangeAuthorizationCode` builds the
long-lived token source from the **request context** passed to
`Authorize()`:

```go
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
...
h.tokenSource = cfg.TokenSource(clientCtx, token)
```

`golang.org/x/oauth2` captures that context and reuses it for **every**
future refresh:
- `oauth2.go`: `Config.TokenSource(ctx, …)` stores `ctx` in
`tokenRefresher{ctx}`
- `tokenRefresher.Token()` (no ctx arg) calls `retrieveToken(tf.ctx, …)`
- `internal/token.go`: `ContextClient(ctx).Do(req.WithContext(ctx))`

`Authorize()` is normally invoked from a request- or connect-scoped
context — e.g. the streamable client transport calls it during
`setMCPHeaders`/`initialize`, and that context is cancelled once the
operation completes. The cached access token works until expiry; the
**first refresh after expiry** then fails instantly with `context
canceled`, before any HTTP request is sent. The connection can no longer
refresh.

`setMCPHeaders` only re-invokes `Authorize()` for an `invalid_grant`
`oauth2.RetrieveError` (#917). A `context canceled` is not a
`RetrieveError`, so it is never recovered — the connection is wedged
until the client reconnects.

### Reproduce

1. Connect a `StreamableClientTransport` with an
`AuthorizationCodeHandler` to a server whose `initialize` returns 401
(so `Authorize()` runs during connect, under the connect context).
2. Let the connect context be cancelled after connect returns (the
common pattern: a `context.WithTimeout(parent, …)` + `defer cancel()`
bounding the interactive flow).
3. Wait for the access token to expire and issue a request.
4. Every request fails with `Post "<token endpoint>": context canceled`
and `dur≈0` — the refresh never hits the network.

## Fix

A token source outlives the request that created it, so bind its
refreshes to `context.Background()` (still carrying the configured HTTP
client via `oauth2.HTTPClient`). The one-shot token exchange keeps using
the request context.

```go
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
h.tokenSource = cfg.TokenSource(refreshCtx, token)
```

`auth` package tests pass. Behaviour is otherwise unchanged: the same
HTTP client value is carried; only the cancellation parent differs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
Thomas Bechtold 2026-06-16 16:42:53 +02:00 • committed by GitHub
commit d9728a88f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -605,7 +605,17 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
h.tokenSource = cfg.TokenSource(clientCtx, token)
// The token source outlives this authorization request: it is stored on the
// handler and used by the transport for the lifetime of the connection. The
// oauth2 library captures the context passed to TokenSource and reuses it for
// every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher
// retains the context and passes it to each refresh round-trip). Binding it to
// the per-request ctx makes all later refreshes fail with "context canceled"
// once that request (or the connect operation that triggered authorization)
// completes. Use a background context that still carries the configured HTTP
// client so refreshes keep working for the life of the token source.
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
h.tokenSource = cfg.TokenSource(refreshCtx, token)
return nil
}