Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447)
This commit is contained in:
parent
9771e6b59c
commit
0c9136841f
26 changed files with 293 additions and 41 deletions
|
|
@ -136,6 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn
|
||||||
| Deprecated | What you do instead |
|
| Deprecated | What you do instead |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. |
|
| `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. |
|
||||||
|
| `AuthSettings(resource_server_url=...)` without `validate_token_resource=` | Set it: `True` has the server refuse bearer tokens your verifier does not report as issued for `resource_server_url`, `False` says your verifier checks the token's audience itself (see **[Authorization](run/authorization.md#a-token-verifier)**). Unset behaves as `False`; 3.0 makes `True` the default whenever `resource_server_url` is set. |
|
||||||
| `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. |
|
| `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. |
|
||||||
|
|
||||||
## Recap
|
## Recap
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ That's the whole triangle. Everything on this page is the middle bullet.
|
||||||
|
|
||||||
The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**:
|
The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**:
|
||||||
|
|
||||||
```python title="server.py" hl_lines="12-14 19-24"
|
```python title="server.py" hl_lines="14-16 21-27"
|
||||||
--8<-- "docs_src/authorization/tutorial001.py"
|
--8<-- "docs_src/authorization/tutorial001.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
* `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.
|
* `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.
|
||||||
* 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.
|
* This one looks the token up in a table; each entry records the resource it was issued for. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and reports who the token was issued for (its `aud`) in `AccessToken.resource`. That code is yours; the SDK only calls it.
|
||||||
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
|
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
|
||||||
|
|
||||||
`AuthSettings` is the public face of your resource server:
|
`AuthSettings` is the public face of your resource server:
|
||||||
|
|
@ -31,6 +31,10 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
|
||||||
* `issuer_url`: the authorization server that issues your tokens.
|
* `issuer_url`: the authorization server that issues your tokens.
|
||||||
* `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.
|
* `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.
|
||||||
* `required_scopes`: every token must carry all of them.
|
* `required_scopes`: every token must carry all of them.
|
||||||
|
* `validate_token_resource`: refuse any token whose `AccessToken.resource` is not `resource_server_url`. Leaving it unset while `resource_server_url` is set warns (`MCPDeprecationWarning`) 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`.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
`examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls
|
`examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls
|
||||||
|
|
@ -86,7 +90,7 @@ This document is how a client that has never heard of your server finds its way
|
||||||
|
|
||||||
Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request:
|
Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request:
|
||||||
|
|
||||||
```python title="server.py" hl_lines="4 32-35"
|
```python title="server.py" hl_lines="4 35-38"
|
||||||
--8<-- "docs_src/authorization/tutorial002.py"
|
--8<-- "docs_src/authorization/tutorial002.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ from mcp.server import MCPServer
|
||||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
|
|
||||||
|
RESOURCE = "http://127.0.0.1:8000/mcp"
|
||||||
|
|
||||||
KNOWN_TOKENS = {
|
KNOWN_TOKENS = {
|
||||||
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
|
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,8 +21,9 @@ mcp = MCPServer(
|
||||||
token_verifier=StaticTokenVerifier(),
|
token_verifier=StaticTokenVerifier(),
|
||||||
auth=AuthSettings(
|
auth=AuthSettings(
|
||||||
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
||||||
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
|
resource_server_url=AnyHttpUrl(RESOURCE),
|
||||||
required_scopes=["notes:read"],
|
required_scopes=["notes:read"],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
|
|
||||||
|
RESOURCE = "http://127.0.0.1:8000/mcp"
|
||||||
|
|
||||||
KNOWN_TOKENS = {
|
KNOWN_TOKENS = {
|
||||||
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
|
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,8 +22,9 @@ mcp = MCPServer(
|
||||||
token_verifier=StaticTokenVerifier(),
|
token_verifier=StaticTokenVerifier(),
|
||||||
auth=AuthSettings(
|
auth=AuthSettings(
|
||||||
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
||||||
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
|
resource_server_url=AnyHttpUrl(RESOURCE),
|
||||||
required_scopes=["notes:read"],
|
required_scopes=["notes:read"],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer:
|
||||||
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
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Store settings for later use in run()
|
# Store settings for later use in run()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ mcp = MCPServer(
|
||||||
# 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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,5 @@ def auth_settings(
|
||||||
required_scopes=scopes,
|
required_scopes=scopes,
|
||||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
|
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
|
||||||
identity_assertion_enabled=identity_assertion_enabled,
|
identity_assertion_enabled=identity_assertion_enabled,
|
||||||
|
validate_token_resource=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class StaticTokenVerifier(TokenVerifier):
|
||||||
client_id="demo-client",
|
client_id="demo-client",
|
||||||
scopes=[REQUIRED_SCOPE],
|
scopes=[REQUIRED_SCOPE],
|
||||||
expires_at=int(time.time()) + 3600,
|
expires_at=int(time.time()) + 3600,
|
||||||
|
resource=RESOURCE_URL,
|
||||||
subject="demo-user",
|
subject="demo-user",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ def build_app() -> Starlette:
|
||||||
issuer_url=AnyHttpUrl(ISSUER),
|
issuer_url=AnyHttpUrl(ISSUER),
|
||||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||||
required_scopes=[REQUIRED_SCOPE],
|
required_scopes=[REQUIRED_SCOPE],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ def build_app() -> Starlette:
|
||||||
issuer_url=AnyHttpUrl(ISSUER),
|
issuer_url=AnyHttpUrl(ISSUER),
|
||||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||||
required_scopes=[REQUIRED_SCOPE],
|
required_scopes=[REQUIRED_SCOPE],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
token_verifier=StaticTokenVerifier(),
|
token_verifier=StaticTokenVerifier(),
|
||||||
transport_security=NO_DNS_REBIND,
|
transport_security=NO_DNS_REBIND,
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,14 @@ def build_app() -> Starlette:
|
||||||
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
|
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
|
||||||
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||||
access = f"access_{secrets.token_hex(16)}"
|
access = f"access_{secrets.token_hex(16)}"
|
||||||
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
|
resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for
|
||||||
|
issued[access] = AccessToken(
|
||||||
|
token=access,
|
||||||
|
client_id=DEMO_CLIENT_ID,
|
||||||
|
scopes=[DEMO_SCOPE],
|
||||||
|
expires_at=None,
|
||||||
|
resource=resource if isinstance(resource, str) else None,
|
||||||
|
)
|
||||||
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
|
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
|
||||||
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
|
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,14 @@ def build_app() -> Starlette:
|
||||||
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
|
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
|
||||||
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||||
access = f"access_{secrets.token_hex(16)}"
|
access = f"access_{secrets.token_hex(16)}"
|
||||||
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
|
resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for
|
||||||
|
issued[access] = AccessToken(
|
||||||
|
token=access,
|
||||||
|
client_id=DEMO_CLIENT_ID,
|
||||||
|
scopes=[DEMO_SCOPE],
|
||||||
|
expires_at=None,
|
||||||
|
resource=resource if isinstance(resource, str) else None,
|
||||||
|
)
|
||||||
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
|
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
|
||||||
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
|
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, principal_components
|
from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedUser(SimpleUser):
|
class AuthenticatedUser(SimpleUser):
|
||||||
"""User with authentication info."""
|
"""User with authentication info."""
|
||||||
|
|
@ -39,10 +42,15 @@ 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.
|
||||||
|
|
||||||
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.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(
|
||||||
|
|
@ -63,8 +71,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:
|
||||||
"""Middleware that requires a valid Bearer token in the Authorization header.
|
"""Middleware that requires a valid Bearer token in the Authorization header.
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -124,7 +125,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: MCPServer doesn't render any of these types in the user response, so it's
|
# 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):
|
class ClientRegistrationOptions(BaseModel):
|
||||||
|
|
@ -40,3 +45,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.",
|
||||||
|
MCPDeprecationWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
|
||||||
|
|
@ -776,7 +776,10 @@ class Server(Generic[LifespanResultT]):
|
||||||
middleware = [
|
middleware = [
|
||||||
Middleware(
|
Middleware(
|
||||||
AuthenticationMiddleware,
|
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),
|
Middleware(AuthContextMiddleware),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1187,7 +1187,12 @@ class MCPServer(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
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ async def test_the_whole_grant_is_one_token_request() -> None:
|
||||||
issuer_url=AnyHttpUrl(tutorial002.ISSUER),
|
issuer_url=AnyHttpUrl(tutorial002.ISSUER),
|
||||||
resource_server_url=AnyHttpUrl(MCP_SERVER_URL),
|
resource_server_url=AnyHttpUrl(MCP_SERVER_URL),
|
||||||
required_scopes=["notes:read"],
|
required_scopes=["notes:read"],
|
||||||
|
validate_token_resource=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2848,11 +2848,11 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||||
source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage",
|
source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage",
|
||||||
behavior="The resource server validates that the token audience matches its resource identifier.",
|
behavior="The resource server validates that the token audience matches its resource identifier.",
|
||||||
transports=("streamable-http",),
|
transports=("streamable-http",),
|
||||||
note="Auth is enforced at the HTTP layer.",
|
note="Auth is enforced at the HTTP layer; the conformant tests enable AuthSettings.validate_token_resource.",
|
||||||
divergence=Divergence(
|
divergence=Divergence(
|
||||||
note=(
|
note=(
|
||||||
"BearerAuthBackend never inspects AccessToken.resource; a token issued for a different "
|
"Off by default: without AuthSettings.validate_token_resource the bearer gate does not compare "
|
||||||
"resource is accepted. Spec MUST."
|
"AccessToken.resource with resource_server_url and the check is the token verifier's. Spec MUST."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@ def auth_settings(
|
||||||
required_scopes: Sequence[str] = ("mcp",),
|
required_scopes: Sequence[str] = ("mcp",),
|
||||||
valid_scopes: Sequence[str] | None = None,
|
valid_scopes: Sequence[str] | None = None,
|
||||||
identity_assertion_enabled: bool = False,
|
identity_assertion_enabled: bool = False,
|
||||||
|
validate_token_resource: bool = False,
|
||||||
) -> AuthSettings:
|
) -> AuthSettings:
|
||||||
"""Build `AuthSettings` for the co-hosted authorization + resource server.
|
"""Build `AuthSettings` for the co-hosted authorization + resource server.
|
||||||
|
|
||||||
|
|
@ -194,7 +195,8 @@ def auth_settings(
|
||||||
|
|
||||||
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
|
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
|
||||||
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
|
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
|
||||||
issue tokens.
|
issue tokens. `validate_token_resource` makes the bearer gate refuse tokens whose verifier
|
||||||
|
does not report them as issued for the resource URL.
|
||||||
"""
|
"""
|
||||||
required = list(required_scopes)
|
required = list(required_scopes)
|
||||||
valid = list(valid_scopes) if valid_scopes is not None else required
|
valid = list(valid_scopes) if valid_scopes is not None else required
|
||||||
|
|
@ -202,6 +204,7 @@ def auth_settings(
|
||||||
issuer_url=AnyHttpUrl(BASE_URL),
|
issuer_url=AnyHttpUrl(BASE_URL),
|
||||||
resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"),
|
resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"),
|
||||||
required_scopes=required,
|
required_scopes=required,
|
||||||
|
validate_token_resource=validate_token_resource,
|
||||||
client_registration_options=ClientRegistrationOptions(
|
client_registration_options=ClientRegistrationOptions(
|
||||||
enabled=True, valid_scopes=valid, default_scopes=required
|
enabled=True, valid_scopes=valid, default_scopes=required
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue