Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447)
This commit is contained in:
parent
9771e6b59c
commit
0c9136841f
5 changed files with 293 additions and 41 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue