Decouple FastMCP access token lifetime from upstream expires_in (#4254)

This commit is contained in:
Jeremiah Lowin 2026-05-30 11:48:23 -04:00 committed by GitHub
commit 58e0f5320b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 511 additions and 10 deletions

View file

@ -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 (560 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:**

View file

@ -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

View file

@ -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,
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,

View file

@ -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,
)

View file

@ -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,

View file

@ -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).