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
4 changed files with 1830 additions and 14 deletions
|
|
@ -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]
|
||||
Loading…
Reference in a new issue