Compare commits

...

3 commits

Author SHA1 Message Date
Jeremiah Lowin
067b76a513
remove "good first issue" label from triage workflow 2026-03-13 18:57:39 -04:00
Jeremiah Lowin
3af099c1eb
remove unused GOOGLE_SCOPE_ALIASES_REVERSE 2026-03-13 18:35:38 -04:00
Jeremiah Lowin
3bc2805b67
fix: normalize Google scope shorthands and surface valid_scopes
Google accepts shorthand scopes like "email" in authorization requests but
returns full URIs like "https://www.googleapis.com/auth/userinfo.email" in
token responses. The verifier now normalizes shorthands at initialization so
the subset check works regardless of which form was used. GoogleProvider also
now exposes valid_scopes for controlling which scopes clients can request
beyond the required minimum.

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-13 18:29:52 -04:00
3 changed files with 139 additions and 3 deletions

View file

@ -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):

View file

@ -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(

View file

@ -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