Skip to content

Commit 0c91368

Browse files
authored
Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447)
1 parent 9771e6b commit 0c91368

26 files changed

Lines changed: 293 additions & 41 deletions

File tree

docs/deprecated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn
136136
| Deprecated | What you do instead |
137137
|---|---|
138138
| `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. |
139+
| `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. |
139140
| `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. |
140141

141142
## Recap

docs/run/authorization.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,23 @@ That's the whole triangle. Everything on this page is the middle bullet.
1818

1919
The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**:
2020

21-
```python title="server.py" hl_lines="12-14 19-24"
21+
```python title="server.py" hl_lines="14-16 21-27"
2222
--8<-- "docs_src/authorization/tutorial001.py"
2323
```
2424

2525
* `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.
26-
* 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.
26+
* 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.
2727
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
2828

2929
`AuthSettings` is the public face of your resource server:
3030

3131
* `issuer_url`: the authorization server that issues your tokens.
3232
* `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.
3333
* `required_scopes`: every token must carry all of them.
34+
* `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.
35+
* 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.
36+
* 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.
37+
* If `aud` is a list, put the entry that equals `resource_server_url` in `resource`.
3438

3539
!!! tip
3640
`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
8690

8791
Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request:
8892

89-
```python title="server.py" hl_lines="4 32-35"
93+
```python title="server.py" hl_lines="4 35-38"
9094
--8<-- "docs_src/authorization/tutorial002.py"
9195
```
9296

docs_src/authorization/tutorial001.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from mcp.server.auth.provider import AccessToken, TokenVerifier
55
from mcp.server.auth.settings import AuthSettings
66

7+
RESOURCE = "http://127.0.0.1:8000/mcp"
8+
79
KNOWN_TOKENS = {
8-
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
10+
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
911
}
1012

1113

@@ -19,8 +21,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
1921
token_verifier=StaticTokenVerifier(),
2022
auth=AuthSettings(
2123
issuer_url=AnyHttpUrl("https://auth.example.com"),
22-
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
24+
resource_server_url=AnyHttpUrl(RESOURCE),
2325
required_scopes=["notes:read"],
26+
validate_token_resource=True,
2427
),
2528
)
2629

docs_src/authorization/tutorial002.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from mcp.server.auth.provider import AccessToken, TokenVerifier
66
from mcp.server.auth.settings import AuthSettings
77

8+
RESOURCE = "http://127.0.0.1:8000/mcp"
9+
810
KNOWN_TOKENS = {
9-
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
11+
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
1012
}
1113

1214

@@ -20,8 +22,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
2022
token_verifier=StaticTokenVerifier(),
2123
auth=AuthSettings(
2224
issuer_url=AnyHttpUrl("https://auth.example.com"),
23-
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
25+
resource_server_url=AnyHttpUrl(RESOURCE),
2426
required_scopes=["notes:read"],
27+
validate_token_resource=True,
2528
),
2629
)
2730

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer:
7171
issuer_url=settings.auth_server_url,
7272
required_scopes=[settings.mcp_scope],
7373
resource_server_url=settings.server_url,
74+
validate_token_resource=True, # tokens must be reported as issued for server_url
7475
),
7576
)
7677
# Store settings for later use in run()

examples/servers/simple-auth/mcp_simple_auth/token_verifier.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,19 @@ async def verify_token(self, token: str) -> AccessToken | None:
6969
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
7070
return None
7171

72+
# `aud` may be a string or a list; report the entry naming this server when there is
73+
# one, otherwise what the token was issued for, so the server can compare it.
74+
aud: str | list[str] | None = data.get("aud")
75+
audiences = aud if isinstance(aud, list) else [aud] if aud else []
76+
own = self.resource_url.rstrip("/")
77+
resource = next((a for a in audiences if a.rstrip("/") == own), audiences[0] if audiences else None)
78+
7279
return AccessToken(
7380
token=token,
7481
client_id=data.get("client_id", "unknown"),
7582
scopes=data.get("scope", "").split() if data.get("scope") else [],
7683
expires_at=data.get("exp"),
77-
resource=data.get("aud"), # Include resource in token
84+
resource=resource,
7885
subject=data.get("sub"), # RFC 7662 subject (resource owner)
7986
claims=data,
8087
)

examples/snippets/servers/oauth_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
2424
# Auth settings for RFC 9728 Protected Resource Metadata
2525
auth=AuthSettings(
2626
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
27-
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
27+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
2828
required_scopes=["user"],
29+
validate_token_resource=True,
2930
),
3031
)
3132

examples/stories/_shared/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,5 @@ def auth_settings(
169169
required_scopes=scopes,
170170
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
171171
identity_assertion_enabled=identity_assertion_enabled,
172+
validate_token_resource=True,
172173
)

examples/stories/bearer_auth/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def verify_token(self, token: str) -> AccessToken | None:
2828
client_id="demo-client",
2929
scopes=[REQUIRED_SCOPE],
3030
expires_at=int(time.time()) + 3600,
31+
resource=RESOURCE_URL,
3132
subject="demo-user",
3233
)
3334

@@ -40,6 +41,7 @@ def build_app() -> Starlette:
4041
issuer_url=AnyHttpUrl(ISSUER),
4142
resource_server_url=AnyHttpUrl(RESOURCE_URL),
4243
required_scopes=[REQUIRED_SCOPE],
44+
validate_token_resource=True,
4345
),
4446
)
4547

examples/stories/bearer_auth/server_lowlevel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques
4646
issuer_url=AnyHttpUrl(ISSUER),
4747
resource_server_url=AnyHttpUrl(RESOURCE_URL),
4848
required_scopes=[REQUIRED_SCOPE],
49+
validate_token_resource=True,
4950
),
5051
token_verifier=StaticTokenVerifier(),
5152
transport_security=NO_DNS_REBIND,

0 commit comments

Comments
 (0)