|
28 | 28 | import mcp.server.request_state as request_state_module |
29 | 29 | from mcp import Client |
30 | 30 | from mcp.server import MCPServer, Server, ServerRequestContext |
| 31 | +from mcp.server.auth.middleware.auth_context import auth_context_var |
| 32 | +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser |
| 33 | +from mcp.server.auth.provider import AccessToken |
31 | 34 | from mcp.server.context import HandlerResult |
32 | 35 | from mcp.server.mcpserver import Context |
33 | 36 | from mcp.server.request_state import ( |
@@ -1191,3 +1194,62 @@ async def test_a_fractional_mint_instant_keeps_the_full_ttl(monkeypatch: pytest. |
1191 | 1194 |
|
1192 | 1195 | assert isinstance(second, CallToolResult) |
1193 | 1196 | assert seen == ["awaiting-confirm"] |
| 1197 | + |
| 1198 | + |
| 1199 | +async def test_default_principal_distinguishes_two_subjects_of_one_oauth_client() -> None: |
| 1200 | + """Spec-mandated (basic/patterns/mrtr server requirement 5, cross-user reuse): with the |
| 1201 | + default binding, state sealed for one user of an OAuth client is rejected for another |
| 1202 | + user of the same client and restored only for the original subject.""" |
| 1203 | + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience="svc") |
| 1204 | + seen: list[str | None] = [] |
| 1205 | + |
| 1206 | + async def mint(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: |
| 1207 | + return InputRequiredResult(input_requests={"confirm": _ask("PIN?")}, request_state="alice-secret") |
| 1208 | + |
| 1209 | + async def restore(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: |
| 1210 | + assert ctx.params is not None |
| 1211 | + seen.append(ctx.params["requestState"]) |
| 1212 | + return CallToolResult(content=[TextContent(text="done")]) |
| 1213 | + |
| 1214 | + def request(token: str | None = None) -> ServerRequestContext[Any, Any]: |
| 1215 | + params: dict[str, Any] = {"name": "fetch_pin", "arguments": {}} |
| 1216 | + if token is not None: |
| 1217 | + params["requestState"] = token |
| 1218 | + return ServerRequestContext( |
| 1219 | + session=cast("Any", None), |
| 1220 | + lifespan_context={}, |
| 1221 | + protocol_version="2026-07-28", |
| 1222 | + method="tools/call", |
| 1223 | + params=params, |
| 1224 | + ) |
| 1225 | + |
| 1226 | + def as_user(subject: str) -> AuthenticatedUser: |
| 1227 | + shared_client = "https://agent.example/client.json" |
| 1228 | + return AuthenticatedUser( |
| 1229 | + AccessToken(token=f"at-{subject}", client_id=shared_client, scopes=[], subject=subject) |
| 1230 | + ) |
| 1231 | + |
| 1232 | + reset = auth_context_var.set(as_user("alice")) |
| 1233 | + try: |
| 1234 | + sealed = await boundary(request(), mint) |
| 1235 | + finally: |
| 1236 | + auth_context_var.reset(reset) |
| 1237 | + assert isinstance(sealed, InputRequiredResult) |
| 1238 | + assert sealed.request_state is not None |
| 1239 | + |
| 1240 | + reset = auth_context_var.set(as_user("bob")) |
| 1241 | + try: |
| 1242 | + with pytest.raises(MCPError) as exc: |
| 1243 | + await boundary(request(sealed.request_state), restore) |
| 1244 | + finally: |
| 1245 | + auth_context_var.reset(reset) |
| 1246 | + _assert_frozen_rejection(exc) |
| 1247 | + assert seen == [] |
| 1248 | + |
| 1249 | + reset = auth_context_var.set(as_user("alice")) |
| 1250 | + try: |
| 1251 | + result = await boundary(request(sealed.request_state), restore) |
| 1252 | + finally: |
| 1253 | + auth_context_var.reset(reset) |
| 1254 | + assert isinstance(result, CallToolResult) |
| 1255 | + assert seen == ["alice-secret"] |
0 commit comments