From 15dbe7ecf0349a31bcb24834e237d44c0599feee Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 27 Oct 2025 13:38:20 -0400
Subject: [PATCH] Add custom token verifier support to OIDCProxy (#2279)
* Add custom token verifier support to OIDCProxy
OIDCProxy now accepts an optional token_verifier parameter to support
non-JWT token formats like opaque tokens from providers such as Clerk.
When provided, the custom verifier is used instead of creating a default
JWTVerifier. Parameters that only apply to JWTVerifier creation (algorithm,
required_scopes) raise clear errors when specified alongside a custom
verifier. Parameters with other purposes (audience for OAuth flow,
timeout_seconds for config fetch) remain allowed.
The custom verifier's required_scopes are automatically loaded and
advertised through OAuth discovery endpoints.
* Document custom token verifier support in OIDC proxy
---
docs/servers/auth/oidc-proxy.mdx | 14 ++-
src/fastmcp/server/auth/oidc_proxy.py | 35 +++++--
tests/server/auth/test_oidc_proxy.py | 134 ++++++++++++++++++++++++++
3 files changed, 172 insertions(+), 11 deletions(-)
diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx
index 27083e882..df25ca1ea 100644
--- a/docs/servers/auth/oidc-proxy.mdx
+++ b/docs/servers/auth/oidc-proxy.mdx
@@ -93,14 +93,22 @@ mcp = FastMCP(name="My Server", auth=auth)
HTTP request timeout in seconds for fetching OIDC configuration
+
+
+
+ Custom token verifier for validating tokens. When provided, FastMCP uses your custom verifier instead of creating a default `JWTVerifier`.
+
+ Cannot be used with `algorithm` or `required_scopes` parameters - configure these on your verifier instead. The verifier's `required_scopes` are automatically loaded and advertised.
+
+
JWT algorithm to use for token verification (e.g., "RS256"). If not specified,
- uses the provider's default.
+ uses the provider's default. Only used when `token_verifier` is not provided.
- List of OAuth scopes to request from the provider. These are automatically
- included in authorization requests.
+ List of OAuth scopes for token validation. These are automatically
+ included in authorization requests. Only used when `token_verifier` is not provided.
diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py
index 4e216f556..d9a3df510 100644
--- a/src/fastmcp/server/auth/oidc_proxy.py
+++ b/src/fastmcp/server/auth/oidc_proxy.py
@@ -206,6 +206,7 @@ class OIDCProxy(OAuthProxy):
audience: str | None = None,
timeout_seconds: int | None = None,
# Token verifier
+ token_verifier: TokenVerifier | None = None,
algorithm: str | None = None,
required_scopes: list[str] | None = None,
# FastMCP server configuration
@@ -231,8 +232,11 @@ class OIDCProxy(OAuthProxy):
client_secret: Client secret for upstream server
audience: Audience for upstream server
timeout_seconds: HTTP request timeout in seconds
- algorithm: Token verifier algorithm
- required_scopes: Required OAuth scopes
+ token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens).
+ If not provided, a JWTVerifier will be created using the OIDC configuration.
+ 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)
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.
@@ -268,6 +272,19 @@ class OIDCProxy(OAuthProxy):
if not base_url:
raise ValueError("Missing required base URL")
+ # Validate that verifier-specific parameters are not used with custom verifier
+ if token_verifier is not None:
+ if algorithm is not None:
+ raise ValueError(
+ "Cannot specify 'algorithm' when providing a custom token_verifier. "
+ "Configure the algorithm on your token verifier instead."
+ )
+ if required_scopes is not None:
+ raise ValueError(
+ "Cannot specify 'required_scopes' when providing a custom token_verifier. "
+ "Configure required scopes on your token verifier instead."
+ )
+
if isinstance(config_url, str):
config_url = AnyHttpUrl(config_url)
@@ -287,12 +304,14 @@ class OIDCProxy(OAuthProxy):
else None
)
- token_verifier = self.get_token_verifier(
- algorithm=algorithm,
- audience=audience,
- required_scopes=required_scopes,
- timeout_seconds=timeout_seconds,
- )
+ # Use custom verifier if provided, otherwise create default JWTVerifier
+ if token_verifier is None:
+ token_verifier = self.get_token_verifier(
+ algorithm=algorithm,
+ audience=audience,
+ required_scopes=required_scopes,
+ timeout_seconds=timeout_seconds,
+ )
init_kwargs = {
"upstream_authorization_endpoint": str(
diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py
index 7608d8b6c..319751bc9 100644
--- a/tests/server/auth/test_oidc_proxy.py
+++ b/tests/server/auth/test_oidc_proxy.py
@@ -8,6 +8,7 @@ from httpx import Response
from pydantic import AnyHttpUrl
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
TEST_ISSUER = "https://example.com"
@@ -649,3 +650,136 @@ class TestOIDCProxyInitialization:
client_secret=TEST_CLIENT_SECRET,
base_url=None, # type: ignore
)
+
+ def test_custom_token_verifier_initialization(self, valid_oidc_configuration_dict):
+ """Test initialization with 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
+
+ # Create custom verifier for opaque tokens
+ custom_verifier = IntrospectionTokenVerifier(
+ introspection_url="https://example.com/oauth/introspect",
+ client_id="introspection-client",
+ client_secret="introspection-secret",
+ required_scopes=["custom", "scopes"],
+ )
+
+ proxy = OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ token_verifier=custom_verifier,
+ jwt_signing_key="test-secret",
+ )
+
+ validate_proxy(mock_get, proxy, oidc_config)
+
+ # Verify the custom verifier is used
+ assert proxy._token_validator is custom_verifier
+ assert isinstance(proxy._token_validator, IntrospectionTokenVerifier)
+
+ # Verify required_scopes are properly loaded from the custom verifier
+ assert proxy.required_scopes == ["custom", "scopes"]
+
+ def test_custom_token_verifier_with_algorithm_raises_error(
+ self, valid_oidc_configuration_dict
+ ):
+ """Test that providing algorithm with custom verifier raises error."""
+ 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",
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="Cannot specify 'algorithm' when providing a custom token_verifier",
+ ):
+ OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ token_verifier=custom_verifier,
+ algorithm="RS256", # This should cause an error
+ jwt_signing_key="test-secret",
+ )
+
+ def test_custom_token_verifier_with_required_scopes_raises_error(
+ self, valid_oidc_configuration_dict
+ ):
+ """Test that providing required_scopes with custom verifier raises error."""
+ 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",
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="Cannot specify 'required_scopes' when providing a custom token_verifier",
+ ):
+ OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ token_verifier=custom_verifier,
+ required_scopes=["read", "write"], # This should cause an error
+ jwt_signing_key="test-secret",
+ )
+
+ def test_custom_token_verifier_with_audience_allowed(
+ self, valid_oidc_configuration_dict
+ ):
+ """Test that providing audience with custom verifier is allowed (for OAuth flow)."""
+ 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",
+ )
+
+ # This should NOT raise an error - audience is for OAuth flow
+ proxy = OIDCProxy(
+ config_url=TEST_CONFIG_URL,
+ client_id=TEST_CLIENT_ID,
+ client_secret=TEST_CLIENT_SECRET,
+ base_url=TEST_BASE_URL,
+ token_verifier=custom_verifier,
+ audience="test-audience", # Should be allowed for OAuth flow
+ jwt_signing_key="test-secret",
+ )
+
+ validate_proxy(mock_get, proxy, oidc_config)
+ assert proxy._extra_authorize_params == {"audience": "test-audience"}
+ assert proxy._extra_token_params == {"audience": "test-audience"}