diff --git a/docs/deprecated.md b/docs/deprecated.md index 40de550f9d..f48f07ac11 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -136,6 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn | 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. | +| `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. | ## Recap diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1e2..dfcbbc9a20 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -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`**: -```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" ``` * `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. `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. * `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. +* `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 `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: -```python title="server.py" hl_lines="4 32-35" +```python title="server.py" hl_lines="4 35-38" --8<-- "docs_src/authorization/tutorial002.py" ``` diff --git a/docs_src/authorization/tutorial001.py b/docs_src/authorization/tutorial001.py index f15f54fd79..d1a9db874f 100644 --- a/docs_src/authorization/tutorial001.py +++ b/docs_src/authorization/tutorial001.py @@ -4,8 +4,10 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings +RESOURCE = "http://127.0.0.1:8000/mcp" + 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 @@ async def verify_token(self, token: str) -> AccessToken | None: token_verifier=StaticTokenVerifier(), auth=AuthSettings( 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"], + validate_token_resource=True, ), ) diff --git a/docs_src/authorization/tutorial002.py b/docs_src/authorization/tutorial002.py index 55b024f2cc..c088442a6c 100644 --- a/docs_src/authorization/tutorial002.py +++ b/docs_src/authorization/tutorial002.py @@ -5,8 +5,10 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings +RESOURCE = "http://127.0.0.1:8000/mcp" + 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 @@ async def verify_token(self, token: str) -> AccessToken | None: token_verifier=StaticTokenVerifier(), auth=AuthSettings( 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"], + validate_token_resource=True, ), ) diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 0320871b12..c53df6a550 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer: issuer_url=settings.auth_server_url, required_scopes=[settings.mcp_scope], 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() diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py index 933935d6a6..687196d18c 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -69,12 +69,19 @@ async def verify_token(self, token: str) -> AccessToken | None: logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") 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( token=token, client_id=data.get("client_id", "unknown"), scopes=data.get("scope", "").split() if data.get("scope") else [], expires_at=data.get("exp"), - resource=data.get("aud"), # Include resource in token + resource=resource, subject=data.get("sub"), # RFC 7662 subject (resource owner) claims=data, ) diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 962ef0615e..204ede4b26 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -24,8 +24,9 @@ async def verify_token(self, token: str) -> AccessToken | None: # Auth settings for RFC 9728 Protected Resource Metadata auth=AuthSettings( 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"], + validate_token_resource=True, ), ) diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py index 35e997242f..cdbf3fd6f7 100644 --- a/examples/stories/_shared/auth.py +++ b/examples/stories/_shared/auth.py @@ -169,4 +169,5 @@ def auth_settings( required_scopes=scopes, client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes), identity_assertion_enabled=identity_assertion_enabled, + validate_token_resource=True, ) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py index 45c9872c3a..31a0f77bfa 100644 --- a/examples/stories/bearer_auth/server.py +++ b/examples/stories/bearer_auth/server.py @@ -28,6 +28,7 @@ async def verify_token(self, token: str) -> AccessToken | None: client_id="demo-client", scopes=[REQUIRED_SCOPE], expires_at=int(time.time()) + 3600, + resource=RESOURCE_URL, subject="demo-user", ) @@ -40,6 +41,7 @@ def build_app() -> Starlette: issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(RESOURCE_URL), required_scopes=[REQUIRED_SCOPE], + validate_token_resource=True, ), ) diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py index e03cb26d03..dd15fa8865 100644 --- a/examples/stories/bearer_auth/server_lowlevel.py +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -46,6 +46,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques issuer_url=AnyHttpUrl(ISSUER), resource_server_url=AnyHttpUrl(RESOURCE_URL), required_scopes=[REQUIRED_SCOPE], + validate_token_resource=True, ), token_verifier=StaticTokenVerifier(), transport_security=NO_DNS_REBIND, diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py index 7e3d910e8f..0ccc3e32f1 100644 --- a/examples/stories/oauth_client_credentials/server.py +++ b/examples/stories/oauth_client_credentials/server.py @@ -66,7 +66,14 @@ async def token_endpoint(request: Request) -> JSONResponse: if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": return JSONResponse({"error": "invalid_client"}, status_code=401) 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) return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py index cde947e9ed..5879bb8208 100644 --- a/examples/stories/oauth_client_credentials/server_lowlevel.py +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -63,7 +63,14 @@ async def token_endpoint(request: Request) -> JSONResponse: if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": return JSONResponse({"error": "invalid_client"}, status_code=401) 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) return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 29413abf2b..1a070cd29d 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -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. + + 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.resource_server_url = resource_server_url async def authenticate(self, conn: HTTPConnection): auth_header = next( @@ -63,8 +71,22 @@ async def authenticate(self, conn: HTTPConnection): 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. diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 644868f3e5..bc8ba3e518 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -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 diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index ae2083a38b..4d0f7f1342 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -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 diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 6df5341e41..8a886dcc24 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -776,7 +776,10 @@ def streamable_http_app( 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), ] diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 1f3e863dbd..fbd2c26dd8 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1187,7 +1187,12 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no # 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 diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py index 1d69c5ebde..3a15ef94ec 100644 --- a/tests/docs_src/test_identity_assertion.py +++ b/tests/docs_src/test_identity_assertion.py @@ -151,6 +151,7 @@ async def test_the_whole_grant_is_one_token_request() -> None: issuer_url=AnyHttpUrl(tutorial002.ISSUER), resource_server_url=AnyHttpUrl(MCP_SERVER_URL), required_scopes=["notes:read"], + validate_token_resource=True, ), ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 11bffba7b5..235fb65cd4 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2848,11 +2848,11 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage", behavior="The resource server validates that the token audience matches its resource identifier.", 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( note=( - "BearerAuthBackend never inspects AccessToken.resource; a token issued for a different " - "resource is accepted. Spec MUST." + "Off by default: without AuthSettings.validate_token_resource the bearer gate does not compare " + "AccessToken.resource with resource_server_url and the check is the token verifier's. Spec MUST." ), ), ), diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 856a1fe9a8..16b84b1de5 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -182,6 +182,7 @@ def auth_settings( required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None, identity_assertion_enabled: bool = False, + validate_token_resource: bool = False, ) -> AuthSettings: """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 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) valid = list(valid_scopes) if valid_scopes is not None else required @@ -202,6 +204,7 @@ def auth_settings( issuer_url=AnyHttpUrl(BASE_URL), resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"), required_scopes=required, + validate_token_resource=validate_token_resource, client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=valid, default_scopes=required ), diff --git a/tests/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 0c54d4fd37..4e422947b4 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -157,6 +157,7 @@ async def exchange_authorization_code( token=refresh, client_id=client.client_id, scopes=authorization_code.scopes, + resource=authorization_code.resource, ) del self.codes[authorization_code.code] return OAuthToken( @@ -183,9 +184,11 @@ async def exchange_refresh_token( if self._fail_next_refresh: self._fail_next_refresh = False raise TokenError(error="invalid_grant", error_description="refresh denied by harness") - access = self.mint_access_token(client_id=client.client_id, scopes=scopes) + access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=refresh_token.resource) new_refresh = f"refresh_{secrets.token_hex(16)}" - self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes) + self.refresh_tokens[new_refresh] = RefreshToken( + token=new_refresh, client_id=client.client_id, scopes=scopes, resource=refresh_token.resource + ) del self.refresh_tokens[refresh_token.token] return OAuthToken( access_token=access, diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index c70a27c52e..aa42c50dfb 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -2,9 +2,9 @@ These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier` seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since -every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status, -the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP -endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test. +every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status +and the `WWW-Authenticate` header structure. The flow side of the same 401 is `test_flow.py`'s +flagship test. """ import time @@ -24,15 +24,23 @@ pytestmark = pytest.mark.anyio REQUIRED_SCOPE = "mcp:read" +RESOURCE = "http://127.0.0.1:8000/mcp" RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" _FUTURE = int(time.time()) + 3600 _PAST = int(time.time()) - 3600 + TOKENS = { - "tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), - "tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST), - "tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE), + "tok-valid": AccessToken( + token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=RESOURCE + ), + "tok-expired": AccessToken( + token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST, resource=RESOURCE + ), + "tok-noscope": AccessToken( + token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE, resource=RESOURCE + ), "tok-wrong-aud": AccessToken( token="tok-wrong-aud", client_id="c", @@ -40,6 +48,7 @@ expires_at=_FUTURE, resource="https://other.example/mcp", ), + "tok-no-aud": AccessToken(token="tok-no-aud", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), } @@ -47,7 +56,7 @@ async def protected() -> AsyncIterator[httpx2.AsyncClient]: """A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge.""" server = Server("rs") - settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) + settings = auth_settings(required_scopes=[REQUIRED_SCOPE], validate_token_resource=True) async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): yield http @@ -157,19 +166,38 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco @requirement("hosting:auth:aud-validation") -async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None: - """A token whose `resource` does not match the server's resource identifier is accepted. - - The spec mandates the resource server validate the token's audience; the bearer backend - never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint - serves it. This pins current behaviour with the divergence recorded on the requirement. +@pytest.mark.parametrize("bearer", ["tok-wrong-aud", "tok-no-aud"]) +async def test_a_token_not_issued_for_this_resource_is_answered_401(protected: httpx2.AsyncClient, bearer: str) -> None: + """Spec-mandated audience check, which the SDK performs when `AuthSettings.validate_token_resource` + is set (off by default, the recorded divergence): a token whose verifier-reported `resource` + (RFC 8707) is another URL, or absent, is answered 401 `invalid_token` like an unrecognized token. """ - response = await post_mcp(protected, bearer="tok-wrong-aud") + response = await post_mcp(protected, bearer=bearer) + + assert response.status_code == 401 + assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token" + + +@requirement("hosting:auth:aud-validation") +async def test_a_token_for_another_resource_is_served_when_validate_token_resource_is_off() -> None: + """The recorded divergence: with `AuthSettings` at their defaults the gate does not compare + `AccessToken.resource` with `resource_server_url`, so a token the verifier reports as issued + for another resource still reaches the MCP endpoint (SDK default; the check is the verifier's).""" + settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) + async with mounted_app(Server("rs"), auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): + response = await post_mcp(http, bearer="tok-wrong-aud") + + assert response.status_code == 200 + + +@requirement("hosting:auth:aud-validation") +async def test_a_token_issued_for_this_resource_is_served(protected: httpx2.AsyncClient) -> None: + """The other half: a token the verifier reports as issued for `resource_server_url` passes the + gate and the MCP endpoint answers the initialize request.""" + response = await post_mcp(protected, bearer="tok-valid") assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/event-stream") - # The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response - # out of the buffered text to prove the MCP endpoint actually answered the initialize request. + # Finite SSE body: pull out the JSON-RPC result to prove the endpoint actually answered. [data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")] assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 610db62e27..32f96bc34b 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -135,6 +135,31 @@ async def test_an_expired_access_token_is_transparently_refreshed_before_the_nex assert storage.tokens.expires_in == 3600 +@requirement("client-auth:refresh:transparent") +async def test_a_refreshed_access_token_is_still_accepted_when_the_server_validates_token_resource() -> None: + """With `AuthSettings.validate_token_resource` on, the access token minted by the refresh grant + still passes the gate: the provider carries the original grant's resource on `RefreshToken` and + onto the refreshed `AccessToken` (SDK-defined propagation), so the request after the refresh is + served rather than answered 401.""" + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(issue_expired_first=True) + server = Server("guarded", on_list_tools=list_tools) + settings = auth_settings(validate_token_resource=True) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as ( + client, + _, + ): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot( + ["authorization_code", "refresh_token"] + ) + assert {t.resource for t in provider.access_tokens.values()} == {f"{BASE_URL}/mcp"} + + @requirement("client-auth:403-scope-upgrade") async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challenged_scope() -> None: """A 403 `insufficient_scope` challenge is answered by one re-authorize with the challenge's scope. diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index 6ab3436771..b7efb937c2 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -4,6 +4,7 @@ from typing import Any, cast import pytest +from pydantic import AnyHttpUrl from starlette.authentication import AuthCredentials from starlette.datastructures import Headers from starlette.requests import Request @@ -273,6 +274,56 @@ async def test_mixed_case_authorization_header( 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 class TestRequireAuthMiddleware: """Tests for the RequireAuthMiddleware class.""" diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64c7..64c2c02460 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -53,6 +53,7 @@ def test_auth_settings_preserves_path_less_issuer(): settings = AuthSettings( issuer_url="https://as.example.com", # type: ignore[arg-type] resource_server_url="https://rs.example.com", # type: ignore[arg-type] + validate_token_resource=True, ) assert str(settings.issuer_url) == "https://as.example.com" assert str(settings.resource_server_url) == "https://rs.example.com" @@ -63,6 +64,7 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): settings = AuthSettings( issuer_url="https://as.example.com", # type: ignore[arg-type] resource_server_url="https://rs.example.com", # type: ignore[arg-type] + validate_token_resource=True, ) metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions()) diff --git a/tests/server/auth/test_settings.py b/tests/server/auth/test_settings.py new file mode 100644 index 0000000000..db7dd26f7d --- /dev/null +++ b/tests/server/auth/test_settings.py @@ -0,0 +1,34 @@ +import warnings + +import pytest +from pydantic import AnyHttpUrl, ValidationError + +from mcp.server.auth.settings import AuthSettings +from mcp.shared.exceptions import MCPDeprecationWarning + +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 an + `MCPDeprecationWarning` pointing at its own `AuthSettings(...)` call (3.0 flips the default).""" + with pytest.warns(MCPDeprecationWarning, 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})