Skip to content

Commit d56f55a

Browse files
authored
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.
1 parent d35b867 commit d56f55a

File tree

3 files changed

+93
-6
lines changed

3 files changed

+93
-6
lines changed

src/fastmcp/server/auth/oauth_proxy.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@
9090

9191
# Default token expiration times
9292
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour
93+
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = (
94+
60 * 60 * 24 * 365
95+
) # 1 year
9396
DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
9497

9598
# HTTP client timeout
@@ -653,6 +656,8 @@ def __init__(
653656
# Consent screen configuration
654657
require_authorization_consent: bool = True,
655658
consent_csp_policy: str | None = None,
659+
# Token expiry fallback
660+
fallback_access_token_expiry_seconds: int | None = None,
656661
):
657662
"""Initialize the OAuth proxy provider.
658663
@@ -702,6 +707,11 @@ def __init__(
702707
If a non-empty string, uses that as the CSP policy value.
703708
This allows organizations with their own CSP policies to override or disable
704709
the built-in CSP directives.
710+
fallback_access_token_expiry_seconds: Expiry time to use when upstream provider
711+
doesn't return `expires_in` in the token response. If not set, uses smart
712+
defaults: 1 hour if a refresh token is available (since we can refresh),
713+
or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
714+
Set explicitly to override these defaults.
705715
"""
706716

707717
# Always enable DCR since we implement it locally for MCP clients
@@ -773,6 +783,11 @@ def __init__(
773783
self._extra_authorize_params: dict[str, str] = extra_authorize_params or {}
774784
self._extra_token_params: dict[str, str] = extra_token_params or {}
775785

786+
# Token expiry fallback (None means use smart default based on refresh token)
787+
self._fallback_access_token_expiry_seconds: int | None = (
788+
fallback_access_token_expiry_seconds
789+
)
790+
776791
if jwt_signing_key is None:
777792
jwt_signing_key = derive_jwt_key(
778793
high_entropy_material=upstream_client_secret,
@@ -1142,9 +1157,18 @@ async def exchange_authorization_code(
11421157
)
11431158

11441159
# Calculate token expiry times
1145-
expires_in = int(
1146-
idp_tokens.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
1147-
)
1160+
# If upstream provides expires_in, use it. Otherwise use fallback based on:
1161+
# - User-provided fallback if set
1162+
# - 1 hour if refresh token available (can refresh when expired)
1163+
# - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps)
1164+
if "expires_in" in idp_tokens:
1165+
expires_in = int(idp_tokens["expires_in"])
1166+
elif self._fallback_access_token_expiry_seconds is not None:
1167+
expires_in = self._fallback_access_token_expiry_seconds
1168+
elif idp_tokens.get("refresh_token"):
1169+
expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
1170+
else:
1171+
expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS
11481172

11491173
# Calculate refresh token expiry if provided by upstream
11501174
# Some providers include refresh_expires_in, some don't
@@ -1381,9 +1405,14 @@ async def exchange_refresh_token(
13811405
raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e
13821406

13831407
# Update stored upstream token
1384-
new_expires_in = int(
1385-
token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
1386-
)
1408+
# In refresh flow, we know there's a refresh token, so default to 1 hour
1409+
# (user override still applies if set)
1410+
if "expires_in" in token_response:
1411+
new_expires_in = int(token_response["expires_in"])
1412+
elif self._fallback_access_token_expiry_seconds is not None:
1413+
new_expires_in = self._fallback_access_token_expiry_seconds
1414+
else:
1415+
new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS
13871416
upstream_token_set.access_token = token_response["access_token"]
13881417
upstream_token_set.expires_at = time.time() + new_expires_in
13891418

src/fastmcp/server/auth/oidc_proxy.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ def __init__(
226226
# Extra parameters
227227
extra_authorize_params: dict[str, str] | None = None,
228228
extra_token_params: dict[str, str] | None = None,
229+
# Token expiry fallback
230+
fallback_access_token_expiry_seconds: int | None = None,
229231
) -> None:
230232
"""Initialize the OIDC proxy provider.
231233
@@ -272,6 +274,10 @@ def __init__(
272274
Example: {"prompt": "consent", "access_type": "offline"}
273275
extra_token_params: Additional parameters to forward to the upstream token endpoint.
274276
Useful for provider-specific parameters during token exchange.
277+
fallback_access_token_expiry_seconds: Expiry time to use when upstream provider
278+
doesn't return `expires_in` in the token response. If not set, uses smart
279+
defaults: 1 hour if a refresh token is available (since we can refresh),
280+
or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps).
275281
"""
276282
if not config_url:
277283
raise ValueError("Missing required config URL")
@@ -344,6 +350,7 @@ def __init__(
344350
"token_endpoint_auth_method": token_endpoint_auth_method,
345351
"require_authorization_consent": require_authorization_consent,
346352
"consent_csp_policy": consent_csp_policy,
353+
"fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,
347354
}
348355

349356
if redirect_path:

tests/server/auth/test_oauth_proxy.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,3 +1417,54 @@ async def test_callback_error_returns_html_page(self):
14171417
assert b"invalid_scope" in response.body
14181418
assert b"doesn't exist" in response.body # HTML-escaped apostrophe
14191419
assert b"OAuth Error" in response.body
1420+
1421+
1422+
class TestFallbackAccessTokenExpiry:
1423+
"""Test fallback access token expiry constants and configuration."""
1424+
1425+
def test_default_constants(self):
1426+
"""Verify the default expiry constants are set correctly."""
1427+
from fastmcp.server.auth.oauth_proxy import (
1428+
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS,
1429+
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
1430+
)
1431+
1432+
assert DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS == 60 * 60 # 1 hour
1433+
assert (
1434+
DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS == 60 * 60 * 24 * 365
1435+
) # 1 year
1436+
1437+
def test_fallback_parameter_stored(self):
1438+
"""Verify fallback_access_token_expiry_seconds is stored on provider."""
1439+
provider = OAuthProxy(
1440+
upstream_authorization_endpoint="https://idp.example.com/authorize",
1441+
upstream_token_endpoint="https://idp.example.com/token",
1442+
upstream_client_id="test-client",
1443+
upstream_client_secret="test-secret",
1444+
token_verifier=JWTVerifier(
1445+
jwks_uri="https://idp.example.com/.well-known/jwks.json",
1446+
issuer="https://idp.example.com",
1447+
),
1448+
base_url="http://localhost:8000",
1449+
jwt_signing_key="test-signing-key",
1450+
fallback_access_token_expiry_seconds=86400,
1451+
)
1452+
1453+
assert provider._fallback_access_token_expiry_seconds == 86400
1454+
1455+
def test_fallback_parameter_defaults_to_none(self):
1456+
"""Verify fallback defaults to None (enabling smart defaults)."""
1457+
provider = OAuthProxy(
1458+
upstream_authorization_endpoint="https://idp.example.com/authorize",
1459+
upstream_token_endpoint="https://idp.example.com/token",
1460+
upstream_client_id="test-client",
1461+
upstream_client_secret="test-secret",
1462+
token_verifier=JWTVerifier(
1463+
jwks_uri="https://idp.example.com/.well-known/jwks.json",
1464+
issuer="https://idp.example.com",
1465+
),
1466+
base_url="http://localhost:8000",
1467+
jwt_signing_key="test-signing-key",
1468+
)
1469+
1470+
assert provider._fallback_access_token_expiry_seconds is None

0 commit comments

Comments
 (0)