diff --git a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py index 5dcb0b3e6..17f7e9cb9 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py @@ -124,6 +124,8 @@ class KeycloakOAuthProxy(OAuthProxy): upstream_client_id: str, upstream_client_secret: str | None = None, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | str | None = None, valid_scopes: list[str] | str | None = None, audience: str | list[str] | None = None, @@ -149,6 +151,13 @@ class KeycloakOAuthProxy(OAuthProxy): upstream_client_id: Client ID of the application registered in Keycloak. upstream_client_secret: Client secret. Optional for public clients. base_url: Public URL of this FastMCP server. + resource_base_url: Optional public base URL for the protected resource + metadata and token audience. Defaults to `base_url`. Set this when + the resource is served under a different origin/path than the + proxy (e.g. behind a gateway). + issuer_url: Issuer URL for OAuth metadata. Defaults to `base_url`. Set + this when the authorization-server metadata must advertise a + different issuer than `base_url` (e.g. behind a gateway). 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 @@ -224,6 +233,8 @@ class KeycloakOAuthProxy(OAuthProxy): valid_scopes=parsed_valid_scopes, redirect_path=redirect_path, base_url=base_url, + resource_base_url=resource_base_url, + issuer_url=issuer_url, jwt_signing_key=jwt_signing_key, client_storage=client_storage, require_authorization_consent=require_authorization_consent, diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 7d6f7c479..21f848fd0 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -995,6 +995,41 @@ class TestKeycloakOAuthProxy: ) assert proxy._redirect_path == "/custom/oauth/callback" + def test_issuer_and_resource_base_url_default_to_base_url(self, jwt_verifier): + proxy = KeycloakOAuthProxy( + realm_url="https://keycloak.example.com/realms/test", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + assert str(proxy.issuer_url).rstrip("/") == "https://proxy.example.com" + assert proxy.resource_base_url is None + + def test_issuer_and_resource_base_url_forwarded(self, jwt_verifier): + """Behind a gateway, switching to KeycloakOAuthProxy must not drop the + issuer/resource overrides that OAuthProxy supports (correct metadata + and JWT audience).""" + proxy = KeycloakOAuthProxy( + realm_url="https://keycloak.example.com/realms/test", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=jwt_verifier, + base_url="https://internal.proxy.local", + issuer_url="https://public.gateway.example.com", + resource_base_url="https://public.gateway.example.com/mcp", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + assert str(proxy.issuer_url).rstrip("/") == "https://public.gateway.example.com" + assert proxy.resource_base_url is not None + assert ( + str(proxy.resource_base_url).rstrip("/") + == "https://public.gateway.example.com/mcp" + ) + async def test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink( self, proxy ):