fix: honor upstream refresh token expiry in OAuthProxy (#3990)

This commit is contained in:
Jeremiah Lowin 2026-04-20 14:03:57 -04:00 committed by GitHub
commit 2d6143c6d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 370 additions and 15 deletions

View file

@ -26,6 +26,7 @@ DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = (
60 * 60 * 24 * 365
) # 1 year
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 * 24 * 365 # 1 year
DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
# HTTP client timeout

View file

@ -78,6 +78,7 @@ from fastmcp.server.auth.oauth_proxy.models import (
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
DEFAULT_AUTH_CODE_EXPIRY_SECONDS,
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS,
HTTP_TIMEOUT_SECONDS,
ClientCode,
JTIMapping,
@ -269,6 +270,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
consent_csp_policy: str | None = None,
# Token expiry fallback
fallback_access_token_expiry_seconds: int | None = None,
fallback_refresh_token_expiry_seconds: int | None = None,
# CIMD (Client ID Metadata Document) support
enable_cimd: bool = True,
):
@ -338,6 +340,12 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
defaults: 1 hour if a refresh token is available (since we can refresh),
or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
Set explicitly to override these defaults.
fallback_refresh_token_expiry_seconds: Expiry time to use when upstream provider
doesn't return `refresh_expires_in` (e.g. Cognito, GitHub, many OIDC IdPs).
Defaults to 1 year. Note: this only controls the FastMCP-issued refresh token
lifetime the actual upstream refresh remains the source of truth. If the
upstream rejects the refresh, the client gets `invalid_grant` and re-auths,
regardless of how much life is left on the FastMCP refresh token.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
client IDs. When True, clients can authenticate using HTTPS URLs as client
IDs, with metadata fetched from the URL. Supports private_key_jwt auth.
@ -436,6 +444,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
self._fallback_access_token_expiry_seconds: int | None = (
fallback_access_token_expiry_seconds
)
self._fallback_refresh_token_expiry_seconds: int = (
fallback_refresh_token_expiry_seconds
if fallback_refresh_token_expiry_seconds is not None
else DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
)
if jwt_signing_key is None:
if upstream_client_secret is None:
@ -1060,12 +1073,15 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
"Upstream refresh token expires in %d seconds", refresh_expires_in
)
else:
# Default to 30 days if upstream doesn't specify
# This is conservative - most providers use longer expiry
refresh_expires_in = 60 * 60 * 24 * 30 # 30 days
# 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.
refresh_expires_in = self._fallback_refresh_token_expiry_seconds
refresh_token_expires_at = time.time() + refresh_expires_in
logger.debug(
"Upstream refresh token expiry unknown, using 30-day default"
"Upstream refresh token expiry unknown, using fallback %d seconds",
refresh_expires_in,
)
# Encrypt and store upstream tokens
@ -1136,7 +1152,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
upstream_token_id=upstream_token_id,
created_at=time.time(),
),
ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days)
ttl=refresh_expires_in or self._fallback_refresh_token_expiry_seconds,
)
# Store refresh token metadata (keyed by hash for security)
@ -1382,8 +1398,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
upstream_token_set.refresh_token_expires_at - time.time()
)
else:
# Default to 30 days if unknown
new_refresh_expires_in = 60 * 60 * 24 * 30
# 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
)
@ -1396,8 +1412,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
refresh_ttl = new_refresh_expires_in or (
int(upstream_token_set.refresh_token_expires_at - time.time())
if upstream_token_set.refresh_token_expires_at
else 60 * 60 * 24 * 30 # Default to 30 days if unknown
else self._fallback_refresh_token_expiry_seconds
)
# Guard against past expiry (e.g. existing upstream refresh has already
# ticked past its recorded expiry). Storage TTL must be > 0.
refresh_ttl = max(refresh_ttl, 1)
await self._upstream_token_store.put(
key=upstream_token_set.upstream_token_id,
value=upstream_token_set,
@ -1434,15 +1453,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
ttl=new_expires_in, # Auto-expire with refreshed access token
)
# Issue NEW minimal FastMCP refresh token (rotation for security)
# Use upstream refresh token expiry to align lifetimes
# Issue NEW minimal FastMCP refresh token (rotation for security).
# Use refresh_ttl so the JWT exp claim matches the storage TTL — otherwise
# providers that don't rotate the refresh token (e.g. Cognito) would get
# a JWT with a hardcoded short exp even when the upstream refresh is
# still valid for much longer.
new_refresh_jti = secrets.token_urlsafe(32)
new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token(
client_id=client.client_id,
scopes=refreshed_scopes,
jti=new_refresh_jti,
expires_in=new_refresh_expires_in
or 60 * 60 * 24 * 30, # Fallback to 30 days
expires_in=refresh_ttl,
upstream_claims=upstream_claims,
)
@ -1587,7 +1608,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
upstream_token_set.refresh_token_expires_at - time.time()
)
else:
new_refresh_expires_in = 60 * 60 * 24 * 30
new_refresh_expires_in = self._fallback_refresh_token_expiry_seconds
upstream_token_set.refresh_token_expires_at = (
time.time() + new_refresh_expires_in
)
@ -1600,7 +1621,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
refresh_ttl = new_refresh_expires_in or (
int(upstream_token_set.refresh_token_expires_at - time.time())
if upstream_token_set.refresh_token_expires_at
else 60 * 60 * 24 * 30
else self._fallback_refresh_token_expiry_seconds
)
await self._upstream_token_store.put(
key=upstream_token_set.upstream_token_id,

View file

@ -233,6 +233,7 @@ class OIDCProxy(OAuthProxy):
extra_token_params: dict[str, str] | None = None,
# Token expiry fallback
fallback_access_token_expiry_seconds: int | None = None,
fallback_refresh_token_expiry_seconds: int | None = None,
# CIMD configuration
enable_cimd: bool = True,
) -> None:
@ -294,6 +295,11 @@ class OIDCProxy(OAuthProxy):
doesn't return `expires_in` in the token response. If not set, uses smart
defaults: 1 hour if a refresh token is available (since we can refresh),
or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
fallback_refresh_token_expiry_seconds: Expiry time to use when upstream provider
doesn't return `refresh_expires_in` (e.g. Cognito, GitHub, many OIDC IdPs).
Defaults to 1 year. The actual upstream refresh remains the source of
truth if upstream rejects the refresh, the client gets `invalid_grant`
and re-auths.
enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support.
When True, clients can use their metadata document URL as client_id instead of
Dynamic Client Registration. Default is True.
@ -384,6 +390,7 @@ class OIDCProxy(OAuthProxy):
"consent_csp_policy": consent_csp_policy,
"forward_resource": forward_resource,
"fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,
"fallback_refresh_token_expiry_seconds": fallback_refresh_token_expiry_seconds,
"enable_cimd": enable_cimd,
}

View file

@ -75,6 +75,7 @@ class Auth0Provider(OIDCProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
) -> None:
"""Initialize Auth0 OAuth provider.
@ -126,6 +127,7 @@ class Auth0Provider(OIDCProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
)
logger.debug(

View file

@ -136,6 +136,7 @@ class AWSCognitoProvider(OIDCProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
):
"""Initialize AWS Cognito OAuth provider.
@ -196,6 +197,7 @@ class AWSCognitoProvider(OIDCProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
)
logger.debug(

View file

@ -115,6 +115,7 @@ class AzureProvider(OAuthProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
base_authority: str = "login.microsoftonline.com",
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
@ -255,6 +256,7 @@ class AzureProvider(OAuthProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
valid_scopes=parsed_required_scopes,
enable_cimd=enable_cimd,
)

View file

@ -289,6 +289,7 @@ class ClerkProvider(OAuthProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
@ -376,6 +377,7 @@ class ClerkProvider(OAuthProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
extra_authorize_params=extra_authorize_params_final or None,
valid_scopes=parsed_valid_scopes,
enable_cimd=enable_cimd,

View file

@ -207,6 +207,7 @@ class DiscordProvider(OAuthProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
@ -278,6 +279,7 @@ class DiscordProvider(OAuthProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
enable_cimd=enable_cimd,
)

View file

@ -222,6 +222,7 @@ class GitHubProvider(OAuthProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
@ -293,6 +294,7 @@ class GitHubProvider(OAuthProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
enable_cimd=enable_cimd,
)

View file

@ -247,6 +247,7 @@ class GoogleProvider(OAuthProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
@ -353,6 +354,7 @@ class GoogleProvider(OAuthProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
extra_authorize_params=extra_authorize_params_final,
valid_scopes=valid_scopes_final,
enable_cimd=enable_cimd,

View file

@ -134,6 +134,7 @@ class OCIProvider(OIDCProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
) -> None:
"""Initialize OCI OIDC provider.
@ -171,6 +172,7 @@ class OCIProvider(OIDCProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
)
logger.debug(

View file

@ -175,6 +175,7 @@ class WorkOSProvider(OAuthProxy):
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
http_client: httpx.AsyncClient | None = None,
enable_cimd: bool = True,
):
@ -246,6 +247,7 @@ class WorkOSProvider(OAuthProxy):
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
enable_cimd=enable_cimd,
)

View file

@ -21,8 +21,10 @@ from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy.models import (
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS,
ClientCode,
JTIMapping,
RefreshTokenMetadata,
UpstreamTokenSet,
_hash_token,
)
@ -333,6 +335,312 @@ class TestFallbackAccessTokenExpiry:
assert provider._fallback_access_token_expiry_seconds is None
class TestFallbackRefreshTokenExpiry:
"""Tests for fallback_refresh_token_expiry_seconds (issue #3987).
When upstream omits `refresh_expires_in`, the FastMCP refresh JWT and the
JTI mapping must use the configured fallback (default 1 year), not a
hardcoded 30-day value. The FastMCP refresh JWT is a signed pointer; the
real upstream refresh remains the source of truth.
"""
@pytest.fixture
def jwt_verifier(self):
verifier = Mock(spec=TokenVerifier)
verifier.required_scopes = ["read", "write"]
verifier.verify_token = AsyncMock(return_value=None)
return verifier
def test_default_constant(self):
assert DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS == 60 * 60 * 24 * 365 # 1 year
def test_fallback_defaults_to_one_year(self, jwt_verifier):
provider = 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=jwt_verifier,
base_url="http://localhost:8000",
jwt_signing_key="test-signing-key",
client_storage=MemoryStore(),
)
assert (
provider._fallback_refresh_token_expiry_seconds
== DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
)
def test_fallback_parameter_stored(self, jwt_verifier):
provider = 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=jwt_verifier,
base_url="http://localhost:8000",
jwt_signing_key="test-signing-key",
fallback_refresh_token_expiry_seconds=7 * 24 * 60 * 60, # 7 days
client_storage=MemoryStore(),
)
assert provider._fallback_refresh_token_expiry_seconds == 7 * 24 * 60 * 60
async def test_initial_exchange_jti_mapping_aligned_with_refresh_expiry(
self, jwt_verifier
):
"""Bug 1: JTI mapping TTL must align with refresh_expires_in.
Previously hardcoded to 30 days, which silently expired the mapping
early when upstream returned a longer refresh lifetime.
"""
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=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret-key",
client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
client = OAuthClientInformationFull(
client_id="test-client",
client_secret="test-secret",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
)
await proxy.register_client(client)
# Upstream returns 1-year refresh expiry — well past the old 30d default
one_year = 60 * 60 * 24 * 365
client_code = ClientCode(
code="long-refresh-code",
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",
"refresh_token": "upstream-refresh",
"expires_in": 3600,
"refresh_expires_in": one_year,
"token_type": "Bearer",
},
expires_at=time.time() + 300,
created_at=time.time(),
)
await proxy._code_store.put(key=client_code.code, value=client_code)
result = await proxy.exchange_authorization_code(
client=client,
authorization_code=AuthorizationCode(
code="long-refresh-code",
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,
),
)
assert result.refresh_token is not None
# The refresh JWT exp claim should reflect the 1-year upstream lifetime
refresh_payload = proxy.jwt_issuer.verify_token(
result.refresh_token, expected_token_use="refresh"
)
# exp - iat should be approximately one_year (allow small clock skew)
assert refresh_payload["exp"] - refresh_payload["iat"] == pytest.approx(
one_year, abs=5
)
async def test_initial_exchange_uses_fallback_when_upstream_silent(
self, jwt_verifier
):
"""When upstream omits refresh_expires_in, fall back to configured value.
Default is 1 year (was previously 30 days, ignoring user config).
"""
custom_fallback = 60 * 60 * 24 * 90 # 90 days
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=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret-key",
fallback_refresh_token_expiry_seconds=custom_fallback,
client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
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="silent-refresh-code",
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",
"refresh_token": "upstream-refresh",
"expires_in": 3600,
# no refresh_expires_in — upstream is silent
"token_type": "Bearer",
},
expires_at=time.time() + 300,
created_at=time.time(),
)
await proxy._code_store.put(key=client_code.code, value=client_code)
result = await proxy.exchange_authorization_code(
client=client,
authorization_code=AuthorizationCode(
code="silent-refresh-code",
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,
),
)
assert result.refresh_token is not None
refresh_payload = proxy.jwt_issuer.verify_token(
result.refresh_token, expected_token_use="refresh"
)
assert refresh_payload["exp"] - refresh_payload["iat"] == pytest.approx(
custom_fallback, abs=5
)
async def test_refresh_jwt_exp_aligned_when_upstream_omits_refresh_token(
self, jwt_verifier
):
"""Bug 2: JWT exp must align with storage TTL when upstream omits refresh_token.
Cognito does NOT return a refresh_token on refresh. Previously the new
FastMCP refresh JWT was issued with hardcoded 30-day exp, while the
storage TTL correctly used the existing upstream refresh lifetime
forcing re-login at day 30 even when the upstream refresh was valid
for much longer.
"""
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=jwt_verifier,
base_url="https://proxy.example.com",
jwt_signing_key="test-secret-key",
client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
client = OAuthClientInformationFull(
client_id="test-client",
client_secret="test-secret",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
)
await proxy.register_client(client)
# Seed an upstream session whose refresh token has 6 months left
now = time.time()
upstream_refresh_expires_at = now + (60 * 60 * 24 * 180)
upstream_token_id = "upstream-id-cognito"
upstream_token_set = UpstreamTokenSet(
upstream_token_id=upstream_token_id,
access_token="upstream-access-old",
refresh_token="upstream-refresh-tok",
refresh_token_expires_at=upstream_refresh_expires_at,
expires_at=now + 60, # access nearly expired (irrelevant here)
token_type="Bearer",
scope="read write",
client_id="test-client",
created_at=now,
raw_token_data={},
)
await proxy._upstream_token_store.put(
key=upstream_token_id,
value=upstream_token_set,
ttl=int(upstream_refresh_expires_at - now),
)
# Issue a FastMCP refresh JWT pointing at this session
old_refresh_jti = "old-refresh-jti"
old_refresh_jwt = proxy.jwt_issuer.issue_refresh_token(
client_id="test-client",
scopes=["read", "write"],
jti=old_refresh_jti,
expires_in=int(upstream_refresh_expires_at - now),
)
await proxy._jti_mapping_store.put(
key=old_refresh_jti,
value=JTIMapping(
jti=old_refresh_jti,
upstream_token_id=upstream_token_id,
created_at=now,
),
ttl=int(upstream_refresh_expires_at - now),
)
await proxy._refresh_token_store.put(
key=_hash_token(old_refresh_jwt),
value=RefreshTokenMetadata(
client_id="test-client",
scopes=["read", "write"],
expires_at=int(upstream_refresh_expires_at),
created_at=now,
),
ttl=int(upstream_refresh_expires_at - now),
)
# Mock the upstream refresh response — Cognito-style: NO refresh_token
async def fake_refresh(url, refresh_token, scope=None, **kwargs):
return {
"access_token": "upstream-access-new",
"expires_in": 3600,
"token_type": "Bearer",
# NO refresh_token, NO refresh_expires_in
}
oauth_client_mock = Mock()
oauth_client_mock.refresh_token = AsyncMock(side_effect=fake_refresh)
with patch.object(
proxy,
"_create_upstream_oauth_client",
return_value=oauth_client_mock,
):
new_token = await proxy.exchange_refresh_token(
client=client,
refresh_token=RefreshToken(
token=old_refresh_jwt,
client_id="test-client",
scopes=["read", "write"],
expires_at=int(upstream_refresh_expires_at),
),
scopes=["read", "write"],
)
assert new_token.refresh_token is not None
# New refresh JWT exp must reflect remaining upstream refresh lifetime
# (~6 months), not a hardcoded 30 days
new_refresh_payload = proxy.jwt_issuer.verify_token(
new_token.refresh_token, expected_token_use="refresh"
)
ttl_remaining = new_refresh_payload["exp"] - new_refresh_payload["iat"]
# Should be close to 180 days, well above the old 30-day cliff
assert ttl_remaining > 60 * 60 * 24 * 90 # at least 90 days
class TestUpstreamTokenStorageTTL:
"""Tests for upstream token storage TTL calculation (issue #2670).
@ -515,7 +823,7 @@ class TestUpstreamTokenStorageTTL:
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.
"""refresh_expires_in=0 should fall back to the configured 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.