49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
412 lines
16 KiB
Python
412 lines
16 KiB
Python
"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`.
|
|
|
|
The provider's authorization server is configuration; these tests assert that authorization-server
|
|
metadata is fetched only from the configured issuer, that the resource server is never consulted for
|
|
AS selection, and that the ID-JAG and client secret reach only the issuer's token endpoint.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import urllib.parse
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from mcp.client.auth import OAuthFlowError, OAuthTokenError
|
|
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin
|
|
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
|
|
|
|
ISSUER = "https://auth.example.com"
|
|
RS = "https://mcp.example.com"
|
|
ASM_PATH = "/.well-known/oauth-authorization-server"
|
|
OIDC_PATH = "/.well-known/openid-configuration"
|
|
|
|
|
|
class InMemoryStorage:
|
|
def __init__(self, tokens: OAuthToken | None = None) -> None:
|
|
self.tokens = tokens
|
|
|
|
async def get_tokens(self) -> OAuthToken | None:
|
|
return self.tokens
|
|
|
|
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
self.tokens = tokens
|
|
|
|
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
raise NotImplementedError
|
|
|
|
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
raise NotImplementedError
|
|
|
|
|
|
def asm_body(*, issuer: str = ISSUER, token_endpoint: str | None = None) -> bytes:
|
|
return json.dumps(
|
|
{
|
|
"issuer": issuer,
|
|
"authorization_endpoint": f"{issuer}/authorize",
|
|
"token_endpoint": token_endpoint or f"{issuer}/token",
|
|
}
|
|
).encode()
|
|
|
|
|
|
def token_body(*, access_token: str = "issued-token", scope: str | None = None) -> bytes:
|
|
payload: dict[str, object] = {"access_token": access_token, "token_type": "Bearer", "expires_in": 3600}
|
|
if scope is not None:
|
|
payload["scope"] = scope
|
|
return json.dumps(payload).encode()
|
|
|
|
|
|
def make_provider(
|
|
storage: InMemoryStorage | None = None,
|
|
*,
|
|
scope: str | None = "mcp",
|
|
token_endpoint_auth_method: str = "client_secret_post",
|
|
record: list[tuple[str, str]] | None = None,
|
|
) -> IdentityAssertionOAuthProvider:
|
|
async def assertion_provider(audience: str, resource: str) -> str:
|
|
if record is not None:
|
|
record.append((audience, resource))
|
|
return "the-id-jag"
|
|
|
|
return IdentityAssertionOAuthProvider(
|
|
server_url=f"{RS}/mcp",
|
|
storage=storage if storage is not None else InMemoryStorage(),
|
|
client_id="test-client-id",
|
|
client_secret="test-client-secret",
|
|
issuer=ISSUER,
|
|
assertion_provider=assertion_provider,
|
|
scope=scope,
|
|
token_endpoint_auth_method=token_endpoint_auth_method, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def mock_transport(
|
|
requests: list[httpx.Request],
|
|
*,
|
|
asm: bytes | int = 200,
|
|
token: bytes | int = 200,
|
|
rs_first_status: int = 401,
|
|
rs_first_headers: dict[str, str] | None = None,
|
|
) -> httpx.MockTransport:
|
|
"""Build a `MockTransport` that records every request and serves the configured ASM and token.
|
|
|
|
`asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body).
|
|
The MCP resource server's first response is `rs_first_status` (default 401) with optional
|
|
headers; subsequent RS requests return 200.
|
|
"""
|
|
rs_hits = 0
|
|
|
|
def handle(request: httpx.Request) -> httpx.Response:
|
|
nonlocal rs_hits
|
|
requests.append(request)
|
|
host, path = request.url.host, request.url.path
|
|
if host == "mcp.example.com":
|
|
rs_hits += 1
|
|
if rs_hits == 1:
|
|
return httpx.Response(rs_first_status, headers=rs_first_headers or {})
|
|
return httpx.Response(200, json={"ok": True})
|
|
if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH):
|
|
if isinstance(asm, int):
|
|
return httpx.Response(asm)
|
|
return httpx.Response(200, content=asm, headers={"content-type": "application/json"})
|
|
if host == "auth.example.com" and path == "/token":
|
|
if isinstance(token, int):
|
|
return httpx.Response(token, json={"error": "invalid_grant"})
|
|
return httpx.Response(200, content=token, headers={"content-type": "application/json"})
|
|
raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover
|
|
|
|
return httpx.MockTransport(handle)
|
|
|
|
|
|
def form(request: httpx.Request) -> dict[str, str]:
|
|
return dict(urllib.parse.parse_qsl(request.content.decode()))
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None:
|
|
"""A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries."""
|
|
requests: list[httpx.Request] = []
|
|
record: list[tuple[str, str]] = []
|
|
storage = InMemoryStorage()
|
|
auth = make_provider(storage, record=record)
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth
|
|
) as http:
|
|
response = await http.post(f"{RS}/mcp")
|
|
|
|
assert [(r.method, str(r.url)) for r in requests] == [
|
|
("POST", f"{RS}/mcp"),
|
|
("GET", f"{ISSUER}{ASM_PATH}"),
|
|
("POST", f"{ISSUER}/token"),
|
|
("POST", f"{RS}/mcp"),
|
|
]
|
|
body = form(requests[2])
|
|
assert body == {
|
|
"grant_type": JWT_BEARER_GRANT_TYPE,
|
|
"assertion": "the-id-jag",
|
|
"client_id": "test-client-id",
|
|
"resource": f"{RS}/mcp",
|
|
"scope": "mcp",
|
|
"client_secret": "test-client-secret",
|
|
}
|
|
assert "Authorization" not in requests[2].headers
|
|
assert record == [(ISSUER, f"{RS}/mcp")]
|
|
assert response.status_code == 200
|
|
assert storage.tokens is not None
|
|
assert storage.tokens.access_token == "issued-token"
|
|
assert storage.tokens.scope == "mcp"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_resource_server_metadata_is_never_consulted() -> None:
|
|
"""No PRM well-known and no RS-origin ASM well-known is ever fetched.
|
|
|
|
This is the by-construction property: the AS is configuration, so the resource server has no
|
|
input into where the ID-JAG or client secret go. Any GET to the RS host fails the test.
|
|
"""
|
|
requests: list[httpx.Request] = []
|
|
auth = make_provider()
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
|
|
) as http:
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
rs_gets = [r for r in requests if r.url.host == "mcp.example.com" and r.method == "GET"]
|
|
assert rs_gets == []
|
|
assert all(r.url.host == "auth.example.com" for r in requests if r.method == "GET")
|
|
# No DCR was attempted anywhere.
|
|
assert not any(r.url.path == "/register" for r in requests)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None:
|
|
"""If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted."""
|
|
requests: list[httpx.Request] = []
|
|
record: list[tuple[str, str]] = []
|
|
auth = make_provider(record=record)
|
|
|
|
async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
|
|
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
# Both RFC 8414 and OIDC well-knowns were tried at the configured issuer; nothing else.
|
|
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}", f"{ISSUER}{OIDC_PATH}"]
|
|
assert record == []
|
|
assert not any(r.url.path == "/token" for r in requests)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_asm_5xx_stops_discovery_and_raises() -> None:
|
|
"""A 5xx at the issuer's well-known stops discovery without trying further URLs."""
|
|
requests: list[httpx.Request] = []
|
|
auth = make_provider()
|
|
|
|
async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
|
|
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None:
|
|
"""RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected."""
|
|
requests: list[httpx.Request] = []
|
|
record: list[tuple[str, str]] = []
|
|
auth = make_provider(record=record)
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth
|
|
) as http:
|
|
with pytest.raises(OAuthFlowError, match="issuer mismatch"):
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
assert record == []
|
|
assert not any(r.url.path == "/token" for r in requests)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None:
|
|
"""A `token_endpoint` off the configured issuer's origin is refused before any credential is sent."""
|
|
requests: list[httpx.Request] = []
|
|
record: list[tuple[str, str]] = []
|
|
auth = make_provider(record=record)
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth
|
|
) as http:
|
|
with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"):
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
assert record == []
|
|
assert not any(r.url.path == "/token" for r in requests)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None:
|
|
"""A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes."""
|
|
requests: list[httpx.Request] = []
|
|
auth = make_provider(scope="mcp")
|
|
|
|
transport = mock_transport(
|
|
requests,
|
|
asm=asm_body(),
|
|
token=token_body(),
|
|
rs_first_status=403,
|
|
rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'},
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
|
|
response = await http.post(f"{RS}/mcp")
|
|
|
|
[token_req] = [r for r in requests if r.url.path == "/token"]
|
|
assert form(token_req)["scope"] == "mcp files:write"
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
|
|
"""A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging."""
|
|
requests: list[httpx.Request] = []
|
|
record: list[tuple[str, str]] = []
|
|
auth = make_provider(record=record)
|
|
|
|
transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"})
|
|
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
|
|
response = await http.post(f"{RS}/mcp")
|
|
|
|
assert response.status_code == 403
|
|
assert record == []
|
|
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
|
|
requests: list[httpx.Request] = []
|
|
auth = make_provider()
|
|
|
|
async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
|
|
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
|
|
requests: list[httpx.Request] = []
|
|
auth = make_provider(token_endpoint_auth_method="client_secret_basic")
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
|
|
) as http:
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
[token_req] = [r for r in requests if r.url.path == "/token"]
|
|
assert "client_secret" not in form(token_req)
|
|
decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode()
|
|
assert decoded == "test-client-id:test-client-secret"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_stored_token_is_reused_without_reauthorizing() -> None:
|
|
"""A valid stored token is sent on the first request; on success no ASM or /token is fetched."""
|
|
requests: list[httpx.Request] = []
|
|
storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600))
|
|
auth = make_provider(storage)
|
|
|
|
transport = mock_transport(requests, rs_first_status=200)
|
|
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
|
|
response = await http.post(f"{RS}/mcp")
|
|
|
|
assert response.status_code == 200
|
|
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
|
|
assert requests[0].headers["Authorization"] == "Bearer cached"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
|
|
"""ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""
|
|
requests: list[httpx.Request] = []
|
|
record: list[tuple[str, str]] = []
|
|
auth = make_provider(record=record)
|
|
rs_hits = 0
|
|
|
|
def handle(request: httpx.Request) -> httpx.Response:
|
|
nonlocal rs_hits
|
|
requests.append(request)
|
|
host, path = request.url.host, request.url.path
|
|
if host == "mcp.example.com":
|
|
rs_hits += 1
|
|
# First and third RS hits draw a 401; second and fourth succeed.
|
|
return httpx.Response(401 if rs_hits in (1, 3) else 200)
|
|
if host == "auth.example.com" and path == ASM_PATH:
|
|
return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"})
|
|
assert host == "auth.example.com" and path == "/token"
|
|
return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http:
|
|
await http.post(f"{RS}/mcp")
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
asm_gets = [r for r in requests if r.url.path == ASM_PATH]
|
|
token_posts = [r for r in requests if r.url.path == "/token"]
|
|
assert len(asm_gets) == 1
|
|
assert len(token_posts) == 2
|
|
assert len(record) == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None:
|
|
"""With no configured scope and no scope in the token response, the stored token records None."""
|
|
requests: list[httpx.Request] = []
|
|
storage = InMemoryStorage()
|
|
auth = make_provider(storage, scope=None)
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
|
|
) as http:
|
|
await http.post(f"{RS}/mcp")
|
|
|
|
[token_req] = [r for r in requests if r.url.path == "/token"]
|
|
assert "scope" not in form(token_req)
|
|
assert storage.tokens is not None
|
|
assert storage.tokens.scope is None
|
|
|
|
|
|
def test_empty_client_secret_is_rejected() -> None:
|
|
async def assertion_provider(audience: str, resource: str) -> str:
|
|
raise NotImplementedError
|
|
|
|
with pytest.raises(ValueError, match="client_secret is required"):
|
|
IdentityAssertionOAuthProvider(
|
|
server_url=f"{RS}/mcp",
|
|
storage=InMemoryStorage(),
|
|
client_id="c",
|
|
client_secret="",
|
|
issuer=ISSUER,
|
|
assertion_provider=assertion_provider,
|
|
)
|
|
|
|
|
|
def test_empty_issuer_is_rejected() -> None:
|
|
async def assertion_provider(audience: str, resource: str) -> str:
|
|
raise NotImplementedError
|
|
|
|
with pytest.raises(ValueError, match="issuer is required"):
|
|
IdentityAssertionOAuthProvider(
|
|
server_url=f"{RS}/mcp",
|
|
storage=InMemoryStorage(),
|
|
client_id="c",
|
|
client_secret="s",
|
|
issuer="",
|
|
assertion_provider=assertion_provider,
|
|
)
|
|
|
|
|
|
def test_origin_normalizes_default_ports() -> None:
|
|
"""`_origin` treats an explicit scheme-default port as equal to the port-less form."""
|
|
assert _origin("https://host") == _origin("https://host:443")
|
|
assert _origin("http://host") == _origin("http://host:80")
|
|
assert _origin("https://host") != _origin("https://host:8443")
|
|
assert _origin("https://host") != _origin("https://other")
|