Skip to content

Commit e57f23a

Browse files
committed
Add AuthSettings.validate_token_resource to check a bearer token's resource
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 (default False) turns this on for the Streamable HTTP and SSE apps; it requires resource_server_url. TokenVerifier.verify_token's docstring and docs/run/authorization.md say where the token's audience goes and when to enable the option versus checking the audience in the verifier. The simple-auth example enables it. The interaction suite enables it for the bearer tests and records that it is off by default on hosting:auth:aud-validation.
1 parent c6762e8 commit e57f23a

12 files changed

Lines changed: 169 additions & 35 deletions

File tree

docs/run/authorization.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
2323
```
2424

2525
* `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement.
26-
* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it.
26+
* This one looks the token up in a table that belongs to this server. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and puts the token's audience (`aud`) in `AccessToken.resource` so the SDK can check the token was issued for this server (see `validate_token_resource` below); if `aud` is a list, use the entry that equals your `resource_server_url`. That code is yours; the SDK only calls it.
2727
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
2828

2929
`AuthSettings` is the public face of your resource server:
3030

3131
* `issuer_url`: the authorization server that issues your tokens.
3232
* `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives.
3333
* `required_scopes`: every token must carry all of them.
34+
* `validate_token_resource`: refuse any token your verifier does not report as issued for `resource_server_url` (its `AccessToken.resource`, compared as a URL, a trailing slash aside). Turn it on when your authorization server binds tokens to the `resource` a client asks for, which is what MCP clients send. If it issues its own audience identifiers instead (an Auth0 API identifier, an Entra application ID), leave it off and check `aud` against that identifier in your verifier: a token you cannot tie to this server should come back as `None`.
3435

3536
!!! tip
3637
`examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer:
7171
issuer_url=settings.auth_server_url,
7272
required_scopes=[settings.mcp_scope],
7373
resource_server_url=settings.server_url,
74+
validate_token_resource=True, # tokens must be reported as issued for server_url
7475
),
7576
)
7677
# Store settings for later use in run()

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

Lines changed: 25 additions & 3 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, principal_components
1112

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

1316
class AuthenticatedUser(SimpleUser):
1417
"""User with authentication info."""
@@ -39,10 +42,15 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
3942

4043

4144
class BearerAuthBackend(AuthenticationBackend):
42-
"""Authentication backend that validates Bearer tokens using a TokenVerifier."""
45+
"""Authentication backend that validates Bearer tokens using a TokenVerifier.
46+
47+
When `resource_server_url` is given, only a token whose `AccessToken.resource`
48+
(its RFC 8707 resource indicator / audience) is that URL is accepted.
49+
"""
4350

44-
def __init__(self, token_verifier: TokenVerifier):
51+
def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None):
4552
self.token_verifier = token_verifier
53+
self.resource_server_url = resource_server_url
4654

4755
async def authenticate(self, conn: HTTPConnection):
4856
auth_header = next(
@@ -63,8 +71,22 @@ async def authenticate(self, conn: HTTPConnection):
6371
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
6472
return None
6573

74+
if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource):
75+
logger.warning(
76+
"Bearer token resource %s is not resource_server_url %s", auth_info.resource, self.resource_server_url
77+
)
78+
return None
79+
6680
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
6781

82+
def _issued_for_this_resource(self, resource: str | None) -> bool:
83+
"""Compare as URLs (so case and default-port spelling do not matter), a trailing slash aside."""
84+
try:
85+
token_resource = str(AnyHttpUrl(resource or ""))
86+
except ValidationError:
87+
return False
88+
return token_resource.removesuffix("/") == str(self.resource_server_url).removesuffix("/")
89+
6890

6991
class RequireAuthMiddleware:
7092
"""Middleware that requires a valid Bearer token in the Authorization header.

src/mcp/server/auth/provider.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,15 @@ class TokenVerifier(Protocol):
124124
"""Protocol for verifying bearer tokens."""
125125

126126
async def verify_token(self, token: str) -> AccessToken | None:
127-
"""Verify a bearer token and return access info if valid."""
127+
"""Verify a bearer token and return access info if valid.
128+
129+
Set `AccessToken.resource` to the resource the token was issued for (its RFC 8707
130+
resource indicator / `aud`; for a list, the entry equal to the server's
131+
`AuthSettings.resource_server_url`). With `AuthSettings.validate_token_resource` the
132+
bearer middleware then refuses any token whose resource is not `resource_server_url`;
133+
without it, confirming the token was issued for this server (for example by passing the
134+
expected audience to your JWT library) is up to the verifier.
135+
"""
128136

129137

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

src/mcp/server/auth/settings.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field
1+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, model_validator
2+
from typing_extensions import Self
23

34

45
class ClientRegistrationOptions(BaseModel):
@@ -40,3 +41,15 @@ class AuthSettings(BaseModel):
4041
description="The URL of the MCP server to be used as the resource identifier "
4142
"and base route to look up OAuth Protected Resource Metadata.",
4243
)
44+
validate_token_resource: bool = Field(
45+
default=False,
46+
description="Only accept tokens the token verifier reports as issued for `resource_server_url` "
47+
"(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization "
48+
"server binds tokens to the `resource` the client requested.",
49+
)
50+
51+
@model_validator(mode="after")
52+
def _validate_token_resource_needs_a_resource(self) -> Self:
53+
if self.validate_token_resource and self.resource_server_url is None:
54+
raise ValueError("validate_token_resource requires resource_server_url")
55+
return self

src/mcp/server/lowlevel/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,10 @@ def streamable_http_app(
776776
middleware = [
777777
Middleware(
778778
AuthenticationMiddleware,
779-
backend=BearerAuthBackend(token_verifier),
779+
backend=BearerAuthBackend(
780+
token_verifier,
781+
resource_server_url=auth.resource_server_url if auth.validate_token_resource else None,
782+
),
780783
),
781784
Middleware(AuthContextMiddleware),
782785
]

src/mcp/server/mcpserver/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1187,7 +1187,12 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
11871187
# extract auth info from request (but do not require it)
11881188
Middleware(
11891189
AuthenticationMiddleware,
1190-
backend=BearerAuthBackend(self._token_verifier),
1190+
backend=BearerAuthBackend(
1191+
self._token_verifier,
1192+
resource_server_url=self.settings.auth.resource_server_url
1193+
if self.settings.auth.validate_token_resource
1194+
else None,
1195+
),
11911196
),
11921197
# Add the auth context middleware to store
11931198
# authenticated user in a contextvar

tests/interaction/_requirements.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2848,11 +2848,11 @@ def __post_init__(self) -> None:
28482848
source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage",
28492849
behavior="The resource server validates that the token audience matches its resource identifier.",
28502850
transports=("streamable-http",),
2851-
note="Auth is enforced at the HTTP layer.",
2851+
note="Auth is enforced at the HTTP layer; the tests enable AuthSettings.validate_token_resource.",
28522852
divergence=Divergence(
28532853
note=(
2854-
"BearerAuthBackend never inspects AccessToken.resource; a token issued for a different "
2855-
"resource is accepted. Spec MUST."
2854+
"Off by default: without AuthSettings.validate_token_resource the bearer gate does not compare "
2855+
"AccessToken.resource with resource_server_url and the check is the token verifier's. Spec MUST."
28562856
),
28572857
),
28582858
),

tests/interaction/auth/_harness.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def auth_settings(
182182
required_scopes: Sequence[str] = ("mcp",),
183183
valid_scopes: Sequence[str] | None = None,
184184
identity_assertion_enabled: bool = False,
185+
validate_token_resource: bool = False,
185186
) -> AuthSettings:
186187
"""Build `AuthSettings` for the co-hosted authorization + resource server.
187188
@@ -194,14 +195,16 @@ def auth_settings(
194195
195196
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
196197
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
197-
issue tokens.
198+
issue tokens. `validate_token_resource` makes the bearer gate refuse tokens whose verifier
199+
does not report them as issued for the resource URL.
198200
"""
199201
required = list(required_scopes)
200202
valid = list(valid_scopes) if valid_scopes is not None else required
201203
return AuthSettings(
202204
issuer_url=AnyHttpUrl(BASE_URL),
203205
resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"),
204206
required_scopes=required,
207+
validate_token_resource=validate_token_resource,
205208
client_registration_options=ClientRegistrationOptions(
206209
enabled=True, valid_scopes=valid, default_scopes=required
207210
),

tests/interaction/auth/test_bearer.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
33
These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier`
44
seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since
5-
every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status,
6-
the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP
7-
endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test.
5+
every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status
6+
and the `WWW-Authenticate` header structure. The flow side of the same 401 is `test_flow.py`'s
7+
flagship test.
88
"""
99

1010
import time
@@ -24,30 +24,33 @@
2424
pytestmark = pytest.mark.anyio
2525

2626
REQUIRED_SCOPE = "mcp:read"
27+
RESOURCE = "http://127.0.0.1:8000/mcp"
2728
RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
2829

2930
_FUTURE = int(time.time()) + 3600
3031
_PAST = int(time.time()) - 3600
3132

33+
34+
def tok(name: str, *, scopes: list[str], expires_at: int, resource: str | None = RESOURCE) -> AccessToken:
35+
return AccessToken(token=name, client_id="c", scopes=scopes, expires_at=expires_at, resource=resource)
36+
37+
3238
TOKENS = {
33-
"tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE),
34-
"tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST),
35-
"tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE),
36-
"tok-wrong-aud": AccessToken(
37-
token="tok-wrong-aud",
38-
client_id="c",
39-
scopes=[REQUIRED_SCOPE],
40-
expires_at=_FUTURE,
41-
resource="https://other.example/mcp",
39+
"tok-valid": tok("tok-valid", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE),
40+
"tok-expired": tok("tok-expired", scopes=[REQUIRED_SCOPE], expires_at=_PAST),
41+
"tok-noscope": tok("tok-noscope", scopes=["other:thing"], expires_at=_FUTURE),
42+
"tok-wrong-aud": tok(
43+
"tok-wrong-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource="https://other.example/mcp"
4244
),
45+
"tok-no-aud": tok("tok-no-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=None),
4346
}
4447

4548

4649
@pytest.fixture
4750
async def protected() -> AsyncIterator[httpx2.AsyncClient]:
4851
"""A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge."""
4952
server = Server("rs")
50-
settings = auth_settings(required_scopes=[REQUIRED_SCOPE])
53+
settings = auth_settings(required_scopes=[REQUIRED_SCOPE], validate_token_resource=True)
5154
async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _):
5255
yield http
5356

@@ -157,19 +160,26 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco
157160

158161

159162
@requirement("hosting:auth:aud-validation")
160-
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None:
161-
"""A token whose `resource` does not match the server's resource identifier is accepted.
162-
163-
The spec mandates the resource server validate the token's audience; the bearer backend
164-
never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint
165-
serves it. This pins current behaviour with the divergence recorded on the requirement.
163+
@pytest.mark.parametrize("bearer", ["tok-wrong-aud", "tok-no-aud"])
164+
async def test_a_token_not_issued_for_this_resource_is_answered_401(protected: httpx2.AsyncClient, bearer: str) -> None:
165+
"""Spec-mandated audience check, which the SDK performs when `AuthSettings.validate_token_resource`
166+
is set (off by default, the recorded divergence): a token whose verifier-reported `resource`
167+
(RFC 8707) is another URL, or absent, is answered 401 `invalid_token` like an unrecognized token.
166168
"""
167-
response = await post_mcp(protected, bearer="tok-wrong-aud")
169+
response = await post_mcp(protected, bearer=bearer)
170+
171+
assert response.status_code == 401
172+
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
173+
174+
175+
@requirement("hosting:auth:aud-validation")
176+
async def test_a_token_issued_for_this_resource_is_served(protected: httpx2.AsyncClient) -> None:
177+
"""The other half: a token the verifier reports as issued for `resource_server_url` passes the
178+
gate and the MCP endpoint answers the initialize request."""
179+
response = await post_mcp(protected, bearer="tok-valid")
168180

169181
assert response.status_code == 200
170-
assert response.headers["content-type"].startswith("text/event-stream")
171-
# The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response
172-
# out of the buffered text to prove the MCP endpoint actually answered the initialize request.
182+
# Finite SSE body: pull out the JSON-RPC result to prove the endpoint actually answered.
173183
[data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")]
174184
assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result
175185

0 commit comments

Comments
 (0)