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:
strawgate 2026-05-16 23:49:06 -05:00
commit bfd38fc45a
2 changed files with 53 additions and 0 deletions

View file

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

View file

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