Skip to content

Commit de58bb0

Browse files
authored
Add DebugTokenVerifier with custom sync/async validation (#2296)
* Add DebugTokenVerifier with custom sync/async validation * move import
1 parent 87adacf commit de58bb0

File tree

4 files changed

+346
-0
lines changed

4 files changed

+346
-0
lines changed

docs/servers/auth/token-verification.mdx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,67 @@ Static token verification stores tokens as plain text and should never be used i
210210
</Warning>
211211

212212

213+
### Debug/Custom Token Verification
214+
215+
The `DebugTokenVerifier` provides maximum flexibility for testing and special cases where standard token verification isn't applicable. It delegates validation to a user-provided callable, making it useful for prototyping, testing scenarios, or handling opaque tokens without introspection endpoints.
216+
217+
```python
218+
from fastmcp import FastMCP
219+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
220+
221+
# Accept all tokens (useful for rapid development)
222+
verifier = DebugTokenVerifier()
223+
224+
mcp = FastMCP(name="Development Server", auth=verifier)
225+
```
226+
227+
By default, `DebugTokenVerifier` accepts any non-empty token as valid. This eliminates authentication barriers during early development, allowing you to focus on core functionality before adding security.
228+
229+
For more controlled testing, provide custom validation logic:
230+
231+
```python
232+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
233+
234+
# Synchronous validation - check token prefix
235+
verifier = DebugTokenVerifier(
236+
validate=lambda token: token.startswith("dev-"),
237+
client_id="development-client",
238+
scopes=["read", "write"]
239+
)
240+
241+
mcp = FastMCP(name="Development Server", auth=verifier)
242+
```
243+
244+
The validation callable can also be async, enabling database lookups or external service calls:
245+
246+
```python
247+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
248+
249+
# Asynchronous validation - check against cache
250+
async def validate_token(token: str) -> bool:
251+
# Check if token exists in Redis, database, etc.
252+
return await redis.exists(f"valid_tokens:{token}")
253+
254+
verifier = DebugTokenVerifier(
255+
validate=validate_token,
256+
client_id="api-client",
257+
scopes=["api:access"]
258+
)
259+
260+
mcp = FastMCP(name="Custom API", auth=verifier)
261+
```
262+
263+
**Use Cases:**
264+
265+
- **Testing**: Accept any token during integration tests without setting up token infrastructure
266+
- **Prototyping**: Quickly validate concepts without authentication complexity
267+
- **Opaque tokens without introspection**: When you have tokens from an IDP that provides no introspection endpoint, and you're willing to accept tokens without validation (validation happens later at the upstream service)
268+
- **Custom token formats**: Implement validation for non-standard token formats or legacy systems
269+
270+
<Warning>
271+
`DebugTokenVerifier` bypasses standard security checks. Only use in controlled environments (development, testing) or when you fully understand the security implications. For production, use proper JWT or introspection-based verification.
272+
</Warning>
273+
213274
### Test Token Generation
214275

215276
Test token generation helps when you need to test JWT verification without setting up complete identity infrastructure. FastMCP includes utilities for generating test key pairs and signed tokens.

src/fastmcp/server/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
AccessToken,
66
AuthProvider,
77
)
8+
from .providers.debug import DebugTokenVerifier
89
from .providers.jwt import JWTVerifier, StaticTokenVerifier
910
from .oauth_proxy import OAuthProxy
1011
from .oidc_proxy import OIDCProxy
@@ -13,6 +14,7 @@
1314
__all__ = [
1415
"AccessToken",
1516
"AuthProvider",
17+
"DebugTokenVerifier",
1618
"JWTVerifier",
1719
"OAuthProvider",
1820
"OAuthProxy",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Debug token verifier for testing and special cases.
2+
3+
This module provides a flexible token verifier that delegates validation
4+
to a custom callable. Useful for testing, development, or scenarios where
5+
standard verification isn't possible (like opaque tokens without introspection).
6+
7+
Example:
8+
```python
9+
from fastmcp import FastMCP
10+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
11+
12+
# Accept all tokens (default - useful for testing)
13+
auth = DebugTokenVerifier()
14+
15+
# Custom sync validation logic
16+
auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-"))
17+
18+
# Custom async validation logic
19+
async def check_cache(token: str) -> bool:
20+
return await redis.exists(f"token:{token}")
21+
22+
auth = DebugTokenVerifier(validate=check_cache)
23+
24+
mcp = FastMCP("My Server", auth=auth)
25+
```
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import inspect
31+
from collections.abc import Awaitable, Callable
32+
33+
from fastmcp.server.auth import TokenVerifier
34+
from fastmcp.server.auth.auth import AccessToken
35+
from fastmcp.utilities.logging import get_logger
36+
37+
logger = get_logger(__name__)
38+
39+
40+
class DebugTokenVerifier(TokenVerifier):
41+
"""Token verifier with custom validation logic.
42+
43+
This verifier delegates token validation to a user-provided callable.
44+
By default, it accepts all non-empty tokens (useful for testing).
45+
46+
Use cases:
47+
- Testing: Accept any token without real verification
48+
- Development: Custom validation logic for prototyping
49+
- Opaque tokens: When you have tokens with no introspection endpoint
50+
51+
WARNING: This bypasses standard security checks. Only use in controlled
52+
environments or when you understand the security implications.
53+
"""
54+
55+
def __init__(
56+
self,
57+
validate: Callable[[str], bool]
58+
| Callable[[str], Awaitable[bool]] = lambda token: True,
59+
client_id: str = "debug-client",
60+
scopes: list[str] | None = None,
61+
required_scopes: list[str] | None = None,
62+
):
63+
"""Initialize the debug token verifier.
64+
65+
Args:
66+
validate: Callable that takes a token string and returns True if valid.
67+
Can be sync or async. Default accepts all tokens.
68+
client_id: Client ID to assign to validated tokens
69+
scopes: Scopes to assign to validated tokens
70+
required_scopes: Required scopes (inherited from TokenVerifier base class)
71+
"""
72+
super().__init__(required_scopes=required_scopes)
73+
self.validate = validate
74+
self.client_id = client_id
75+
self.scopes = scopes or []
76+
77+
async def verify_token(self, token: str) -> AccessToken | None:
78+
"""Verify token using custom validation logic.
79+
80+
Args:
81+
token: The token string to validate
82+
83+
Returns:
84+
AccessToken if validation succeeds, None otherwise
85+
"""
86+
# Reject empty tokens
87+
if not token or not token.strip():
88+
logger.debug("Rejecting empty token")
89+
return None
90+
91+
try:
92+
# Call validation function and await if result is awaitable
93+
result = self.validate(token)
94+
if inspect.isawaitable(result):
95+
is_valid = await result
96+
else:
97+
is_valid = result
98+
99+
if not is_valid:
100+
logger.debug("Token validation failed: callable returned False")
101+
return None
102+
103+
# Return valid AccessToken
104+
return AccessToken(
105+
token=token,
106+
client_id=self.client_id,
107+
scopes=self.scopes,
108+
expires_at=None, # No expiration
109+
claims={"token": token}, # Store original token in claims
110+
)
111+
112+
except Exception as e:
113+
logger.debug("Token validation error: %s", e, exc_info=True)
114+
return None
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Unit tests for DebugTokenVerifier."""
2+
3+
import re
4+
5+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
6+
7+
8+
class TestDebugTokenVerifier:
9+
"""Test DebugTokenVerifier initialization and validation."""
10+
11+
def test_init_defaults(self):
12+
"""Test initialization with default parameters."""
13+
verifier = DebugTokenVerifier()
14+
15+
assert verifier.client_id == "debug-client"
16+
assert verifier.scopes == []
17+
assert verifier.required_scopes == []
18+
assert callable(verifier.validate)
19+
20+
def test_init_custom_parameters(self):
21+
"""Test initialization with custom parameters."""
22+
verifier = DebugTokenVerifier(
23+
validate=lambda t: t.startswith("valid-"),
24+
client_id="custom-client",
25+
scopes=["read", "write"],
26+
required_scopes=["admin"],
27+
)
28+
29+
assert verifier.client_id == "custom-client"
30+
assert verifier.scopes == ["read", "write"]
31+
assert verifier.required_scopes == ["admin"]
32+
33+
async def test_verify_token_default_accepts_all(self):
34+
"""Test that default verifier accepts all non-empty tokens."""
35+
verifier = DebugTokenVerifier()
36+
37+
result = await verifier.verify_token("any-token")
38+
39+
assert result is not None
40+
assert result.token == "any-token"
41+
assert result.client_id == "debug-client"
42+
assert result.scopes == []
43+
assert result.expires_at is None
44+
assert result.claims == {"token": "any-token"}
45+
46+
async def test_verify_token_rejects_empty(self):
47+
"""Test that empty tokens are rejected even with default verifier."""
48+
verifier = DebugTokenVerifier()
49+
50+
# Empty string
51+
assert await verifier.verify_token("") is None
52+
53+
# Whitespace only
54+
assert await verifier.verify_token(" ") is None
55+
56+
async def test_verify_token_sync_callable_success(self):
57+
"""Test token verification with custom sync callable that passes."""
58+
verifier = DebugTokenVerifier(
59+
validate=lambda t: t.startswith("valid-"),
60+
client_id="test-client",
61+
scopes=["read"],
62+
)
63+
64+
result = await verifier.verify_token("valid-token-123")
65+
66+
assert result is not None
67+
assert result.token == "valid-token-123"
68+
assert result.client_id == "test-client"
69+
assert result.scopes == ["read"]
70+
assert result.expires_at is None
71+
assert result.claims == {"token": "valid-token-123"}
72+
73+
async def test_verify_token_sync_callable_failure(self):
74+
"""Test token verification with custom sync callable that fails."""
75+
verifier = DebugTokenVerifier(validate=lambda t: t.startswith("valid-"))
76+
77+
result = await verifier.verify_token("invalid-token")
78+
79+
assert result is None
80+
81+
async def test_verify_token_async_callable_success(self):
82+
"""Test token verification with custom async callable that passes."""
83+
84+
async def async_validator(token: str) -> bool:
85+
# Simulate async operation (e.g., database check)
86+
return token in {"token1", "token2", "token3"}
87+
88+
verifier = DebugTokenVerifier(
89+
validate=async_validator,
90+
client_id="async-client",
91+
scopes=["admin"],
92+
)
93+
94+
result = await verifier.verify_token("token2")
95+
96+
assert result is not None
97+
assert result.token == "token2"
98+
assert result.client_id == "async-client"
99+
assert result.scopes == ["admin"]
100+
101+
async def test_verify_token_async_callable_failure(self):
102+
"""Test token verification with custom async callable that fails."""
103+
104+
async def async_validator(token: str) -> bool:
105+
return token in {"token1", "token2", "token3"}
106+
107+
verifier = DebugTokenVerifier(validate=async_validator)
108+
109+
result = await verifier.verify_token("token99")
110+
111+
assert result is None
112+
113+
async def test_verify_token_callable_exception(self):
114+
"""Test that exceptions in validate callable are handled gracefully."""
115+
116+
def failing_validator(token: str) -> bool:
117+
raise ValueError("Something went wrong")
118+
119+
verifier = DebugTokenVerifier(validate=failing_validator)
120+
121+
result = await verifier.verify_token("any-token")
122+
123+
assert result is None
124+
125+
async def test_verify_token_async_callable_exception(self):
126+
"""Test that exceptions in async validate callable are handled gracefully."""
127+
128+
async def failing_async_validator(token: str) -> bool:
129+
raise ValueError("Async validation failed")
130+
131+
verifier = DebugTokenVerifier(validate=failing_async_validator)
132+
133+
result = await verifier.verify_token("any-token")
134+
135+
assert result is None
136+
137+
async def test_verify_token_whitelist_pattern(self):
138+
"""Test using verifier with a whitelist of allowed tokens."""
139+
allowed_tokens = {"secret-token-1", "secret-token-2", "admin-token"}
140+
141+
verifier = DebugTokenVerifier(validate=lambda t: t in allowed_tokens)
142+
143+
# Allowed tokens
144+
assert await verifier.verify_token("secret-token-1") is not None
145+
assert await verifier.verify_token("admin-token") is not None
146+
147+
# Disallowed tokens
148+
assert await verifier.verify_token("unknown-token") is None
149+
assert await verifier.verify_token("hacker-token") is None
150+
151+
async def test_verify_token_pattern_matching(self):
152+
"""Test using verifier with regex-like pattern matching."""
153+
154+
pattern = re.compile(r"^[A-Z]{3}-\d{4}-[a-z]{2}$")
155+
156+
verifier = DebugTokenVerifier(
157+
validate=lambda t: bool(pattern.match(t)),
158+
client_id="pattern-client",
159+
)
160+
161+
# Valid patterns
162+
result = await verifier.verify_token("ABC-1234-xy")
163+
assert result is not None
164+
assert result.client_id == "pattern-client"
165+
166+
# Invalid patterns
167+
assert await verifier.verify_token("abc-1234-xy") is None # Wrong case
168+
assert await verifier.verify_token("ABC-123-xy") is None # Wrong digits
169+
assert await verifier.verify_token("ABC-1234-xyz") is None # Too many chars

0 commit comments

Comments
 (0)