Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447)

This commit is contained in:
Max 2026-09-05 03:35:55 +01:00 • committed by GitHub
commit 0c9136841f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 293 additions and 41 deletions

View file

@ -1,14 +1,17 @@
import json
import logging
import time
from typing import Any, TypedDict
from pydantic import AnyHttpUrl
from pydantic import AnyHttpUrl, ValidationError
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
from starlette.requests import HTTPConnection
from starlette.types import Receive, Scope, Send
from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components
logger = logging.getLogger(__name__)
class AuthenticatedUser(SimpleUser):
"""User with authentication info."""
@ -39,10 +42,15 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
class BearerAuthBackend(AuthenticationBackend):
"""Authentication backend that validates Bearer tokens using a TokenVerifier."""
"""Authentication backend that validates Bearer tokens using a TokenVerifier.
def __init__(self, token_verifier: 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, *, resource_server_url: AnyHttpUrl | None = None):
self.token_verifier = token_verifier
self.resource_server_url = resource_server_url
async def authenticate(self, conn: HTTPConnection):
auth_header = next(
@ -63,8 +71,22 @@ class BearerAuthBackend(AuthenticationBackend):
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
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)
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:
"""Middleware that requires a valid Bearer token in the Authorization header.

View file

@ -46,6 +46,7 @@ class RefreshToken(BaseModel):
client_id: str
scopes: list[str]
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
@ -124,7 +125,15 @@ class TokenVerifier(Protocol):
"""Protocol for verifying bearer tokens."""
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: MCPServer doesn't render any of these types in the user response, so it's

View file

@ -1,4 +1,9 @@
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field
import warnings
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Self
from mcp.shared.exceptions import MCPDeprecationWarning
class ClientRegistrationOptions(BaseModel):
@ -40,3 +45,26 @@ class AuthSettings(BaseModel):
description="The URL of the MCP server to be used as the resource identifier "
"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.",
MCPDeprecationWarning,
stacklevel=3,
)
return self

View file

@ -776,7 +776,10 @@ class Server(Generic[LifespanResultT]):
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(token_verifier),
backend=BearerAuthBackend(
token_verifier,
resource_server_url=auth.resource_server_url if auth.validate_token_resource else None,
),
),
Middleware(AuthContextMiddleware),
]

View file

@ -1187,7 +1187,12 @@ class MCPServer(Generic[LifespanResultT]):
# extract auth info from request (but do not require it)
Middleware(
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
# authenticated user in a contextvar