Skip to content

fix(auth): require audience when introspecting via Google tokeninfo - #6236

Open
SashaMIT wants to merge 1 commit into
stacklok:mainfrom
SashaMIT:fix/google-tokeninfo-audience
Open

fix(auth): require audience when introspecting via Google tokeninfo#6236
SashaMIT wants to merge 1 commit into
stacklok:mainfrom
SashaMIT:fix/google-tokeninfo-audience

Conversation

@SashaMIT

@SashaMIT SashaMIT commented Aug 7, 2026

Copy link
Copy Markdown

Problem

The Google tokeninfo path has a validation hole:

  1. Google's tokeninfo endpoint returns no iss claim. GoogleProvider.parseGoogleResponse (token.go) compensates by setting claims["iss"] = "https://accounts.google.com" locally.
  2. validateClaims then compares that fabricated value against the configured issuer — a check that is self-satisfying: it passes for every token tokeninfo accepts and proves nothing about which OAuth client the token was minted for.
  3. The only remaining binding to the deployment is the audience check, and it is conditional: if v.audience != "". Audience is optional everywhere — NewTokenValidator doesn't require it, --oidc-audience defaults to empty, the operator CRD marks it Optional.

Net effect: a deployment configured with issuer https://accounts.google.com + introspection URL https://oauth2.googleapis.com/tokeninfo + no audience accepts any valid Google OAuth access token — including one minted for an unrelated attacker-registered OAuth client. That token passes ValidateToken, becomes an auth.Identity, and lands in the request context.

Fix

NewTokenValidator refuses IntrospectionURL == GoogleTokeninfoURL with an empty audience at startup, with an error explaining why. Fail-closed beats a silently-accepting runtime.

Tests

New TestNewTokenValidator_GoogleTokeninfoRequiresAudience: tokeninfo-without-audience is rejected with the explanatory error; the same config with an audience is accepted. Full pkg/auth suite passes.

Made with Cursor

Made with Cursor

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.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix, this closes a real gap — tokeninfo doesn't give you a verifiable iss so audience was the only thing actually binding the token to this deployment. Two small optional nits below, neither blocking.

Comment thread pkg/auth/token.go
// 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 == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this only catches a literally empty audience. If someone sets it to " " by accident (bad env templating etc.) it'd sail past this check, then fail every single token at runtime instead of failing loudly at startup. Might be worth trimming, same as the issuer check does elsewhere:

Suggested change
if config.IntrospectionURL == GoogleTokeninfoURL && config.Audience == "" {
if config.IntrospectionURL == GoogleTokeninfoURL && strings.TrimSpace(config.Audience) == "" {

Optional, not blocking.

Comment thread pkg/auth/token_test.go
})
}

func TestNewTokenValidator_GoogleTokeninfoRequiresAudience(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional style nit: this reads fine as-is, but since it's really one test with two input variants (empty vs set audience), our testing rules lean toward table-driven for this shape rather than two sequential imperative blocks. Up to you whether it's worth the churn for two cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants