Skip to content

Commit d9728a8

Browse files
toabctlclaudeguglielmo-san
authored
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>
1 parent 96074c9 commit d9728a8

3 files changed

Lines changed: 153 additions & 2 deletions

File tree

auth/authorization_code.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,17 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
605605
if err != nil {
606606
return fmt.Errorf("token exchange failed: %w", err)
607607
}
608-
h.tokenSource = cfg.TokenSource(clientCtx, token)
608+
// The token source outlives this authorization request: it is stored on the
609+
// handler and used by the transport for the lifetime of the connection. The
610+
// oauth2 library captures the context passed to TokenSource and reuses it for
611+
// every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher
612+
// retains the context and passes it to each refresh round-trip). Binding it to
613+
// the per-request ctx makes all later refreshes fail with "context canceled"
614+
// once that request (or the connect operation that triggered authorization)
615+
// completes. Use a background context that still carries the configured HTTP
616+
// client so refreshes keep working for the life of the token source.
617+
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
618+
h.tokenSource = cfg.TokenSource(refreshCtx, token)
609619
return nil
610620
}
611621

auth/authorization_code_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,101 @@ func TestAuthorize(t *testing.T) {
115115
}
116116
}
117117

118+
// TestAuthorize_RefreshAfterContextCancel verifies that the token source built
119+
// by Authorize keeps refreshing after the context passed to Authorize is
120+
// cancelled. The token source is stored on the handler and used by the
121+
// transport for the whole connection lifetime, whereas Authorize is typically
122+
// called from a request- or connect-scoped context. golang.org/x/oauth2
123+
// captures the context handed to Config.TokenSource and reuses it for every
124+
// refresh, so binding it to the request context made every refresh after the
125+
// access token expired fail with "context canceled". Regression test for that.
126+
func TestAuthorize_RefreshAfterContextCancel(t *testing.T) {
127+
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
128+
// expires_in below oauth2's 10s expiry delta, so the reuse token source
129+
// treats the access token as expired immediately and must refresh.
130+
AccessTokenTTL: 1,
131+
IssueRefreshToken: true,
132+
RegistrationConfig: &oauthtest.RegistrationConfig{
133+
PreregisteredClients: map[string]oauthtest.ClientInfo{
134+
"test_client_id": {
135+
Secret: "test_client_secret",
136+
RedirectURIs: []string{"http://localhost:12345/callback"},
137+
},
138+
},
139+
},
140+
})
141+
authServer.Start(t)
142+
143+
resourceMux := http.NewServeMux()
144+
resourceServer := httptest.NewServer(resourceMux)
145+
t.Cleanup(resourceServer.Close)
146+
resourceURL := resourceServer.URL + "/resource"
147+
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
148+
Resource: resourceURL,
149+
AuthorizationServers: []string{authServer.URL()},
150+
}))
151+
152+
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
153+
RedirectURL: "http://localhost:12345/callback",
154+
PreregisteredClient: &oauthex.ClientCredentials{
155+
ClientID: "test_client_id",
156+
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
157+
},
158+
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
159+
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
160+
resp, err := client.Get(args.URL)
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
163+
}
164+
defer resp.Body.Close()
165+
location, err := resp.Location()
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to get location header: %v", err)
168+
}
169+
return &AuthorizationResult{
170+
Code: location.Query().Get("code"),
171+
State: location.Query().Get("state"),
172+
Iss: location.Query().Get("iss"),
173+
}, nil
174+
},
175+
})
176+
if err != nil {
177+
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
178+
}
179+
180+
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
181+
resp := &http.Response{
182+
StatusCode: http.StatusUnauthorized,
183+
Header: make(http.Header),
184+
Body: http.NoBody,
185+
Request: req,
186+
}
187+
resp.Header.Set("WWW-Authenticate", "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource")
188+
189+
// Authorize under a context that we cancel immediately afterwards, mimicking
190+
// the request/connect context the transport passes and that is already done
191+
// by the time a later token refresh runs.
192+
ctx, cancel := context.WithCancel(context.Background())
193+
if err := handler.Authorize(ctx, req, resp); err != nil {
194+
t.Fatalf("Authorize failed: %v", err)
195+
}
196+
cancel()
197+
198+
tokenSource, err := handler.TokenSource(context.Background())
199+
if err != nil {
200+
t.Fatalf("Failed to get token source: %v", err)
201+
}
202+
// The access token is already expired, so this forces a refresh round-trip.
203+
// It must not fail with "context canceled" from the cancelled Authorize ctx.
204+
token, err := tokenSource.Token()
205+
if err != nil {
206+
t.Fatalf("token refresh after Authorize context cancellation failed: %v", err)
207+
}
208+
if token.AccessToken != "test_access_token_refreshed" {
209+
t.Errorf("expected refreshed access token %q, got %q", "test_access_token_refreshed", token.AccessToken)
210+
}
211+
}
212+
118213
func TestAuthorize_ScopeAccumulation(t *testing.T) {
119214
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
120215
RegistrationConfig: &oauthtest.RegistrationConfig{

internal/oauthtest/fake_authorization_server.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ type Config struct {
8989
// TokenScopeFunc, if set, is called with the scope from the authorization
9090
// request and returns the scope string to include in the token response.
9191
TokenScopeFunc func(requestedScope string) string
92+
// AccessTokenTTL, if non-zero, is the expires_in (in seconds) returned by the
93+
// /token endpoint for both the authorization_code and refresh_token grants.
94+
// When zero a default of 3600 is used. Set it small to force a client's reuse
95+
// token source to treat the access token as expired and refresh it.
96+
AccessTokenTTL int
97+
// IssueRefreshToken, if true, includes a refresh_token in token responses and
98+
// enables grant_type=refresh_token at the /token endpoint.
99+
IssueRefreshToken bool
100+
}
101+
102+
// testRefreshToken is the refresh token issued and accepted by the fake server
103+
// when Config.IssueRefreshToken is set.
104+
const testRefreshToken = "test_refresh_token"
105+
106+
// accessTokenExpiresIn returns the expires_in value to use in token responses.
107+
func (s *FakeAuthorizationServer) accessTokenExpiresIn() int {
108+
if s.config.AccessTokenTTL != 0 {
109+
return s.config.AccessTokenTTL
110+
}
111+
return 3600
92112
}
93113

94114
// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing.
@@ -298,6 +318,8 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req
298318
s.handleJWTBearerGrant(w, r)
299319
case "client_credentials":
300320
s.handleClientCredentialsGrant(w, r)
321+
case "refresh_token":
322+
s.handleRefreshTokenGrant(w, r)
301323
default:
302324
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
303325
}
@@ -329,7 +351,10 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr
329351
resp := map[string]any{
330352
"access_token": "test_access_token",
331353
"token_type": "Bearer",
332-
"expires_in": 3600,
354+
"expires_in": s.accessTokenExpiresIn(),
355+
}
356+
if s.config.IssueRefreshToken {
357+
resp["refresh_token"] = testRefreshToken
333358
}
334359
if s.config.TokenScopeFunc != nil {
335360
if scope := s.config.TokenScopeFunc(codeInfo.Scope); scope != "" {
@@ -340,6 +365,27 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr
340365
json.NewEncoder(w).Encode(resp)
341366
}
342367

368+
// handleRefreshTokenGrant implements grant_type=refresh_token (RFC 6749 Section
369+
// 6) when Config.IssueRefreshToken is set, returning a distinct access token so
370+
// callers can observe that a refresh occurred.
371+
func (s *FakeAuthorizationServer) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
372+
if !s.config.IssueRefreshToken {
373+
http.Error(w, "refresh_token grant not supported", http.StatusBadRequest)
374+
return
375+
}
376+
if r.Form.Get("refresh_token") != testRefreshToken {
377+
http.Error(w, "invalid refresh_token", http.StatusBadRequest)
378+
return
379+
}
380+
w.Header().Set("Content-Type", "application/json")
381+
json.NewEncoder(w).Encode(map[string]any{
382+
"access_token": "test_access_token_refreshed",
383+
"token_type": "Bearer",
384+
"expires_in": s.accessTokenExpiresIn(),
385+
"refresh_token": testRefreshToken,
386+
})
387+
}
388+
343389
func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) {
344390
if s.config.JWTBearerConfig == nil {
345391
http.Error(w, "JWT bearer grant not supported", http.StatusBadRequest)

0 commit comments

Comments
 (0)