mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Compare commits
3 commits
main
...
fix/google
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
067b76a513 |
||
|
|
3af099c1eb |
||
|
|
3bc2805b67 |
3 changed files with 139 additions and 3 deletions
1
.github/workflows/marvin-label-triage.yml
vendored
1
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue