Support RFC 8693 token exchange for enterprise IdP flows (SEP-990) (#2988)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
ecdf09d44f
commit
c0ecb70e24
7 changed files with 1830 additions and 14 deletions
411
tests/client/auth/extensions/test_identity_assertion.py
Normal file
411
tests/client/auth/extensions/test_identity_assertion.py
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
"""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")
|
||||
|
|
@ -12,7 +12,7 @@ from inline_snapshot import Is, snapshot
|
|||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider, PKCEParameters
|
||||
from mcp.client.auth.exceptions import OAuthFlowError
|
||||
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
|
||||
from mcp.client.auth.utils import (
|
||||
build_oauth_authorization_server_metadata_discovery_urls,
|
||||
build_protected_resource_metadata_discovery_urls,
|
||||
|
|
@ -2860,6 +2860,17 @@ async def test_handle_token_response_backfills_omitted_scope_from_request(
|
|||
assert stored.scope == "read admin"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handle_token_response_raises_on_non_2xx_with_body(oauth_provider: OAuthClientProvider):
|
||||
response = httpx.Response(
|
||||
400,
|
||||
json={"error": "invalid_grant"},
|
||||
request=httpx.Request("POST", "https://auth.example.com/token"),
|
||||
)
|
||||
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
|
||||
await oauth_provider._handle_token_response(response)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handle_refresh_response_carries_prior_scope_and_refresh_token_when_omitted(
|
||||
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage
|
||||
|
|
|
|||
|
|
@ -3689,6 +3689,64 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"elsewhere into the auth provider's state, so the absence cannot be observed end to end."
|
||||
),
|
||||
),
|
||||
"client-auth:identity-assertion": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"The identity-assertion provider (SEP-990) presents an enterprise IdP-issued ID-JAG to the MCP "
|
||||
"authorization server via the RFC 7523 jwt-bearer grant, with no authorize or registration step, "
|
||||
"and the issued bearer token authorizes subsequent requests."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:identity-assertion:assertion-callback": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"The identity-assertion provider sources the ID-JAG from its async assertion_provider callback, "
|
||||
"invoked with the authorization server's issuer as audience and the MCP server's resource "
|
||||
"identifier, and sends it as `assertion` on the RFC 7523 jwt-bearer request."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:identity-assertion:issuer-pinning": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"The identity-assertion provider's authorization server is configuration: metadata is "
|
||||
"fetched only from the configured issuer's RFC 8414 well-known, the resource server is "
|
||||
"never consulted for AS selection, and the ID-JAG and client secret are not sent unless "
|
||||
"that metadata validates."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:identity-assertion:disabled-rejected": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"When the authorization server has the identity-assertion grant disabled, the token endpoint "
|
||||
"rejects it with unsupported_grant_type and the connection fails rather than issuing a token."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:identity-assertion:invalid-assertion": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A jwt-bearer request whose ID-JAG the authorization server rejects surfaces as an OAuth error "
|
||||
"and the connection fails rather than proceeding with a bearer token."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:identity-assertion:metadata-advertised": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"When the identity-assertion grant is enabled, the authorization-server metadata advertises the "
|
||||
"jwt-bearer grant type and the id-jag grant profile in authorization_grant_profiles_supported."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# stdio transport
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -178,7 +178,10 @@ class HeadlessOAuth:
|
|||
|
||||
|
||||
def auth_settings(
|
||||
*, required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None
|
||||
*,
|
||||
required_scopes: Sequence[str] = ("mcp",),
|
||||
valid_scopes: Sequence[str] | None = None,
|
||||
identity_assertion_enabled: bool = False,
|
||||
) -> AuthSettings:
|
||||
"""Build `AuthSettings` for the co-hosted authorization + resource server.
|
||||
|
||||
|
|
@ -188,6 +191,10 @@ def auth_settings(
|
|||
validation; tests pass a wider set when they need the protected-resource metadata's
|
||||
`scopes_supported` (which mirrors `required_scopes`) to differ from what the client may
|
||||
register or when AS metadata should advertise additional scopes such as `offline_access`.
|
||||
|
||||
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
|
||||
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
|
||||
issue tokens.
|
||||
"""
|
||||
required = list(required_scopes)
|
||||
valid = list(valid_scopes) if valid_scopes is not None else required
|
||||
|
|
@ -199,6 +206,7 @@ def auth_settings(
|
|||
enabled=True, valid_scopes=valid, default_scopes=required
|
||||
),
|
||||
revocation_options=RevocationOptions(enabled=False),
|
||||
identity_assertion_enabled=identity_assertion_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from mcp.server.auth.provider import (
|
|||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
IdentityAssertionParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
TokenError,
|
||||
|
|
@ -25,6 +26,10 @@ from tests.interaction._connect import BASE_URL
|
|||
|
||||
_TOKEN_LIFETIME_SECONDS = 3600
|
||||
|
||||
# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with
|
||||
# invalid_grant, standing in for the signature/policy validation a real AS performs.
|
||||
VALID_ASSERTION = "valid-id-jag"
|
||||
|
||||
|
||||
class InMemoryAuthorizationServerProvider(
|
||||
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
||||
|
|
@ -71,6 +76,9 @@ class InMemoryAuthorizationServerProvider(
|
|||
self.codes: dict[str, AuthorizationCode] = {}
|
||||
self.refresh_tokens: dict[str, RefreshToken] = {}
|
||||
self.access_tokens: dict[str, AccessToken] = {}
|
||||
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
|
||||
# for tests to assert what the client sent (None until the first exchange).
|
||||
self.last_assertion_params: IdentityAssertionParams | None = None
|
||||
|
||||
def _next_expires_in(self) -> int:
|
||||
self._tokens_issued += 1
|
||||
|
|
@ -187,6 +195,28 @@ class InMemoryAuthorizationServerProvider(
|
|||
refresh_token=new_refresh,
|
||||
)
|
||||
|
||||
async def exchange_identity_assertion(
|
||||
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
|
||||
) -> OAuthToken:
|
||||
"""Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990).
|
||||
|
||||
Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with
|
||||
invalid_grant (standing in for signature/policy validation). The granted scopes are exactly
|
||||
those the client requested; a real provider would derive them from the validated ID-JAG.
|
||||
"""
|
||||
self.last_assertion_params = params
|
||||
assert client.client_id is not None
|
||||
if params.assertion != VALID_ASSERTION:
|
||||
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
|
||||
scopes = params.scopes if params.scopes is not None else self._default_scopes
|
||||
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
|
||||
return OAuthToken(
|
||||
access_token=access,
|
||||
token_type="Bearer",
|
||||
expires_in=self._next_expires_in(),
|
||||
scope=" ".join(scopes),
|
||||
)
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
|
||||
raise NotImplementedError
|
||||
|
|
|
|||
302
tests/interaction/auth/test_identity_assertion.py
Normal file
302
tests/interaction/auth/test_identity_assertion.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""End-to-end SEP-990 Identity Assertion (RFC 7523 jwt-bearer) flows.
|
||||
|
||||
These exercise the FULL stack: the real `IdentityAssertionOAuthProvider` on the client and the real
|
||||
authorization-server token endpoint on the server, with `InMemoryAuthorizationServerProvider`
|
||||
implementing `exchange_identity_assertion`. The client supplies the ID-JAG through its
|
||||
`assertion_provider` callback; the provider validates it and the issued bearer authorizes the MCP
|
||||
request.
|
||||
|
||||
Recording-first: the recorded `/token` request is asserted before the call result, so a surprise in
|
||||
the exchange path produces a readable diff of what fired.
|
||||
"""
|
||||
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import ListToolsResult, Tool
|
||||
|
||||
from mcp.client.auth import OAuthFlowError, OAuthTokenError
|
||||
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
|
||||
from tests.interaction._connect import BASE_URL, mounted_app
|
||||
from tests.interaction._requirements import requirement
|
||||
from tests.interaction.auth._harness import (
|
||||
InMemoryTokenStorage,
|
||||
RecordedRequest,
|
||||
auth_settings,
|
||||
connect_with_oauth,
|
||||
record_requests,
|
||||
)
|
||||
from tests.interaction.auth._provider import VALID_ASSERTION, InMemoryAuthorizationServerProvider
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
ASM_ROOT = "/.well-known/oauth-authorization-server"
|
||||
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
|
||||
CLIENT_ID = "enterprise-mcp-client"
|
||||
CLIENT_SECRET = "enterprise-secret"
|
||||
# The AS metadata issuer carries a trailing slash (built from an AnyHttpUrl object); the client
|
||||
# pins against exactly that.
|
||||
EXPECTED_ISSUER = f"{BASE_URL}/"
|
||||
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
|
||||
|
||||
|
||||
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
|
||||
return [r for r in recorded if r.method == method and r.path == path]
|
||||
|
||||
|
||||
def form_body(request: RecordedRequest) -> dict[str, str]:
|
||||
return dict(parse_qsl(request.content.decode()))
|
||||
|
||||
|
||||
def preregister_confidential_client(provider: InMemoryAuthorizationServerProvider) -> None:
|
||||
"""Seed a pre-registered confidential client allowed to use the identity-assertion grant.
|
||||
|
||||
SEP-990 clients are provisioned out of band (DCR refuses the grant), so the server already knows
|
||||
the client; `IdentityAssertionOAuthProvider` presents the same id + secret without registering.
|
||||
"""
|
||||
provider.clients[CLIENT_ID] = OAuthClientInformationFull(
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
|
||||
def identity_assertion_provider(
|
||||
storage: InMemoryTokenStorage,
|
||||
*,
|
||||
assertion: str = VALID_ASSERTION,
|
||||
issuer: str = EXPECTED_ISSUER,
|
||||
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 assertion
|
||||
|
||||
return IdentityAssertionOAuthProvider(
|
||||
server_url=f"{BASE_URL}/mcp",
|
||||
storage=storage,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
issuer=issuer,
|
||||
assertion_provider=assertion_provider,
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion")
|
||||
async def test_identity_assertion_obtains_a_token_and_authorizes_the_request() -> None:
|
||||
"""The identity-assertion provider connects end to end with no authorize/register step.
|
||||
|
||||
The full stack runs: the client posts grant_type=jwt-bearer with the ID-JAG as `assertion` to the
|
||||
real token endpoint, the provider validates it and issues a bearer, and the bearer authorizes
|
||||
list_tools. The recorded sequence proves no /authorize or /register request was made.
|
||||
"""
|
||||
recorded, on_request = record_requests()
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
preregister_confidential_client(provider)
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
storage = InMemoryTokenStorage()
|
||||
auth = identity_assertion_provider(storage)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with connect_with_oauth(
|
||||
server,
|
||||
provider=provider,
|
||||
settings=auth_settings(identity_assertion_enabled=True),
|
||||
auth=auth,
|
||||
on_request=on_request,
|
||||
) as (client, headless):
|
||||
result = await client.list_tools()
|
||||
|
||||
# Recording-first: assert what fired before the call result.
|
||||
assert headless.authorize_url is None
|
||||
assert find(recorded, "GET", "/authorize") == []
|
||||
assert find(recorded, "POST", "/register") == []
|
||||
# The AS is configuration: PRM is never fetched, so the resource server has no input into where
|
||||
# the credentials go.
|
||||
assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded)
|
||||
|
||||
[token_req] = find(recorded, "POST", "/token")
|
||||
body = form_body(token_req)
|
||||
assert body == snapshot(
|
||||
{
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": "valid-id-jag",
|
||||
"client_id": "enterprise-mcp-client",
|
||||
"resource": "http://127.0.0.1:8000/mcp",
|
||||
"scope": "mcp",
|
||||
"client_secret": "enterprise-secret",
|
||||
}
|
||||
)
|
||||
|
||||
assert result.tools[0].name == "echo"
|
||||
assert provider.last_assertion_params is not None
|
||||
assert provider.last_assertion_params.assertion == VALID_ASSERTION
|
||||
assert storage.tokens is not None
|
||||
assert storage.tokens.access_token in provider.access_tokens
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion")
|
||||
async def test_configured_scope_is_sent_regardless_of_server_advertised_scopes() -> None:
|
||||
"""The caller's configured scope reaches the wire; server-advertised scopes have no effect.
|
||||
|
||||
The provider has no scope-selection step, so this is true by construction; the test pins it.
|
||||
Here the AS advertises `mcp extra` but the client requested only `mcp`, so the recorded `/token`
|
||||
body must carry `scope=mcp`, not the advertised superset.
|
||||
"""
|
||||
recorded, on_request = record_requests()
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
preregister_confidential_client(provider)
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
auth = identity_assertion_provider(InMemoryTokenStorage())
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with connect_with_oauth(
|
||||
server,
|
||||
provider=provider,
|
||||
# AS metadata advertises a broader scopes_supported than the client requests.
|
||||
settings=auth_settings(identity_assertion_enabled=True, valid_scopes=["mcp", "extra"]),
|
||||
auth=auth,
|
||||
on_request=on_request,
|
||||
) as (client, _):
|
||||
await client.list_tools()
|
||||
|
||||
[token_req] = find(recorded, "POST", "/token")
|
||||
assert form_body(token_req)["scope"] == "mcp"
|
||||
assert provider.last_assertion_params is not None
|
||||
assert provider.last_assertion_params.scopes == ["mcp"]
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion:assertion-callback")
|
||||
async def test_assertion_callback_receives_issuer_audience_and_resource() -> None:
|
||||
"""The assertion_provider gets the AS issuer as audience and the MCP resource identifier."""
|
||||
record: list[tuple[str, str]] = []
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
preregister_confidential_client(provider)
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
auth = identity_assertion_provider(InMemoryTokenStorage(), record=record)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with connect_with_oauth(
|
||||
server,
|
||||
provider=provider,
|
||||
settings=auth_settings(identity_assertion_enabled=True),
|
||||
auth=auth,
|
||||
) as (client, _):
|
||||
await client.list_tools()
|
||||
|
||||
assert record == [(EXPECTED_ISSUER, f"{BASE_URL}/mcp")]
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion:issuer-pinning")
|
||||
async def test_unexpected_issuer_aborts_before_sending_credentials() -> None:
|
||||
"""When the configured issuer's metadata fails RFC 8414 validation, no credential is sent.
|
||||
|
||||
The AS issuer is configuration; metadata is fetched from that issuer's well-known and validated
|
||||
per RFC 8414 section 3.3. Here the in-process server's metadata has a different issuer than the
|
||||
one the client is configured for, so validation fails before the assertion callback is invoked
|
||||
or any credential is posted. PRM is never fetched and no DCR is attempted.
|
||||
"""
|
||||
recorded, on_request = record_requests()
|
||||
record: list[tuple[str, str]] = []
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
preregister_confidential_client(provider)
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
# The served AS metadata has issuer BASE_URL/, but the client is configured for a different one.
|
||||
auth = identity_assertion_provider(InMemoryTokenStorage(), issuer="https://corp-as.example/", record=record)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError, match="issuer mismatch"), flatten_subgroups=True):
|
||||
await connect_with_oauth(
|
||||
server,
|
||||
provider=provider,
|
||||
settings=auth_settings(identity_assertion_enabled=True),
|
||||
auth=auth,
|
||||
on_request=on_request,
|
||||
).__aenter__()
|
||||
|
||||
assert record == []
|
||||
assert provider.last_assertion_params is None
|
||||
assert find(recorded, "POST", "/token") == []
|
||||
assert find(recorded, "POST", "/register") == []
|
||||
assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded)
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion:disabled-rejected")
|
||||
async def test_identity_assertion_is_rejected_when_disabled_on_the_server() -> None:
|
||||
"""With the grant disabled, the token endpoint returns unsupported_grant_type and the flow fails."""
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
preregister_confidential_client(provider)
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
auth = identity_assertion_provider(InMemoryTokenStorage())
|
||||
|
||||
with anyio.fail_after(5):
|
||||
with pytest.RaisesGroup(
|
||||
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*unsupported_grant_type"),
|
||||
flatten_subgroups=True,
|
||||
):
|
||||
await connect_with_oauth(
|
||||
server,
|
||||
provider=provider,
|
||||
settings=auth_settings(identity_assertion_enabled=False),
|
||||
auth=auth,
|
||||
).__aenter__()
|
||||
|
||||
assert provider.last_assertion_params is None
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion:invalid-assertion")
|
||||
async def test_a_rejected_assertion_aborts_the_flow() -> None:
|
||||
"""An ID-JAG the provider rejects surfaces as OAuthTokenError; no bearer is issued."""
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
preregister_confidential_client(provider)
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
storage = InMemoryTokenStorage()
|
||||
auth = identity_assertion_provider(storage, assertion="forged-id-jag")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
with pytest.RaisesGroup(
|
||||
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*invalid_grant"),
|
||||
flatten_subgroups=True,
|
||||
):
|
||||
await connect_with_oauth(
|
||||
server,
|
||||
provider=provider,
|
||||
settings=auth_settings(identity_assertion_enabled=True),
|
||||
auth=auth,
|
||||
).__aenter__()
|
||||
|
||||
assert provider.last_assertion_params is not None
|
||||
assert provider.last_assertion_params.assertion == "forged-id-jag"
|
||||
assert storage.tokens is None
|
||||
|
||||
|
||||
@requirement("client-auth:identity-assertion:metadata-advertised")
|
||||
async def test_metadata_advertises_jwt_bearer_grant_and_id_jag_profile() -> None:
|
||||
"""When enabled, AS metadata lists the jwt-bearer grant and the id-jag grant profile."""
|
||||
server = Server("bare")
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
|
||||
async with mounted_app(
|
||||
server, auth=auth_settings(identity_assertion_enabled=True), auth_server_provider=provider
|
||||
) as (http, _):
|
||||
response = await http.get(ASM_ROOT)
|
||||
|
||||
assert response.status_code == 200
|
||||
metadata = OAuthMetadata.model_validate_json(response.content)
|
||||
assert metadata.grant_types_supported is not None
|
||||
assert JWT_BEARER_GRANT_TYPE in metadata.grant_types_supported
|
||||
assert metadata.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
|
||||
397
tests/server/auth/test_identity_assertion.py
Normal file
397
tests/server/auth/test_identity_assertion.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"""Server-side SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) handling."""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
IdentityAssertionParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
TokenError,
|
||||
)
|
||||
from mcp.server.auth.routes import build_metadata, create_auth_routes
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
|
||||
|
||||
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
|
||||
VALID_ASSERTION = "valid-id-jag"
|
||||
CONFIDENTIAL_CLIENT_ID = "enterprise-client"
|
||||
CONFIDENTIAL_CLIENT_SECRET = "enterprise-secret"
|
||||
|
||||
|
||||
class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
||||
"""A provider that implements `exchange_identity_assertion`; everything else is unused here."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.tokens: dict[str, AccessToken] = {}
|
||||
self.last_params: IdentityAssertionParams | None = None
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
assert client_info.client_id is not None
|
||||
self.clients[client_info.client_id] = client_info
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
return self.tokens.get(token)
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_identity_assertion(
|
||||
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
|
||||
) -> OAuthToken:
|
||||
self.last_params = params
|
||||
# Stand-in for RFC 7523 §3 / SEP-990 §5.1 assertion validation.
|
||||
if params.assertion != VALID_ASSERTION:
|
||||
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
|
||||
assert client.client_id is not None
|
||||
scopes = params.scopes or ["mcp"]
|
||||
access = f"access_{secrets.token_hex(16)}"
|
||||
self.tokens[access] = AccessToken(
|
||||
token=access,
|
||||
client_id=client.client_id,
|
||||
scopes=scopes,
|
||||
expires_at=int(time.time()) + 3600,
|
||||
resource=params.resource,
|
||||
subject="assertion-user",
|
||||
)
|
||||
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> IdentityAssertionProvider:
|
||||
prov = IdentityAssertionProvider()
|
||||
# Pre-register a confidential client (DCR refuses the grant; see the DCR test).
|
||||
prov.clients[CONFIDENTIAL_CLIENT_ID] = OAuthClientInformationFull(
|
||||
client_id=CONFIDENTIAL_CLIENT_ID,
|
||||
client_secret=CONFIDENTIAL_CLIENT_SECRET,
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
return prov
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(provider: IdentityAssertionProvider) -> Starlette:
|
||||
routes = create_auth_routes(
|
||||
provider,
|
||||
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
|
||||
revocation_options=RevocationOptions(enabled=False),
|
||||
identity_assertion_enabled=True,
|
||||
)
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app: Starlette):
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
yield http
|
||||
|
||||
|
||||
def assertion_form(**overrides: str) -> dict[str, str]:
|
||||
form = {
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"client_id": CONFIDENTIAL_CLIENT_ID,
|
||||
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
|
||||
"assertion": VALID_ASSERTION,
|
||||
}
|
||||
form.update(overrides)
|
||||
return form
|
||||
|
||||
|
||||
def test_build_metadata_advertises_id_jag_profile_when_enabled():
|
||||
enabled = build_metadata(
|
||||
AnyHttpUrl("https://auth.example.com"),
|
||||
None,
|
||||
ClientRegistrationOptions(),
|
||||
RevocationOptions(),
|
||||
supports_identity_assertion=True,
|
||||
)
|
||||
assert JWT_BEARER_GRANT_TYPE in (enabled.grant_types_supported or [])
|
||||
assert enabled.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
|
||||
# The grant is confidential-only, so the `none` auth method is NOT advertised.
|
||||
assert "none" not in (enabled.token_endpoint_auth_methods_supported or [])
|
||||
|
||||
disabled = build_metadata(
|
||||
AnyHttpUrl("https://auth.example.com"),
|
||||
None,
|
||||
ClientRegistrationOptions(),
|
||||
RevocationOptions(),
|
||||
)
|
||||
assert JWT_BEARER_GRANT_TYPE not in (disabled.grant_types_supported or [])
|
||||
assert disabled.authorization_grant_profiles_supported is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient):
|
||||
response = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert JWT_BEARER_GRANT_TYPE in body["grant_types_supported"]
|
||||
assert body["authorization_grant_profiles_supported"] == [ID_JAG_GRANT_PROFILE]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
||||
response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp"))
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
body = response.json()
|
||||
assert body["token_type"] == "Bearer"
|
||||
assert "issued_token_type" not in body # plain RFC 6749 response under jwt-bearer
|
||||
|
||||
issued = await provider.load_access_token(body["access_token"])
|
||||
assert issued is not None
|
||||
assert issued.scopes == ["mcp"]
|
||||
assert issued.subject == "assertion-user"
|
||||
|
||||
assert provider.last_params is not None
|
||||
assert provider.last_params.assertion == VALID_ASSERTION
|
||||
assert provider.last_params.scopes == ["mcp"]
|
||||
assert provider.last_params.resource == "https://mcp.example.com/mcp"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient):
|
||||
response = await client.post("/token", data=assertion_form(assertion="forged"))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"error": "invalid_grant", "error_description": "assertion is not valid"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejected_when_disabled(provider: IdentityAssertionProvider):
|
||||
routes = create_auth_routes(
|
||||
provider,
|
||||
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
|
||||
revocation_options=RevocationOptions(enabled=False),
|
||||
identity_assertion_enabled=False,
|
||||
)
|
||||
app = Starlette(routes=routes)
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
response = await http.post("/token", data=assertion_form())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "unsupported_grant_type"
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
||||
"""A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion."""
|
||||
provider.clients["public-client"] = OAuthClientInformationFull(
|
||||
client_id="public-client",
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="none",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": VALID_ASSERTION},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "unauthorized_client"
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejects_secretless_confidential_client(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A client registered with a secret-based method but no stored secret fails authentication.
|
||||
|
||||
`ClientAuthenticator` rejects this misconfiguration as `invalid_client`, so the request never
|
||||
reaches the jwt-bearer handler or the provider hook.
|
||||
"""
|
||||
provider.clients["secretless-client"] = OAuthClientInformationFull(
|
||||
client_id="secretless-client",
|
||||
client_secret=None,
|
||||
redirect_uris=None,
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "secretless-client", "assertion": VALID_ASSERTION},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
body = response.json()
|
||||
assert body["error"] == "invalid_client"
|
||||
assert "no stored secret" in body["error_description"]
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient):
|
||||
"""A jwt-bearer request without the required `assertion` fails validation with invalid_request."""
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"client_id": CONFIDENTIAL_CLIENT_ID,
|
||||
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "invalid_request"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_without_the_grant_registered_is_rejected(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A confidential client whose registration omits the jwt-bearer grant is refused the grant."""
|
||||
provider.clients["no-grant-client"] = OAuthClientInformationFull(
|
||||
client_id="no-grant-client",
|
||||
client_secret="s",
|
||||
redirect_uris=None,
|
||||
grant_types=["authorization_code"],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"client_id": "no-grant-client",
|
||||
"client_secret": "s",
|
||||
"assertion": VALID_ASSERTION,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "unsupported_grant_type"
|
||||
assert provider.last_params is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dcr_refuses_to_register_the_jwt_bearer_grant(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration."""
|
||||
response = await client.post(
|
||||
"/register",
|
||||
json={
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", JWT_BEARER_GRANT_TYPE],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["error"] == "invalid_client_metadata"
|
||||
assert JWT_BEARER_GRANT_TYPE in body["error_description"]
|
||||
|
||||
# A registration without the jwt-bearer grant still succeeds and is stored.
|
||||
ok = await client.post(
|
||||
"/register",
|
||||
json={
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
assert ok.json()["client_id"] in provider.clients
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_provider_rejects_identity_assertion():
|
||||
"""A provider that does not override `exchange_identity_assertion` rejects with unsupported_grant_type."""
|
||||
|
||||
class BareProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
bare = BareProvider()
|
||||
client_info = OAuthClientInformationFull(
|
||||
redirect_uris=None,
|
||||
client_id="c",
|
||||
grant_types=[JWT_BEARER_GRANT_TYPE],
|
||||
)
|
||||
params = IdentityAssertionParams(assertion=VALID_ASSERTION)
|
||||
with pytest.raises(TokenError) as excinfo:
|
||||
await bare.exchange_identity_assertion(client_info, params)
|
||||
assert excinfo.value.error == "unsupported_grant_type"
|
||||
Loading…
Reference in a new issue