From 3c13c841114a2235e4efc1d0a08500b0c28618be Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Fri, 7 Aug 2026 14:29:13 +0700 Subject: [PATCH] fix(auth): require audience when introspecting via Google tokeninfo Google's tokeninfo response carries no iss claim; the GoogleProvider synthesises iss locally, so a configured-issuer check against it is self-satisfying and proves nothing about which OAuth client the token was minted for. The only real binding to the deployment is the audience check - which is skipped when audience is empty. A deployment pointed at https://oauth2.googleapis.com/tokeninfo with no audience accepted ANY valid Google access token, including one minted for an unrelated attacker-controlled OAuth client. NewTokenValidator now refuses that combination at startup instead of silently accepting cross-client tokens at runtime. --- pkg/auth/token.go | 13 +++++++++++++ pkg/auth/token_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/pkg/auth/token.go b/pkg/auth/token.go index 1102df1879..68e2e8cb59 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -640,6 +640,19 @@ func NewTokenValidator(ctx context.Context, config TokenValidatorConfig, opts .. return nil, ErrMissingIssuerAndJWKSURL } + // Google's tokeninfo endpoint returns no iss claim - parseGoogleResponse + // synthesises iss locally, so a configured-issuer check against it is + // self-satisfying and proves nothing about which OAuth client the token + // was minted for. The only real binding to this deployment is the + // audience check, which is skipped when audience is empty. Without it, + // ANY valid Google access token (e.g. one minted for an attacker's own + // unrelated OAuth client) passes validation. Refuse the combination at + // startup instead of silently accepting cross-client tokens at runtime. + if config.IntrospectionURL == GoogleTokeninfoURL && config.Audience == "" { + return nil, fmt.Errorf("audience is required when using Google's tokeninfo endpoint for introspection: " + + "without it any valid Google access token is accepted regardless of the OAuth client it was minted for") + } + // Create HTTP client with CA bundle and auth token support for JWKS httpClient, err := networking.NewHttpClientBuilder(). WithCABundle(config.CACertPath). diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go index e4c4ee910b..9c2dc7db8a 100644 --- a/pkg/auth/token_test.go +++ b/pkg/auth/token_test.go @@ -3075,3 +3075,30 @@ func TestEnsureJWKSRegistered_NonFatalRegistrationErrors(t *testing.T) { require.True(t, validator.jwksRegistered) }) } + +func TestNewTokenValidator_GoogleTokeninfoRequiresAudience(t *testing.T) { + t.Setenv("TOOLHIVE_SKIP_OIDC_DISCOVERY", "true") + + // Google tokeninfo + no audience must be rejected at startup: tokeninfo + // returns no iss claim (the provider synthesises it locally), so an + // issuer check is self-satisfying and the audience check is the only + // binding to this deployment. Without it, any valid Google access token + // passes regardless of which OAuth client minted it. + _, err := NewTokenValidator(context.Background(), TokenValidatorConfig{ + Issuer: "https://accounts.google.com", + JWKSURL: "https://www.googleapis.com/oauth2/v3/certs", + IntrospectionURL: GoogleTokeninfoURL, + Audience: "", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "audience is required") + + // With an audience configured, the same setup must be accepted. + _, err = NewTokenValidator(context.Background(), TokenValidatorConfig{ + Issuer: "https://accounts.google.com", + JWKSURL: "https://www.googleapis.com/oauth2/v3/certs", + IntrospectionURL: GoogleTokeninfoURL, + Audience: "my-client-id.apps.googleusercontent.com", + }) + require.NoError(t, err) +}