mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-19 03:54:18 +02:00
Treat refresh_expires_in=0 as missing, fall back to 30-day default (#3514)
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 <jlowin@users.noreply.github.com>
This commit is contained in:
parent
b5a45034ab
commit
32dfe50f39
2 changed files with 64 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue