Skip to content

Commit 0022629

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 b16b652 commit 0022629

9 files changed

Lines changed: 617 additions & 1121 deletions

File tree

.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
@@ -296,9 +305,98 @@ async def run_auth_code_client(server_url: str) -> None:
296305
await _run_auth_session(server_url, oauth_auth)
297306

298307

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

345-
# Perform token exchange flow
346-
async with httpx.AsyncClient() as client:
347-
# Step 1: Set OAuth metadata manually (since we're not going through full OAuth flow)
348-
logger.debug(f"Setting OAuth metadata for {server_url}")
349-
from pydantic import AnyUrl as PydanticAnyUrl
350-
351-
from mcp.shared.auth import OAuthMetadata
352-
353-
# Extract base URL from server_url
354-
base_url = server_url.replace("/mcp", "")
355-
token_endpoint_url = f"{base_url}/oauth/token"
356-
auth_endpoint_url = f"{base_url}/oauth/authorize"
357-
358-
enterprise_auth.context.oauth_metadata = OAuthMetadata(
359-
issuer=mcp_server_auth_issuer,
360-
authorization_endpoint=PydanticAnyUrl(auth_endpoint_url),
361-
token_endpoint=PydanticAnyUrl(token_endpoint_url),
362-
)
363-
logger.debug(f"OAuth metadata set, token_endpoint: {token_endpoint_url}")
364-
365-
# Step 2: Exchange ID token for ID-JAG
366-
logger.debug("Exchanging ID token for ID-JAG")
367-
id_jag = await enterprise_auth.exchange_token_for_id_jag(client)
368-
logger.debug(f"Obtained ID-JAG: {id_jag[:50]}...")
369-
370-
# Step 3: Exchange ID-JAG for access token
371-
logger.debug("Exchanging ID-JAG for access token")
372-
access_token = await enterprise_auth.exchange_id_jag_for_access_token(client, id_jag)
373-
logger.debug(f"Obtained access token, expires in: {access_token.expires_in}s")
374-
375-
# Step 4: Verify we can make authenticated requests
376-
logger.debug("Verifying access token with MCP endpoint")
377-
auth_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {access_token.access_token}"})
378-
response = await auth_client.get(server_url.replace("/mcp", "") + "/mcp")
379-
if response.status_code == 200:
380-
logger.debug(f"Successfully authenticated with MCP server: {response.json()}")
381-
else:
382-
logger.warning(f"MCP server returned {response.status_code}")
383-
384-
logger.debug("Enterprise auth flow completed successfully")
443+
await _run_auth_session(server_url, enterprise_auth)
385444

386445

387446
@register("auth/enterprise-saml-exchange")
388447
async def run_enterprise_saml_exchange(server_url: str) -> None:
389-
"""Enterprise managed auth: SAML assertion exchange flow."""
448+
"""Enterprise managed auth: SAML assertion exchange flow (RFC 8693)."""
390449
from mcp.client.auth.extensions.enterprise_managed_auth import (
391450
EnterpriseAuthOAuthClientProvider,
392451
TokenExchangeParameters,
@@ -430,51 +489,12 @@ async def run_enterprise_saml_exchange(server_url: str) -> None:
430489
token_exchange_params=token_exchange_params,
431490
)
432491

433-
# Perform token exchange flow
434-
async with httpx.AsyncClient() as client:
435-
# Step 1: Set OAuth metadata manually (since we're not going through full OAuth flow)
436-
logger.debug(f"Setting OAuth metadata for {server_url}")
437-
from pydantic import AnyUrl as PydanticAnyUrl
438-
439-
from mcp.shared.auth import OAuthMetadata
440-
441-
# Extract base URL from server_url
442-
base_url = server_url.replace("/mcp", "")
443-
token_endpoint_url = f"{base_url}/oauth/token"
444-
auth_endpoint_url = f"{base_url}/oauth/authorize"
445-
446-
enterprise_auth.context.oauth_metadata = OAuthMetadata(
447-
issuer=mcp_server_auth_issuer,
448-
authorization_endpoint=PydanticAnyUrl(auth_endpoint_url),
449-
token_endpoint=PydanticAnyUrl(token_endpoint_url),
450-
)
451-
logger.debug(f"OAuth metadata set, token_endpoint: {token_endpoint_url}")
452-
453-
# Step 2: Exchange SAML assertion for ID-JAG
454-
logger.debug("Exchanging SAML assertion for ID-JAG")
455-
id_jag = await enterprise_auth.exchange_token_for_id_jag(client)
456-
logger.debug(f"Obtained ID-JAG from SAML: {id_jag[:50]}...")
457-
458-
# Step 3: Exchange ID-JAG for access token
459-
logger.debug("Exchanging ID-JAG for access token")
460-
access_token = await enterprise_auth.exchange_id_jag_for_access_token(client, id_jag)
461-
logger.debug(f"Obtained access token, expires in: {access_token.expires_in}s")
462-
463-
# Step 4: Verify we can make authenticated requests
464-
logger.debug("Verifying access token with MCP endpoint")
465-
auth_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {access_token.access_token}"})
466-
response = await auth_client.get(server_url.replace("/mcp", "") + "/mcp")
467-
if response.status_code == 200:
468-
logger.debug(f"Successfully authenticated with MCP server: {response.json()}")
469-
else:
470-
logger.warning(f"MCP server returned {response.status_code}")
471-
472-
logger.debug("SAML enterprise auth flow completed successfully")
492+
await _run_auth_session(server_url, enterprise_auth)
473493

474494

475495
@register("auth/enterprise-id-jag-validation")
476496
async def run_id_jag_validation(server_url: str) -> None:
477-
"""Validate ID-JAG token structure and claims."""
497+
"""Validate ID-JAG token structure and claims (SEP-990)."""
478498
from mcp.client.auth.extensions.enterprise_managed_auth import (
479499
EnterpriseAuthOAuthClientProvider,
480500
TokenExchangeParameters,
@@ -528,7 +548,7 @@ async def run_id_jag_validation(server_url: str) -> None:
528548
# Validate required claims
529549
assert claims.typ == "oauth-id-jag+jwt", f"Invalid typ: {claims.typ}"
530550
assert claims.jti, "Missing jti claim"
531-
assert claims.iss == mcp_server_auth_issuer or claims.iss, "Missing or invalid iss claim"
551+
assert claims.iss, "Missing iss claim"
532552
assert claims.sub, "Missing sub claim"
533553
assert claims.aud, "Missing aud claim"
534554
assert claims.resource == mcp_server_resource_id, f"Invalid resource: {claims.resource}"

0 commit comments

Comments
 (0)