From 5ef06f230682468f6747c7a26ef9fe03019a8aa9 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 26 Apr 2026 00:55:46 -0500 Subject: [PATCH 01/10] fix: treat Keycloak refresh_expires_in=0 as never-expires sentinel --- .../fastmcp/server/auth/oauth_proxy/proxy.py | 117 ++++++++++-------- 1 file changed, 66 insertions(+), 51 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index c94d43b21..864defd5f 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -1091,19 +1091,22 @@ class OAuthProxy(OAuthProvider, ConsentMixin): refresh_expires_in = None refresh_token_expires_at = None if idp_tokens.get("refresh_token"): - if "refresh_expires_in" in idp_tokens and int( - idp_tokens["refresh_expires_in"] - ): - refresh_expires_in = int(idp_tokens["refresh_expires_in"]) - refresh_token_expires_at = time.time() + refresh_expires_in - logger.debug( - "Upstream refresh token expires in %d seconds", refresh_expires_in - ) - else: - # Upstream didn't specify; use configured fallback (default 1 year). - # The FastMCP refresh JWT is just a signed pointer — if the real - # upstream refresh has expired or been revoked, the next refresh - # call to upstream will fail and the client re-auths. + if "refresh_expires_in" in idp_tokens: + val = int(idp_tokens["refresh_expires_in"]) + if val > 0: + refresh_expires_in = val + refresh_token_expires_at = time.time() + refresh_expires_in + logger.debug( + "Upstream refresh token expires in %d seconds", + refresh_expires_in, + ) + elif val == 0: + # Keycloak "never expires" sentinel - keep None + pass + else: + pass + if refresh_expires_in is None: + # Upstream didn't specify or had invalid/zero value; use fallback. refresh_expires_in = self._fallback_refresh_token_expiry_seconds refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( @@ -1408,28 +1411,34 @@ class OAuthProxy(OAuthProvider, ConsentMixin): logger.debug("Upstream refresh token rotated") # Update refresh token expiry if provided - if "refresh_expires_in" in token_response and int( - token_response["refresh_expires_in"] - ): - new_refresh_expires_in = int(token_response["refresh_expires_in"]) - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - logger.debug( - "Upstream refresh token expires in %d seconds", - new_refresh_expires_in, - ) - elif upstream_token_set.refresh_token_expires_at: - # Keep existing expiry if upstream doesn't provide new one - new_refresh_expires_in = int( - upstream_token_set.refresh_token_expires_at - time.time() - ) - else: - # Upstream rotated the refresh token but gave no expiry; use fallback - new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) + keycloak_never_expires = False + if "refresh_expires_in" in token_response: + val = int(token_response["refresh_expires_in"]) + if val > 0: + new_refresh_expires_in = val + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) + logger.debug( + "Upstream refresh token expires in %d seconds", + new_refresh_expires_in, + ) + elif val == 0: + # Keycloak "never expires" sentinel + keycloak_never_expires = True + # else: negative/invalid - fall through to elif + if not keycloak_never_expires and new_refresh_expires_in is None: + if upstream_token_set.refresh_token_expires_at: + # Keep existing expiry if upstream doesn't provide new one + new_refresh_expires_in = int( + upstream_token_set.refresh_token_expires_at - time.time() + ) + else: + # Upstream rotated the refresh token but gave no expiry; use fallback + new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) upstream_token_set.raw_token_data = { **upstream_token_set.raw_token_data, @@ -1623,22 +1632,28 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if new_upstream_refresh := token_response.get("refresh_token"): if new_upstream_refresh != upstream_token_set.refresh_token: upstream_token_set.refresh_token = new_upstream_refresh - if "refresh_expires_in" in token_response and int( - token_response["refresh_expires_in"] - ): - new_refresh_expires_in = int(token_response["refresh_expires_in"]) - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - elif upstream_token_set.refresh_token_expires_at: - new_refresh_expires_in = int( - upstream_token_set.refresh_token_expires_at - time.time() - ) - else: - new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) + keycloak_never_expires = False + if "refresh_expires_in" in token_response: + val = int(token_response["refresh_expires_in"]) + if val > 0: + new_refresh_expires_in = val + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) + elif val == 0: + # Keycloak "never expires" sentinel + keycloak_never_expires = True + # else: negative/invalid - fall through + if not keycloak_never_expires and new_refresh_expires_in is None: + if upstream_token_set.refresh_token_expires_at: + new_refresh_expires_in = int( + upstream_token_set.refresh_token_expires_at - time.time() + ) + else: + new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) upstream_token_set.raw_token_data = { **upstream_token_set.raw_token_data, From 07e3808e2b6914860eee65d0b7effa2aa7e8c644 Mon Sep 17 00:00:00 2001 From: strawgate Date: Tue, 12 May 2026 22:39:35 -0500 Subject: [PATCH 02/10] fix: skip fallback expiry when Keycloak sends refresh_expires_in=0 The initial auth-code exchange path had a bug where val==0 kept refresh_expires_in as None, causing the fallback to be applied despite the Keycloak never-expires sentinel. Now uses the same keycloak_never_expires flag pattern as the other two code paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fastmcp/server/auth/oauth_proxy/proxy.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 864defd5f..4744249e2 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -1091,6 +1091,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): refresh_expires_in = None refresh_token_expires_at = None if idp_tokens.get("refresh_token"): + keycloak_never_expires = False if "refresh_expires_in" in idp_tokens: val = int(idp_tokens["refresh_expires_in"]) if val > 0: @@ -1101,12 +1102,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin): refresh_expires_in, ) elif val == 0: - # Keycloak "never expires" sentinel - keep None - pass - else: - pass - if refresh_expires_in is None: - # Upstream didn't specify or had invalid/zero value; use fallback. + # Keycloak "never expires" sentinel — skip fallback + keycloak_never_expires = True + # else: negative/invalid — fall through to fallback + if not keycloak_never_expires and refresh_expires_in is None: + # Upstream didn't specify; use configured fallback (default 1 year). refresh_expires_in = self._fallback_refresh_token_expiry_seconds refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( From 68534df4f3bffec42e2121fa6004f1fa0711c1fc Mon Sep 17 00:00:00 2001 From: strawgate Date: Tue, 12 May 2026 22:59:24 -0500 Subject: [PATCH 03/10] Fix: remove keycloak_never_expires flag, let val<=0 fallthrough to default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fastmcp/server/auth/oauth_proxy/proxy.py | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 4744249e2..1ffacf05e 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -1091,7 +1091,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): refresh_expires_in = None refresh_token_expires_at = None if idp_tokens.get("refresh_token"): - keycloak_never_expires = False if "refresh_expires_in" in idp_tokens: val = int(idp_tokens["refresh_expires_in"]) if val > 0: @@ -1101,11 +1100,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", refresh_expires_in, ) - elif val == 0: - # Keycloak "never expires" sentinel — skip fallback - keycloak_never_expires = True - # else: negative/invalid — fall through to fallback - if not keycloak_never_expires and refresh_expires_in is None: + if refresh_expires_in is None: # Upstream didn't specify; use configured fallback (default 1 year). refresh_expires_in = self._fallback_refresh_token_expiry_seconds refresh_token_expires_at = time.time() + refresh_expires_in @@ -1411,7 +1406,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): logger.debug("Upstream refresh token rotated") # Update refresh token expiry if provided - keycloak_never_expires = False if "refresh_expires_in" in token_response: val = int(token_response["refresh_expires_in"]) if val > 0: @@ -1423,11 +1417,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", new_refresh_expires_in, ) - elif val == 0: - # Keycloak "never expires" sentinel - keycloak_never_expires = True - # else: negative/invalid - fall through to elif - if not keycloak_never_expires and new_refresh_expires_in is None: + if new_refresh_expires_in is None: if upstream_token_set.refresh_token_expires_at: # Keep existing expiry if upstream doesn't provide new one new_refresh_expires_in = int( @@ -1632,7 +1622,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if new_upstream_refresh := token_response.get("refresh_token"): if new_upstream_refresh != upstream_token_set.refresh_token: upstream_token_set.refresh_token = new_upstream_refresh - keycloak_never_expires = False if "refresh_expires_in" in token_response: val = int(token_response["refresh_expires_in"]) if val > 0: @@ -1640,11 +1629,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in ) - elif val == 0: - # Keycloak "never expires" sentinel - keycloak_never_expires = True - # else: negative/invalid - fall through - if not keycloak_never_expires and new_refresh_expires_in is None: + if new_refresh_expires_in is None: if upstream_token_set.refresh_token_expires_at: new_refresh_expires_in = int( upstream_token_set.refresh_token_expires_at - time.time() From 08baf0763b07b6444892d735f5bb2ee70ff2ff13 Mon Sep 17 00:00:00 2001 From: strawgate Date: Wed, 13 May 2026 12:38:50 -0500 Subject: [PATCH 04/10] fix: clear stale refresh_token_expires_at for Keycloak offline tokens on subsequent refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Keycloak returns refresh_expires_in=0 (offline token, never expires) on every token response, the previous code only guarded the initial exchange path. On subsequent exchange_refresh_token and transparent refresh cycles, the code would fall through to 'keep existing expiry' — inheriting the wall-clock timestamp set at initial exchange. After ~1 year that decayed to ~0 seconds, issuing FastMCP RTs with 1-second TTL and forcing re-auth even though the Keycloak offline token was still valid. Fix: add elif val == 0 to both refresh paths that clears refresh_token_expires_at to None. The fallback branch then issues a fresh full fallback-TTL FastMCP RT on every cycle, matching the 'always-valid' semantics of offline tokens. Also improves the debug log message to distinguish 'never expires' from 'expiry not provided' so operators can see exactly what Keycloak sent. Adds a regression test: test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fastmcp/server/auth/oauth_proxy/proxy.py | 22 +++- tests/server/auth/oauth_proxy/test_tokens.py | 108 +++++++++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 1ffacf05e..e72262049 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -1100,8 +1100,19 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", refresh_expires_in, ) + elif val == 0: + # Keycloak offline_access tokens use 0 to mean "never expires". + # refresh_token_expires_at stays None (no upstream expiry to track). + # We still need a finite FastMCP RT TTL; use the configured fallback. + # The FastMCP RT is renewed on every transparent refresh, so active + # sessions roll forward automatically. + logger.debug( + "Upstream refresh_expires_in=0 (never expires); " + "FastMCP RT will use fallback TTL of %d seconds", + self._fallback_refresh_token_expiry_seconds, + ) if refresh_expires_in is None: - # Upstream didn't specify; use configured fallback (default 1 year). + # Upstream didn't specify expiry (or signalled never-expires with 0). refresh_expires_in = self._fallback_refresh_token_expiry_seconds refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( @@ -1417,6 +1428,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", new_refresh_expires_in, ) + elif val == 0: + # Keycloak offline token — never expires upstream. + # Clear any stale expires_at so the fallback below issues + # a fresh full-length FastMCP RT instead of a decaying one. + upstream_token_set.refresh_token_expires_at = None if new_refresh_expires_in is None: if upstream_token_set.refresh_token_expires_at: # Keep existing expiry if upstream doesn't provide new one @@ -1629,6 +1645,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in ) + elif val == 0: + # Keycloak offline token — never expires upstream. + # Clear stale expires_at so fallback issues a fresh full-length RT. + upstream_token_set.refresh_token_expires_at = None if new_refresh_expires_in is None: if upstream_token_set.refresh_token_expires_at: new_refresh_expires_in = int( diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 635819fe4..045e6ce66 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -879,8 +879,114 @@ class TestUpstreamTokenStorageTTL: ) assert refresh_meta is not None + async def test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink( + self, proxy + ): + """Repeated refresh cycles with refresh_expires_in=0 must not shrink the RT TTL. + + Keycloak offline tokens return refresh_expires_in=0 on every token response. + The first exchange correctly stores a 1-year FastMCP RT. Subsequent + exchange_refresh_token calls (triggered by the MCP client refreshing its tokens) + must each issue a new FastMCP RT with the same full fallback TTL — not a + gradually decaying value computed from the original wall-clock timestamp. + """ + import jwt as pyjwt + + client = OAuthClientInformationFull( + client_id="kc-shrink-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await proxy.register_client(client) + + # ── step 1: initial authorization code exchange ────────────────────── + client_code = ClientCode( + code="kc-offline-code", + client_id="kc-shrink-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="test-challenge", + code_challenge_method="S256", + scopes=["read"], + idp_tokens={ + "access_token": "upstream-at-v1", + "refresh_token": "upstream-rt-v1", + "expires_in": 3600, + "refresh_expires_in": 0, # Keycloak offline sentinel + "token_type": "Bearer", + }, + expires_at=time.time() + 300, + created_at=time.time(), + ) + await proxy._code_store.put(key=client_code.code, value=client_code) + + auth_code = AuthorizationCode( + code="kc-offline-code", + scopes=["read"], + expires_at=time.time() + 300, + client_id="kc-shrink-client", + code_challenge="test-challenge", + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + ) + result1 = await proxy.exchange_authorization_code( + client=client, + authorization_code=auth_code, + ) + assert result1.refresh_token is not None + first_rt = result1.refresh_token + first_payload = pyjwt.decode( + first_rt, options={"verify_signature": False, "verify_exp": False} + ) + first_ttl = first_payload["exp"] - first_payload["iat"] + # First RT should be the full fallback TTL (1 year ± some seconds) + assert first_ttl > 60 * 60 * 24 * 300, ( + f"First RT TTL {first_ttl}s should be close to 1 year" + ) + + # ── step 2: simulate a later refresh cycle (Keycloak returns 0 again) ─ + async def fake_refresh(url, refresh_token, scope=None, **_kwargs): + return { + "access_token": "upstream-at-v2", + "refresh_token": "upstream-rt-v2", + "expires_in": 3600, + "refresh_expires_in": 0, # same Keycloak sentinel + "token_type": "Bearer", + } + + mock_client = Mock() + mock_client.refresh_token = AsyncMock(side_effect=fake_refresh) + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_client + ): + first_rt_meta = await proxy._refresh_token_store.get( + key=_hash_token(first_rt) + ) + assert first_rt_meta is not None + result2 = await proxy.exchange_refresh_token( + client=client, + refresh_token=RefreshToken( + token=first_rt, + client_id="kc-shrink-client", + scopes=["read"], + expires_at=first_rt_meta.expires_at, + ), + scopes=["read"], + ) + + assert result2.refresh_token is not None + second_payload = pyjwt.decode( + result2.refresh_token, + options={"verify_signature": False, "verify_exp": False}, + ) + second_ttl = second_payload["exp"] - second_payload["iat"] + # After a refresh cycle the new RT must still have a full fallback TTL, + # not a value that has shrunk from the original timestamp. + assert second_ttl > 60 * 60 * 24 * 300, ( + f"Refreshed RT TTL {second_ttl}s shrank — expected close to 1 year" + ) + + -class TestTransparentUpstreamRefresh: """Tests for transparent upstream token refresh in load_access_token. When the upstream token expires but a refresh token is available, the proxy From 97649771d1b08fe756fa68c6a9c68960c716ce5e Mon Sep 17 00:00:00 2001 From: strawgate Date: Wed, 13 May 2026 15:30:15 -0500 Subject: [PATCH 05/10] feat: add KeycloakOAuthProxy with offline token support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak returns refresh_expires_in=0 for offline_access tokens to signal 'this refresh token never expires'. Base OAuthProxy has no way to know this is intentional rather than a malformed response, so it falls through to the standard 1-year wall-clock fallback — which causes the FastMCP refresh token TTL to shrink on every subsequent refresh cycle until it reaches ~0 after one year, forcing re-authentication even though the Keycloak offline token is still valid. This commit: - Adds KeycloakOAuthProxy(OAuthProxy) to providers/keycloak.py with a convenience __init__ that derives OIDC endpoints from realm_url - Adds _zero_refresh_expiry_means_never_expires: bool = False class attr on OAuthProxy; KeycloakOAuthProxy sets it to True - Adds refresh_token_never_expires: bool = False to UpstreamTokenSet so the intent is visible in stored state - When the flag is set and val==0: marks the token as never-expiring and clears refresh_token_expires_at so subsequent refresh cycles always get a fresh full fallback-TTL FastMCP RT instead of a decaying one - Base OAuthProxy is completely unchanged for val==0: falls through to the existing 1-year wall-clock fallback as before Tests: - test_refresh_expires_in_zero_issues_refresh_token: KeycloakOAuthProxy correctly issues a refresh token and marks upstream as never-expiring - test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink: TTL stays at ~1 year after repeated refresh cycles - test_base_proxy_does_not_treat_zero_as_never_expires: confirms base OAuthProxy behaviour is unaffected Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fastmcp/server/auth/oauth_proxy/models.py | 1 + .../fastmcp/server/auth/oauth_proxy/proxy.py | 51 ++++--- .../fastmcp/server/auth/providers/keycloak.py | 140 ++++++++++++++++++ tests/server/auth/oauth_proxy/test_tokens.py | 127 +++++++++++++++- 4 files changed, 297 insertions(+), 22 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py index 5d2366a76..57f56047d 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py @@ -92,6 +92,7 @@ class UpstreamTokenSet(BaseModel): refresh_token_expires_at: ( float | None ) # Unix timestamp when refresh token expires (if known) + refresh_token_never_expires: bool = False # True = upstream explicitly said "no expiry" (e.g. Keycloak offline_access) expires_at: float # Unix timestamp when access token expires token_type: str # Usually "Bearer" scope: str # Space-separated scopes diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index e72262049..b87a3d9b5 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -231,6 +231,12 @@ class OAuthProxy(OAuthProvider, ConsentMixin): - Generic: Works with any spec-compliant provider """ + # Subclasses can set this to True to treat refresh_expires_in=0 from the + # upstream as "this refresh token never expires" rather than an unknown expiry. + # RFC 6749 does not define refresh_expires_in; 0 is a Keycloak-specific + # convention for offline_access tokens. Do not enable for other providers. + _zero_refresh_expiry_means_never_expires: bool = False + def __init__( self, *, @@ -1090,6 +1096,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Some providers include refresh_expires_in, some don't refresh_expires_in = None refresh_token_expires_at = None + refresh_token_never_expires = False if idp_tokens.get("refresh_token"): if "refresh_expires_in" in idp_tokens: val = int(idp_tokens["refresh_expires_in"]) @@ -1100,21 +1107,24 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", refresh_expires_in, ) - elif val == 0: - # Keycloak offline_access tokens use 0 to mean "never expires". + elif val == 0 and self._zero_refresh_expiry_means_never_expires: + # Provider explicitly signals "no expiry" (e.g. Keycloak offline_access). # refresh_token_expires_at stays None (no upstream expiry to track). # We still need a finite FastMCP RT TTL; use the configured fallback. # The FastMCP RT is renewed on every transparent refresh, so active - # sessions roll forward automatically. + # sessions roll forward automatically without ever hitting a hard wall. + refresh_token_never_expires = True logger.debug( "Upstream refresh_expires_in=0 (never expires); " "FastMCP RT will use fallback TTL of %d seconds", self._fallback_refresh_token_expiry_seconds, ) if refresh_expires_in is None: - # Upstream didn't specify expiry (or signalled never-expires with 0). + # Upstream didn't specify expiry; use configured fallback (default 1 year). refresh_expires_in = self._fallback_refresh_token_expiry_seconds - refresh_token_expires_at = time.time() + refresh_expires_in + if not refresh_token_never_expires: + # Track wall-clock expiry only for tokens with a real deadline. + refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( "Upstream refresh token expiry unknown, using fallback %d seconds", refresh_expires_in, @@ -1128,6 +1138,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if idp_tokens.get("refresh_token") else None, refresh_token_expires_at=refresh_token_expires_at, + refresh_token_never_expires=refresh_token_never_expires, expires_at=time.time() + expires_in, token_type=idp_tokens.get("token_type", "Bearer"), scope=" ".join(granted_scopes), @@ -1428,10 +1439,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", new_refresh_expires_in, ) - elif val == 0: - # Keycloak offline token — never expires upstream. - # Clear any stale expires_at so the fallback below issues - # a fresh full-length FastMCP RT instead of a decaying one. + elif val == 0 and self._zero_refresh_expiry_means_never_expires: + # Provider signals "no expiry" — mark and clear stale wall-clock time + # so the fallback below always issues a fresh full-length FastMCP RT. + upstream_token_set.refresh_token_never_expires = True upstream_token_set.refresh_token_expires_at = None if new_refresh_expires_in is None: if upstream_token_set.refresh_token_expires_at: @@ -1440,11 +1451,12 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.refresh_token_expires_at - time.time() ) else: - # Upstream rotated the refresh token but gave no expiry; use fallback + # Upstream rotated the refresh token but gave no expiry (or never-expires); use fallback new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) + if not upstream_token_set.refresh_token_never_expires: + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) upstream_token_set.raw_token_data = { **upstream_token_set.raw_token_data, @@ -1645,9 +1657,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in ) - elif val == 0: - # Keycloak offline token — never expires upstream. - # Clear stale expires_at so fallback issues a fresh full-length RT. + elif val == 0 and self._zero_refresh_expiry_means_never_expires: + # Provider signals "no expiry" — mark and clear stale wall-clock time. + upstream_token_set.refresh_token_never_expires = True upstream_token_set.refresh_token_expires_at = None if new_refresh_expires_in is None: if upstream_token_set.refresh_token_expires_at: @@ -1656,9 +1668,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) else: new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) + if not upstream_token_set.refresh_token_never_expires: + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) upstream_token_set.raw_token_data = { **upstream_token_set.raw_token_data, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py index d018bc4b0..9c4a74cfc 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py @@ -2,13 +2,19 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Literal + from pydantic import AnyHttpUrl from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from key_value.aio.protocols import AsyncKeyValue + logger = get_logger(__name__) @@ -72,3 +78,137 @@ class KeycloakAuthProvider(RemoteAuthProvider): authorization_servers=[AnyHttpUrl(self.realm_url)], base_url=AnyHttpUrl(str(base_url).rstrip("/")), ) + + +class KeycloakOAuthProxy(OAuthProxy): + """OAuth proxy for Keycloak identity providers. + + Use this instead of `OAuthProxy` when proxying to Keycloak. It handles + Keycloak-specific token response conventions, most importantly + `refresh_expires_in=0`, which Keycloak uses to indicate that a refresh + token obtained with the `offline_access` scope never expires. Standard + `OAuthProxy` treats `0` as an unknown expiry, which causes the FastMCP + refresh token TTL to shrink on every refresh cycle until it hits zero + and forces the user to re-authenticate — even though the Keycloak + offline token is still valid. + + All other behaviour is identical to `OAuthProxy`. Pass `realm_url` for + automatic endpoint discovery, or supply the individual endpoint URLs + directly. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.keycloak import KeycloakOAuthProxy + + auth = KeycloakOAuthProxy( + realm_url="https://keycloak.example.com/realms/myrealm", + upstream_client_id="my-client", + upstream_client_secret="my-secret", + base_url="https://my-mcp-server.example.com", + jwt_signing_key="some-secret", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + # Keycloak uses refresh_expires_in=0 for offline_access tokens ("never expires"). + _zero_refresh_expiry_means_never_expires: bool = True + + def __init__( + self, + *, + realm_url: AnyHttpUrl | str | None = None, + upstream_client_id: str, + upstream_client_secret: str | None = None, + base_url: AnyHttpUrl | str, + required_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) + upstream_authorization_endpoint: str | None = None, + upstream_token_endpoint: str | None = None, + upstream_revocation_endpoint: str | None = None, + # Pass-through OAuthProxy options + jwt_signing_key: str | bytes | None = None, + client_storage: AsyncKeyValue | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + allowed_client_redirect_uris: list[str] | None = None, + fallback_refresh_token_expiry_seconds: int | None = None, + ): + """Initialize the Keycloak OAuth proxy. + + Args: + realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm"). + Used to derive authorization and token endpoints automatically. Required unless + `upstream_authorization_endpoint` and `upstream_token_endpoint` are both provided. + 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. + required_scopes: Scopes to require on incoming tokens. Defaults to `["openid"]`. + 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. + upstream_authorization_endpoint: Override the authorization endpoint URL. + Required if `realm_url` is not provided. + upstream_token_endpoint: Override the token endpoint URL. + Required if `realm_url` is not provided. + upstream_revocation_endpoint: Optional token revocation endpoint. + jwt_signing_key: Secret for signing FastMCP JWTs. + client_storage: Storage backend for OAuth state. + require_authorization_consent: Consent screen behaviour (default True). + allowed_client_redirect_uris: Allowed MCP client redirect URI patterns. + fallback_refresh_token_expiry_seconds: FastMCP RT lifetime when Keycloak + returns `refresh_expires_in=0`. Defaults to 1 year. The token is + re-issued automatically on every refresh cycle, so active sessions + remain valid indefinitely. + """ + if realm_url is None and ( + upstream_authorization_endpoint is None or upstream_token_endpoint is None + ): + raise ValueError( + "Either realm_url or both upstream_authorization_endpoint and " + "upstream_token_endpoint must be provided." + ) + + realm = str(realm_url).rstrip("/") if realm_url else None + oidc_base = f"{realm}/protocol/openid-connect" if realm else None + + resolved_auth_endpoint = upstream_authorization_endpoint or f"{oidc_base}/auth" + resolved_token_endpoint = upstream_token_endpoint or f"{oidc_base}/token" + resolved_revocation_endpoint = upstream_revocation_endpoint or ( + f"{oidc_base}/revoke" if oidc_base else None + ) + + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + if token_verifier is None: + if realm is None: + raise ValueError( + "token_verifier must be provided when realm_url is not set." + ) + token_verifier = JWTVerifier( + jwks_uri=f"{realm}/protocol/openid-connect/certs", + issuer=realm, + algorithm="RS256", + required_scopes=parsed_scopes, + audience=audience, + ) + + super().__init__( + upstream_authorization_endpoint=resolved_auth_endpoint, + upstream_token_endpoint=resolved_token_endpoint, + upstream_revocation_endpoint=resolved_revocation_endpoint, + upstream_client_id=upstream_client_id, + upstream_client_secret=upstream_client_secret, + token_verifier=token_verifier, + base_url=base_url, + jwt_signing_key=jwt_signing_key, + client_storage=client_storage, + require_authorization_consent=require_authorization_consent, + allowed_client_redirect_uris=allowed_client_redirect_uris, + fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + ) diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 045e6ce66..34f4b869d 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -29,6 +29,7 @@ from fastmcp.server.auth.oauth_proxy.models import ( _hash_token, ) from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.auth.providers.keycloak import KeycloakOAuthProxy class TestOAuthProxyTokenEndpointAuth: @@ -822,6 +823,37 @@ class TestUpstreamTokenStorageTTL: ) assert upstream_tokens is not None + +class TestKeycloakOAuthProxy: + """Tests specific to KeycloakOAuthProxy's offline token handling. + + Keycloak returns refresh_expires_in=0 for offline_access tokens to signal + "no time-based expiry". KeycloakOAuthProxy must handle this correctly: + issue a refresh token, and never let the FastMCP RT TTL shrink across + repeated refresh cycles. + """ + + @pytest.fixture + def jwt_verifier(self): + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read", "write"] + verifier.verify_token = AsyncMock(return_value=None) + return verifier + + @pytest.fixture + def proxy(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(), + ) + proxy.set_mcp_path("/mcp") + return proxy + async def test_refresh_expires_in_zero_issues_refresh_token(self, proxy): """refresh_expires_in=0 should fall back to the configured default. @@ -879,6 +911,20 @@ class TestUpstreamTokenStorageTTL: ) assert refresh_meta is not None + # Upstream token set should be marked as never-expiring + jti_mapping = await proxy._jti_mapping_store.get( + key=proxy.jwt_issuer.verify_token( + result.refresh_token, expected_token_use="refresh" + )["jti"] + ) + assert jti_mapping is not None + upstream_token_set = await proxy._upstream_token_store.get( + key=jti_mapping.upstream_token_id + ) + assert upstream_token_set is not None + assert upstream_token_set.refresh_token_never_expires is True + assert upstream_token_set.refresh_token_expires_at is None + async def test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink( self, proxy ): @@ -938,7 +984,6 @@ class TestUpstreamTokenStorageTTL: first_rt, options={"verify_signature": False, "verify_exp": False} ) first_ttl = first_payload["exp"] - first_payload["iat"] - # First RT should be the full fallback TTL (1 year ± some seconds) assert first_ttl > 60 * 60 * 24 * 300, ( f"First RT TTL {first_ttl}s should be close to 1 year" ) @@ -979,14 +1024,90 @@ class TestUpstreamTokenStorageTTL: options={"verify_signature": False, "verify_exp": False}, ) second_ttl = second_payload["exp"] - second_payload["iat"] - # After a refresh cycle the new RT must still have a full fallback TTL, - # not a value that has shrunk from the original timestamp. assert second_ttl > 60 * 60 * 24 * 300, ( f"Refreshed RT TTL {second_ttl}s shrank — expected close to 1 year" ) + async def test_base_proxy_does_not_treat_zero_as_never_expires(self): + """Base OAuthProxy must not interpret refresh_expires_in=0 as never-expires. + + Only KeycloakOAuthProxy opts in to that behaviour. The base proxy should + fall through to the standard fallback (1-year wall-clock timestamp), preserving + existing behaviour for all other providers. + """ + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + verifier.verify_token = AsyncMock(return_value=None) + + base_proxy = OAuthProxy( + upstream_authorization_endpoint="https://idp.example.com/authorize", + upstream_token_endpoint="https://idp.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=verifier, + base_url="https://proxy.example.com", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + base_proxy.set_mcp_path("/mcp") + + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await base_proxy.register_client(client) + + client_code = ClientCode( + code="base-zero-code", + client_id="test-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="test-challenge", + code_challenge_method="S256", + scopes=["read"], + idp_tokens={ + "access_token": "upstream-at", + "refresh_token": "upstream-rt", + "expires_in": 3600, + "refresh_expires_in": 0, + "token_type": "Bearer", + }, + expires_at=time.time() + 300, + created_at=time.time(), + ) + await base_proxy._code_store.put(key=client_code.code, value=client_code) + + auth_code = AuthorizationCode( + code="base-zero-code", + scopes=["read"], + expires_at=time.time() + 300, + client_id="test-client", + code_challenge="test-challenge", + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + ) + result = await base_proxy.exchange_authorization_code( + client=client, + authorization_code=auth_code, + ) + assert result.refresh_token is not None + + jti_mapping = await base_proxy._jti_mapping_store.get( + key=base_proxy.jwt_issuer.verify_token( + result.refresh_token, expected_token_use="refresh" + )["jti"] + ) + assert jti_mapping is not None + upstream_token_set = await base_proxy._upstream_token_store.get( + key=jti_mapping.upstream_token_id + ) + assert upstream_token_set is not None + # Base proxy: never_expires stays False, expires_at is set (wall-clock fallback) + assert upstream_token_set.refresh_token_never_expires is False + assert upstream_token_set.refresh_token_expires_at is not None +class TestTransparentUpstreamRefresh: """Tests for transparent upstream token refresh in load_access_token. When the upstream token expires but a refresh token is available, the proxy From 853097a25600b5ef4d1b7ed140ef5978ba1c64e8 Mon Sep 17 00:00:00 2001 From: strawgate Date: Wed, 13 May 2026 15:54:40 -0500 Subject: [PATCH 06/10] refactor: replace flag with _upstream_refresh_token_never_expires override Replaces the _zero_refresh_expiry_means_never_expires class attribute with a proper override hook. The base class stays free of any provider-specific knowledge; KeycloakOAuthProxy overrides the method and returns True for val==0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fastmcp/server/auth/oauth_proxy/proxy.py | 22 ++++++++++++------- .../fastmcp/server/auth/providers/keycloak.py | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index b87a3d9b5..698cc2885 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -231,11 +231,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin): - Generic: Works with any spec-compliant provider """ - # Subclasses can set this to True to treat refresh_expires_in=0 from the - # upstream as "this refresh token never expires" rather than an unknown expiry. - # RFC 6749 does not define refresh_expires_in; 0 is a Keycloak-specific - # convention for offline_access tokens. Do not enable for other providers. - _zero_refresh_expiry_means_never_expires: bool = False + def _upstream_refresh_token_never_expires(self, refresh_expires_in: int) -> bool: + """Return True if the upstream's refresh_expires_in value signals the token never expires. + + Override in subclasses to handle provider-specific conventions. The base + implementation always returns False — an unknown value is treated as a + normal (finite) expiry and falls through to the configured fallback TTL. + + Args: + refresh_expires_in: The raw integer value from the upstream token response. + """ + return False def __init__( self, @@ -1107,7 +1113,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", refresh_expires_in, ) - elif val == 0 and self._zero_refresh_expiry_means_never_expires: + elif val == 0 and self._upstream_refresh_token_never_expires(val): # Provider explicitly signals "no expiry" (e.g. Keycloak offline_access). # refresh_token_expires_at stays None (no upstream expiry to track). # We still need a finite FastMCP RT TTL; use the configured fallback. @@ -1439,7 +1445,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Upstream refresh token expires in %d seconds", new_refresh_expires_in, ) - elif val == 0 and self._zero_refresh_expiry_means_never_expires: + elif val == 0 and self._upstream_refresh_token_never_expires(val): # Provider signals "no expiry" — mark and clear stale wall-clock time # so the fallback below always issues a fresh full-length FastMCP RT. upstream_token_set.refresh_token_never_expires = True @@ -1657,7 +1663,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in ) - elif val == 0 and self._zero_refresh_expiry_means_never_expires: + elif val == 0 and self._upstream_refresh_token_never_expires(val): # Provider signals "no expiry" — mark and clear stale wall-clock time. upstream_token_set.refresh_token_never_expires = True upstream_token_set.refresh_token_expires_at = None diff --git a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py index 9c4a74cfc..3f5424b38 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py @@ -114,7 +114,8 @@ class KeycloakOAuthProxy(OAuthProxy): """ # Keycloak uses refresh_expires_in=0 for offline_access tokens ("never expires"). - _zero_refresh_expiry_means_never_expires: bool = True + def _upstream_refresh_token_never_expires(self, refresh_expires_in: int) -> bool: + return refresh_expires_in == 0 def __init__( self, From bfd38fc45a7b56842d87c4aa2c39e0a010f43e15 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sat, 16 May 2026 23:49:06 -0500 Subject: [PATCH 07/10] 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 ): From 9402d37e1bfd17c6944a789f5ad4d8af5b9def86 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 00:26:17 -0500 Subject: [PATCH 08/10] test+docs: transparent-refresh sentinel coverage and KeycloakOAuthProxy docs 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 --- docs/integrations/keycloak.mdx | 44 +++++ tests/server/auth/oauth_proxy/test_tokens.py | 159 +++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/docs/integrations/keycloak.mdx b/docs/integrations/keycloak.mdx index 22d61f132..e6f3f1c96 100644 --- a/docs/integrations/keycloak.mdx +++ b/docs/integrations/keycloak.mdx @@ -139,3 +139,47 @@ auth = KeycloakAuthProvider( token_verifier=custom_verifier, ) ``` + +## Proxying to Keycloak with Offline Tokens + +`KeycloakAuthProvider` expects clients that can complete Dynamic Client +Registration and the full OAuth flow themselves. When your MCP clients cannot +do that — or when you want FastMCP to act as the OAuth client and broker tokens +on their behalf — use `KeycloakOAuthProxy` instead. It behaves like the generic +`OAuthProxy`, but understands one Keycloak-specific convention that the generic +proxy gets wrong. + +Keycloak issues long-lived *offline* refresh tokens (requested via the +`offline_access` scope) and signals "this refresh token has no fixed expiry" by +returning `refresh_expires_in=0`. The generic `OAuthProxy` interprets `0` as an +unknown/zero lifetime, so the FastMCP refresh token TTL shrinks on every refresh +cycle until it hits zero and forces the user to re-authenticate — even though +the underlying Keycloak offline token is still valid. `KeycloakOAuthProxy` +treats `0` as the never-expires sentinel and keeps offline sessions alive +indefinitely. + +Because offline tokens require the `offline_access` scope at registration time, +pass `valid_scopes` so clients can register for it. Keep `required_scopes` +focused on what every access token must carry — `valid_scopes` controls what +Dynamic Client Registration will accept, independently of token verification: + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.keycloak import KeycloakOAuthProxy + +auth = KeycloakOAuthProxy( + realm_url="https://keycloak.example.com/realms/myrealm", + upstream_client_id="my-client", + upstream_client_secret="my-secret", + base_url="https://my-mcp-server.example.com", + jwt_signing_key="a-strong-signing-secret", + required_scopes=["openid"], + valid_scopes=["openid", "offline_access"], +) + +mcp = FastMCP("My App", auth=auth) +``` + +The FastMCP refresh token is reissued on every refresh cycle, so as long as the +client keeps refreshing, the offline session remains valid without further user +interaction. diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 873a9524e..68f200937 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -1539,3 +1539,162 @@ class TestTransparentUpstreamRefresh: returned = await mock_verifier.verify_token(call.args[0]) if returned: assert "upstream_claims" not in returned.claims + + +class TestKeycloakTransparentRefreshZero: + """The transparent-refresh path (load_access_token) must preserve the + Keycloak never-expires sentinel when a rotated offline token comes back + with refresh_expires_in=0. + + This is the third token path (alongside auth-code exchange and explicit + refresh-token exchange) and was previously only covered indirectly. + """ + + @pytest.fixture + def mock_verifier(self): + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + + async def verify(token: str) -> AccessToken | None: + if token.startswith("refreshed-") or token.startswith("valid-"): + return AccessToken( + token=token, + client_id="test-client", + scopes=["read"], + expires_at=int(time.time() + 3600), + ) + return None + + verifier.verify_token = AsyncMock(side_effect=verify) + return verifier + + @pytest.fixture + def proxy(self, mock_verifier): + proxy = KeycloakOAuthProxy( + realm_url="https://keycloak.example.com/realms/test", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=mock_verifier, + base_url="https://proxy.example.com", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + proxy.set_mcp_path("/mcp") + return proxy + + async def _setup_expired_session(self, proxy) -> str: + upstream_token_id = "upstream-tok-id" + access_jti = "test-access-jti" + await proxy._upstream_token_store.put( + key=upstream_token_id, + value=UpstreamTokenSet( + upstream_token_id=upstream_token_id, + access_token="expired-upstream-access", + refresh_token="upstream-refresh-tok", + refresh_token_expires_at=time.time() + 86400, + expires_at=time.time() - 60, + token_type="Bearer", + scope="read", + client_id="test-client", + created_at=time.time() - 3600, + ), + ttl=86400, + ) + await proxy._jti_mapping_store.put( + key=access_jti, + value=JTIMapping( + jti=access_jti, + upstream_token_id=upstream_token_id, + created_at=time.time(), + ), + ttl=3600, + ) + return proxy.jwt_issuer.issue_access_token( + client_id="test-client", + scopes=["read"], + jti=access_jti, + expires_in=3600, + ) + + async def test_transparent_refresh_zero_preserves_never_expires(self, proxy): + fastmcp_jwt = await self._setup_expired_session(proxy) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "refreshed-upstream-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-upstream-refresh", + "refresh_expires_in": 0, # Keycloak offline sentinel + "scope": "read", + } + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is not None + assert result.token == "refreshed-upstream-access" + + stored = await proxy._upstream_token_store.get(key="upstream-tok-id") + assert stored is not None + assert stored.refresh_token == "rotated-upstream-refresh" + # The sentinel must be preserved, not reset to a wall-clock deadline. + assert stored.refresh_token_never_expires is True + assert stored.refresh_token_expires_at is None + + async def test_base_proxy_transparent_refresh_zero_is_not_never_expires(self): + """The base OAuthProxy must NOT treat a transparent-refresh + refresh_expires_in=0 as never-expires (opt-in only).""" + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read"] + + async def verify(token: str) -> AccessToken | None: + if token.startswith("refreshed-"): + return AccessToken( + token=token, + client_id="test-client", + scopes=["read"], + expires_at=int(time.time() + 3600), + ) + return None + + verifier.verify_token = AsyncMock(side_effect=verify) + proxy = OAuthProxy( + upstream_authorization_endpoint="https://idp.example.com/authorize", + upstream_token_endpoint="https://idp.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=verifier, + base_url="https://proxy.example.com", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + proxy.set_mcp_path("/mcp") + fastmcp_jwt = await TestKeycloakTransparentRefreshZero._setup_expired_session( + self, proxy + ) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "refreshed-upstream-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-upstream-refresh", + "refresh_expires_in": 0, + "scope": "read", + } + ) + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + result = await proxy.load_access_token(fastmcp_jwt) + + assert result is not None + stored = await proxy._upstream_token_store.get(key="upstream-tok-id") + assert stored is not None + assert stored.refresh_token_never_expires is False From f19df2dce743d030b7264f795e62015d0a13e556 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sun, 17 May 2026 12:11:59 -0500 Subject: [PATCH 09/10] fix: forward redirect_path through KeycloakOAuthProxy for custom callbacks 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 | 7 +++++ tests/server/auth/oauth_proxy/test_tokens.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py index dd874dcbf..5dcb0b3e6 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/keycloak.py @@ -132,6 +132,7 @@ class KeycloakOAuthProxy(OAuthProxy): upstream_authorization_endpoint: str | None = None, upstream_token_endpoint: str | None = None, upstream_revocation_endpoint: str | None = None, + redirect_path: str | None = None, # Pass-through OAuthProxy options jwt_signing_key: str | bytes | None = None, client_storage: AsyncKeyValue | None = None, @@ -162,6 +163,11 @@ class KeycloakOAuthProxy(OAuthProxy): upstream_token_endpoint: Override the token endpoint URL. Required if `realm_url` is not provided. upstream_revocation_endpoint: Optional token revocation endpoint. + redirect_path: Callback path registered with the upstream Keycloak + client. Defaults to `/auth/callback`. Set this when migrating an + existing deployment whose Keycloak client uses a non-default + callback path, so the authorization request keeps matching the + registered redirect URI. jwt_signing_key: Secret for signing FastMCP JWTs. client_storage: Storage backend for OAuth state. require_authorization_consent: Consent screen behaviour (default True). @@ -216,6 +222,7 @@ class KeycloakOAuthProxy(OAuthProxy): upstream_client_secret=upstream_client_secret, token_verifier=token_verifier, valid_scopes=parsed_valid_scopes, + redirect_path=redirect_path, 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 68f200937..7d6f7c479 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -968,6 +968,33 @@ class TestKeycloakOAuthProxy: "offline_access", ] + def test_redirect_path_defaults_to_auth_callback(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 proxy._redirect_path == "/auth/callback" + + def test_redirect_path_forwarded_for_custom_callback(self, jwt_verifier): + """Migrating a deployment whose Keycloak client uses a non-default + callback path must not silently revert to /auth/callback.""" + 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", + redirect_path="/custom/oauth/callback", + jwt_signing_key="test-secret-key", + client_storage=MemoryStore(), + ) + assert proxy._redirect_path == "/custom/oauth/callback" + async def test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink( self, proxy ): From 2e1178fa84073f6ba871f4abc047d94506323a86 Mon Sep 17 00:00:00 2001 From: strawgate Date: Mon, 18 May 2026 22:05:29 -0500 Subject: [PATCH 10/10] fix: forward resource_base_url and issuer_url through KeycloakOAuthProxy 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 | 11 ++++++ tests/server/auth/oauth_proxy/test_tokens.py | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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 ):