[v1.x] Add AuthSettings.validate_token_resource to check a bearer token's resource (#3451)
Some checks failed
Main branch checks / checks (push) Has been cancelled
Some checks failed
Main branch checks / checks (push) Has been cancelled
This commit is contained in:
parent
2106335745
commit
8c2fa6eaba
10 changed files with 175 additions and 9 deletions
|
|
@ -40,8 +40,9 @@ mcp = FastMCP(
|
||||||
# Auth settings for RFC 9728 Protected Resource Metadata
|
# Auth settings for RFC 9728 Protected Resource Metadata
|
||||||
auth=AuthSettings(
|
auth=AuthSettings(
|
||||||
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
|
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
|
||||||
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
|
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
|
||||||
required_scopes=["user"],
|
required_scopes=["user"],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,6 +75,12 @@ For a complete example with separate Authorization Server and Resource Server im
|
||||||
|
|
||||||
See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/src/mcp/server/auth/provider.py) for more details on implementing token validation.
|
See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/src/mcp/server/auth/provider.py) for more details on implementing token validation.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- If `aud` is a list, put the entry that equals `resource_server_url` in `resource`.
|
||||||
|
|
||||||
## Client-Side Authentication
|
## Client-Side Authentication
|
||||||
|
|
||||||
The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) for connecting to protected MCP servers:
|
The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) for connecting to protected MCP servers:
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
|
||||||
issuer_url=settings.auth_server_url,
|
issuer_url=settings.auth_server_url,
|
||||||
required_scopes=[settings.mcp_scope],
|
required_scopes=[settings.mcp_scope],
|
||||||
resource_server_url=settings.server_url,
|
resource_server_url=settings.server_url,
|
||||||
|
validate_token_resource=True, # tokens must be reported as issued for server_url
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,19 @@ class IntrospectionTokenVerifier(TokenVerifier):
|
||||||
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
|
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# `aud` may be a string or a list; report the entry naming this server when there is
|
||||||
|
# one, otherwise what the token was issued for, so the server can compare it.
|
||||||
|
aud: str | list[str] | None = data.get("aud")
|
||||||
|
audiences = aud if isinstance(aud, list) else [aud] if aud else []
|
||||||
|
own = self.resource_url.rstrip("/")
|
||||||
|
resource = next((a for a in audiences if a.rstrip("/") == own), audiences[0] if audiences else None)
|
||||||
|
|
||||||
return AccessToken(
|
return AccessToken(
|
||||||
token=token,
|
token=token,
|
||||||
client_id=data.get("client_id", "unknown"),
|
client_id=data.get("client_id", "unknown"),
|
||||||
scopes=data.get("scope", "").split() if data.get("scope") else [],
|
scopes=data.get("scope", "").split() if data.get("scope") else [],
|
||||||
expires_at=data.get("exp"),
|
expires_at=data.get("exp"),
|
||||||
resource=data.get("aud"), # Include resource in token
|
resource=resource,
|
||||||
subject=data.get("sub"), # RFC 7662 subject (resource owner)
|
subject=data.get("sub"), # RFC 7662 subject (resource owner)
|
||||||
claims=data,
|
claims=data,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,9 @@ mcp = FastMCP(
|
||||||
# Auth settings for RFC 9728 Protected Resource Metadata
|
# Auth settings for RFC 9728 Protected Resource Metadata
|
||||||
auth=AuthSettings(
|
auth=AuthSettings(
|
||||||
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
|
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
|
||||||
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
|
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
|
||||||
required_scopes=["user"],
|
required_scopes=["user"],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl, ValidationError
|
||||||
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
|
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
from starlette.types import Receive, Scope, Send
|
from starlette.types import Receive, Scope, Send
|
||||||
|
|
||||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedUser(SimpleUser):
|
class AuthenticatedUser(SimpleUser):
|
||||||
"""User with authentication info."""
|
"""User with authentication info."""
|
||||||
|
|
@ -46,10 +49,14 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
|
||||||
class BearerAuthBackend(AuthenticationBackend):
|
class BearerAuthBackend(AuthenticationBackend):
|
||||||
"""
|
"""
|
||||||
Authentication backend that validates Bearer tokens using a TokenVerifier.
|
Authentication backend that validates Bearer tokens using a TokenVerifier.
|
||||||
|
|
||||||
|
When `resource_server_url` is given, only a token whose `AccessToken.resource`
|
||||||
|
(its RFC 8707 resource indicator / audience) is that URL is accepted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, token_verifier: TokenVerifier):
|
def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None):
|
||||||
self.token_verifier = token_verifier
|
self.token_verifier = token_verifier
|
||||||
|
self.resource_server_url = resource_server_url
|
||||||
|
|
||||||
async def authenticate(self, conn: HTTPConnection):
|
async def authenticate(self, conn: HTTPConnection):
|
||||||
auth_header = next(
|
auth_header = next(
|
||||||
|
|
@ -70,8 +77,22 @@ class BearerAuthBackend(AuthenticationBackend):
|
||||||
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
|
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource):
|
||||||
|
logger.warning(
|
||||||
|
"Bearer token resource %r is not resource_server_url %s", auth_info.resource, self.resource_server_url
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
|
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
|
||||||
|
|
||||||
|
def _issued_for_this_resource(self, resource: str | None) -> bool:
|
||||||
|
"""Compare as URLs (so case and default-port spelling do not matter), a trailing slash aside."""
|
||||||
|
try:
|
||||||
|
token_resource = str(AnyHttpUrl(resource or ""))
|
||||||
|
except ValidationError:
|
||||||
|
return False
|
||||||
|
return token_resource.removesuffix("/") == str(self.resource_server_url).removesuffix("/")
|
||||||
|
|
||||||
|
|
||||||
class RequireAuthMiddleware:
|
class RequireAuthMiddleware:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class RefreshToken(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
scopes: list[str]
|
scopes: list[str]
|
||||||
expires_at: int | None = None
|
expires_at: int | None = None
|
||||||
|
resource: str | None = None # RFC 8707 resource indicator; propagate to refreshed AccessTokens
|
||||||
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
|
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,7 +98,15 @@ class TokenVerifier(Protocol):
|
||||||
"""Protocol for verifying bearer tokens."""
|
"""Protocol for verifying bearer tokens."""
|
||||||
|
|
||||||
async def verify_token(self, token: str) -> AccessToken | None:
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
"""Verify a bearer token and return access info if valid."""
|
"""Verify a bearer token and return access info if valid.
|
||||||
|
|
||||||
|
Set `AccessToken.resource` to the resource the token was issued for (its RFC 8707
|
||||||
|
resource indicator / `aud`; for a list, the entry equal to the server's
|
||||||
|
`AuthSettings.resource_server_url`). With `AuthSettings.validate_token_resource` the
|
||||||
|
bearer middleware then refuses any token whose resource is not `resource_server_url`;
|
||||||
|
without it, confirming the token was issued for this server (for example by passing the
|
||||||
|
expected audience to your JWT library) is up to the verifier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# NOTE: FastMCP doesn't render any of these types in the user response, so it's
|
# NOTE: FastMCP doesn't render any of these types in the user response, so it's
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
import warnings
|
||||||
|
|
||||||
|
from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
|
||||||
class ClientRegistrationOptions(BaseModel):
|
class ClientRegistrationOptions(BaseModel):
|
||||||
|
|
@ -28,3 +31,26 @@ class AuthSettings(BaseModel):
|
||||||
description="The URL of the MCP server to be used as the resource identifier "
|
description="The URL of the MCP server to be used as the resource identifier "
|
||||||
"and base route to look up OAuth Protected Resource Metadata.",
|
"and base route to look up OAuth Protected Resource Metadata.",
|
||||||
)
|
)
|
||||||
|
validate_token_resource: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Only accept tokens the token verifier reports as issued for `resource_server_url` "
|
||||||
|
"(`AccessToken.resource`, the RFC 8707 resource indicator). Enable it when your authorization "
|
||||||
|
"server binds tokens to the `resource` the client requested; set it to False when your token "
|
||||||
|
"verifier checks the token's audience itself. With `resource_server_url` set, leaving it unset warns "
|
||||||
|
"and behaves as False; 3.0 makes True the default there.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_validate_token_resource(self) -> Self:
|
||||||
|
if self.validate_token_resource and self.resource_server_url is None:
|
||||||
|
raise ValueError("validate_token_resource requires resource_server_url")
|
||||||
|
if self.validate_token_resource is None and self.resource_server_url is not None:
|
||||||
|
warnings.warn(
|
||||||
|
"`AuthSettings.validate_token_resource` is not set, so bearer tokens are not checked "
|
||||||
|
"against `resource_server_url`; it will default to True in 3.0 when `resource_server_url` is "
|
||||||
|
"set. Set it to True to have the server refuse tokens issued for another resource, or to "
|
||||||
|
"False if your TokenVerifier validates the token's audience itself.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
|
||||||
|
|
@ -881,7 +881,12 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
# extract auth info from request (but do not require it)
|
# extract auth info from request (but do not require it)
|
||||||
Middleware(
|
Middleware(
|
||||||
AuthenticationMiddleware,
|
AuthenticationMiddleware,
|
||||||
backend=BearerAuthBackend(self._token_verifier),
|
backend=BearerAuthBackend(
|
||||||
|
self._token_verifier,
|
||||||
|
resource_server_url=self.settings.auth.resource_server_url
|
||||||
|
if self.settings.auth.validate_token_resource
|
||||||
|
else None,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
# Add the auth context middleware to store
|
# Add the auth context middleware to store
|
||||||
# authenticated user in a contextvar
|
# authenticated user in a contextvar
|
||||||
|
|
@ -999,7 +1004,12 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
middleware = [
|
middleware = [
|
||||||
Middleware(
|
Middleware(
|
||||||
AuthenticationMiddleware,
|
AuthenticationMiddleware,
|
||||||
backend=BearerAuthBackend(self._token_verifier),
|
backend=BearerAuthBackend(
|
||||||
|
self._token_verifier,
|
||||||
|
resource_server_url=self.settings.auth.resource_server_url
|
||||||
|
if self.settings.auth.validate_token_resource
|
||||||
|
else None,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Middleware(AuthContextMiddleware),
|
Middleware(AuthContextMiddleware),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import time
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.authentication import AuthCredentials
|
from starlette.authentication import AuthCredentials
|
||||||
from starlette.datastructures import Headers
|
from starlette.datastructures import Headers
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
@ -265,6 +266,56 @@ class TestBearerAuthBackend:
|
||||||
assert user.access_token == valid_access_token
|
assert user.access_token == valid_access_token
|
||||||
|
|
||||||
|
|
||||||
|
class SingleTokenVerifier:
|
||||||
|
"""A `TokenVerifier` that knows exactly one token."""
|
||||||
|
|
||||||
|
def __init__(self, access_token: AccessToken) -> None:
|
||||||
|
self.access_token = access_token
|
||||||
|
|
||||||
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
|
return self.access_token if token == self.access_token.token else None
|
||||||
|
|
||||||
|
|
||||||
|
RS = "https://api.example.com/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("resource_server_url", "token_resource", "accepted"),
|
||||||
|
[
|
||||||
|
(None, "https://other.example.com/mcp", True), # nothing configured to compare against
|
||||||
|
(None, None, True),
|
||||||
|
(RS, None, False), # the verifier did not report what the token was issued for
|
||||||
|
(RS, RS, True),
|
||||||
|
(RS, RS + "/", True),
|
||||||
|
(RS, "https://API.EXAMPLE.COM:443/mcp", True), # same URL, different spelling
|
||||||
|
(RS, "https://api.example.com", False),
|
||||||
|
(RS, RS + "/child", False),
|
||||||
|
(RS, "https://api.example.com/other", False),
|
||||||
|
(RS, "https://other.example.com/mcp", False),
|
||||||
|
(RS, "api.example.com", False), # not a URL
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_backend_accepts_only_tokens_issued_for_its_resource(
|
||||||
|
resource_server_url: str | None, token_resource: str | None, accepted: bool
|
||||||
|
):
|
||||||
|
"""With `resource_server_url` set, only a token whose `resource` (RFC 8707) is that URL is
|
||||||
|
accepted and anything else is treated like an unrecognized token (spec-mandated audience
|
||||||
|
check); without it the verifier's answer stands (SDK-defined, the default wiring)."""
|
||||||
|
token = AccessToken(token="t", client_id="c", scopes=["read"], resource=token_resource)
|
||||||
|
backend = BearerAuthBackend(
|
||||||
|
SingleTokenVerifier(token),
|
||||||
|
resource_server_url=AnyHttpUrl(resource_server_url) if resource_server_url else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await backend.authenticate(Request({"type": "http", "headers": [(b"authorization", b"Bearer t")]}))
|
||||||
|
|
||||||
|
if accepted:
|
||||||
|
assert result is not None and result[1].access_token == token
|
||||||
|
else:
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
class TestRequireAuthMiddleware:
|
class TestRequireAuthMiddleware:
|
||||||
"""Tests for the RequireAuthMiddleware class."""
|
"""Tests for the RequireAuthMiddleware class."""
|
||||||
|
|
|
||||||
33
tests/server/auth/test_settings.py
Normal file
33
tests/server/auth/test_settings.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import AnyHttpUrl, ValidationError
|
||||||
|
|
||||||
|
from mcp.server.auth.settings import AuthSettings
|
||||||
|
|
||||||
|
ISSUER = AnyHttpUrl("https://auth.example.com")
|
||||||
|
RESOURCE = AnyHttpUrl("https://mcp.example.com/mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_token_resource_requires_a_resource_server_url():
|
||||||
|
"""SDK-defined: asking the bearer gate to compare tokens against `resource_server_url` without
|
||||||
|
configuring one is refused at construction time rather than silently comparing nothing."""
|
||||||
|
AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE, validate_token_resource=True)
|
||||||
|
with pytest.raises(ValidationError, match="validate_token_resource requires resource_server_url"):
|
||||||
|
AuthSettings(issuer_url=ISSUER, resource_server_url=None, validate_token_resource=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaving_validate_token_resource_unset_warns_when_a_resource_server_url_is_configured():
|
||||||
|
"""Unset behaves as False but says so: a resource server that has not chosen gets a
|
||||||
|
`DeprecationWarning` pointing at its own `AuthSettings(...)` call (3.0 flips the default)."""
|
||||||
|
with pytest.warns(DeprecationWarning, match="validate_token_resource") as record:
|
||||||
|
settings = AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE)
|
||||||
|
assert settings.validate_token_resource is None
|
||||||
|
assert record[0].filename == __file__
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kwargs", [{"validate_token_resource": False}, {"resource_server_url": None}])
|
||||||
|
def test_an_explicit_choice_or_no_resource_server_url_does_not_warn(kwargs: dict[str, object]):
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error")
|
||||||
|
AuthSettings.model_validate({"issuer_url": ISSUER, "resource_server_url": RESOURCE, **kwargs})
|
||||||
Loading…
Reference in a new issue