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