From 32dfe50f39d0dca4fcbf8075ff523d99f625706b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:49:50 -0400 Subject: [PATCH] Treat refresh_expires_in=0 as missing, fall back to 30-day default (#3514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak returns refresh_expires_in=0 for offline tokens (offline_access scope), meaning "no fixed time-based expiry". The truthiness check on this value caused the proxy to skip issuing a PROXY_RT, forcing browser re-auth every hour. Closes #3509 🤖 Generated with Claude Code Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/server/auth/oauth_proxy/proxy.py | 8 ++- tests/server/auth/oauth_proxy/test_tokens.py | 58 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index 111688777..34dfdca0a 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -982,7 +982,9 @@ 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: + 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( @@ -1292,7 +1294,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): logger.debug("Upstream refresh token rotated") # Update refresh token expiry if provided - if "refresh_expires_in" in token_response: + 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 diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index b7e16431f..affa31a80 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -17,6 +17,7 @@ from fastmcp.server.auth.oauth_proxy.models import ( DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS, DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, ClientCode, + _hash_token, ) from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -501,3 +502,60 @@ class TestUpstreamTokenStorageTTL: key=jti_mapping.upstream_token_id ) assert upstream_tokens is not None + + async def test_refresh_expires_in_zero_issues_refresh_token(self, proxy): + """refresh_expires_in=0 should fall back to 30-day default. + + Keycloak returns refresh_expires_in=0 for offline tokens (offline_access scope), + meaning "no fixed time-based expiry". The proxy should still issue a PROXY_RT. + """ + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await proxy.register_client(client) + + client_code = ClientCode( + code="test-auth-code-keycloak-offline", + client_id="test-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="test-challenge", + code_challenge_method="S256", + scopes=["read", "write"], + idp_tokens={ + "access_token": "upstream-access-token-kc", + "refresh_token": "upstream-refresh-token-kc", + "expires_in": 3600, + "refresh_expires_in": 0, # Keycloak offline token convention + "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="test-auth-code-keycloak-offline", + scopes=["read", "write"], + 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 proxy.exchange_authorization_code( + client=client, + authorization_code=auth_code, + ) + + # refresh_expires_in=0 must NOT prevent refresh token issuance + assert result.access_token is not None + assert result.refresh_token is not None + + # Verify refresh token metadata was stored + refresh_meta = await proxy._refresh_token_store.get( + key=_hash_token(result.refresh_token) + ) + assert refresh_meta is not None