Skip to content

Commit db9530c

Browse files
committed
Reject bearer tokens that carry no audience claim
When `AuthSettings.resource_server_url` is configured, `BearerAuthBackend` previously ran the RFC 8707 audience comparison only for tokens whose verifier populated `AccessToken.resource`: a token carrying no resource indicator at all was accepted. The MCP authorization spec requires a resource server to only accept tokens issued specifically for it, so the gate now fails closed: a verified token with no `resource` is answered `401 invalid_token` ("The access token carries no audience claim"). `resource_server_url=None` still means there is no audience to enforce. For verifiers that validate the audience themselves and cannot surface the claim (for example a JWT decoder configured with the expected audience), the new `AuthSettings.verifier_validates_audience=True` opts the gate out. The `AuthSettings.enforced_audience` property derives the single value both server wirings pass to `BearerAuthBackend`, whose signature is unchanged. `RefreshToken` gains an optional `resource` field so an authorization server provider can carry the original grant's audience binding through `exchange_refresh_token`; without it every refreshed access token would be audience-unbound and rejected by the hardened gate. The docs tutorials and example servers now populate `AccessToken.resource` (and the client-credentials demo token endpoint honors the RFC 8707 `resource` parameter) so they pass the check they teach. The migration guide entry for audience validation is rewritten for the fail-closed behavior.
1 parent abf4ce4 commit db9530c

15 files changed

Lines changed: 133 additions & 33 deletions

File tree

docs/advanced/authorization.md

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

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

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

@@ -27,7 +27,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
2727
`AuthSettings` is the public face of your resource server:
2828

2929
* `issuer_url`: the authorization server that issues your tokens.
30-
* `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. When your verifier returns an `AccessToken.resource`, the SDK rejects the token unless it matches this URL, so a token issued for a different resource never reaches a tool.
30+
* `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. The SDK rejects any token whose `AccessToken.resource` does not match this URL, including one whose verifier left `resource` unset, so a token issued for a different resource (or for no resource) never reaches a tool. If your verifier validates the audience itself and cannot surface the claim, set `verifier_validates_audience=True`.
3131
* `required_scopes`: every token must carry all of them.
3232

3333
!!! tip
@@ -83,7 +83,7 @@ This document is how a client that has never heard of your server finds its way
8383

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

86-
```python title="server.py" hl_lines="4 32-35"
86+
```python title="server.py" hl_lines="4 34-37"
8787
--8<-- "docs_src/authorization/tutorial002.py"
8888
```
8989

docs/migration.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,9 +1401,18 @@ issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207.
14011401
already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the
14021402
preserved form.
14031403

1404-
### Bearer tokens with a mismatched audience are rejected
1404+
### Bearer tokens are rejected unless their audience names this server
14051405

1406-
`BearerAuthBackend` now compares `AccessToken.resource` against `AuthSettings.resource_server_url` and answers a token whose RFC 8707 resource indicator does not name this server with `401 invalid_token`. The check is canonical-URI equality, so a token issued for `https://host/` is not accepted by a server at `https://host/mcp`. It is skipped when either side is `None` — populate `AccessToken.resource` only when your verifier surfaces the underlying audience claim. `BearerAuthBackend.__init__` gains a keyword-only `resource_server_url: AnyHttpUrl | None = None`, wired automatically from `AuthSettings`; pass it only if you construct the backend directly.
1406+
`BearerAuthBackend` now compares `AccessToken.resource` against `AuthSettings.resource_server_url` and answers any token whose RFC 8707 resource indicator does not name this server — **including a token that carries no resource indicator at all** — with `401 invalid_token`. The comparison is canonical-URI equality, so a token issued for `https://host/` is not accepted by a server at `https://host/mcp`.
1407+
1408+
To migrate, do exactly one of:
1409+
1410+
- **Populate `AccessToken.resource`** from the token's `aud` claim (an introspection response's `aud`, or the decoded JWT's `aud`) in your `TokenVerifier`. This is the recommended path and what the SDK's examples now show.
1411+
- **Set `AuthSettings(verifier_validates_audience=True)`** if your verifier already validates the audience itself and cannot surface it — for example a JWT library configured with `audience=` that fails decoding on a mismatch. This tells the bearer gate not to repeat a check your verifier already performed. Do not set it just to make the `401` go away: with it set, the SDK performs no audience validation of its own at all.
1412+
1413+
Leaving `resource_server_url=None` continues to disable the check entirely (there is no audience to compare against), but a protected server should configure it: it is also the value published as RFC 9728 Protected Resource Metadata. If your authorization server does not support RFC 8707 resource indicators, your tokens will not carry an audience — audit that before opting out, because accepting audience-unbound tokens is what the MCP specification's audience-validation MUST exists to prevent.
1414+
1415+
`RefreshToken` gains an optional `resource` field so an `OAuthAuthorizationServerProvider` can propagate the original grant's audience binding through `exchange_refresh_token`; without it a refreshed access token would carry no audience and be rejected. `BearerAuthBackend.__init__` gains a keyword-only `resource_server_url: AnyHttpUrl | None = None`, wired automatically from `AuthSettings.enforced_audience`; `None` (the default, and what the SDK passes when `verifier_validates_audience` is set) means no audience is enforced.
14071416

14081417
### Lowlevel `Server`: `subscribe` capability now correctly reported
14091418

docs_src/authorization/tutorial001.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from mcp.server.auth.settings import AuthSettings
66

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

1113

docs_src/authorization/tutorial002.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from mcp.server.auth.settings import AuthSettings
77

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

1214

examples/stories/bearer_auth/server.py

Lines changed: 1 addition & 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

examples/stories/oauth_client_credentials/server.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from mcp.server.mcpserver import MCPServer
1414
from mcp.shared.auth import OAuthMetadata, OAuthToken
1515
from stories._hosting import NO_DNS_REBIND, run_app_from_args
16-
from stories._shared.auth import BASE_URL, auth_settings
16+
from stories._shared.auth import BASE_URL, MCP_URL, auth_settings
1717

1818
# DEMO ONLY — never hard-code real credentials.
1919
DEMO_CLIENT_ID = "demo-m2m-client"
@@ -65,8 +65,17 @@ async def token_endpoint(request: Request) -> JSONResponse:
6565
creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode()
6666
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
6767
return JSONResponse({"error": "invalid_client"}, status_code=401)
68+
# RFC 8707 §2.2: this AS protects exactly one resource. Anything else (or a missing
69+
# indicator) is answered with `invalid_target`, and the issued token is audience-bound
70+
# to that one resource so the bearer gate accepts it. Never mint whatever audience the
71+
# client names: a multi-resource AS that does so hands out tokens for resources the
72+
# client was never granted.
73+
if form.get("resource") != MCP_URL:
74+
return JSONResponse({"error": "invalid_target"}, status_code=400)
6875
access = f"access_{secrets.token_hex(16)}"
69-
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
76+
issued[access] = AccessToken(
77+
token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None, resource=MCP_URL
78+
)
7079
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
7180
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
7281

examples/stories/oauth_client_credentials/server_lowlevel.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from mcp.server.lowlevel import Server
1919
from mcp.shared.auth import OAuthMetadata, OAuthToken
2020
from stories._hosting import NO_DNS_REBIND, run_app_from_args
21-
from stories._shared.auth import BASE_URL, auth_settings
21+
from stories._shared.auth import BASE_URL, MCP_URL, auth_settings
2222

2323
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
2424

@@ -62,8 +62,17 @@ async def token_endpoint(request: Request) -> JSONResponse:
6262
creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode()
6363
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
6464
return JSONResponse({"error": "invalid_client"}, status_code=401)
65+
# RFC 8707 §2.2: this AS protects exactly one resource. Anything else (or a missing
66+
# indicator) is answered with `invalid_target`, and the issued token is audience-bound
67+
# to that one resource so the bearer gate accepts it. Never mint whatever audience the
68+
# client names: a multi-resource AS that does so hands out tokens for resources the
69+
# client was never granted.
70+
if form.get("resource") != MCP_URL:
71+
return JSONResponse({"error": "invalid_target"}, status_code=400)
6572
access = f"access_{secrets.token_hex(16)}"
66-
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
73+
issued[access] = AccessToken(
74+
token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None, resource=MCP_URL
75+
)
6776
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
6877
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
6978

src/mcp/server/auth/middleware/bearer_auth.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class BearerAuthBackend(AuthenticationBackend):
6969
"""Authentication backend that validates Bearer tokens using a TokenVerifier."""
7070

7171
def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None) -> None:
72+
"""Validate bearer tokens with `token_verifier` and, when `resource_server_url` is set,
73+
enforce that every token's RFC 8707 audience names exactly that resource.
74+
75+
`resource_server_url=None` means there is no audience to enforce; verification stops at
76+
the verifier's answer and the expiry check.
77+
"""
7278
self.token_verifier = token_verifier
7379
self.resource_server_url = resource_server_url
7480

@@ -86,12 +92,11 @@ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, Bas
8692
return AuthCredentials(), InvalidTokenUser("The access token is malformed or unknown")
8793
if auth_info.expires_at is not None and auth_info.expires_at < int(time.time()):
8894
return AuthCredentials(), InvalidTokenUser("The access token has expired")
89-
if (
90-
self.resource_server_url is not None
91-
and auth_info.resource is not None
92-
and not check_token_audience(auth_info.resource, self.resource_server_url)
93-
):
94-
return AuthCredentials(), InvalidTokenUser("The access token was issued for a different resource")
95+
if self.resource_server_url is not None:
96+
if auth_info.resource is None:
97+
return AuthCredentials(), InvalidTokenUser("The access token carries no audience claim")
98+
if not check_token_audience(auth_info.resource, self.resource_server_url):
99+
return AuthCredentials(), InvalidTokenUser("The access token was issued for a different resource")
95100

96101
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
97102

src/mcp/server/auth/provider.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class RefreshToken(BaseModel):
3333
client_id: str
3434
scopes: list[str]
3535
expires_at: int | None = None
36+
resource: str | None = None # RFC 8707 resource indicator; propagate to refreshed AccessTokens
3637
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
3738

3839

src/mcp/server/auth/settings.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,23 @@ class AuthSettings(BaseModel):
3434
description="The URL of the MCP server to be used as the resource identifier "
3535
"and base route to look up OAuth Protected Resource Metadata.",
3636
)
37+
38+
verifier_validates_audience: bool = Field(
39+
default=False,
40+
description="Set when your TokenVerifier validates the token's audience itself and "
41+
"therefore never populates AccessToken.resource (for example a JWT decoder configured "
42+
"with the expected audience). The bearer gate then skips its own audience check. "
43+
"Leave False to have the SDK reject any token whose resource indicator is absent or "
44+
"names a different server.",
45+
)
46+
47+
@property
48+
def enforced_audience(self) -> AnyHttpUrl | None:
49+
"""The resource identifier the bearer gate compares each token's audience against.
50+
51+
`None` when no `resource_server_url` is configured, or when
52+
`verifier_validates_audience` declares that the verifier already did the check -- in
53+
both cases the gate has nothing of its own to enforce. Both server wirings read this,
54+
so it is the single source of the should-the-gate-audience-check decision.
55+
"""
56+
return None if self.verifier_validates_audience else self.resource_server_url

0 commit comments

Comments
 (0)