mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-18 03:29:11 +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):
|
STATUS (apply if applicable):
|
||||||
- needs more info: Issue lacks reproduction steps, error messages, or clear description
|
- 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)
|
- invalid: Spam, completely off-topic, or nonsensical (often LLM-generated)
|
||||||
|
|
||||||
AREA LABELS (apply ONLY when thematically central to the issue):
|
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__)
|
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):
|
class GoogleTokenVerifier(TokenVerifier):
|
||||||
"""Token verifier for Google OAuth tokens.
|
"""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
|
the client is reused across calls and the caller is responsible for its
|
||||||
lifecycle. When None (default), a fresh client is created per call.
|
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.timeout_seconds = timeout_seconds
|
||||||
self._http_client = http_client
|
self._http_client = http_client
|
||||||
|
|
||||||
|
|
@ -202,6 +223,7 @@ class GoogleProvider(OAuthProxy):
|
||||||
issuer_url: AnyHttpUrl | str | None = None,
|
issuer_url: AnyHttpUrl | str | None = None,
|
||||||
redirect_path: str | None = None,
|
redirect_path: str | None = None,
|
||||||
required_scopes: list[str] | None = None,
|
required_scopes: list[str] | None = None,
|
||||||
|
valid_scopes: list[str] | None = None,
|
||||||
timeout_seconds: int = 10,
|
timeout_seconds: int = 10,
|
||||||
allowed_client_redirect_uris: list[str] | None = None,
|
allowed_client_redirect_uris: list[str] | None = None,
|
||||||
client_storage: AsyncKeyValue | None = None,
|
client_storage: AsyncKeyValue | None = None,
|
||||||
|
|
@ -224,6 +246,12 @@ class GoogleProvider(OAuthProxy):
|
||||||
- "openid" for OpenID Connect (default)
|
- "openid" for OpenID Connect (default)
|
||||||
- "https://www.googleapis.com/auth/userinfo.email" for email access
|
- "https://www.googleapis.com/auth/userinfo.email" for email access
|
||||||
- "https://www.googleapis.com/auth/userinfo.profile" for profile info
|
- "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)
|
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.
|
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.
|
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"]
|
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
|
# 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(
|
token_verifier = GoogleTokenVerifier(
|
||||||
required_scopes=required_scopes_final,
|
required_scopes=required_scopes_final,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
|
@ -286,6 +326,7 @@ class GoogleProvider(OAuthProxy):
|
||||||
require_authorization_consent=require_authorization_consent,
|
require_authorization_consent=require_authorization_consent,
|
||||||
consent_csp_policy=consent_csp_policy,
|
consent_csp_policy=consent_csp_policy,
|
||||||
extra_authorize_params=extra_authorize_params_final,
|
extra_authorize_params=extra_authorize_params_final,
|
||||||
|
valid_scopes=valid_scopes_final,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
import pytest
|
import pytest
|
||||||
from key_value.aio.stores.memory import MemoryStore
|
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
|
@pytest.fixture
|
||||||
|
|
@ -134,3 +139,94 @@ class TestGoogleProvider:
|
||||||
# Defaults should still be present
|
# Defaults should still be present
|
||||||
assert provider._extra_authorize_params["access_type"] == "offline"
|
assert provider._extra_authorize_params["access_type"] == "offline"
|
||||||
assert provider._extra_authorize_params["prompt"] == "consent"
|
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