Skip to content

Commit 871a7cc

Browse files
committed
feat: Add conformance tests for enterprise managed authorization (SEP-990)
Implements comprehensive conformance testing for enterprise managed authorization flows including RFC 8693 token exchange and RFC 7523 JWT bearer grant with ID-JAG tokens. - Add 3 conformance test scenarios to client.py: * auth/enterprise-id-jag-validation - Validates ID-JAG token structure * auth/enterprise-token-exchange - Tests OIDC ID Token → ID-JAG → Access Token flow * auth/enterprise-saml-exchange - Tests SAML Assertion → ID-JAG → Access Token flow - Create enterprise_auth_server.py: * Implements RFC 8693 token exchange endpoint (/token-exchange) * Implements RFC 7523 JWT bearer grant endpoint (/oauth/token) * Provides OAuth metadata endpoint for discovery * Supports both OIDC ID tokens and SAML assertions * Issues ID-JAG tokens with proper structure (typ: oauth-id-jag+jwt) * Validates bearer tokens and provides protected MCP endpoints - Add run-enterprise-auth-with-server.sh: * Starts mock server on port 3002 * Dynamically fetches test context * Runs all 3 enterprise auth scenarios * Reports detailed test results * Cleans up servers on exit - Update conformance.yml workflow: * Add enterprise-auth-conformance job * Runs on every pull request * Marked as optional (continue-on-error: true) * Tests run in parallel with other conformance checks - Add fastapi>=0.115.0 to dev dependencies * Required for mock server implementation * Only needed for conformance testing * Update uv.lock accordingly - Fix docstring formatting in enterprise_managed_auth_client.py: * Update get_id_token_from_idp() to follow PEP 257 (D212) * Fix multi-line docstring to start summary on first line * Apply fix to both example file and README.md * Ensures all example tests pass - Minor updates to enterprise_managed_auth.py: * Improve error handling * Add validation for token exchange parameters ✅ ID-JAG Token Validation ✅ OIDC ID Token Exchange Flow ✅ SAML Assertion Exchange Flow - RFC 8693 Token Exchange (ID Token and SAML) - RFC 7523 JWT Bearer Grant - ID-JAG token structure validation - OAuth metadata discovery - Bearer token authentication - Error handling and edge cases - SEP-990: Enterprise Managed Authorization - RFC 8693: OAuth 2.0 Token Exchange - RFC 7523: JWT Profile for OAuth 2.0 Client Authentication - RFC 8707: Resource Indicators for OAuth 2.0 Run conformance tests: ```bash ./.github/actions/conformance/run-enterprise-auth-with-server.sh # Conflicts: # .github/workflows/conformance.yml
1 parent ab8539a commit 871a7cc

File tree

9 files changed

+808
-20
lines changed

9 files changed

+808
-20
lines changed

.github/actions/conformance/client.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
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)
1922
auth/* - Authorization code flow (default for auth scenarios)
2023
"""
2124

@@ -314,6 +317,255 @@ async def run_auth_code_client(server_url: str) -> None:
314317
await _run_auth_session(server_url, oauth_auth)
315318

316319

320+
@register("auth/enterprise-token-exchange")
321+
async def run_enterprise_token_exchange(server_url: str) -> None:
322+
"""Enterprise managed auth: Token exchange flow (RFC 8693)."""
323+
from mcp.client.auth.extensions.enterprise_managed_auth import (
324+
EnterpriseAuthOAuthClientProvider,
325+
TokenExchangeParameters,
326+
)
327+
328+
context = get_conformance_context()
329+
id_token = context.get("id_token")
330+
idp_token_endpoint = context.get("idp_token_endpoint")
331+
mcp_server_auth_issuer = context.get("mcp_server_auth_issuer")
332+
mcp_server_resource_id = context.get("mcp_server_resource_id")
333+
scope = context.get("scope")
334+
335+
if not id_token:
336+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'id_token'")
337+
if not idp_token_endpoint:
338+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
339+
if not mcp_server_auth_issuer:
340+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_auth_issuer'")
341+
if not mcp_server_resource_id:
342+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_resource_id'")
343+
344+
# Create token exchange parameters
345+
token_exchange_params = TokenExchangeParameters.from_id_token(
346+
id_token=id_token,
347+
mcp_server_auth_issuer=mcp_server_auth_issuer,
348+
mcp_server_resource_id=mcp_server_resource_id,
349+
scope=scope,
350+
)
351+
352+
# Create enterprise auth provider
353+
enterprise_auth = EnterpriseAuthOAuthClientProvider(
354+
server_url=server_url,
355+
client_metadata=OAuthClientMetadata(
356+
client_name="conformance-enterprise-client",
357+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
358+
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
359+
response_types=["token"],
360+
),
361+
storage=InMemoryTokenStorage(),
362+
idp_token_endpoint=idp_token_endpoint,
363+
token_exchange_params=token_exchange_params,
364+
)
365+
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")
406+
407+
408+
@register("auth/enterprise-saml-exchange")
409+
async def run_enterprise_saml_exchange(server_url: str) -> None:
410+
"""Enterprise managed auth: SAML assertion exchange flow."""
411+
from mcp.client.auth.extensions.enterprise_managed_auth import (
412+
EnterpriseAuthOAuthClientProvider,
413+
TokenExchangeParameters,
414+
)
415+
416+
context = get_conformance_context()
417+
saml_assertion = context.get("saml_assertion")
418+
idp_token_endpoint = context.get("idp_token_endpoint")
419+
mcp_server_auth_issuer = context.get("mcp_server_auth_issuer")
420+
mcp_server_resource_id = context.get("mcp_server_resource_id")
421+
scope = context.get("scope")
422+
423+
if not saml_assertion:
424+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'saml_assertion'")
425+
if not idp_token_endpoint:
426+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
427+
if not mcp_server_auth_issuer:
428+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_auth_issuer'")
429+
if not mcp_server_resource_id:
430+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_resource_id'")
431+
432+
# Create token exchange parameters for SAML
433+
token_exchange_params = TokenExchangeParameters.from_saml_assertion(
434+
saml_assertion=saml_assertion,
435+
mcp_server_auth_issuer=mcp_server_auth_issuer,
436+
mcp_server_resource_id=mcp_server_resource_id,
437+
scope=scope,
438+
)
439+
440+
# Create enterprise auth provider
441+
enterprise_auth = EnterpriseAuthOAuthClientProvider(
442+
server_url=server_url,
443+
client_metadata=OAuthClientMetadata(
444+
client_name="conformance-enterprise-saml-client",
445+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
446+
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
447+
response_types=["token"],
448+
),
449+
storage=InMemoryTokenStorage(),
450+
idp_token_endpoint=idp_token_endpoint,
451+
token_exchange_params=token_exchange_params,
452+
)
453+
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")
494+
495+
496+
@register("auth/enterprise-id-jag-validation")
497+
async def run_id_jag_validation(server_url: str) -> None:
498+
"""Validate ID-JAG token structure and claims."""
499+
from mcp.client.auth.extensions.enterprise_managed_auth import (
500+
EnterpriseAuthOAuthClientProvider,
501+
TokenExchangeParameters,
502+
decode_id_jag,
503+
validate_token_exchange_params,
504+
)
505+
506+
context = get_conformance_context()
507+
id_token = context.get("id_token")
508+
idp_token_endpoint = context.get("idp_token_endpoint")
509+
mcp_server_auth_issuer = context.get("mcp_server_auth_issuer")
510+
mcp_server_resource_id = context.get("mcp_server_resource_id")
511+
512+
if not all([id_token, idp_token_endpoint, mcp_server_auth_issuer, mcp_server_resource_id]):
513+
raise RuntimeError("Missing required context parameters for ID-JAG validation")
514+
515+
# Create and validate token exchange parameters
516+
token_exchange_params = TokenExchangeParameters.from_id_token(
517+
id_token=id_token,
518+
mcp_server_auth_issuer=mcp_server_auth_issuer,
519+
mcp_server_resource_id=mcp_server_resource_id,
520+
)
521+
522+
logger.debug("Validating token exchange parameters")
523+
validate_token_exchange_params(token_exchange_params)
524+
logger.debug("Token exchange parameters validated successfully")
525+
526+
# Create enterprise auth provider
527+
enterprise_auth = EnterpriseAuthOAuthClientProvider(
528+
server_url=server_url,
529+
client_metadata=OAuthClientMetadata(
530+
client_name="conformance-validation-client",
531+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
532+
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
533+
response_types=["token"],
534+
),
535+
storage=InMemoryTokenStorage(),
536+
idp_token_endpoint=idp_token_endpoint,
537+
token_exchange_params=token_exchange_params,
538+
)
539+
540+
async with httpx.AsyncClient() as client:
541+
# Get ID-JAG
542+
id_jag = await enterprise_auth.exchange_token_for_id_jag(client)
543+
logger.debug(f"Obtained ID-JAG for validation: {id_jag[:50]}...")
544+
545+
# Decode and validate ID-JAG claims
546+
logger.debug("Decoding ID-JAG token")
547+
claims = decode_id_jag(id_jag)
548+
549+
# Validate required claims
550+
assert claims.typ == "oauth-id-jag+jwt", f"Invalid typ: {claims.typ}"
551+
assert claims.jti, "Missing jti claim"
552+
assert claims.iss == mcp_server_auth_issuer or claims.iss, "Missing or invalid iss claim"
553+
assert claims.sub, "Missing sub claim"
554+
assert claims.aud, "Missing aud claim"
555+
assert claims.resource == mcp_server_resource_id, f"Invalid resource: {claims.resource}"
556+
assert claims.client_id, "Missing client_id claim"
557+
assert claims.exp > claims.iat, "Invalid expiration"
558+
559+
logger.debug("ID-JAG validated successfully:")
560+
logger.debug(f" Subject: {claims.sub}")
561+
logger.debug(f" Issuer: {claims.iss}")
562+
logger.debug(f" Audience: {claims.aud}")
563+
logger.debug(f" Resource: {claims.resource}")
564+
logger.debug(f" Client ID: {claims.client_id}")
565+
566+
logger.debug("ID-JAG validation completed successfully")
567+
568+
317569
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
318570
"""Common session logic for all OAuth flows."""
319571
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)

0 commit comments

Comments
 (0)