|
2 | 2 |
|
3 | 3 | These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier` |
4 | 4 | seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since |
5 | | -every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status, |
6 | | -the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP |
7 | | -endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test. |
| 5 | +every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status |
| 6 | +and the `WWW-Authenticate` header structure. The flow side of the same 401 is `test_flow.py`'s |
| 7 | +flagship test. |
8 | 8 | """ |
9 | 9 |
|
10 | 10 | import time |
|
24 | 24 | pytestmark = pytest.mark.anyio |
25 | 25 |
|
26 | 26 | REQUIRED_SCOPE = "mcp:read" |
| 27 | +RESOURCE = "http://127.0.0.1:8000/mcp" |
27 | 28 | RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" |
28 | 29 |
|
29 | 30 | _FUTURE = int(time.time()) + 3600 |
30 | 31 | _PAST = int(time.time()) - 3600 |
31 | 32 |
|
| 33 | + |
| 34 | +def tok(name: str, *, scopes: list[str], expires_at: int, resource: str | None = RESOURCE) -> AccessToken: |
| 35 | + return AccessToken(token=name, client_id="c", scopes=scopes, expires_at=expires_at, resource=resource) |
| 36 | + |
| 37 | + |
32 | 38 | TOKENS = { |
33 | | - "tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), |
34 | | - "tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST), |
35 | | - "tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE), |
36 | | - "tok-wrong-aud": AccessToken( |
37 | | - token="tok-wrong-aud", |
38 | | - client_id="c", |
39 | | - scopes=[REQUIRED_SCOPE], |
40 | | - expires_at=_FUTURE, |
41 | | - resource="https://other.example/mcp", |
| 39 | + "tok-valid": tok("tok-valid", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE), |
| 40 | + "tok-expired": tok("tok-expired", scopes=[REQUIRED_SCOPE], expires_at=_PAST), |
| 41 | + "tok-noscope": tok("tok-noscope", scopes=["other:thing"], expires_at=_FUTURE), |
| 42 | + "tok-wrong-aud": tok( |
| 43 | + "tok-wrong-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource="https://other.example/mcp" |
42 | 44 | ), |
| 45 | + "tok-no-aud": tok("tok-no-aud", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE, resource=None), |
43 | 46 | } |
44 | 47 |
|
45 | 48 |
|
46 | 49 | @pytest.fixture |
47 | 50 | async def protected() -> AsyncIterator[httpx2.AsyncClient]: |
48 | 51 | """A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge.""" |
49 | 52 | server = Server("rs") |
50 | | - settings = auth_settings(required_scopes=[REQUIRED_SCOPE]) |
| 53 | + settings = auth_settings(required_scopes=[REQUIRED_SCOPE], validate_token_resource=True) |
51 | 54 | async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _): |
52 | 55 | yield http |
53 | 56 |
|
@@ -157,19 +160,26 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco |
157 | 160 |
|
158 | 161 |
|
159 | 162 | @requirement("hosting:auth:aud-validation") |
160 | | -async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None: |
161 | | - """A token whose `resource` does not match the server's resource identifier is accepted. |
162 | | -
|
163 | | - The spec mandates the resource server validate the token's audience; the bearer backend |
164 | | - never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint |
165 | | - serves it. This pins current behaviour with the divergence recorded on the requirement. |
| 163 | +@pytest.mark.parametrize("bearer", ["tok-wrong-aud", "tok-no-aud"]) |
| 164 | +async def test_a_token_not_issued_for_this_resource_is_answered_401(protected: httpx2.AsyncClient, bearer: str) -> None: |
| 165 | + """Spec-mandated audience check, which the SDK performs when `AuthSettings.validate_token_resource` |
| 166 | + is set (off by default, the recorded divergence): a token whose verifier-reported `resource` |
| 167 | + (RFC 8707) is another URL, or absent, is answered 401 `invalid_token` like an unrecognized token. |
166 | 168 | """ |
167 | | - response = await post_mcp(protected, bearer="tok-wrong-aud") |
| 169 | + response = await post_mcp(protected, bearer=bearer) |
| 170 | + |
| 171 | + assert response.status_code == 401 |
| 172 | + assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token" |
| 173 | + |
| 174 | + |
| 175 | +@requirement("hosting:auth:aud-validation") |
| 176 | +async def test_a_token_issued_for_this_resource_is_served(protected: httpx2.AsyncClient) -> None: |
| 177 | + """The other half: a token the verifier reports as issued for `resource_server_url` passes the |
| 178 | + gate and the MCP endpoint answers the initialize request.""" |
| 179 | + response = await post_mcp(protected, bearer="tok-valid") |
168 | 180 |
|
169 | 181 | assert response.status_code == 200 |
170 | | - assert response.headers["content-type"].startswith("text/event-stream") |
171 | | - # The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response |
172 | | - # out of the buffered text to prove the MCP endpoint actually answered the initialize request. |
| 182 | + # Finite SSE body: pull out the JSON-RPC result to prove the endpoint actually answered. |
173 | 183 | [data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")] |
174 | 184 | assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result |
175 | 185 |
|
|
0 commit comments