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:
Marcelo Trylesinski 2026-06-26 17:57:10 +02:00 • committed by GitHub
commit c0ecb70e24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1830 additions and 14 deletions

View 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")

View file

@ -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

View file

@ -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
# ═══════════════════════════════════════════════════════════════════════════

View file

@ -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,
)

View file

@ -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

View 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]

View 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"