diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 03727ec87..1e0f61578 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -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. + +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. + + ### 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: diff --git a/fastmcp_slim/fastmcp/server/auth/providers/github.py b/fastmcp_slim/fastmcp/server/auth/providers/github.py index 214d24c3b..9353c1726 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/github.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/github.py @@ -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. diff --git a/fastmcp_slim/fastmcp/server/auth/providers/google.py b/fastmcp_slim/fastmcp/server/auth/providers/google.py index a8536b223..d15bb6df5 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/google.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/google.py @@ -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 diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 3deae3bcb..068185e1b 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -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(