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:
Jeremiah Lowin 2025-12-09 21:31:17 -05:00 committed by GitHub
commit d56f55a12a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 93 additions and 6 deletions

View file

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

View file

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

View file

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