diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index dcb22c638..dd1400086 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -273,7 +273,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -292,7 +292,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -305,7 +305,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index c9339aa72..183380ed9 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -19,7 +19,7 @@ This implementation is based on: ## Classes -### `OIDCConfiguration` +### `OIDCConfiguration` OIDC Configuration. @@ -27,7 +27,7 @@ OIDC Configuration. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(cls, config_url: AnyHttpUrl) -> Self @@ -41,7 +41,7 @@ Get the OIDC configuration for the specified config URL. - `timeout_seconds`: HTTP request timeout in seconds -### `OIDCProxy` +### `OIDCProxy` OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index cca772db3..0a72ae9f9 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -1259,7 +1259,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): time.time() + new_refresh_expires_in ) - upstream_token_set.raw_token_data = token_response + upstream_token_set.raw_token_data = { + **upstream_token_set.raw_token_data, + **token_response, + } # Calculate refresh TTL for storage refresh_ttl = new_refresh_expires_in or ( int(upstream_token_set.refresh_token_expires_at - time.time()) @@ -1367,6 +1370,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Token Validation # ------------------------------------------------------------------------- + def _get_verification_token( + self, upstream_token_set: UpstreamTokenSet + ) -> str | None: + """Get the token string to pass to the token verifier. + + Returns the upstream access token by default. Subclasses can override + to verify a different token (e.g., the OIDC id_token for providers + that issue opaque access tokens). + """ + return upstream_token_set.access_token + async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] """Validate FastMCP JWT by swapping for upstream token. @@ -1405,14 +1419,31 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # 3. Validate with upstream provider (delegated to TokenVerifier) # This calls the real token validator (GitHub API, JWKS, etc.) - validated = await self._token_validator.verify_token( - upstream_token_set.access_token - ) + verification_token = self._get_verification_token(upstream_token_set) + if verification_token is None: + logger.debug("No verification token available") + return None + validated = await self._token_validator.verify_token(verification_token) if not validated: logger.debug("Upstream token validation failed") return None + # When the verification token differs from the access token + # (e.g., id_token verification), ensure the returned AccessToken + # carries the upstream access token and its scopes, not the + # verification token's values. + if verification_token != upstream_token_set.access_token: + validated = validated.model_copy( + update={ + "token": upstream_token_set.access_token, + "scopes": upstream_token_set.scope.split() + if upstream_token_set.scope + else validated.scopes, + "expires_at": int(upstream_token_set.expires_at), + } + ) + logger.debug( "Token swap successful for JTI=%s (upstream validated)", jti[:8] ) diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 3a000129f..418bea34c 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -18,6 +18,7 @@ from typing_extensions import Self from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.logging import get_logger @@ -209,6 +210,7 @@ class OIDCProxy(OAuthProxy): token_verifier: TokenVerifier | None = None, algorithm: str | None = None, required_scopes: list[str] | None = None, + verify_id_token: bool = False, # FastMCP server configuration base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, @@ -245,6 +247,9 @@ class OIDCProxy(OAuthProxy): Cannot be used with algorithm or required_scopes parameters (configure these on your verifier instead). algorithm: Token verifier algorithm (only used if token_verifier is not provided) required_scopes: Required scopes for token validation (only used if token_verifier is not provided) + verify_id_token: If True, verify the OIDC id_token instead of the access_token. + Useful for providers that issue opaque (non-JWT) access tokens, since the + id_token is always a standard JWT verifiable via the provider's JWKS. base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. @@ -330,14 +335,22 @@ class OIDCProxy(OAuthProxy): # Use custom verifier if provided, otherwise create default JWTVerifier if token_verifier is None: + # When verifying id_tokens: + # - aud is always the OAuth client_id (per OIDC Core §2), not + # the API audience, so use client_id for audience validation. + # - id_tokens don't carry scope/scp claims, so don't pass + # required_scopes to the verifier (scope enforcement happens + # at the FastMCP token level instead). + verifier_audience = client_id if verify_id_token else audience + verifier_scopes = None if verify_id_token else required_scopes token_verifier = self.get_token_verifier( algorithm=algorithm, - audience=audience, - required_scopes=required_scopes, + audience=verifier_audience, + required_scopes=verifier_scopes, timeout_seconds=timeout_seconds, ) - init_kwargs = { + init_kwargs: dict[str, object] = { "upstream_authorization_endpoint": str( self.oidc_config.authorization_endpoint ), @@ -383,6 +396,39 @@ class OIDCProxy(OAuthProxy): super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type] + self._verify_id_token = verify_id_token + + # When verify_id_token strips scopes from the verifier, restore + # them on the provider so they're still advertised to clients + # and enforced at the FastMCP token level. We also need to + # recompute derived state that OAuthProxy.__init__ already built + # from the (empty) verifier scopes. + if verify_id_token and required_scopes: + self.required_scopes = required_scopes + self._default_scope_str = " ".join(required_scopes) + if self.client_registration_options: + self.client_registration_options.valid_scopes = required_scopes + if self._cimd_manager is not None: + self._cimd_manager.default_scope = self._default_scope_str + + def _get_verification_token( + self, upstream_token_set: UpstreamTokenSet + ) -> str | None: + """Get the token to verify from the upstream token set. + + When verify_id_token is enabled, returns the id_token from the + upstream token response instead of the access_token. + """ + if self._verify_id_token: + id_token = upstream_token_set.raw_token_data.get("id_token") + if id_token is None: + logger.warning( + "verify_id_token is enabled but no id_token found in" + " upstream token response" + ) + return id_token + return upstream_token_set.access_token + def get_oidc_configuration( self, config_url: AnyHttpUrl, diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index e3dd95e1c..b4fbd32a5 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -7,6 +7,7 @@ import pytest from httpx import Response from pydantic import AnyHttpUrl +from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -876,3 +877,285 @@ class TestOIDCProxyInitialization: "audience": "original-audience", "custom": "value", } + + +def _make_upstream_token_set(*, id_token: str | None = None) -> UpstreamTokenSet: + """Create an UpstreamTokenSet with optional id_token.""" + raw_token_data: dict[str, str] = {"access_token": "opaque-access-token"} + if id_token is not None: + raw_token_data["id_token"] = id_token + return UpstreamTokenSet( + upstream_token_id="test-id", + access_token="opaque-access-token", + refresh_token=None, + refresh_token_expires_at=None, + expires_at=9999999999.0, + token_type="Bearer", + scope="openid", + client_id="test-client", + created_at=1000000000.0, + raw_token_data=raw_token_data, + ) + + +class TestVerifyIdToken: + """Tests for verify_id_token functionality.""" + + def test_verify_id_token_disabled_by_default(self, valid_oidc_configuration_dict): + """Default behavior: verify the access_token.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + ) + token_set = _make_upstream_token_set(id_token="jwt-id-token") + + assert proxy._get_verification_token(token_set) == "opaque-access-token" + + def test_verify_id_token_returns_id_token(self, valid_oidc_configuration_dict): + """When enabled, verify the id_token instead of access_token.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + token_set = _make_upstream_token_set(id_token="jwt-id-token") + + assert proxy._get_verification_token(token_set) == "jwt-id-token" + + def test_verify_id_token_returns_none_when_missing( + self, valid_oidc_configuration_dict + ): + """When enabled but id_token is absent, return None.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + token_set = _make_upstream_token_set(id_token=None) + + assert proxy._get_verification_token(token_set) is None + + def test_verify_id_token_works_with_custom_verifier( + self, valid_oidc_configuration_dict + ): + """verify_id_token can be combined with a custom token_verifier.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + custom_verifier = IntrospectionTokenVerifier( + introspection_url="https://example.com/oauth/introspect", + client_id="introspection-client", + client_secret="introspection-secret", + ) + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + verify_id_token=True, + token_verifier=custom_verifier, + ) + token_set = _make_upstream_token_set(id_token="jwt-id-token") + + assert proxy._get_verification_token(token_set) == "jwt-id-token" + assert proxy._token_validator is custom_verifier + + def test_verify_id_token_survives_refresh_without_id_token( + self, valid_oidc_configuration_dict + ): + """id_token from original auth is preserved when refresh omits it.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + + token_set = _make_upstream_token_set(id_token="original-id-token") + + # Simulate a refresh response that omits id_token — + # the merge in exchange_refresh_token should preserve it + refresh_response = { + "access_token": "new-access-token", + "token_type": "Bearer", + } + token_set.raw_token_data = {**token_set.raw_token_data, **refresh_response} + token_set.access_token = "new-access-token" + + assert proxy._get_verification_token(token_set) == "original-id-token" + + def test_verify_id_token_updated_when_refresh_includes_it( + self, valid_oidc_configuration_dict + ): + """id_token is updated when refresh response includes a new one.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + + token_set = _make_upstream_token_set(id_token="original-id-token") + + # Simulate a refresh response that includes a new id_token + refresh_response = { + "access_token": "new-access-token", + "id_token": "refreshed-id-token", + } + token_set.raw_token_data = {**token_set.raw_token_data, **refresh_response} + token_set.access_token = "new-access-token" + + assert proxy._get_verification_token(token_set) == "refreshed-id-token" + + def test_verify_id_token_uses_client_id_as_verifier_audience( + self, valid_oidc_configuration_dict + ): + """When verify_id_token is enabled, the verifier audience should be + client_id (matching id_token.aud), not the API audience parameter.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + audience="https://api.example.com", + verify_id_token=True, + ) + + assert isinstance(proxy._token_validator, JWTVerifier) + assert proxy._token_validator.audience == TEST_CLIENT_ID + + # The API audience should still be sent upstream + assert ( + proxy._extra_authorize_params["audience"] == "https://api.example.com" + ) + assert proxy._extra_token_params["audience"] == "https://api.example.com" + + def test_verify_id_token_without_audience_uses_client_id( + self, valid_oidc_configuration_dict + ): + """When verify_id_token is enabled without an audience param, + the verifier audience should still be client_id.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + verify_id_token=True, + ) + + assert isinstance(proxy._token_validator, JWTVerifier) + assert proxy._token_validator.audience == TEST_CLIENT_ID + + def test_verify_id_token_does_not_enforce_scopes_on_verifier( + self, valid_oidc_configuration_dict + ): + """When verify_id_token is enabled, required_scopes should not be + passed to the JWTVerifier since id_tokens don't carry scope claims.""" + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + ) as mock_get: + oidc_config = OIDCConfiguration.model_validate( + valid_oidc_configuration_dict + ) + mock_get.return_value = oidc_config + + proxy = OIDCProxy( + config_url=TEST_CONFIG_URL, + client_id=TEST_CLIENT_ID, + client_secret=TEST_CLIENT_SECRET, + base_url=TEST_BASE_URL, + jwt_signing_key="test-secret", + required_scopes=["read", "write"], + verify_id_token=True, + ) + + assert isinstance(proxy._token_validator, JWTVerifier) + assert proxy._token_validator.required_scopes == [] + + # Scopes should still be advertised via the proxy's required_scopes + assert proxy.required_scopes == ["read", "write"] + + # Derived scope state should also be recomputed + assert proxy._default_scope_str == "read write" + assert proxy.client_registration_options is not None + assert proxy.client_registration_options.valid_scopes == [ + "read", + "write", + ]