diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index b1f3516b6..b802754f0 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -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 diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 0fc801107..d218e75d4 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -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: diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index 40edf3114..8c214ca20 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -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