mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Add audience pinning to GoogleTokenVerifier (#4827)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
19d9360cc1
commit
822c82c93f
4 changed files with 107 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue