|
5 | 5 |
|
6 | 6 | Contract: |
7 | 7 | - 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) |
9 | 9 | - Server URL as last CLI argument (sys.argv[1]) |
10 | 10 | - Must exit 0 within 30 seconds |
11 | 11 |
|
|
16 | 16 | elicitation-sep1034-client-defaults - Elicitation with default accept callback |
17 | 17 | auth/client-credentials-jwt - Client credentials with private_key_jwt |
18 | 18 | 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) |
22 | 23 | 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. |
23 | 32 | """ |
24 | 33 |
|
25 | 34 | import asyncio |
@@ -317,9 +326,98 @@ async def run_auth_code_client(server_url: str) -> None: |
317 | 326 | await _run_auth_session(server_url, oauth_auth) |
318 | 327 |
|
319 | 328 |
|
| 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 | + |
320 | 418 | @register("auth/enterprise-token-exchange") |
321 | 419 | 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.""" |
323 | 421 | from mcp.client.auth.extensions.enterprise_managed_auth import ( |
324 | 422 | EnterpriseAuthOAuthClientProvider, |
325 | 423 | TokenExchangeParameters, |
@@ -363,51 +461,12 @@ async def run_enterprise_token_exchange(server_url: str) -> None: |
363 | 461 | token_exchange_params=token_exchange_params, |
364 | 462 | ) |
365 | 463 |
|
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) |
406 | 465 |
|
407 | 466 |
|
408 | 467 | @register("auth/enterprise-saml-exchange") |
409 | 468 | 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).""" |
411 | 470 | from mcp.client.auth.extensions.enterprise_managed_auth import ( |
412 | 471 | EnterpriseAuthOAuthClientProvider, |
413 | 472 | TokenExchangeParameters, |
@@ -451,51 +510,12 @@ async def run_enterprise_saml_exchange(server_url: str) -> None: |
451 | 510 | token_exchange_params=token_exchange_params, |
452 | 511 | ) |
453 | 512 |
|
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) |
494 | 514 |
|
495 | 515 |
|
496 | 516 | @register("auth/enterprise-id-jag-validation") |
497 | 517 | 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).""" |
499 | 519 | from mcp.client.auth.extensions.enterprise_managed_auth import ( |
500 | 520 | EnterpriseAuthOAuthClientProvider, |
501 | 521 | TokenExchangeParameters, |
@@ -549,7 +569,7 @@ async def run_id_jag_validation(server_url: str) -> None: |
549 | 569 | # Validate required claims |
550 | 570 | assert claims.typ == "oauth-id-jag+jwt", f"Invalid typ: {claims.typ}" |
551 | 571 | 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" |
553 | 573 | assert claims.sub, "Missing sub claim" |
554 | 574 | assert claims.aud, "Missing aud claim" |
555 | 575 | assert claims.resource == mcp_server_resource_id, f"Invalid resource: {claims.resource}" |
|
0 commit comments