Add audience pinning to GoogleTokenVerifier (#4827)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
nate nowack 2026-08-13 14:59:16 -05:00 committed by GitHub
commit 822c82c93f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 107 additions and 0 deletions

View file

@ -389,6 +389,10 @@ The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from yo
See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider.
<Warning>
Provider-specific verifiers like `GitHubTokenVerifier` and `GoogleTokenVerifier` confirm that a token is a valid credential for that provider — not that it was issued to *your* application. GitHub tokens carry no audience claim at all, so any valid GitHub credential (including a personal access token) will verify. Inside the OAuth proxy this is safe: the proxy issues its own tokens to clients and only runs the verifier against upstream tokens it obtained through its own OAuth flow. If you use one of these verifiers standalone, you are authenticating "any user of that provider" unless you constrain it — `GoogleTokenVerifier` accepts an `audience` parameter to pin tokens to your OAuth client ID.
</Warning>
### Scope Configuration
OAuth scopes control what permissions your application requests from users. They're configured through your `TokenVerifier` (required for the OAuth proxy to validate tokens from your provider). Set `required_scopes` to automatically request the permissions your application needs:

View file

@ -44,6 +44,15 @@ class GitHubTokenVerifier(TokenVerifier):
GitHub OAuth tokens are opaque (not JWTs), so we verify them
by calling GitHub's API to check if they're valid and get user info.
Warning:
GitHub tokens carry no audience claim, so this verifier cannot tell
which OAuth app (if any) a token was issued for any valid GitHub
credential, including a personal access token, will verify. Used
inside `GitHubProvider` this is safe, because the proxy only ever
checks tokens it obtained through its own OAuth flow. As a standalone
verifier it authenticates "some GitHub user", not "a user of your
app" — only use it that way if that is genuinely your access model.
Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive
integer to cache successful verification results and avoid repeated
GitHub API calls for the same token.

View file

@ -70,6 +70,7 @@ class GoogleTokenVerifier(TokenVerifier):
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx2.AsyncClient | None = None,
audience: str | list[str] | None = None,
):
"""Initialize the Google token verifier.
@ -79,6 +80,12 @@ class GoogleTokenVerifier(TokenVerifier):
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
audience: Expected `aud` value (your Google OAuth client ID) or list of
allowed values. When set, tokens minted for any other OAuth client are
rejected. When None (default), any valid Google token is accepted
regardless of which OAuth client it was issued to only appropriate
when the token's provenance is guaranteed elsewhere (as in
`GoogleProvider`, which obtains tokens through its own OAuth flow).
"""
normalized = (
[_normalize_google_scope(s) for s in required_scopes]
@ -88,6 +95,7 @@ class GoogleTokenVerifier(TokenVerifier):
super().__init__(required_scopes=normalized)
self.timeout_seconds = timeout_seconds
self._http_client = http_client
self.audience = audience
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a Google OAuth token using the tokeninfo endpoint.
@ -126,6 +134,18 @@ class GoogleTokenVerifier(TokenVerifier):
logger.debug("Google tokeninfo missing 'aud' claim")
return None
if self.audience is not None:
allowed = (
self.audience
if isinstance(self.audience, list)
else [self.audience]
)
if aud not in allowed:
logger.debug(
"Google token 'aud' does not match expected audience"
)
return None
# sub is required (unique Google user ID)
sub = token_data.get("sub")
if not sub:
@ -338,6 +358,7 @@ class GoogleProvider(OAuthProxy):
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
audience=client_id,
)
# Set Google-specific defaults for extra authorize params

View file

@ -40,6 +40,20 @@ class TestGoogleProvider:
assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123"
assert str(provider.base_url) == "https://myserver.com/"
def test_verifier_audience_pinned_to_client_id(self, memory_storage: MemoryStore):
"""The provider's token verifier only accepts tokens minted for its own client."""
provider = GoogleProvider(
client_id="123456789.apps.googleusercontent.com",
client_secret="GOCSPX-test123",
base_url="https://myserver.com",
jwt_signing_key="test-secret",
client_storage=memory_storage,
)
verifier = provider._token_validator
assert isinstance(verifier, GoogleTokenVerifier)
assert verifier.audience == "123456789.apps.googleusercontent.com"
def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = GoogleProvider(
@ -341,6 +355,65 @@ class TestGoogleTokenVerifier:
assert result is None
async def test_audience_match_accepted(self, httpx_mock: HTTPXMock):
"""When audience is configured, a token with a matching 'aud' is accepted."""
httpx_mock.add_response(
url=_TOKENINFO_RE,
json={
"aud": "123.apps.googleusercontent.com",
"sub": "12345",
"scope": "openid",
"expires_in": "3600",
},
)
httpx_mock.add_response(url=_USERINFO_RE, json={"sub": "12345"})
verifier = GoogleTokenVerifier(audience="123.apps.googleusercontent.com")
result = await verifier.verify_token("valid-token")
assert result is not None
assert result.claims["aud"] == "123.apps.googleusercontent.com"
async def test_audience_mismatch_rejected(self, httpx_mock: HTTPXMock):
"""A valid Google token minted for a different OAuth client is rejected."""
httpx_mock.add_response(
url=_TOKENINFO_RE,
json={
"aud": "attacker.apps.googleusercontent.com",
"sub": "12345",
"scope": "openid",
"expires_in": "3600",
},
)
verifier = GoogleTokenVerifier(audience="123.apps.googleusercontent.com")
result = await verifier.verify_token("foreign-client-token")
assert result is None
async def test_audience_list_match_accepted(self, httpx_mock: HTTPXMock):
"""A list audience accepts any listed client ID and rejects others."""
httpx_mock.add_response(
url=_TOKENINFO_RE,
json={
"aud": "456.apps.googleusercontent.com",
"sub": "12345",
"scope": "openid",
"expires_in": "3600",
},
)
httpx_mock.add_response(url=_USERINFO_RE, json={"sub": "12345"})
verifier = GoogleTokenVerifier(
audience=[
"123.apps.googleusercontent.com",
"456.apps.googleusercontent.com",
]
)
result = await verifier.verify_token("valid-token")
assert result is not None
async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock):
"""A 200 response without 'sub' is rejected."""
httpx_mock.add_response(