diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index aff1267ff..54ca7b764 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -510,7 +510,31 @@ This architecture also prevents [token passthrough](#token-passthrough) — see **Token expiry alignment:** -FastMCP token lifetimes match the upstream token lifetimes. When the upstream token expires, the FastMCP token also expires, maintaining consistent security boundaries. +By default, FastMCP token lifetimes match the upstream token lifetimes. When the upstream token expires, the FastMCP token also expires, maintaining consistent security boundaries. + +**Extending the FastMCP token lifetime:** + +Some upstream providers issue short-lived access tokens (5–60 minutes is common). Because the FastMCP token is a reference into the proxy's storage rather than the upstream credential itself, its client-facing lifetime can be longer than the upstream token's without weakening security: every request re-validates the upstream token and transparently refreshes it when it has expired, so a revoked or genuinely expired upstream session still fails validation and forces re-authentication. + +This matters for MCP clients that don't refresh gracefully. For example, [`mcp-remote`](https://github.com/geelen/mcp-remote) (used by Claude Desktop) has known issues handling access-token expiry, so a short upstream lifetime can push users through a full OAuth flow after every idle period. Set `fastmcp_access_token_expiry_seconds` to decouple the FastMCP token lifetime from the upstream `expires_in`: + +```python +from fastmcp.server.auth import OAuthProxy + +auth = OAuthProxy( + upstream_authorization_endpoint="https://provider.com/oauth/authorize", + upstream_token_endpoint="https://provider.com/oauth/token", + upstream_client_id="your-client-id", + upstream_client_secret="your-client-secret", + token_verifier=token_verifier, + base_url="https://your-server.com", + fastmcp_access_token_expiry_seconds=60 * 60 * 24, # 24 hours +) +``` + +The upstream token's real expiry is preserved internally to drive transparent refresh; only the FastMCP-issued token lives longer. This parameter is available on every provider built on the OAuth proxy (`GitHubProvider`, `GoogleProvider`, `AzureProvider`, and the rest). + +Extending the lifetime only works when the upstream provider issues a refresh token, since that's what lets the proxy renew the access token behind the scenes. When the upstream provides no refresh token, the FastMCP token lifetime is capped at the upstream `expires_in` — issuing a longer-lived token would claim a validity the proxy can't honor. **Refresh tokens:** diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 97930c4dc..fcb38a1f6 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -273,6 +273,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, fallback_refresh_token_expiry_seconds: int | None = None, + # FastMCP-issued access token lifetime (decoupled from upstream) + fastmcp_access_token_expiry_seconds: int | None = None, # Token refresh threshold token_expiry_threshold_seconds: int = 0, # CIMD (Client ID Metadata Document) support @@ -350,6 +352,21 @@ class OAuthProxy(OAuthProvider, ConsentMixin): 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. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token (JWT), decoupling it from the upstream provider's `expires_in`. By + default (None) the FastMCP access token mirrors the upstream access token + lifetime. The FastMCP JWT is a reference token — `load_access_token` + re-validates the upstream token on every request and transparently refreshes + it when expired — so issuing a longer-lived FastMCP token does not extend + upstream access: a revoked or expired upstream session still fails validation + and forces re-auth. Set this for bridges whose upstream issues short-lived + access tokens (5-60 min) that some MCP clients can't refresh gracefully + (e.g. `mcp-remote`), where the short client-facing TTL forces a full re-auth + on every idle period. Only affects the FastMCP-issued token; the upstream + token's real expiry is preserved internally to drive transparent refresh. + When the upstream provider issues no refresh token there is no way to renew + the access token, so the lifetime is capped at the upstream `expires_in` + regardless of this value (it can still be used to shorten it). token_expiry_threshold_seconds: Number of seconds before actual expiry to consider a token as expired (default 0). This prevents race conditions where a token passes the expiry check but expires before the next operation completes. @@ -458,6 +475,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if fallback_refresh_token_expiry_seconds is not None else DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS ) + self._fastmcp_access_token_expiry_seconds: int | None = ( + fastmcp_access_token_expiry_seconds + ) self._token_expiry_threshold_seconds: int = token_expiry_threshold_seconds if jwt_signing_key is None: @@ -1108,6 +1128,20 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Access token TTL: %d seconds (default, no refresh token)", expires_in ) + # The FastMCP-issued access token is a reference into our storage and may + # outlive the upstream access token, because transparent refresh keeps the + # upstream token fresh on each request. `expires_in` stays the upstream + # lifetime; this drives only the FastMCP JWT, its JTI mapping, and the + # response's expires_in. Extending past the upstream lifetime is only safe + # when we can refresh: without an upstream refresh token there is no way to + # renew the access token, so the FastMCP token must not claim to outlive the + # upstream token it points at. + fastmcp_access_expires_in = expires_in + if self._fastmcp_access_token_expiry_seconds is not None: + fastmcp_access_expires_in = self._fastmcp_access_token_expiry_seconds + if not idp_tokens.get("refresh_token"): + fastmcp_access_expires_in = min(fastmcp_access_expires_in, expires_in) + # Calculate refresh token expiry if provided by upstream # Some providers include refresh_expires_in, some don't refresh_expires_in = None @@ -1152,7 +1186,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): key=upstream_token_id, value=upstream_token_set, ttl=max( - refresh_expires_in or 0, expires_in, 1 + refresh_expires_in or 0, expires_in, fastmcp_access_expires_in, 1 ), # Keep until longest-lived token expires (min 1s for safety) ) logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8]) @@ -1167,7 +1201,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client_id=client.client_id, scopes=granted_scopes, jti=access_jti, - expires_in=expires_in, + expires_in=fastmcp_access_expires_in, upstream_claims=upstream_claims, ) @@ -1191,7 +1225,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_id=upstream_token_id, created_at=time.time(), ), - ttl=expires_in, # Auto-expire with access token + ttl=fastmcp_access_expires_in, # Auto-expire with FastMCP access token ) if refresh_jti: await self._jti_mapping_store.put( @@ -1228,7 +1262,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): return OAuthToken( access_token=fastmcp_access_token, token_type="Bearer", - expires_in=expires_in, + expires_in=fastmcp_access_expires_in, refresh_token=fastmcp_refresh_token, scope=" ".join(granted_scopes), ) @@ -1441,6 +1475,15 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_set.access_token = token_response["access_token"] upstream_token_set.expires_at = time.time() + new_expires_in + # See exchange_authorization_code: the FastMCP access token may outlive the + # upstream one. `new_expires_in` stays the upstream lifetime (drives + # upstream_token_set.expires_at and transparent refresh). + fastmcp_access_expires_in = ( + self._fastmcp_access_token_expiry_seconds + if self._fastmcp_access_token_expiry_seconds is not None + else new_expires_in + ) + # Prefer IdP-granted scopes from refresh response (RFC 6749 §5.1) refreshed_scopes: list[str] = ( parse_scopes(token_response["scope"]) or [] @@ -1501,7 +1544,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): key=upstream_token_set.upstream_token_id, value=upstream_token_set, ttl=max( - refresh_ttl, new_expires_in, 1 + refresh_ttl, new_expires_in, fastmcp_access_expires_in, 1 ), # Keep until longest-lived token expires (min 1s for safety) ) @@ -1518,7 +1561,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client_id=client.client_id, scopes=refreshed_scopes, jti=new_access_jti, - expires_in=new_expires_in, + expires_in=fastmcp_access_expires_in, upstream_claims=upstream_claims, ) @@ -1530,7 +1573,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): upstream_token_id=upstream_token_set.upstream_token_id, created_at=time.time(), ), - ttl=new_expires_in, # Auto-expire with refreshed access token + ttl=fastmcp_access_expires_in, # Auto-expire with FastMCP access token ) # Issue NEW minimal FastMCP refresh token (rotation for security). @@ -1591,7 +1634,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): return OAuthToken( access_token=new_fastmcp_access, token_type="Bearer", - expires_in=new_expires_in, + expires_in=fastmcp_access_expires_in, refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated) scope=" ".join(refreshed_scopes), ) @@ -1709,7 +1752,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin): await self._upstream_token_store.put( key=upstream_token_set.upstream_token_id, value=upstream_token_set, - ttl=max(refresh_ttl, new_expires_in, 1), + # Include the configured FastMCP lifetime so the upstream token never + # expires before an extended access JTI mapping that still points at it. + ttl=max( + refresh_ttl, + new_expires_in, + self._fastmcp_access_token_expiry_seconds or 0, + 1, + ), ) return upstream_token_set diff --git a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py index a70ec2c3e..879b390eb 100644 --- a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py @@ -234,6 +234,8 @@ class OIDCProxy(OAuthProxy): # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, fallback_refresh_token_expiry_seconds: int | None = None, + # FastMCP-issued access token lifetime (decoupled from upstream) + fastmcp_access_token_expiry_seconds: int | None = None, # Token refresh threshold token_expiry_threshold_seconds: int = 0, # CIMD configuration @@ -302,6 +304,14 @@ class OIDCProxy(OAuthProxy): 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. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token (JWT), decoupling it from the upstream provider's `expires_in`. By + default (None) the FastMCP access token mirrors the upstream access token + lifetime. The FastMCP JWT is a reference token re-validated against upstream + on every request, so a longer FastMCP lifetime does not extend upstream + access — a revoked or expired upstream session still fails validation. Set + this for bridges whose upstream issues short-lived access tokens that some + MCP clients can't refresh gracefully (e.g. `mcp-remote`). token_expiry_threshold_seconds: Number of seconds before actual expiry to consider a token as expired (default 0). Prevents race conditions where a token passes the expiry check but expires before the next operation completes. @@ -396,6 +406,7 @@ class OIDCProxy(OAuthProxy): "forward_resource": forward_resource, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, "fallback_refresh_token_expiry_seconds": fallback_refresh_token_expiry_seconds, + "fastmcp_access_token_expiry_seconds": fastmcp_access_token_expiry_seconds, "token_expiry_threshold_seconds": token_expiry_threshold_seconds, "enable_cimd": enable_cimd, } diff --git a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py index ff8c2a937..be73bc035 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py @@ -76,6 +76,7 @@ class Auth0Provider(OIDCProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, ) -> None: """Initialize Auth0 OAuth provider. @@ -106,6 +107,17 @@ class Auth0Provider(OIDCProxy): When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string auth0_required_scopes = ( @@ -129,6 +141,7 @@ class Auth0Provider(OIDCProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, ) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/aws.py b/fastmcp_slim/fastmcp/server/auth/providers/aws.py index 5ede1bda1..f662cd157 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/aws.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/aws.py @@ -137,6 +137,7 @@ class AWSCognitoProvider(OIDCProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, ): """Initialize AWS Cognito OAuth provider. @@ -167,6 +168,17 @@ class AWSCognitoProvider(OIDCProxy): When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string required_scopes_final = ( @@ -199,6 +211,7 @@ class AWSCognitoProvider(OIDCProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, ) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/azure.py b/fastmcp_slim/fastmcp/server/auth/providers/azure.py index 95eb4a288..c1b094f9c 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/azure.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/azure.py @@ -116,6 +116,7 @@ class AzureProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, base_authority: str = "login.microsoftonline.com", token_issuer: str | None = None, @@ -180,6 +181,17 @@ class AzureProvider(OAuthProxy): is responsible for its lifecycle. When None (default), a fresh client is created per fetch. enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs (default True). Set to False to disable. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string parsed_required_scopes = parse_scopes(required_scopes) @@ -263,6 +275,7 @@ class AzureProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, valid_scopes=parsed_required_scopes, enable_cimd=enable_cimd, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py index da9408781..21d7c3b48 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py @@ -290,6 +290,7 @@ class ClerkProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, @@ -336,6 +337,17 @@ class ClerkProvider(OAuthProxy): per call. enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs (default True). Set to False to disable. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ domain = domain.rstrip("/") @@ -379,6 +391,7 @@ class ClerkProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, extra_authorize_params=extra_authorize_params_final or None, valid_scopes=parsed_valid_scopes, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/discord.py b/fastmcp_slim/fastmcp/server/auth/providers/discord.py index 67d533e58..da13a074f 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/discord.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/discord.py @@ -208,6 +208,7 @@ class DiscordProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, @@ -247,6 +248,17 @@ class DiscordProvider(OAuthProxy): is responsible for its lifecycle. When None (default), a fresh client is created per call. enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs (default True). Set to False to disable. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string required_scopes_final = ( @@ -281,6 +293,7 @@ class DiscordProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, enable_cimd=enable_cimd, ) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/github.py b/fastmcp_slim/fastmcp/server/auth/providers/github.py index b21d28135..d14aeb08f 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/github.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/github.py @@ -223,6 +223,7 @@ class GitHubProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, @@ -263,6 +264,17 @@ class GitHubProvider(OAuthProxy): is responsible for its lifecycle. When None (default), a fresh client is created per call. enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs (default True). Set to False to disable. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string required_scopes_final = ( @@ -296,6 +308,7 @@ class GitHubProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, enable_cimd=enable_cimd, ) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/google.py b/fastmcp_slim/fastmcp/server/auth/providers/google.py index ec5bd5550..07f757001 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/google.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/google.py @@ -248,6 +248,7 @@ class GoogleProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, @@ -300,6 +301,17 @@ class GoogleProvider(OAuthProxy): is responsible for its lifecycle. When None (default), a fresh client is created per call. enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs (default True). Set to False to disable. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string # Google requires at least one scope - openid is the minimal OIDC scope @@ -356,6 +368,7 @@ class GoogleProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, extra_authorize_params=extra_authorize_params_final, valid_scopes=valid_scopes_final, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/oci.py b/fastmcp_slim/fastmcp/server/auth/providers/oci.py index e6c71a302..5c9c5e697 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/oci.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/oci.py @@ -135,6 +135,7 @@ class OCIProvider(OIDCProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, ) -> None: """Initialize OCI OIDC provider. @@ -151,6 +152,17 @@ class OCIProvider(OIDCProxy): required_scopes: Required OCI scopes (defaults to ["openid"]) redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. + token_expiry_threshold_seconds: Number of seconds before actual expiry to + treat a token as expired, refreshing early to avoid races. Defaults to 0. """ # Parse scopes if provided as string oci_required_scopes = ( @@ -174,6 +186,7 @@ class OCIProvider(OIDCProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, ) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/workos.py b/fastmcp_slim/fastmcp/server/auth/providers/workos.py index 27c04bdda..0a3f556b1 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/workos.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/workos.py @@ -177,6 +177,7 @@ class WorkOSProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, @@ -217,6 +218,15 @@ class WorkOSProvider(OAuthProxy): extra_authorize_params: Additional parameters to forward to WorkOS's authorization endpoint. Useful for forcing scopes like `offline_access` so WorkOS issues a refresh token, e.g. ``{"scope": "openid profile email offline_access"}``. + fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued + refresh token when the upstream provider omits `refresh_expires_in` + (e.g. Cognito, GitHub, many OIDC IdPs). Defaults to 1 year. The upstream + refresh remains the source of truth. See `OAuthProxy` for details. + fastmcp_access_token_expiry_seconds: Lifetime for the FastMCP-issued access + token, decoupling it from the upstream provider's `expires_in`. Defaults + to None (mirror the upstream lifetime). Set this for bridges whose + upstream issues short-lived access tokens that some MCP clients can't + refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. token_expiry_threshold_seconds: Number of seconds before actual expiry to consider a token as expired (default 0). Prevents race conditions where a token passes the expiry check but expires before the next operation completes. @@ -264,6 +274,7 @@ class WorkOSProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, extra_authorize_params=extra_authorize_params, valid_scopes=valid_scopes_final, diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index d8b755c57..5315ba23e 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -694,6 +694,307 @@ class TestFallbackRefreshTokenExpiry: assert ttl_remaining > 60 * 60 * 24 * 90 # at least 90 days +class TestFastMCPAccessTokenExpiry: + """Tests for fastmcp_access_token_expiry_seconds (issue #4252). + + The FastMCP-issued access token is a reference into FastMCP storage; its + lifetime can be decoupled from the upstream provider's short `expires_in` + so MCP clients that don't refresh gracefully (e.g. mcp-remote) aren't forced + through a full re-auth on every idle period. The upstream token's real expiry + is preserved internally to drive transparent refresh. + """ + + @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 _make_proxy(self, jwt_verifier, **kwargs): + return 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(), + **kwargs, + ) + + def test_parameter_stored(self, jwt_verifier): + proxy = self._make_proxy( + jwt_verifier, fastmcp_access_token_expiry_seconds=86400 + ) + assert proxy._fastmcp_access_token_expiry_seconds == 86400 + + def test_parameter_defaults_to_none(self, jwt_verifier): + proxy = self._make_proxy(jwt_verifier) + assert proxy._fastmcp_access_token_expiry_seconds is None + + async def _exchange(self, proxy, code="test-code", **idp_token_overrides): + 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) + + idp_tokens = { + "access_token": "upstream-access", + "refresh_token": "upstream-refresh", + "expires_in": 3600, + "token_type": "Bearer", + **idp_token_overrides, + } + # Allow callers to drop a default key by overriding it with None + # (e.g. refresh_token=None to simulate a provider that issues none). + idp_tokens = {k: v for k, v in idp_tokens.items() if v is not None} + client_code = ClientCode( + code=code, + client_id="test-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="test-challenge", + code_challenge_method="S256", + scopes=["read", "write"], + idp_tokens=idp_tokens, + expires_at=time.time() + 300, + created_at=time.time(), + ) + await proxy._code_store.put(key=client_code.code, value=client_code) + + return client, await proxy.exchange_authorization_code( + client=client, + authorization_code=AuthorizationCode( + code=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, + ), + ) + + async def test_initial_exchange_decouples_access_token_from_upstream( + self, jwt_verifier + ): + """A long FastMCP TTL applies even though upstream returns expires_in=3600.""" + one_day = 60 * 60 * 24 + proxy = self._make_proxy( + jwt_verifier, fastmcp_access_token_expiry_seconds=one_day + ) + + _, result = await self._exchange(proxy) + + # Response and JWT exp reflect the configured FastMCP lifetime, not 3600 + assert result.expires_in == one_day + access_payload = proxy.jwt_issuer.verify_token(result.access_token) + assert access_payload["exp"] - access_payload["iat"] == pytest.approx( + one_day, abs=5 + ) + + async def test_upstream_token_expiry_preserved_for_transparent_refresh( + self, jwt_verifier + ): + """Decoupling must not corrupt the upstream token's real expiry. + + The stored upstream token still expires at ~upstream expires_in so that + transparent refresh fires; only the FastMCP-issued token lives longer. + """ + one_day = 60 * 60 * 24 + proxy = self._make_proxy( + jwt_verifier, fastmcp_access_token_expiry_seconds=one_day + ) + + _, result = await self._exchange(proxy) + + access_jti = proxy.jwt_issuer.verify_token(result.access_token)["jti"] + jti_mapping = await proxy._jti_mapping_store.get(key=access_jti) + assert jti_mapping is not None + stored = await proxy._upstream_token_store.get( + key=jti_mapping.upstream_token_id + ) + assert stored is not None + # Upstream access token expiry tracks the upstream lifetime (~3600s), + # NOT the 1-day FastMCP token lifetime. + assert stored.expires_at - time.time() == pytest.approx(3600, abs=30) + + async def test_access_jti_mapping_ttl_matches_configured_lifetime( + self, jwt_verifier + ): + """The access JTI mapping must outlive the upstream access token. + + The JWT exp and the JTI mapping TTL are set from the same value, so a + drift between them would let the JWT verify while its storage lookup has + already expired — silently breaking long-idle sessions. Guard the TTL + passed to storage directly, since wall-clock expiry can't be exercised + in a fast unit test. + """ + one_week = 60 * 60 * 24 * 7 + proxy = self._make_proxy( + jwt_verifier, fastmcp_access_token_expiry_seconds=one_week + ) + + original_put = proxy._jti_mapping_store.put + calls: list[dict] = [] + + async def spy(**kwargs): + calls.append(kwargs) + return await original_put(**kwargs) + + with patch.object(proxy._jti_mapping_store, "put", side_effect=spy): + _, result = await self._exchange(proxy) + + access_jti = proxy.jwt_issuer.verify_token(result.access_token)["jti"] + access_call = next(c for c in calls if c["value"].jti == access_jti) + assert access_call["ttl"] == one_week + + @pytest.mark.parametrize( + "configured, expected", + [ + (60 * 60 * 24 * 7, 3600), # configured > upstream -> capped at upstream + (600, 600), # configured < upstream -> honored (still <= upstream) + ], + ) + async def test_no_refresh_token_does_not_extend_past_upstream( + self, jwt_verifier, configured, expected + ): + """Without an upstream refresh token, the FastMCP token can't be renewed. + + Issuing a token that claims to outlive the upstream access token would be + a lie — there's no way to transparently refresh it — so the lifetime is + capped at the upstream `expires_in` when no refresh token is present. + """ + proxy = self._make_proxy( + jwt_verifier, fastmcp_access_token_expiry_seconds=configured + ) + + _, result = await self._exchange(proxy, refresh_token=None) + + assert result.expires_in == expected + access_payload = proxy.jwt_issuer.verify_token(result.access_token) + assert access_payload["exp"] - access_payload["iat"] == pytest.approx( + expected, abs=5 + ) + + async def test_extended_token_survives_upstream_expiry_via_refresh(self): + """End-to-end: a long-lived FastMCP token keeps working after the upstream + access token expires, by transparently refreshing underneath. + + Proves the pieces integrate: the long-exp JWT still verifies, its JTI + mapping still resolves, and an expired upstream token triggers transparent + refresh rather than a 401. + """ + one_week = 60 * 60 * 24 * 7 + + verifier = Mock(spec=TokenVerifier) + verifier.required_scopes = ["read", "write"] + + async def verify(token: str) -> AccessToken | None: + if token.startswith("refreshed-"): + return AccessToken( + token=token, + client_id="test-client", + scopes=["read", "write"], + expires_at=int(time.time() + 3600), + ) + return None # original upstream token is treated as invalid/expired + + verifier.verify_token = AsyncMock(side_effect=verify) + proxy = self._make_proxy(verifier, fastmcp_access_token_expiry_seconds=one_week) + + _, result = await self._exchange(proxy) + + # Force the stored upstream access token to be expired. + access_jti = proxy.jwt_issuer.verify_token(result.access_token)["jti"] + jti_mapping = await proxy._jti_mapping_store.get(key=access_jti) + assert jti_mapping is not None + stored = await proxy._upstream_token_store.get( + key=jti_mapping.upstream_token_id + ) + assert stored is not None + stored.expires_at = time.time() - 60 + await proxy._upstream_token_store.put( + key=jti_mapping.upstream_token_id, value=stored, ttl=one_week + ) + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "refreshed-upstream-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "upstream-refresh", + "scope": "read write", + } + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + loaded = await proxy.load_access_token(result.access_token) + + assert loaded is not None + assert loaded.token == "refreshed-upstream-access" + mock_oauth_client.refresh_token.assert_called_once() + + async def test_initial_exchange_default_mirrors_upstream(self, jwt_verifier): + """With the param unset, the FastMCP access token mirrors upstream.""" + proxy = self._make_proxy(jwt_verifier) + + _, result = await self._exchange(proxy) + + assert result.expires_in == 3600 + access_payload = proxy.jwt_issuer.verify_token(result.access_token) + assert access_payload["exp"] - access_payload["iat"] == pytest.approx( + 3600, abs=5 + ) + + async def test_refresh_exchange_decouples_access_token(self, jwt_verifier): + """Re-issued access tokens on refresh also honor the configured lifetime.""" + one_day = 60 * 60 * 24 + proxy = self._make_proxy( + jwt_verifier, fastmcp_access_token_expiry_seconds=one_day + ) + + client, result = await self._exchange(proxy) + assert result.refresh_token is not None + + mock_oauth_client = AsyncMock() + mock_oauth_client.refresh_token = AsyncMock( + return_value={ + "access_token": "refreshed-upstream-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "upstream-refresh", + "scope": "read write", + } + ) + + with patch.object( + proxy, "_create_upstream_oauth_client", return_value=mock_oauth_client + ): + refreshed = await proxy.exchange_refresh_token( + client=client, + refresh_token=RefreshToken( + token=result.refresh_token, + client_id="test-client", + scopes=["read", "write"], + ), + scopes=["read", "write"], + ) + + assert refreshed.expires_in == one_day + access_payload = proxy.jwt_issuer.verify_token(refreshed.access_token) + assert access_payload["exp"] - access_payload["iat"] == pytest.approx( + one_day, abs=5 + ) + + class TestUpstreamTokenStorageTTL: """Tests for upstream token storage TTL calculation (issue #2670).