mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Add smart fallback for missing access token expiry (#2587)
When upstream OAuth providers don't return expires_in (like GitHub OAuth Apps), use smart defaults: 1 hour if refresh token available, 1 year if not. Adds fallback_access_token_expiry_seconds parameter to override.
This commit is contained in:
parent
d35b867b10
commit
d56f55a12a
3 changed files with 93 additions and 6 deletions
|
|
@ -90,6 +90,9 @@ logger = get_logger(__name__)
|
|||
|
||||
# Default token expiration times
|
||||
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_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
|
||||
|
||||
# HTTP client timeout
|
||||
|
|
@ -653,6 +656,8 @@ class OAuthProxy(OAuthProvider):
|
|||
# Consent screen configuration
|
||||
require_authorization_consent: bool = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
# Token expiry fallback
|
||||
fallback_access_token_expiry_seconds: int | None = None,
|
||||
):
|
||||
"""Initialize the OAuth proxy provider.
|
||||
|
||||
|
|
@ -702,6 +707,11 @@ class OAuthProxy(OAuthProvider):
|
|||
If a non-empty string, uses that as the CSP policy value.
|
||||
This allows organizations with their own CSP policies to override or disable
|
||||
the built-in CSP directives.
|
||||
fallback_access_token_expiry_seconds: Expiry time to use when upstream provider
|
||||
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).
|
||||
Set explicitly to override these defaults.
|
||||
"""
|
||||
|
||||
# Always enable DCR since we implement it locally for MCP clients
|
||||
|
|
@ -773,6 +783,11 @@ class OAuthProxy(OAuthProvider):
|
|||
self._extra_authorize_params: dict[str, str] = extra_authorize_params or {}
|
||||
self._extra_token_params: dict[str, str] = extra_token_params or {}
|
||||
|
||||
# Token expiry fallback (None means use smart default based on refresh token)
|
||||
self._fallback_access_token_expiry_seconds: int | None = (
|
||||
fallback_access_token_expiry_seconds
|
||||
)
|
||||
|
||||
if jwt_signing_key is None:
|
||||
jwt_signing_key = derive_jwt_key(
|
||||
high_entropy_material=upstream_client_secret,
|
||||
|
|
@ -1142,9 +1157,18 @@ class OAuthProxy(OAuthProvider):
|
|||
)
|
||||
|
||||
# Calculate token expiry times
|
||||
expires_in = int(
|
||||
idp_tokens.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
||||
)
|
||||
# If upstream provides expires_in, use it. Otherwise use fallback based on:
|
||||
# - User-provided fallback if set
|
||||
# - 1 hour if refresh token available (can refresh when expired)
|
||||
# - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps)
|
||||
if "expires_in" in idp_tokens:
|
||||
expires_in = int(idp_tokens["expires_in"])
|
||||
elif self._fallback_access_token_expiry_seconds is not None:
|
||||
expires_in = self._fallback_access_token_expiry_seconds
|
||||
elif idp_tokens.get("refresh_token"):
|
||||
expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
|
||||
else:
|
||||
expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS
|
||||
|
||||
# Calculate refresh token expiry if provided by upstream
|
||||
# Some providers include refresh_expires_in, some don't
|
||||
|
|
@ -1381,9 +1405,14 @@ class OAuthProxy(OAuthProvider):
|
|||
raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e
|
||||
|
||||
# Update stored upstream token
|
||||
new_expires_in = int(
|
||||
token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
||||
)
|
||||
# In refresh flow, we know there's a refresh token, so default to 1 hour
|
||||
# (user override still applies if set)
|
||||
if "expires_in" in token_response:
|
||||
new_expires_in = int(token_response["expires_in"])
|
||||
elif self._fallback_access_token_expiry_seconds is not None:
|
||||
new_expires_in = self._fallback_access_token_expiry_seconds
|
||||
else:
|
||||
new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
|
||||
upstream_token_set.access_token = token_response["access_token"]
|
||||
upstream_token_set.expires_at = time.time() + new_expires_in
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ class OIDCProxy(OAuthProxy):
|
|||
# Extra parameters
|
||||
extra_authorize_params: dict[str, str] | None = None,
|
||||
extra_token_params: dict[str, str] | None = None,
|
||||
# Token expiry fallback
|
||||
fallback_access_token_expiry_seconds: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the OIDC proxy provider.
|
||||
|
||||
|
|
@ -272,6 +274,10 @@ class OIDCProxy(OAuthProxy):
|
|||
Example: {"prompt": "consent", "access_type": "offline"}
|
||||
extra_token_params: Additional parameters to forward to the upstream token endpoint.
|
||||
Useful for provider-specific parameters during token exchange.
|
||||
fallback_access_token_expiry_seconds: Expiry time to use when upstream provider
|
||||
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).
|
||||
"""
|
||||
if not config_url:
|
||||
raise ValueError("Missing required config URL")
|
||||
|
|
@ -344,6 +350,7 @@ class OIDCProxy(OAuthProxy):
|
|||
"token_endpoint_auth_method": token_endpoint_auth_method,
|
||||
"require_authorization_consent": require_authorization_consent,
|
||||
"consent_csp_policy": consent_csp_policy,
|
||||
"fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,
|
||||
}
|
||||
|
||||
if redirect_path:
|
||||
|
|
|
|||
|
|
@ -1417,3 +1417,54 @@ class TestErrorPageRendering:
|
|||
assert b"invalid_scope" in response.body
|
||||
assert b"doesn't exist" in response.body # HTML-escaped apostrophe
|
||||
assert b"OAuth Error" in response.body
|
||||
|
||||
|
||||
class TestFallbackAccessTokenExpiry:
|
||||
"""Test fallback access token expiry constants and configuration."""
|
||||
|
||||
def test_default_constants(self):
|
||||
"""Verify the default expiry constants are set correctly."""
|
||||
from fastmcp.server.auth.oauth_proxy import (
|
||||
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
|
||||
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
)
|
||||
|
||||
assert DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS == 60 * 60 # 1 hour
|
||||
assert (
|
||||
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS == 60 * 60 * 24 * 365
|
||||
) # 1 year
|
||||
|
||||
def test_fallback_parameter_stored(self):
|
||||
"""Verify fallback_access_token_expiry_seconds is stored on provider."""
|
||||
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=JWTVerifier(
|
||||
jwks_uri="https://idp.example.com/.well-known/jwks.json",
|
||||
issuer="https://idp.example.com",
|
||||
),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-signing-key",
|
||||
fallback_access_token_expiry_seconds=86400,
|
||||
)
|
||||
|
||||
assert provider._fallback_access_token_expiry_seconds == 86400
|
||||
|
||||
def test_fallback_parameter_defaults_to_none(self):
|
||||
"""Verify fallback defaults to None (enabling smart defaults)."""
|
||||
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=JWTVerifier(
|
||||
jwks_uri="https://idp.example.com/.well-known/jwks.json",
|
||||
issuer="https://idp.example.com",
|
||||
),
|
||||
base_url="http://localhost:8000",
|
||||
jwt_signing_key="test-signing-key",
|
||||
)
|
||||
|
||||
assert provider._fallback_access_token_expiry_seconds is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue