Skip to content

Commit ebdc7e6

Browse files
committed
[v1.x] Add AuthSettings.validate_token_resource to check a bearer token's resource
v1.x backport. BearerAuthBackend takes an optional resource_server_url; when it is set, only a token the verifier reports as issued for that URL (AccessToken.resource, the RFC 8707 resource indicator, compared as a URL with a trailing slash tolerated) is accepted, and anything else is answered 401 like an unrecognized token. AuthSettings.validate_token_resource turns this on for FastMCP's SSE and Streamable HTTP apps; leaving it unset while resource_server_url is set emits a DeprecationWarning and behaves as False, and 3.0 makes True the default there. RefreshToken gains an optional resource so a provider can carry the binding through the refresh grant. TokenVerifier.verify_token's docstring and docs/authorization.md say where the token's audience goes and when to enable the option versus checking the audience in the verifier. The oauth_server snippet and the simple-auth example set it.
1 parent 2106335 commit ebdc7e6

10 files changed

Lines changed: 175 additions & 9 deletions

File tree

docs/authorization.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ mcp = FastMCP(
4040
# Auth settings for RFC 9728 Protected Resource Metadata
4141
auth=AuthSettings(
4242
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
43-
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
43+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
4444
required_scopes=["user"],
45+
validate_token_resource=True,
4546
),
4647
)
4748

@@ -74,6 +75,12 @@ For a complete example with separate Authorization Server and Resource Server im
7475

7576
See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/src/mcp/server/auth/provider.py) for more details on implementing token validation.
7677

78+
A verifier should report who the token was issued for (its `aud`) in `AccessToken.resource`. `AuthSettings(validate_token_resource=True)` then refuses any token whose `resource` is not `resource_server_url`. Leaving it unset while `resource_server_url` is set warns (`DeprecationWarning`) and behaves as `False`; 3.0 makes `True` the default for resource servers.
79+
80+
- Turn it on when your authorization server binds tokens to the `resource` the client requested, which MCP clients always send. Keep `resource_server_url` the exact URL clients connect to.
81+
- Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check `aud` in your verifier instead, returning `None` for a token that isn't for this server.
82+
- If `aud` is a list, put the entry that equals `resource_server_url` in `resource`.
83+
7784
## Client-Side Authentication
7885

7986
The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) for connecting to protected MCP servers:

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
7575
issuer_url=settings.auth_server_url,
7676
required_scopes=[settings.mcp_scope],
7777
resource_server_url=settings.server_url,
78+
validate_token_resource=True, # tokens must be reported as issued for server_url
7879
),
7980
)
8081

examples/servers/simple-auth/mcp_simple_auth/token_verifier.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,19 @@ async def verify_token(self, token: str) -> AccessToken | None:
6969
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
7070
return None
7171

72+
# `aud` may be a string or a list; report the entry naming this server when there is
73+
# one, otherwise what the token was issued for, so the server can compare it.
74+
aud: str | list[str] | None = data.get("aud")
75+
audiences = aud if isinstance(aud, list) else [aud] if aud else []
76+
own = self.resource_url.rstrip("/")
77+
resource = next((a for a in audiences if a.rstrip("/") == own), audiences[0] if audiences else None)
78+
7279
return AccessToken(
7380
token=token,
7481
client_id=data.get("client_id", "unknown"),
7582
scopes=data.get("scope", "").split() if data.get("scope") else [],
7683
expires_at=data.get("exp"),
77-
resource=data.get("aud"), # Include resource in token
84+
resource=resource,
7885
subject=data.get("sub"), # RFC 7662 subject (resource owner)
7986
claims=data,
8087
)

examples/snippets/servers/oauth_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
2626
# Auth settings for RFC 9728 Protected Resource Metadata
2727
auth=AuthSettings(
2828
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
29-
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
29+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
3030
required_scopes=["user"],
31+
validate_token_resource=True,
3132
),
3233
)
3334

src/mcp/server/auth/middleware/bearer_auth.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import json
2+
import logging
23
import time
34
from typing import Any, TypedDict
45

5-
from pydantic import AnyHttpUrl
6+
from pydantic import AnyHttpUrl, ValidationError
67
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
78
from starlette.requests import HTTPConnection
89
from starlette.types import Receive, Scope, Send
910

1011
from mcp.server.auth.provider import AccessToken, TokenVerifier
1112

13+
logger = logging.getLogger(__name__)
14+
1215

1316
class AuthenticatedUser(SimpleUser):
1417
"""User with authentication info."""
@@ -46,10 +49,14 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
4649
class BearerAuthBackend(AuthenticationBackend):
4750
"""
4851
Authentication backend that validates Bearer tokens using a TokenVerifier.
52+
53+
When `resource_server_url` is given, only a token whose `AccessToken.resource`
54+
(its RFC 8707 resource indicator / audience) is that URL is accepted.
4955
"""
5056

51-
def __init__(self, token_verifier: TokenVerifier):
57+
def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None):
5258
self.token_verifier = token_verifier
59+
self.resource_server_url = resource_server_url
5360

5461
async def authenticate(self, conn: HTTPConnection):
5562
auth_header = next(
@@ -70,8 +77,22 @@ async def authenticate(self, conn: HTTPConnection):
7077
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
7178
return None
7279

80+
if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource):
81+
logger.warning(
82+
"Bearer token resource %r is not resource_server_url %s", auth_info.resource, self.resource_server_url
83+
)
84+
return None
85+
7386
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
7487

88+
def _issued_for_this_resource(self, resource: str | None) -> bool:
89+
"""Compare as URLs (so case and default-port spelling do not matter), a trailing slash aside."""
90+
try:
91+
token_resource = str(AnyHttpUrl(resource or ""))
92+
except ValidationError:
93+
return False
94+
return token_resource.removesuffix("/") == str(self.resource_server_url).removesuffix("/")
95+
7596

7697
class RequireAuthMiddleware:
7798
"""

src/mcp/server/auth/provider.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class RefreshToken(BaseModel):
3333
client_id: str
3434
scopes: list[str]
3535
expires_at: int | None = None
36+
resource: str | None = None # RFC 8707 resource indicator; propagate to refreshed AccessTokens
3637
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
3738

3839

@@ -97,7 +98,15 @@ class TokenVerifier(Protocol):
9798
"""Protocol for verifying bearer tokens."""
9899

99100
async def verify_token(self, token: str) -> AccessToken | None:
100-
"""Verify a bearer token and return access info if valid."""
101+
"""Verify a bearer token and return access info if valid.
102+
103+
Set `AccessToken.resource` to the resource the token was issued for (its RFC 8707
104+
resource indicator / `aud`; for a list, the entry equal to the server's
105+
`AuthSettings.resource_server_url`). With `AuthSettings.validate_token_resource` the
106+
bearer middleware then refuses any token whose resource is not `resource_server_url`;
107+
without it, confirming the token was issued for this server (for example by passing the
108+
expected audience to your JWT library) is up to the verifier.
109+
"""
101110

102111

103112
# NOTE: FastMCP doesn't render any of these types in the user response, so it's

src/mcp/server/auth/settings.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from pydantic import AnyHttpUrl, BaseModel, Field
1+
import warnings
2+
3+
from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
4+
from typing_extensions import Self
25

36

47
class ClientRegistrationOptions(BaseModel):
@@ -28,3 +31,26 @@ class AuthSettings(BaseModel):
2831
description="The URL of the MCP server to be used as the resource identifier "
2932
"and base route to look up OAuth Protected Resource Metadata.",
3033
)
34+
validate_token_resource: bool | None = Field(
35+
default=None,
36+
description="Only accept tokens the token verifier reports as issued for `resource_server_url` "
37+
"(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization "
38+
"server binds tokens to the `resource` the client requested; set it to False when your token "
39+
"verifier checks the token's audience itself. With `resource_server_url` set, leaving it unset warns "
40+
"and behaves as False; 3.0 makes True the default there.",
41+
)
42+
43+
@model_validator(mode="after")
44+
def _check_validate_token_resource(self) -> Self:
45+
if self.validate_token_resource and self.resource_server_url is None:
46+
raise ValueError("validate_token_resource requires resource_server_url")
47+
if self.validate_token_resource is None and self.resource_server_url is not None:
48+
warnings.warn(
49+
"`AuthSettings.validate_token_resource` is not set, so bearer tokens are not checked "
50+
"against `resource_server_url`; it will default to True in 3.0 when `resource_server_url` is "
51+
"set. Set it to True to have the server refuse tokens issued for another resource, or to "
52+
"False if your TokenVerifier validates the token's audience itself.",
53+
DeprecationWarning,
54+
stacklevel=3,
55+
)
56+
return self

src/mcp/server/fastmcp/server.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -881,7 +881,12 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
881881
# extract auth info from request (but do not require it)
882882
Middleware(
883883
AuthenticationMiddleware,
884-
backend=BearerAuthBackend(self._token_verifier),
884+
backend=BearerAuthBackend(
885+
self._token_verifier,
886+
resource_server_url=self.settings.auth.resource_server_url
887+
if self.settings.auth.validate_token_resource
888+
else None,
889+
),
885890
),
886891
# Add the auth context middleware to store
887892
# authenticated user in a contextvar
@@ -999,7 +1004,12 @@ def streamable_http_app(self) -> Starlette:
9991004
middleware = [
10001005
Middleware(
10011006
AuthenticationMiddleware,
1002-
backend=BearerAuthBackend(self._token_verifier),
1007+
backend=BearerAuthBackend(
1008+
self._token_verifier,
1009+
resource_server_url=self.settings.auth.resource_server_url
1010+
if self.settings.auth.validate_token_resource
1011+
else None,
1012+
),
10031013
),
10041014
Middleware(AuthContextMiddleware),
10051015
]

tests/server/auth/middleware/test_bearer_auth.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any, cast
77

88
import pytest
9+
from pydantic import AnyHttpUrl
910
from starlette.authentication import AuthCredentials
1011
from starlette.datastructures import Headers
1112
from starlette.requests import Request
@@ -265,6 +266,56 @@ async def test_mixed_case_authorization_header(
265266
assert user.access_token == valid_access_token
266267

267268

269+
class SingleTokenVerifier:
270+
"""A `TokenVerifier` that knows exactly one token."""
271+
272+
def __init__(self, access_token: AccessToken) -> None:
273+
self.access_token = access_token
274+
275+
async def verify_token(self, token: str) -> AccessToken | None:
276+
return self.access_token if token == self.access_token.token else None
277+
278+
279+
RS = "https://api.example.com/mcp"
280+
281+
282+
@pytest.mark.anyio
283+
@pytest.mark.parametrize(
284+
("resource_server_url", "token_resource", "accepted"),
285+
[
286+
(None, "https://other.example.com/mcp", True), # nothing configured to compare against
287+
(None, None, True),
288+
(RS, None, False), # the verifier did not report what the token was issued for
289+
(RS, RS, True),
290+
(RS, RS + "/", True),
291+
(RS, "https://API.EXAMPLE.COM:443/mcp", True), # same URL, different spelling
292+
(RS, "https://api.example.com", False),
293+
(RS, RS + "/child", False),
294+
(RS, "https://api.example.com/other", False),
295+
(RS, "https://other.example.com/mcp", False),
296+
(RS, "api.example.com", False), # not a URL
297+
],
298+
)
299+
async def test_backend_accepts_only_tokens_issued_for_its_resource(
300+
resource_server_url: str | None, token_resource: str | None, accepted: bool
301+
):
302+
"""With `resource_server_url` set, only a token whose `resource` (RFC 8707) is that URL is
303+
accepted and anything else is treated like an unrecognized token (spec-mandated audience
304+
check); without it the verifier's answer stands (SDK-defined, the default wiring)."""
305+
token = AccessToken(token="t", client_id="c", scopes=["read"], resource=token_resource)
306+
backend = BearerAuthBackend(
307+
SingleTokenVerifier(token),
308+
resource_server_url=AnyHttpUrl(resource_server_url) if resource_server_url else None,
309+
)
310+
311+
result = await backend.authenticate(Request({"type": "http", "headers": [(b"authorization", b"Bearer t")]}))
312+
313+
if accepted:
314+
assert result is not None and result[1].access_token == token
315+
else:
316+
assert result is None
317+
318+
268319
@pytest.mark.anyio
269320
class TestRequireAuthMiddleware:
270321
"""Tests for the RequireAuthMiddleware class."""

tests/server/auth/test_settings.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import warnings
2+
3+
import pytest
4+
from pydantic import AnyHttpUrl, ValidationError
5+
6+
from mcp.server.auth.settings import AuthSettings
7+
8+
ISSUER = AnyHttpUrl("https://auth.example.com")
9+
RESOURCE = AnyHttpUrl("https://mcp.example.com/mcp")
10+
11+
12+
def test_validate_token_resource_requires_a_resource_server_url():
13+
"""SDK-defined: asking the bearer gate to compare tokens against `resource_server_url` without
14+
configuring one is refused at construction time rather than silently comparing nothing."""
15+
AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE, validate_token_resource=True)
16+
with pytest.raises(ValidationError, match="validate_token_resource requires resource_server_url"):
17+
AuthSettings(issuer_url=ISSUER, resource_server_url=None, validate_token_resource=True)
18+
19+
20+
def test_leaving_validate_token_resource_unset_warns_when_a_resource_server_url_is_configured():
21+
"""Unset behaves as False but says so: a resource server that has not chosen gets a
22+
`DeprecationWarning` pointing at its own `AuthSettings(...)` call (3.0 flips the default)."""
23+
with pytest.warns(DeprecationWarning, match="validate_token_resource") as record:
24+
settings = AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE)
25+
assert settings.validate_token_resource is None
26+
assert record[0].filename == __file__
27+
28+
29+
@pytest.mark.parametrize("kwargs", [{"validate_token_resource": False}, {"resource_server_url": None}])
30+
def test_an_explicit_choice_or_no_resource_server_url_does_not_warn(kwargs: dict[str, object]):
31+
with warnings.catch_warnings():
32+
warnings.simplefilter("error")
33+
AuthSettings.model_validate({"issuer_url": ISSUER, "resource_server_url": RESOURCE, **kwargs})

0 commit comments

Comments
 (0)