diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 526722013..8557248a9 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -57,8 +57,11 @@ def _normalize_google_scope(scope: str) -> str: class GoogleTokenVerifier(TokenVerifier): """Token verifier for Google OAuth tokens. - Google OAuth tokens are opaque (not JWTs), so we verify them - by calling Google's tokeninfo API to check if they're valid and get user info. + Google OAuth tokens are opaque (not JWTs), so we verify them by calling + Google's tokeninfo endpoint with the access token as a query parameter. + This returns the OAuth app ID (``aud``), granted scopes, and expiry time. + User profile data (name, picture, etc.) is fetched separately from the + v2 userinfo endpoint when the token is valid. """ def __init__( @@ -87,16 +90,23 @@ class GoogleTokenVerifier(TokenVerifier): self._http_client = http_client async def verify_token(self, token: str) -> AccessToken | None: - """Verify Google OAuth token by calling Google's tokeninfo API.""" + """Verify a Google OAuth token using the tokeninfo endpoint. + + Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN`` + to validate the token and retrieve the OAuth app ID (``aud``), granted + scopes, and expiry time. On success, fetches user profile data from + the v2 userinfo endpoint to populate name, picture, and locale claims. + """ try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: - # Use Google's tokeninfo endpoint to validate the token + # Step 1: Verify token via tokeninfo endpoint. + # Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email. response = await client.get( - "https://www.googleapis.com/oauth2/v1/tokeninfo", + "https://oauth2.googleapis.com/tokeninfo", params={"access_token": token}, headers={"User-Agent": "FastMCP-Google-OAuth"}, ) @@ -108,19 +118,23 @@ class GoogleTokenVerifier(TokenVerifier): ) return None - token_info = response.json() + token_data = response.json() - # Check if token is expired - expires_in = token_info.get("expires_in") - if expires_in and int(expires_in) <= 0: - logger.debug("Google token has expired") + # aud is the OAuth app ID (client_id / audience) + aud = token_data.get("aud") + if not aud: + logger.debug("Google tokeninfo missing 'aud' claim") return None - # Extract scopes from token info - scope_string = token_info.get("scope", "") - token_scopes = [ - scope.strip() for scope in scope_string.split(" ") if scope.strip() - ] + # sub is required (unique Google user ID) + sub = token_data.get("sub") + if not sub: + logger.debug("Google tokeninfo missing 'sub' claim") + return None + + # Parse scopes directly from the tokeninfo response (space-separated) + scope_str = token_data.get("scope", "") + token_scopes = scope_str.split() if scope_str else [] # Check required scopes if self.required_scopes: @@ -134,46 +148,46 @@ class GoogleTokenVerifier(TokenVerifier): ) return None - # Get additional user info if we have the right scopes - user_data = {} - if "openid" in token_scopes or "profile" in token_scopes: - try: - userinfo_response = await client.get( - "https://www.googleapis.com/oauth2/v2/userinfo", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Google-OAuth", - }, - ) - if userinfo_response.status_code == 200: - user_data = userinfo_response.json() - except Exception as e: - logger.debug("Failed to fetch Google user info: %s", e) + # Compute expiry from expires_in (seconds until expiry) + expires_at: int | None = None + expires_in = token_data.get("expires_in") + if expires_in is not None: + with contextlib.suppress(ValueError, TypeError): + expires_at = int(time.time()) + int(expires_in) - # Calculate expiration time - expires_at = None - if expires_in: - expires_at = int(time.time() + int(expires_in)) + # Step 2: Fetch user profile from v2 userinfo endpoint. + # tokeninfo provides auth data; userinfo provides name, picture, locale. + user_data: dict = {} + try: + userinfo_response = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Google-OAuth", + }, + ) + if userinfo_response.status_code == 200: + user_data = userinfo_response.json() + except Exception as e: + logger.debug("Failed to fetch Google user profile: %s", e) - # Create AccessToken with Google user info access_token = AccessToken( token=token, - client_id=token_info.get( - "audience", "unknown" - ), # Use audience as client_id + client_id=aud, scopes=token_scopes, expires_at=expires_at, claims={ - "sub": user_data.get("id") - or token_info.get("user_id", "unknown"), - "email": user_data.get("email"), + "sub": sub, + "aud": aud, + "email": token_data.get("email") or user_data.get("email"), + "email_verified": token_data.get("email_verified") + or user_data.get("verified_email"), "name": user_data.get("name"), "picture": user_data.get("picture"), "given_name": user_data.get("given_name"), "family_name": user_data.get("family_name"), "locale": user_data.get("locale"), - "google_user_data": user_data, - "google_token_info": token_info, + "google_user_data": user_data or None, }, ) logger.debug("Google token verified successfully") diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 1fa8a5eed..4af76c52a 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -1,7 +1,11 @@ """Tests for Google OAuth provider.""" +import re +import time + import pytest from key_value.aio.stores.memory import MemoryStore +from pytest_httpx import HTTPXMock from fastmcp.server.auth.providers.google import ( GOOGLE_SCOPE_ALIASES, @@ -231,3 +235,302 @@ class TestGoogleScopeNormalization: """Verify the alias map covers the known Google shorthands.""" assert "email" in GOOGLE_SCOPE_ALIASES assert "profile" in GOOGLE_SCOPE_ALIASES + + +# Regex patterns for URL matching (tokeninfo uses query params) +_TOKENINFO_RE = re.compile(r"https://oauth2\.googleapis\.com/tokeninfo") +_USERINFO_RE = re.compile(r"https://www\.googleapis\.com/oauth2/v2/userinfo") + + +class TestGoogleTokenVerifier: + """Test GoogleTokenVerifier.verify_token() using the tokeninfo endpoint.""" + + TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo" + USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" + + async def test_valid_token_openid_only(self, httpx_mock: HTTPXMock): + """A token with only openid scope is accepted; client_id comes from 'aud'.""" + 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() + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "123.apps.googleusercontent.com" + assert result.scopes == ["openid"] + assert result.expires_at is not None + assert result.claims["sub"] == "12345" + assert result.claims["aud"] == "123.apps.googleusercontent.com" + + async def test_valid_token_with_email_and_profile(self, httpx_mock: HTTPXMock): + """A token with email+profile scope returns correct scopes and profile claims.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "email": "user@example.com", + "email_verified": "true", + "scope": "openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={ + "sub": "12345", + "email": "user@example.com", + "verified_email": True, + "name": "Test User", + "picture": "https://example.com/photo.jpg", + "given_name": "Test", + "family_name": "User", + "locale": "en", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "123.apps.googleusercontent.com" + assert "openid" in result.scopes + assert "https://www.googleapis.com/auth/userinfo.email" in result.scopes + assert "https://www.googleapis.com/auth/userinfo.profile" in result.scopes + assert result.claims["email"] == "user@example.com" + assert result.claims["name"] == "Test User" + assert result.claims["picture"] == "https://example.com/photo.jpg" + + async def test_expired_or_invalid_token_returns_none(self, httpx_mock: HTTPXMock): + """HTTP 400 from tokeninfo endpoint causes verify_token to return None.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + status_code=400, + json={ + "error": "invalid_token", + "error_description": "Token has been expired or revoked.", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("expired-token") + + assert result is None + + async def test_missing_aud_returns_none(self, httpx_mock: HTTPXMock): + """A 200 response without 'aud' is rejected.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={"sub": "12345", "scope": "openid", "expires_in": "3600"}, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("token-without-aud") + + assert result is None + + async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock): + """A 200 response without 'sub' is rejected.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "scope": "openid", + "expires_in": "3600", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("token-without-sub") + + assert result is None + + async def test_required_scopes_satisfied(self, httpx_mock: HTTPXMock): + """Token with required scopes passes the scope check.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "email": "user@example.com", + "scope": "openid https://www.googleapis.com/auth/userinfo.email", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345", "email": "user@example.com"}, + ) + + verifier = GoogleTokenVerifier( + required_scopes=["openid", "email"], + ) + result = await verifier.verify_token("valid-token") + + assert result is not None + + async def test_required_scopes_not_satisfied_returns_none( + self, httpx_mock: HTTPXMock + ): + """Token without required scopes is rejected.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid", + "expires_in": "3600", + }, + ) + + verifier = GoogleTokenVerifier( + required_scopes=[ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ], + ) + result = await verifier.verify_token("token-missing-email-scope") + + assert result is None + + async def test_uses_query_param_not_bearer_header(self, httpx_mock: HTTPXMock): + """verify_token sends the token as a query parameter to tokeninfo, not a Bearer header.""" + 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() + await verifier.verify_token("my-access-token") + + requests = httpx_mock.get_requests() + tokeninfo_req = requests[0] + assert "access_token=my-access-token" in str(tokeninfo_req.url) + assert "Authorization" not in tokeninfo_req.headers + + async def test_calls_tokeninfo_endpoint(self, httpx_mock: HTTPXMock): + """verify_token calls the tokeninfo endpoint, not the userinfo endpoint, for verification.""" + 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() + await verifier.verify_token("valid-token") + + requests = httpx_mock.get_requests() + assert len(requests) >= 1 + assert "tokeninfo" in str(requests[0].url) + assert "oauth2.googleapis.com" in str(requests[0].url) + + async def test_expires_at_computed_from_expires_in(self, httpx_mock: HTTPXMock): + """expires_at is set from expires_in returned by tokeninfo.""" + 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"}, + ) + + before = int(time.time()) + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + after = int(time.time()) + + assert result is not None + assert result.expires_at is not None + assert before + 3600 <= result.expires_at <= after + 3600 + + async def test_profile_data_fetched_from_userinfo(self, httpx_mock: HTTPXMock): + """Profile data (name, picture, locale) comes from the v2 userinfo endpoint.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid https://www.googleapis.com/auth/userinfo.profile", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={ + "sub": "12345", + "name": "Test User", + "picture": "https://example.com/photo.jpg", + "given_name": "Test", + "family_name": "User", + "locale": "en", + }, + ) + + verifier = GoogleTokenVerifier() + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.claims["name"] == "Test User" + assert result.claims["picture"] == "https://example.com/photo.jpg" + assert result.claims["given_name"] == "Test" + assert result.claims["family_name"] == "User" + assert result.claims["locale"] == "en" + + async def test_non_openid_scopes_checked_correctly(self, httpx_mock: HTTPXMock): + """Calendar scope is correctly checked from the tokeninfo scope string.""" + httpx_mock.add_response( + url=_TOKENINFO_RE, + json={ + "aud": "123.apps.googleusercontent.com", + "sub": "12345", + "scope": "openid https://www.googleapis.com/auth/calendar", + "expires_in": "3600", + }, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "12345"}, + ) + + verifier = GoogleTokenVerifier( + required_scopes=["openid", "https://www.googleapis.com/auth/calendar"], + ) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert "https://www.googleapis.com/auth/calendar" in result.scopes diff --git a/tests/server/middleware/test_rate_limiting.py b/tests/server/middleware/test_rate_limiting.py index 23132324f..f7d23f7af 100644 --- a/tests/server/middleware/test_rate_limiting.py +++ b/tests/server/middleware/test_rate_limiting.py @@ -421,22 +421,20 @@ class TestRateLimitingMiddlewareIntegration: async def test_global_rate_limiting(self, rate_limit_server): """Test global rate limiting across all clients.""" - rate_limit_server.add_middleware( - RateLimitingMiddleware( - max_requests_per_second=6.0, - burst_capacity=5, # 1 init + 2 list_tools + 2 calls before limit - global_limit=True, # Accounting for initialization and list_tools calls - ) + middleware = RateLimitingMiddleware( + max_requests_per_second=0.001, + burst_capacity=1000, + global_limit=True, ) + rate_limit_server.add_middleware(middleware) async with Client(rate_limit_server) as client: - # Use up the global capacity await client.call_tool("quick_action", {"message": "1"}) - await client.call_tool("quick_action", {"message": "2"}) - # Should be globally rate limited + middleware.global_limiter.tokens = 0 + with pytest.raises(ToolError, match="Global rate limit exceeded"): - await client.call_tool("quick_action", {"message": "3"}) + await client.call_tool("quick_action", {"message": "blocked"}) async def test_rate_limiting_recovery_over_time(self, rate_limit_server): """Test that rate limiting allows requests again after time passes."""