Skip to content

Commit 0738723

Browse files
committed
feat(auth): migrate enterprise auth conformance tests to @modelcontextprotocol/conformance v0.1.14
Refactored enterprise managed authorization implementation and conformance tests to use the official @modelcontextprotocol/conformance npm package (v0.1.14) instead of custom mock servers. **Key Changes:** **Implementation:** - Refactored `EnterpriseAuthOAuthClientProvider._perform_authorization()` to return `httpx.Request` instead of executing requests directly - Moved error handling to parent class `OAuthClientProvider.async_auth_flow()` - Updated method signatures: - `exchange_token_for_id_jag(client)` → returns `str` (ID-JAG) - `exchange_id_jag_for_access_token(id_jag)` → returns `httpx.Request` (not OAuthToken) **Conformance Tests:** - Migrated from custom mock server to official conformance package v0.1.14 - Removed custom `enterprise_auth_server.py` (332 lines) - Removed custom `run-enterprise-auth-with-server.sh` (169 lines) - Added `run-enterprise-auth-conformance.sh` using official conformance scenarios - Updated `client.py` to support SEP-990 conformance tests: - `auth/cross-app-access-complete-flow` - `auth/enterprise-token-exchange` - `auth/enterprise-jwt-bearer` - All 9/9 conformance checks passing **Tests:** - Added 2 new test cases for 100% code coverage - Updated 32 existing tests to match new implementation - Removed 3 duplicate/skipped tests - Total: 29 tests passing, 0 failing, 100% coverage **Documentation:** - Updated `README.md` Enterprise Managed Authorization section - Replaced manual token exchange examples with automatic auth flow pattern - Added advanced manual flow example showing correct method signatures - Updated `examples/snippets/clients/enterprise_managed_auth_client.py` - Removed unused imports **Related:** - PR: modelcontextprotocol/conformance#110 - Spec: SEP-990 (Enterprise Managed Authorization) - Package: @modelcontextprotocol/conformance@0.1.14 **Testing:** - ✅ All unit tests passing - ✅ 100% code coverage for enterprise_managed_auth.py - ✅ Conformance tests passing (9/9 checks) - ✅ GitHub Actions workflow updated
1 parent 1a2ea18 commit 0738723

File tree

9 files changed

+617
-1121
lines changed

9 files changed

+617
-1121
lines changed

.github/actions/conformance/client.py

Lines changed: 108 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
Contract:
77
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
8-
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
8+
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for auth scenarios)
99
- Server URL as last CLI argument (sys.argv[1])
1010
- Must exit 0 within 30 seconds
1111
@@ -16,10 +16,19 @@
1616
elicitation-sep1034-client-defaults - Elicitation with default accept callback
1717
auth/client-credentials-jwt - Client credentials with private_key_jwt
1818
auth/client-credentials-basic - Client credentials with client_secret_basic
19-
auth/enterprise-token-exchange - Enterprise auth with OIDC ID token (SEP-990)
20-
auth/enterprise-saml-exchange - Enterprise auth with SAML assertion (SEP-990)
21-
auth/enterprise-id-jag-validation - Validate ID-JAG token structure (SEP-990)
19+
auth/cross-app-access-complete-flow - Enterprise managed OAuth (SEP-990) - v0.1.14+
20+
auth/enterprise-token-exchange - Enterprise auth with OIDC ID token (legacy name)
21+
auth/enterprise-saml-exchange - Enterprise auth with SAML assertion (legacy name)
22+
auth/enterprise-id-jag-validation - Validate ID-JAG token structure (legacy name)
2223
auth/* - Authorization code flow (default for auth scenarios)
24+
25+
Enterprise Auth (SEP-990):
26+
The conformance package v0.1.14+ (https://github.com/modelcontextprotocol/conformance/pull/110)
27+
provides the scenario 'auth/cross-app-access-complete-flow' which tests the complete
28+
enterprise managed OAuth flow: IDP ID token → ID-JAG → access token.
29+
30+
The client receives test context (idp_id_token, idp_token_endpoint, etc.) via
31+
MCP_CONFORMANCE_CONTEXT environment variable and performs the token exchange flows automatically.
2332
"""
2433

2534
import asyncio
@@ -317,9 +326,98 @@ async def run_auth_code_client(server_url: str) -> None:
317326
await _run_auth_session(server_url, oauth_auth)
318327

319328

329+
@register("auth/cross-app-access-complete-flow")
330+
async def run_cross_app_access_complete_flow(server_url: str) -> None:
331+
"""Enterprise managed auth: Complete SEP-990 flow (OIDC ID token → ID-JAG → access token).
332+
333+
This scenario is provided by @modelcontextprotocol/conformance@0.1.14+ (PR #110).
334+
It tests the complete enterprise managed OAuth flow using token exchange (RFC 8693)
335+
and JWT bearer grant (RFC 7523).
336+
"""
337+
from mcp.client.auth.extensions.enterprise_managed_auth import (
338+
EnterpriseAuthOAuthClientProvider,
339+
TokenExchangeParameters,
340+
)
341+
342+
context = get_conformance_context()
343+
# The conformance package provides these fields
344+
idp_id_token = context.get("idp_id_token")
345+
idp_token_endpoint = context.get("idp_token_endpoint")
346+
idp_issuer = context.get("idp_issuer")
347+
348+
# For cross-app access, we need to determine the MCP server's resource ID and auth issuer
349+
# The conformance package sets up the auth server, and the MCP server URL is passed to us
350+
351+
if not idp_id_token:
352+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'")
353+
if not idp_token_endpoint:
354+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
355+
if not idp_issuer:
356+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_issuer'")
357+
358+
# Extract base URL and construct auth issuer and resource ID
359+
# The conformance test sets up auth server at a known location
360+
base_url = server_url.replace("/mcp", "")
361+
auth_issuer = context.get("auth_issuer", base_url)
362+
resource_id = context.get("resource_id", server_url)
363+
364+
logger.debug(f"Cross-app access flow:")
365+
logger.debug(f" IDP Issuer: {idp_issuer}")
366+
logger.debug(f" IDP Token Endpoint: {idp_token_endpoint}")
367+
logger.debug(f" Auth Issuer: {auth_issuer}")
368+
logger.debug(f" Resource ID: {resource_id}")
369+
370+
# Create token exchange parameters from IDP ID token
371+
token_exchange_params = TokenExchangeParameters.from_id_token(
372+
id_token=idp_id_token,
373+
mcp_server_auth_issuer=auth_issuer,
374+
mcp_server_resource_id=resource_id,
375+
scope=context.get("scope"),
376+
)
377+
378+
# Get pre-configured client credentials from context (if provided)
379+
client_id = context.get("client_id")
380+
client_secret = context.get("client_secret")
381+
382+
# Create storage and pre-configure client info if credentials are provided
383+
storage = InMemoryTokenStorage()
384+
385+
# Create enterprise auth provider
386+
enterprise_auth = EnterpriseAuthOAuthClientProvider(
387+
server_url=server_url,
388+
client_metadata=OAuthClientMetadata(
389+
client_name="conformance-cross-app-client",
390+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
391+
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
392+
response_types=["token"],
393+
),
394+
storage=storage,
395+
idp_token_endpoint=idp_token_endpoint,
396+
token_exchange_params=token_exchange_params,
397+
)
398+
399+
# If client credentials are provided in context, use them instead of dynamic registration
400+
if client_id and client_secret:
401+
from mcp.shared.auth import OAuthClientInformationFull
402+
403+
logger.debug(f"Using pre-configured client credentials: {client_id}")
404+
client_info = OAuthClientInformationFull(
405+
client_id=client_id,
406+
client_secret=client_secret,
407+
token_endpoint_auth_method="client_secret_basic",
408+
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
409+
response_types=["token"],
410+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
411+
)
412+
enterprise_auth.context.client_info = client_info
413+
await storage.set_client_info(client_info)
414+
415+
await _run_auth_session(server_url, enterprise_auth)
416+
417+
320418
@register("auth/enterprise-token-exchange")
321419
async def run_enterprise_token_exchange(server_url: str) -> None:
322-
"""Enterprise managed auth: Token exchange flow (RFC 8693)."""
420+
"""Enterprise managed auth: Token exchange flow (RFC 8693) with OIDC ID token."""
323421
from mcp.client.auth.extensions.enterprise_managed_auth import (
324422
EnterpriseAuthOAuthClientProvider,
325423
TokenExchangeParameters,
@@ -363,51 +461,12 @@ async def run_enterprise_token_exchange(server_url: str) -> None:
363461
token_exchange_params=token_exchange_params,
364462
)
365463

366-
# Perform token exchange flow
367-
async with httpx.AsyncClient() as client:
368-
# Step 1: Set OAuth metadata manually (since we're not going through full OAuth flow)
369-
logger.debug(f"Setting OAuth metadata for {server_url}")
370-
from pydantic import AnyUrl as PydanticAnyUrl
371-
372-
from mcp.shared.auth import OAuthMetadata
373-
374-
# Extract base URL from server_url
375-
base_url = server_url.replace("/mcp", "")
376-
token_endpoint_url = f"{base_url}/oauth/token"
377-
auth_endpoint_url = f"{base_url}/oauth/authorize"
378-
379-
enterprise_auth.context.oauth_metadata = OAuthMetadata(
380-
issuer=mcp_server_auth_issuer,
381-
authorization_endpoint=PydanticAnyUrl(auth_endpoint_url),
382-
token_endpoint=PydanticAnyUrl(token_endpoint_url),
383-
)
384-
logger.debug(f"OAuth metadata set, token_endpoint: {token_endpoint_url}")
385-
386-
# Step 2: Exchange ID token for ID-JAG
387-
logger.debug("Exchanging ID token for ID-JAG")
388-
id_jag = await enterprise_auth.exchange_token_for_id_jag(client)
389-
logger.debug(f"Obtained ID-JAG: {id_jag[:50]}...")
390-
391-
# Step 3: Exchange ID-JAG for access token
392-
logger.debug("Exchanging ID-JAG for access token")
393-
access_token = await enterprise_auth.exchange_id_jag_for_access_token(client, id_jag)
394-
logger.debug(f"Obtained access token, expires in: {access_token.expires_in}s")
395-
396-
# Step 4: Verify we can make authenticated requests
397-
logger.debug("Verifying access token with MCP endpoint")
398-
auth_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {access_token.access_token}"})
399-
response = await auth_client.get(server_url.replace("/mcp", "") + "/mcp")
400-
if response.status_code == 200:
401-
logger.debug(f"Successfully authenticated with MCP server: {response.json()}")
402-
else:
403-
logger.warning(f"MCP server returned {response.status_code}")
404-
405-
logger.debug("Enterprise auth flow completed successfully")
464+
await _run_auth_session(server_url, enterprise_auth)
406465

407466

408467
@register("auth/enterprise-saml-exchange")
409468
async def run_enterprise_saml_exchange(server_url: str) -> None:
410-
"""Enterprise managed auth: SAML assertion exchange flow."""
469+
"""Enterprise managed auth: SAML assertion exchange flow (RFC 8693)."""
411470
from mcp.client.auth.extensions.enterprise_managed_auth import (
412471
EnterpriseAuthOAuthClientProvider,
413472
TokenExchangeParameters,
@@ -451,51 +510,12 @@ async def run_enterprise_saml_exchange(server_url: str) -> None:
451510
token_exchange_params=token_exchange_params,
452511
)
453512

454-
# Perform token exchange flow
455-
async with httpx.AsyncClient() as client:
456-
# Step 1: Set OAuth metadata manually (since we're not going through full OAuth flow)
457-
logger.debug(f"Setting OAuth metadata for {server_url}")
458-
from pydantic import AnyUrl as PydanticAnyUrl
459-
460-
from mcp.shared.auth import OAuthMetadata
461-
462-
# Extract base URL from server_url
463-
base_url = server_url.replace("/mcp", "")
464-
token_endpoint_url = f"{base_url}/oauth/token"
465-
auth_endpoint_url = f"{base_url}/oauth/authorize"
466-
467-
enterprise_auth.context.oauth_metadata = OAuthMetadata(
468-
issuer=mcp_server_auth_issuer,
469-
authorization_endpoint=PydanticAnyUrl(auth_endpoint_url),
470-
token_endpoint=PydanticAnyUrl(token_endpoint_url),
471-
)
472-
logger.debug(f"OAuth metadata set, token_endpoint: {token_endpoint_url}")
473-
474-
# Step 2: Exchange SAML assertion for ID-JAG
475-
logger.debug("Exchanging SAML assertion for ID-JAG")
476-
id_jag = await enterprise_auth.exchange_token_for_id_jag(client)
477-
logger.debug(f"Obtained ID-JAG from SAML: {id_jag[:50]}...")
478-
479-
# Step 3: Exchange ID-JAG for access token
480-
logger.debug("Exchanging ID-JAG for access token")
481-
access_token = await enterprise_auth.exchange_id_jag_for_access_token(client, id_jag)
482-
logger.debug(f"Obtained access token, expires in: {access_token.expires_in}s")
483-
484-
# Step 4: Verify we can make authenticated requests
485-
logger.debug("Verifying access token with MCP endpoint")
486-
auth_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {access_token.access_token}"})
487-
response = await auth_client.get(server_url.replace("/mcp", "") + "/mcp")
488-
if response.status_code == 200:
489-
logger.debug(f"Successfully authenticated with MCP server: {response.json()}")
490-
else:
491-
logger.warning(f"MCP server returned {response.status_code}")
492-
493-
logger.debug("SAML enterprise auth flow completed successfully")
513+
await _run_auth_session(server_url, enterprise_auth)
494514

495515

496516
@register("auth/enterprise-id-jag-validation")
497517
async def run_id_jag_validation(server_url: str) -> None:
498-
"""Validate ID-JAG token structure and claims."""
518+
"""Validate ID-JAG token structure and claims (SEP-990)."""
499519
from mcp.client.auth.extensions.enterprise_managed_auth import (
500520
EnterpriseAuthOAuthClientProvider,
501521
TokenExchangeParameters,
@@ -549,7 +569,7 @@ async def run_id_jag_validation(server_url: str) -> None:
549569
# Validate required claims
550570
assert claims.typ == "oauth-id-jag+jwt", f"Invalid typ: {claims.typ}"
551571
assert claims.jti, "Missing jti claim"
552-
assert claims.iss == mcp_server_auth_issuer or claims.iss, "Missing or invalid iss claim"
572+
assert claims.iss, "Missing iss claim"
553573
assert claims.sub, "Missing sub claim"
554574
assert claims.aud, "Missing aud claim"
555575
assert claims.resource == mcp_server_resource_id, f"Invalid resource: {claims.resource}"

0 commit comments

Comments
 (0)