mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
feat: forward valid_scopes through KeycloakOAuthProxy for offline_access DCR
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
853097a256
commit
bfd38fc45a
2 changed files with 53 additions and 0 deletions
|
|
@ -125,6 +125,7 @@ class KeycloakOAuthProxy(OAuthProxy):
|
|||
upstream_client_secret: str | None = None,
|
||||
base_url: AnyHttpUrl | str,
|
||||
required_scopes: list[str] | str | None = None,
|
||||
valid_scopes: list[str] | str | None = None,
|
||||
audience: str | list[str] | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
# Direct endpoint overrides (optional if realm_url is provided)
|
||||
|
|
@ -148,6 +149,11 @@ class KeycloakOAuthProxy(OAuthProxy):
|
|||
upstream_client_secret: Client secret. Optional for public clients.
|
||||
base_url: Public URL of this FastMCP server.
|
||||
required_scopes: Scopes to require on incoming tokens. Defaults to `["openid"]`.
|
||||
valid_scopes: Scopes advertised to clients via the `/.well-known` endpoints
|
||||
and accepted during Dynamic Client Registration. Defaults to
|
||||
`required_scopes`. Set this to include `offline_access` so clients can
|
||||
register for the long-lived offline refresh tokens this proxy handles
|
||||
without also forcing `offline_access` onto every access token.
|
||||
audience: Optional JWT audience for token validation. Recommended for production.
|
||||
token_verifier: Custom token verifier. Defaults to a JWTVerifier configured
|
||||
for the Keycloak realm's JWKS endpoint.
|
||||
|
|
@ -185,6 +191,9 @@ class KeycloakOAuthProxy(OAuthProxy):
|
|||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
parsed_valid_scopes = (
|
||||
parse_scopes(valid_scopes) if valid_scopes is not None else None
|
||||
)
|
||||
|
||||
if token_verifier is None:
|
||||
if realm is None:
|
||||
|
|
@ -206,6 +215,7 @@ class KeycloakOAuthProxy(OAuthProxy):
|
|||
upstream_client_id=upstream_client_id,
|
||||
upstream_client_secret=upstream_client_secret,
|
||||
token_verifier=token_verifier,
|
||||
valid_scopes=parsed_valid_scopes,
|
||||
base_url=base_url,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
client_storage=client_storage,
|
||||
|
|
|
|||
|
|
@ -925,6 +925,49 @@ class TestKeycloakOAuthProxy:
|
|||
assert upstream_token_set.refresh_token_never_expires is True
|
||||
assert upstream_token_set.refresh_token_expires_at is None
|
||||
|
||||
def test_valid_scopes_forwarded_for_dcr(self, jwt_verifier):
|
||||
"""valid_scopes must be advertised/accepted for DCR independently of required_scopes.
|
||||
|
||||
Offline tokens (the whole point of this proxy) require clients to register
|
||||
the `offline_access` scope. Without a separate valid_scopes pass-through,
|
||||
DCR scope validation falls back to required_scopes and rejects it.
|
||||
"""
|
||||
proxy = KeycloakOAuthProxy(
|
||||
realm_url="https://keycloak.example.com/realms/test",
|
||||
upstream_client_id="test-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
valid_scopes=["openid", "offline_access"],
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret-key",
|
||||
client_storage=MemoryStore(),
|
||||
)
|
||||
assert proxy.client_registration_options is not None
|
||||
assert proxy.client_registration_options.valid_scopes == [
|
||||
"openid",
|
||||
"offline_access",
|
||||
]
|
||||
# required_scopes (token verification) stays independent of valid_scopes (DCR)
|
||||
assert jwt_verifier.required_scopes == ["read", "write"]
|
||||
|
||||
def test_valid_scopes_accepts_string(self, jwt_verifier):
|
||||
"""A space/comma-delimited string is parsed like required_scopes."""
|
||||
proxy = KeycloakOAuthProxy(
|
||||
realm_url="https://keycloak.example.com/realms/test",
|
||||
upstream_client_id="test-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
valid_scopes="openid offline_access",
|
||||
base_url="https://proxy.example.com",
|
||||
jwt_signing_key="test-secret-key",
|
||||
client_storage=MemoryStore(),
|
||||
)
|
||||
assert proxy.client_registration_options is not None
|
||||
assert proxy.client_registration_options.valid_scopes == [
|
||||
"openid",
|
||||
"offline_access",
|
||||
]
|
||||
|
||||
async def test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink(
|
||||
self, proxy
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue