From 2d6143c6d8ef74afa9d100b1ddb30104d79f8f7a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:03:57 -0400 Subject: [PATCH] fix: honor upstream refresh token expiry in OAuthProxy (#3990) --- src/fastmcp/server/auth/oauth_proxy/models.py | 1 + src/fastmcp/server/auth/oauth_proxy/proxy.py | 49 ++- src/fastmcp/server/auth/oidc_proxy.py | 7 + src/fastmcp/server/auth/providers/auth0.py | 2 + src/fastmcp/server/auth/providers/aws.py | 2 + src/fastmcp/server/auth/providers/azure.py | 2 + src/fastmcp/server/auth/providers/clerk.py | 2 + src/fastmcp/server/auth/providers/discord.py | 2 + src/fastmcp/server/auth/providers/github.py | 2 + src/fastmcp/server/auth/providers/google.py | 2 + src/fastmcp/server/auth/providers/oci.py | 2 + src/fastmcp/server/auth/providers/workos.py | 2 + tests/server/auth/oauth_proxy/test_tokens.py | 310 +++++++++++++++++- 13 files changed, 370 insertions(+), 15 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py index 129617a82..5d2366a76 100644 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -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 diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index 9471ccff7..98c2ffc16 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -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, diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 549c375ad..a84e12db8 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -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, } diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 28c50d060..1fb7cf140 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 6837dc5b5..b11d733ea 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 0f0763b97..716fff04b 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -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, ) diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py index b17261573..b00e4e8c5 100644 --- a/src/fastmcp/server/auth/providers/clerk.py +++ b/src/fastmcp/server/auth/providers/clerk.py @@ -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, diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index edf407ae1..0f71307a6 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -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, ) diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 57ee16799..b60007f95 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -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, ) diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 55dcad17c..57e19fcbd 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -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, diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index ae765299d..671ad61c6 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 91ab83dc1..fb7e969eb 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -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, ) diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index e802b2ade..635819fe4 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -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.