diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index 8a3f5f47d..ff3f658bb 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -96,7 +96,6 @@ jobs: STATUS (apply if applicable): - needs more info: Issue lacks reproduction steps, error messages, or clear description - - good first issue: ONLY if it's clearly scoped, has obvious solution, and touches limited files - invalid: Spam, completely off-topic, or nonsensical (often LLM-generated) AREA LABELS (apply ONLY when thematically central to the issue): diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 265a9dd48..4673252b8 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -37,6 +37,22 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +GOOGLE_SCOPE_ALIASES: dict[str, str] = { + "email": "https://www.googleapis.com/auth/userinfo.email", + "profile": "https://www.googleapis.com/auth/userinfo.profile", +} + + +def _normalize_google_scope(scope: str) -> str: + """Normalize a Google scope shorthand to its canonical full URI. + + Google accepts shorthand scopes like "email" and "profile" in authorization + requests, but returns the full URI form in token responses. This normalizes + to the full URI so comparisons work regardless of which form was used. + """ + return GOOGLE_SCOPE_ALIASES.get(scope, scope) + + class GoogleTokenVerifier(TokenVerifier): """Token verifier for Google OAuth tokens. @@ -60,7 +76,12 @@ class GoogleTokenVerifier(TokenVerifier): the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ - super().__init__(required_scopes=required_scopes) + normalized = ( + [_normalize_google_scope(s) for s in required_scopes] + if required_scopes + else required_scopes + ) + super().__init__(required_scopes=normalized) self.timeout_seconds = timeout_seconds self._http_client = http_client @@ -202,6 +223,7 @@ class GoogleProvider(OAuthProxy): issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, timeout_seconds: int = 10, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, @@ -224,6 +246,12 @@ class GoogleProvider(OAuthProxy): - "openid" for OpenID Connect (default) - "https://www.googleapis.com/auth/userinfo.email" for email access - "https://www.googleapis.com/auth/userinfo.profile" for profile info + Google scope shorthands like "email" and "profile" are automatically + normalized to their full URI forms for token verification. + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. Use this + when you want clients to be able to request additional scopes beyond the + required minimum. Shorthands are normalized to full URI forms. timeout_seconds: HTTP request timeout for Google API calls (defaults to 10) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. @@ -251,7 +279,19 @@ class GoogleProvider(OAuthProxy): parse_scopes(required_scopes) if required_scopes is not None else ["openid"] ) + # Normalize valid_scopes if provided + parsed_valid_scopes = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) + valid_scopes_final = ( + [_normalize_google_scope(s) for s in parsed_valid_scopes] + if parsed_valid_scopes is not None + else None + ) + # Create Google token verifier + # Normalization of shorthand scopes (e.g. "email" -> full URI) happens + # inside GoogleTokenVerifier so required_scopes match what Google returns. token_verifier = GoogleTokenVerifier( required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, @@ -286,6 +326,7 @@ class GoogleProvider(OAuthProxy): require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, extra_authorize_params=extra_authorize_params_final, + valid_scopes=valid_scopes_final, ) logger.debug( diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 0f6bd6c89..21db24e71 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -3,7 +3,12 @@ import pytest from key_value.aio.stores.memory import MemoryStore -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.google import ( + GOOGLE_SCOPE_ALIASES, + GoogleProvider, + GoogleTokenVerifier, + _normalize_google_scope, +) @pytest.fixture @@ -134,3 +139,94 @@ class TestGoogleProvider: # Defaults should still be present assert provider._extra_authorize_params["access_type"] == "offline" assert provider._extra_authorize_params["prompt"] == "consent" + + def test_valid_scopes_passed_through(self, memory_storage: MemoryStore): + """Test that valid_scopes is passed to OAuthProxy.""" + provider = GoogleProvider( + client_id="123456789.apps.googleusercontent.com", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + required_scopes=["openid"], + valid_scopes=["openid", "email", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + # Shorthands should be normalized to full URIs + assert set(reg_options.valid_scopes) == { + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + + def test_valid_scopes_defaults_to_required(self, memory_storage: MemoryStore): + """Test that valid_scopes defaults to required_scopes when not provided.""" + provider = GoogleProvider( + client_id="123456789.apps.googleusercontent.com", + client_secret="GOCSPX-test123", + base_url="https://myserver.com", + required_scopes=["openid", "email"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + # Should fall back to the (normalized) required_scopes + assert set(reg_options.valid_scopes) == { + "openid", + "https://www.googleapis.com/auth/userinfo.email", + } + + +class TestGoogleScopeNormalization: + """Test Google scope shorthand normalization.""" + + @pytest.mark.parametrize( + "shorthand, expected", + [ + ("email", "https://www.googleapis.com/auth/userinfo.email"), + ("profile", "https://www.googleapis.com/auth/userinfo.profile"), + ("openid", "openid"), + ( + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.email", + ), + ( + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar", + ), + ], + ) + def test_normalize_google_scope(self, shorthand: str, expected: str): + assert _normalize_google_scope(shorthand) == expected + + def test_verifier_normalizes_required_scopes(self): + """GoogleTokenVerifier should normalize shorthands in required_scopes.""" + verifier = GoogleTokenVerifier( + required_scopes=["openid", "email", "profile"], + ) + + assert set(verifier.required_scopes) == { + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + + def test_verifier_full_uris_unchanged(self): + """Full URIs should pass through normalization unchanged.""" + scopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ] + verifier = GoogleTokenVerifier(required_scopes=scopes) + assert verifier.required_scopes == scopes + + def test_alias_map_is_bidirectional(self): + """Verify the alias map covers the known Google shorthands.""" + assert "email" in GOOGLE_SCOPE_ALIASES + assert "profile" in GOOGLE_SCOPE_ALIASES