From bfd38fc45a7b56842d87c4aa2c39e0a010f43e15 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sat, 16 May 2026 23:49:06 -0500 Subject: [PATCH] feat: forward valid_scopes through KeycloakOAuthProxy for offline_access DCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.7 --- .../fastmcp/server/auth/providers/keycloak.py | 10 +++++ tests/server/auth/oauth_proxy/test_tokens.py | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py index 3f5424b38..dd874dcbf 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py @@ -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, diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 34f4b869d..873a9524e 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -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 ):