Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
694df9c
- Implemented SEP-990 feature for providing support for Enterprise Ma…
BinoyOza-okta Nov 21, 2025
b7e15f2
Added test cases for missing lines of code.
BinoyOza-okta Nov 25, 2025
5071c78
- Added tests cases for few of the missing lines. src/mcp/client/auth…
BinoyOza-okta Nov 25, 2025
3c79818
- Fixed pre-commit errors.
BinoyOza-okta Nov 25, 2025
fb27df8
- Tried to fix the ruff error.
BinoyOza-okta Nov 25, 2025
b647ec8
- Fixed ruff errors.
BinoyOza-okta Nov 26, 2025
e9aad31
- Removed server side changes for enterprise_managed_auth.py
BinoyOza-okta Nov 26, 2025
578c38e
- Added README.md changes for SEP-990 implementation for enterprise m…
BinoyOza-okta Nov 26, 2025
e84df79
- Resolved pyright checks error.
BinoyOza-okta Nov 26, 2025
3057e0c
- Resolved README.md file fixes for removing unused imports.
BinoyOza-okta Nov 27, 2025
10ebba7
- Resolved pyright errors.
BinoyOza-okta Nov 27, 2025
4934924
- Added new test cases for the missing code lines.
BinoyOza-okta Nov 27, 2025
1cddcc5
- Fixed the failing test cases.
BinoyOza-okta Nov 27, 2025
3c74ecd
- Fixed the test cases.
BinoyOza-okta Nov 27, 2025
a9f5c31
- Added typing for request payload structures TokenExchangeRequestDat…
BinoyOza-okta Dec 12, 2025
ab8539a
- Updated test case to include IDJAGClaims type model to verify payload.
BinoyOza-okta Dec 12, 2025
871a7cc
feat: Add conformance tests for enterprise managed authorization (SEP…
BinoyOza-okta Jan 22, 2026
1a2ea18
- Fix uv.lock.
BinoyOza-okta Jan 30, 2026
0738723
feat(auth): migrate enterprise auth conformance tests to @modelcontex…
BinoyOza-okta Feb 19, 2026
eef2596
Fixed end-of-file-fixer for test_enterprise_managed_auth_client.py.
BinoyOza-okta Feb 19, 2026
61aee47
Fixed pre-commit hooks issues.
BinoyOza-okta Feb 19, 2026
fa3ab0f
Fixed Ruff formatting issues.
BinoyOza-okta Feb 19, 2026
bb1acbf
Fixed pyright issues.
BinoyOza-okta Feb 19, 2026
e41cadf
Moved the changes from README.md to README.v2.md.
BinoyOza-okta Feb 19, 2026
bd5f741
- Removed Unicode characters from the example snippet file.
BinoyOza-okta Feb 20, 2026
cd15337
- Removed unused parameters from the EnterpriseAuthOAuthClientProvide…
BinoyOza-okta Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 273 additions & 1 deletion .github/actions/conformance/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

Contract:
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for auth scenarios)
- Server URL as last CLI argument (sys.argv[1])
- Must exit 0 within 30 seconds

Expand All @@ -16,7 +16,19 @@
elicitation-sep1034-client-defaults - Elicitation with default accept callback
auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic
auth/cross-app-access-complete-flow - Enterprise managed OAuth (SEP-990) - v0.1.14+
auth/enterprise-token-exchange - Enterprise auth with OIDC ID token (legacy name)
auth/enterprise-saml-exchange - Enterprise auth with SAML assertion (legacy name)
auth/enterprise-id-jag-validation - Validate ID-JAG token structure (legacy name)
auth/* - Authorization code flow (default for auth scenarios)

Enterprise Auth (SEP-990):
The conformance package v0.1.14+ (https://github.com/modelcontextprotocol/conformance/pull/110)
provides the scenario 'auth/cross-app-access-complete-flow' which tests the complete
enterprise managed OAuth flow: IDP ID token → ID-JAG → access token.

The client receives test context (idp_id_token, idp_token_endpoint, etc.) via
MCP_CONFORMANCE_CONTEXT environment variable and performs the token exchange flows automatically.
"""

import asyncio
Expand Down Expand Up @@ -314,6 +326,266 @@ async def run_auth_code_client(server_url: str) -> None:
await _run_auth_session(server_url, oauth_auth)


@register("auth/cross-app-access-complete-flow")
async def run_cross_app_access_complete_flow(server_url: str) -> None:
"""Enterprise managed auth: Complete SEP-990 flow (OIDC ID token → ID-JAG → access token).

This scenario is provided by @modelcontextprotocol/conformance@0.1.14+ (PR #110).
It tests the complete enterprise managed OAuth flow using token exchange (RFC 8693)
and JWT bearer grant (RFC 7523).
"""
from mcp.client.auth.extensions.enterprise_managed_auth import (
EnterpriseAuthOAuthClientProvider,
TokenExchangeParameters,
)

context = get_conformance_context()
# The conformance package provides these fields
idp_id_token = context.get("idp_id_token")
idp_token_endpoint = context.get("idp_token_endpoint")
idp_issuer = context.get("idp_issuer")

# For cross-app access, we need to determine the MCP server's resource ID and auth issuer
# The conformance package sets up the auth server, and the MCP server URL is passed to us

if not idp_id_token:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'")
if not idp_token_endpoint:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
if not idp_issuer:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_issuer'")

# Extract base URL and construct auth issuer and resource ID
# The conformance test sets up auth server at a known location
base_url = server_url.replace("/mcp", "")
auth_issuer = context.get("auth_issuer", base_url)
resource_id = context.get("resource_id", server_url)

logger.debug("Cross-app access flow:")
logger.debug(f" IDP Issuer: {idp_issuer}")
logger.debug(f" IDP Token Endpoint: {idp_token_endpoint}")
logger.debug(f" Auth Issuer: {auth_issuer}")
logger.debug(f" Resource ID: {resource_id}")

# Create token exchange parameters from IDP ID token
token_exchange_params = TokenExchangeParameters.from_id_token(
id_token=idp_id_token,
mcp_server_auth_issuer=auth_issuer,
mcp_server_resource_id=resource_id,
scope=context.get("scope"),
)

# Get pre-configured client credentials from context (if provided)
client_id = context.get("client_id")
client_secret = context.get("client_secret")

# Create storage and pre-configure client info if credentials are provided
storage = InMemoryTokenStorage()

# Create enterprise auth provider
enterprise_auth = EnterpriseAuthOAuthClientProvider(
server_url=server_url,
client_metadata=OAuthClientMetadata(
client_name="conformance-cross-app-client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
response_types=["token"],
),
storage=storage,
idp_token_endpoint=idp_token_endpoint,
token_exchange_params=token_exchange_params,
)

# If client credentials are provided in context, use them instead of dynamic registration
if client_id and client_secret:
from mcp.shared.auth import OAuthClientInformationFull

logger.debug(f"Using pre-configured client credentials: {client_id}")
client_info = OAuthClientInformationFull(
client_id=client_id,
client_secret=client_secret,
token_endpoint_auth_method="client_secret_basic",
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
response_types=["token"],
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
)
enterprise_auth.context.client_info = client_info
await storage.set_client_info(client_info)

await _run_auth_session(server_url, enterprise_auth)


@register("auth/enterprise-token-exchange")
async def run_enterprise_token_exchange(server_url: str) -> None:
"""Enterprise managed auth: Token exchange flow (RFC 8693) with OIDC ID token."""
from mcp.client.auth.extensions.enterprise_managed_auth import (
EnterpriseAuthOAuthClientProvider,
TokenExchangeParameters,
)

context = get_conformance_context()
id_token = context.get("id_token")
idp_token_endpoint = context.get("idp_token_endpoint")
mcp_server_auth_issuer = context.get("mcp_server_auth_issuer")
mcp_server_resource_id = context.get("mcp_server_resource_id")
scope = context.get("scope")

if not id_token:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'id_token'")
if not idp_token_endpoint:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
if not mcp_server_auth_issuer:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_auth_issuer'")
if not mcp_server_resource_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_resource_id'")

# Create token exchange parameters
token_exchange_params = TokenExchangeParameters.from_id_token(
id_token=id_token,
mcp_server_auth_issuer=mcp_server_auth_issuer,
mcp_server_resource_id=mcp_server_resource_id,
scope=scope,
)

# Create enterprise auth provider
enterprise_auth = EnterpriseAuthOAuthClientProvider(
server_url=server_url,
client_metadata=OAuthClientMetadata(
client_name="conformance-enterprise-client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
response_types=["token"],
),
storage=InMemoryTokenStorage(),
idp_token_endpoint=idp_token_endpoint,
token_exchange_params=token_exchange_params,
)

await _run_auth_session(server_url, enterprise_auth)


@register("auth/enterprise-saml-exchange")
async def run_enterprise_saml_exchange(server_url: str) -> None:
"""Enterprise managed auth: SAML assertion exchange flow (RFC 8693)."""
from mcp.client.auth.extensions.enterprise_managed_auth import (
EnterpriseAuthOAuthClientProvider,
TokenExchangeParameters,
)

context = get_conformance_context()
saml_assertion = context.get("saml_assertion")
idp_token_endpoint = context.get("idp_token_endpoint")
mcp_server_auth_issuer = context.get("mcp_server_auth_issuer")
mcp_server_resource_id = context.get("mcp_server_resource_id")
scope = context.get("scope")

if not saml_assertion:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'saml_assertion'")
if not idp_token_endpoint:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
if not mcp_server_auth_issuer:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_auth_issuer'")
if not mcp_server_resource_id:
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'mcp_server_resource_id'")

# Create token exchange parameters for SAML
token_exchange_params = TokenExchangeParameters.from_saml_assertion(
saml_assertion=saml_assertion,
mcp_server_auth_issuer=mcp_server_auth_issuer,
mcp_server_resource_id=mcp_server_resource_id,
scope=scope,
)

# Create enterprise auth provider
enterprise_auth = EnterpriseAuthOAuthClientProvider(
server_url=server_url,
client_metadata=OAuthClientMetadata(
client_name="conformance-enterprise-saml-client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
response_types=["token"],
),
storage=InMemoryTokenStorage(),
idp_token_endpoint=idp_token_endpoint,
token_exchange_params=token_exchange_params,
)

await _run_auth_session(server_url, enterprise_auth)


@register("auth/enterprise-id-jag-validation")
async def run_id_jag_validation(server_url: str) -> None:
"""Validate ID-JAG token structure and claims (SEP-990)."""
from mcp.client.auth.extensions.enterprise_managed_auth import (
EnterpriseAuthOAuthClientProvider,
TokenExchangeParameters,
decode_id_jag,
validate_token_exchange_params,
)

context = get_conformance_context()
id_token = context.get("id_token")
idp_token_endpoint = context.get("idp_token_endpoint")
mcp_server_auth_issuer = context.get("mcp_server_auth_issuer")
mcp_server_resource_id = context.get("mcp_server_resource_id")

if not all([id_token, idp_token_endpoint, mcp_server_auth_issuer, mcp_server_resource_id]):
raise RuntimeError("Missing required context parameters for ID-JAG validation")

# Create and validate token exchange parameters
token_exchange_params = TokenExchangeParameters.from_id_token(
id_token=id_token,
mcp_server_auth_issuer=mcp_server_auth_issuer,
mcp_server_resource_id=mcp_server_resource_id,
)

logger.debug("Validating token exchange parameters")
validate_token_exchange_params(token_exchange_params)
logger.debug("Token exchange parameters validated successfully")

# Create enterprise auth provider
enterprise_auth = EnterpriseAuthOAuthClientProvider(
server_url=server_url,
client_metadata=OAuthClientMetadata(
client_name="conformance-validation-client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
response_types=["token"],
),
storage=InMemoryTokenStorage(),
idp_token_endpoint=idp_token_endpoint,
token_exchange_params=token_exchange_params,
)

async with httpx.AsyncClient() as client:
# Get ID-JAG
id_jag = await enterprise_auth.exchange_token_for_id_jag(client)
logger.debug(f"Obtained ID-JAG for validation: {id_jag[:50]}...")

# Decode and validate ID-JAG claims
logger.debug("Decoding ID-JAG token")
claims = decode_id_jag(id_jag)

# Validate required claims
assert claims.typ == "oauth-id-jag+jwt", f"Invalid typ: {claims.typ}"
assert claims.jti, "Missing jti claim"
assert claims.iss, "Missing iss claim"
assert claims.sub, "Missing sub claim"
assert claims.aud, "Missing aud claim"
assert claims.resource == mcp_server_resource_id, f"Invalid resource: {claims.resource}"
assert claims.client_id, "Missing client_id claim"
assert claims.exp > claims.iat, "Invalid expiration"

logger.debug("ID-JAG validated successfully:")
logger.debug(f" Subject: {claims.sub}")
logger.debug(f" Issuer: {claims.iss}")
logger.debug(f" Audience: {claims.aud}")
logger.debug(f" Resource: {claims.resource}")
logger.debug(f" Client ID: {claims.client_id}")

logger.debug("ID-JAG validation completed successfully")


async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
"""Common session logic for all OAuth flows."""
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
Expand Down
89 changes: 89 additions & 0 deletions .github/actions/conformance/run-enterprise-auth-conformance.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/bin/bash
set -e

# Enterprise Auth Conformance Test Runner
# Runs conformance tests for SEP-990 enterprise managed authorization
#
# This script uses the @modelcontextprotocol/conformance package v0.1.14+
# which includes enterprise auth scenarios from PR #110

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../../.."

echo "==================================================================="
echo " Enterprise Auth Conformance Tests (SEP-990)"
echo "==================================================================="
echo ""
echo "Package: @modelcontextprotocol/conformance@0.1.14"
echo "Scenario: auth/cross-app-access-complete-flow"
echo "PR: https://github.com/modelcontextprotocol/conformance/pull/110"
echo "Release: https://github.com/modelcontextprotocol/conformance/releases/tag/v0.1.14"
echo ""

# Load nvm if available
export NVM_DIR="$HOME/.nvm"
if [ -s "$NVM_DIR/nvm.sh" ]; then
\. "$NVM_DIR/nvm.sh"

# Try to use Node 22 if available, otherwise any version >= 18
if nvm ls 22 &> /dev/null; then
echo "Switching to Node.js 22..."
nvm use 22
elif nvm ls 20 &> /dev/null; then
echo "Switching to Node.js 20..."
nvm use 20
elif nvm ls 18 &> /dev/null; then
echo "Switching to Node.js 18..."
nvm use 18
fi
fi

# Check Node version after attempting to switch
NODE_VERSION=$(node --version | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
echo "⚠️ Error: Node.js version $NODE_VERSION detected"
echo " Conformance package requires Node.js >= 18"
echo " Current version: $(node --version)"
echo ""
echo " To run locally, install Node 18+ via nvm:"
echo " nvm install 22"
echo " nvm use 22"
echo ""
echo " Then run this script again."
echo ""
exit 1
fi

echo "Using Node.js $(node --version)"
echo ""

# Ensure dependencies are synced
echo "Syncing dependencies..."
uv sync --frozen --all-extras --package mcp

echo ""
echo "Running enterprise auth conformance tests..."
echo ""

# Use public npm registry for conformance package
# Run the cross-app-access-complete-flow scenario which tests SEP-990
npm_config_registry=https://registry.npmjs.org \
npx -y @modelcontextprotocol/conformance@0.1.14 client \
--command 'uv run --frozen python .github/actions/conformance/client.py' \
--scenario auth/cross-app-access-complete-flow

EXIT_CODE=$?

echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ Enterprise auth conformance tests PASSED!"
else
echo "❌ Enterprise auth conformance tests FAILED (exit code: $EXIT_CODE)"
echo ""
echo "Common issues:"
echo " - Node.js version too old (need >= 18)"
echo " - Dependencies not synced (run: uv sync --frozen --all-extras --package mcp)"
echo " - Network issues accessing npm registry"
fi

exit $EXIT_CODE
Loading