-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Implement SEP-990 Enterprise Managed OAuth #1721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
BinoyOza-okta
wants to merge
26
commits into
modelcontextprotocol:main
Choose a base branch
from
BinoyOza-okta:feature/sep-990-enterprise-oauth
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,267
−1
Open
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 b7e15f2
Added test cases for missing lines of code.
BinoyOza-okta 5071c78
- Added tests cases for few of the missing lines. src/mcp/client/auth…
BinoyOza-okta 3c79818
- Fixed pre-commit errors.
BinoyOza-okta fb27df8
- Tried to fix the ruff error.
BinoyOza-okta b647ec8
- Fixed ruff errors.
BinoyOza-okta e9aad31
- Removed server side changes for enterprise_managed_auth.py
BinoyOza-okta 578c38e
- Added README.md changes for SEP-990 implementation for enterprise m…
BinoyOza-okta e84df79
- Resolved pyright checks error.
BinoyOza-okta 3057e0c
- Resolved README.md file fixes for removing unused imports.
BinoyOza-okta 10ebba7
- Resolved pyright errors.
BinoyOza-okta 4934924
- Added new test cases for the missing code lines.
BinoyOza-okta 1cddcc5
- Fixed the failing test cases.
BinoyOza-okta 3c74ecd
- Fixed the test cases.
BinoyOza-okta a9f5c31
- Added typing for request payload structures TokenExchangeRequestDat…
BinoyOza-okta ab8539a
- Updated test case to include IDJAGClaims type model to verify payload.
BinoyOza-okta 871a7cc
feat: Add conformance tests for enterprise managed authorization (SEP…
BinoyOza-okta 1a2ea18
- Fix uv.lock.
BinoyOza-okta 0738723
feat(auth): migrate enterprise auth conformance tests to @modelcontex…
BinoyOza-okta eef2596
Fixed end-of-file-fixer for test_enterprise_managed_auth_client.py.
BinoyOza-okta 61aee47
Fixed pre-commit hooks issues.
BinoyOza-okta fa3ab0f
Fixed Ruff formatting issues.
BinoyOza-okta bb1acbf
Fixed pyright issues.
BinoyOza-okta e41cadf
Moved the changes from README.md to README.v2.md.
BinoyOza-okta bd5f741
- Removed Unicode characters from the example snippet file.
BinoyOza-okta cd15337
- Removed unused parameters from the EnterpriseAuthOAuthClientProvide…
BinoyOza-okta File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please delete these and below |
||
| 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) | ||
|
|
||
89 changes: 89 additions & 0 deletions
89
.github/actions/conformance/run-enterprise-auth-conformance.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| #!/bin/bash | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please remove this script |
||
| 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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these scenarios do not exist, only the top one does.