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
4 changed files with 1830 additions and 14 deletions

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]