mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 12:34:17 +02:00
fix: honor upstream refresh token expiry in OAuthProxy (#3990)
This commit is contained in:
parent
db6bc3e94c
commit
2d6143c6d8
13 changed files with 370 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue