Repository navigation
feat(tool/http): support Google access tokens from ADC - #3792
hugosmoreira wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for obtaining and sending Google OAuth 2.0 access tokens from Application Default Credentials (ADC) in HTTP tools, including documentation, configuration updates, and integration tests. The reviewer feedback correctly identifies a critical Go anti-pattern where context.Context is stored inside the adcTokenProvider struct. Because the initialization context is short-lived, this can lead to runtime failures due to context cancellation when the tool is invoked later. The reviewer recommends refactoring the token provider and helper functions to accept the active request context dynamically during invocation, and updating the corresponding tests.
| var googleAccessTokenProvider *adcTokenProvider | ||
| if cfg.SendGoogleAccessToken { | ||
| googleAccessTokenProvider = &adcTokenProvider{ctx: ctx} | ||
| } |
There was a problem hiding this comment.
Storing context.Context in a struct is a Go anti-pattern. Additionally, using the initialization context ctx (which is typically short-lived or cancelled after startup) for lazy credential loading during invocation will cause runtime failures (e.g., context canceled) when the tool is invoked later. Instead, we should pass the request context dynamically during Invoke.
| var googleAccessTokenProvider *adcTokenProvider | |
| if cfg.SendGoogleAccessToken { | |
| googleAccessTokenProvider = &adcTokenProvider{ctx: ctx} | |
| } | |
| var googleAccessTokenProvider *adcTokenProvider | |
| if cfg.SendGoogleAccessToken { | |
| googleAccessTokenProvider = &adcTokenProvider{} | |
| } |
| type adcTokenProvider struct { | ||
| ctx context.Context | ||
| mu sync.Mutex | ||
| tokenSource oauth2.TokenSource | ||
| } | ||
|
|
||
| func (p *adcTokenProvider) getTokenSource() (oauth2.TokenSource, error) { | ||
| if p == nil { | ||
| return nil, fmt.Errorf("google ADC token provider is not initialized") | ||
| } | ||
| if p.ctx == nil { | ||
| return nil, fmt.Errorf("google ADC token provider context is not initialized") | ||
| } | ||
|
|
||
| p.mu.Lock() | ||
| defer p.mu.Unlock() | ||
| if p.tokenSource == nil { | ||
| credentials, err := google.FindDefaultCredentials(p.ctx, sources.CloudPlatformScope) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("unable to initialize Google ADC: %w", err) | ||
| } | ||
| if credentials.TokenSource == nil { | ||
| return nil, fmt.Errorf("google ADC did not provide a token source") | ||
| } | ||
| p.tokenSource = credentials.TokenSource | ||
| } | ||
| return p.tokenSource, nil | ||
| } | ||
|
|
||
| func (p *adcTokenProvider) Token() (*oauth2.Token, error) { | ||
| tokenSource, err := p.getTokenSource() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| token, err := tokenSource.Token() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("unable to get Google access token from ADC: %w", err) | ||
| } | ||
| return token, nil | ||
| } | ||
|
|
||
| func setGoogleAccessToken( | ||
| req *http.Request, | ||
| tokenProvider *adcTokenProvider, | ||
| ) error { | ||
| token, err := tokenProvider.Token() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if !token.Valid() { | ||
| return fmt.Errorf("google ADC returned an invalid or expired access token") | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+token.AccessToken) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Remove the ctx field from adcTokenProvider and update its methods to accept context.Context dynamically. This ensures that the active request context is used for credential discovery and token fetching, avoiding potential context cancellation issues.
type adcTokenProvider struct {
mu sync.Mutex
tokenSource oauth2.TokenSource
}
func (p *adcTokenProvider) getTokenSource(ctx context.Context) (oauth2.TokenSource, error) {
if p == nil {
return nil, fmt.Errorf("google ADC token provider is not initialized")
}
p.mu.Lock()
defer p.mu.Unlock()
if p.tokenSource == nil {
credentials, err := google.FindDefaultCredentials(ctx, sources.CloudPlatformScope)
if err != nil {
return nil, fmt.Errorf("unable to initialize Google ADC: %w", err)
}
if credentials.TokenSource == nil {
return nil, fmt.Errorf("google ADC did not provide a token source")
}
p.tokenSource = credentials.TokenSource
}
return p.tokenSource, nil
}
func (p *adcTokenProvider) Token(ctx context.Context) (*oauth2.Token, error) {
tokenSource, err := p.getTokenSource(ctx)
if err != nil {
return nil, err
}
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("unable to get Google access token from ADC: %w", err)
}
return token, nil
}
func setGoogleAccessToken(
ctx context.Context,
req *http.Request,
tokenProvider *adcTokenProvider,
) error {
token, err := tokenProvider.Token(ctx)
if err != nil {
return err
}
if !token.Valid() {
return fmt.Errorf("google ADC returned an invalid or expired access token")
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
return nil
}| if t.Cfg.SendGoogleAccessToken { | ||
| if err := setGoogleAccessToken(req, t.googleAccessTokenProvider); err != nil { | ||
| return nil, util.NewClientServerError("error authenticating HTTP request with Google ADC", http.StatusInternalServerError, err) | ||
| } | ||
| } |
There was a problem hiding this comment.
Pass the active request context ctx to setGoogleAccessToken during tool invocation.
| if t.Cfg.SendGoogleAccessToken { | |
| if err := setGoogleAccessToken(req, t.googleAccessTokenProvider); err != nil { | |
| return nil, util.NewClientServerError("error authenticating HTTP request with Google ADC", http.StatusInternalServerError, err) | |
| } | |
| } | |
| if t.Cfg.SendGoogleAccessToken { | |
| if err := setGoogleAccessToken(ctx, req, t.googleAccessTokenProvider); err != nil { | |
| return nil, util.NewClientServerError("error authenticating HTTP request with Google ADC", http.StatusInternalServerError, err) | |
| } | |
| } |
| err = setGoogleAccessToken( | ||
| req, | ||
| &adcTokenProvider{ | ||
| ctx: context.Background(), | ||
| tokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}), | ||
| }, | ||
| ) |
There was a problem hiding this comment.
Update the test to pass context.Background() to setGoogleAccessToken and remove the ctx field from the adcTokenProvider initialization.
| err = setGoogleAccessToken( | |
| req, | |
| &adcTokenProvider{ | |
| ctx: context.Background(), | |
| tokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}), | |
| }, | |
| ) | |
| err = setGoogleAccessToken( | |
| context.Background(), | |
| req, | |
| &adcTokenProvider{ | |
| tokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}), | |
| }, | |
| ) |
| err = setGoogleAccessToken( | ||
| req, | ||
| &adcTokenProvider{ | ||
| ctx: context.Background(), | ||
| tokenSource: errorTokenSource{err: errors.New("credentials unavailable")}, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
Update the test to pass context.Background() to setGoogleAccessToken and remove the ctx field from the adcTokenProvider initialization.
| err = setGoogleAccessToken( | |
| req, | |
| &adcTokenProvider{ | |
| ctx: context.Background(), | |
| tokenSource: errorTokenSource{err: errors.New("credentials unavailable")}, | |
| }, | |
| ) | |
| err = setGoogleAccessToken( | |
| context.Background(), | |
| req, | |
| &adcTokenProvider{ | |
| tokenSource: errorTokenSource{err: errors.New("credentials unavailable")}, | |
| }, | |
| ) |
|
Pushed 99b4401 to address the token-source context lifetime and cancellation behavior. The provider now caches only the token and refreshes it using the active invocation context. Concurrent callers share an active refresh, waiting callers can honor their own deadlines, and a completed request does not leave a canceled context attached to future refreshes. Regression tests cover cancellation, retry, and concurrent reuse. Restoring the previous implementation reproduces four expected cancellation failures. Local verification on Windows with Go 1.26.4:
Verification commandsWindows race builds used Zig 0.15.2, $taskRaceLinkFlags = '-extldflags "-lapi-ms-win-core-synch-l1-2-0 -Wl,--no-dynamicbase"'
go test -race -ldflags $taskRaceLinkFlags -mod=readonly -p 1 -timeout 120s -json ./cmd/... ./internal/...
go test -race -ldflags $taskRaceLinkFlags -mod=readonly -p 1 -timeout 90s -v ./tests/http -run '^TestHttpToolSendsGoogleAccessToken$' -count=3
# Also run from the unchanged checkout to confirm the wider-suite failure:
go test -race -ldflags $taskRaceLinkFlags -mod=readonly -p 1 -timeout 60s -v ./internal/sources/http -run '^TestRedirectLoopbackIntegration$' -count=1
golangci-lint run --timeout 10m -j 1 --modules-download-mode readonly --max-same-issues 0 --max-issues-per-linter 0 ./...
go mod tidy -diffThe hosted integration and evaluation checks on this revision both report that they need |
Description
Adds optional
sendGoogleAccessTokensupport to HTTP tools. When enabled, Toolbox lazily obtains a Google OAuth access token from Application Default Credentials (ADC), using the Cloud Platform scope, and sets the outgoing Bearer Authorization header after configured headers. The option defaults to false.The provider caches valid tokens and uses the active invocation context for refresh. Tests cover cancellation, retry, concurrent reuse, and refresh after the previous request ends.
Integrated main
06ea49d9a(1.16.0), retaining thetext/templateNOLINT comment. Updated the metadata regression for upstream's removal ofTool.Authorized: it checks the required-auth getter and shared authorization helper, while retaining annotations and scope assertions. Token-handling code is unchanged by this integration.Security: credentials come from server-side ADC rather than tool parameters. The HTTP tool preserves configured auth/scope metadata and does not override server authorization checks. The documentation limits this option to trusted HTTPS endpoints. This PR does not change existing REST or MCP authorization enforcement.
Validation
Local Windows/amd64, Go 1.27.3 and Zig 0.15.2:
internal/sources/http.TestRedirectLoopbackIntegration, reportingexpected request to fail connecting to 10.0.0.1. The same focused command fails at the same assertion on unchanged main06ea49d9ain this environment. The full suite is not green.go mod tidy -diff, normal package and binary builds, CLI help, changed-file formatting and all three documentation linters pass.golangci-lint2.15.0 passes with zero issues.Real Google credentials, the remaining credentialed HTTP integration suites, Linux/macOS and hosted global coverage, and Hugo rendering were not tested locally. The PR remains draft pending maintainer-authorized hosted verification.
Local verification commands
Windows race tests use Zig 0.15.2,
GOMAXPROCS=2, and the test-binary linker settings below. The final full suite uses four package workers. Normal builds do not use the race-specific linker flags.PR Checklist
Fixes #1030 🦕