feat: add OAuth per-user auth and Streamable HTTP transport - #32
Merged
Conversation
Replace stdio transport with Streamable HTTP for OAuth-compatible communication. Add uvicorn dependency, MCP_HOST/MCP_PORT env var configuration, and transport tests.
Implement ArkISPAuthFromToken that bridges CyberArk Identity OAuth JWTs into ark-sdk-python's ArkISPAuth interface. Add CyberArkMCPServer.from_token() factory method for creating per-user server instances from Bearer tokens. Add PyJWT and cryptography dependencies. Update CLAUDE.md for new architecture. 14 new tests, 232 total passing.
Implement CyberArkTokenVerifier (MCP SDK TokenVerifier protocol) for JWT verification against CyberArk Identity JWKS endpoint, and UserSessionManager for per-user session lifecycle with SHA-256 keying, TTL expiry, max_sessions limits, and graceful shutdown. Add OAuthError and SessionExpiredError exceptions. 31 new tests, 263 total passing.
Add dual-mode support: OAuth per-user mode with CyberArkTokenVerifier + UserSessionManager, and legacy service account mode. Add create_mcp_server() factory, is_oauth_mode() detection, update execute_tool() for per-user session resolution via access token, update AppContext and app_lifespan for dual mode. 14 new integration tests, 277 total passing.
Update CLAUDE.md (277 tests, completed status, new file structure), ARCHITECTURE.md (dual-mode auth flows, new modules, config vars), README.md (OAuth + legacy config sections), API_REFERENCE.md (dual auth docs). Create docs/CYBERARK_IDENTITY_SETUP.md with OAuth app setup guide.
…ID env var The OIDC app ID (`__idaptive_cybr_user_oidc`) is a well-known constant built into every CyberArk Identity tenant and used as the default by ark-sdk-python. Requiring users to configure it added unnecessary setup friction. This reduces OAuth configuration from 2 required env vars to 1 (`CYBERARK_IDENTITY_TENANT_URL`). The constant is defined in token_verifier.py and used for JWT audience validation. 278 tests passing, zero regression.
Default transport is now stdio (restoring compatibility with Claude Desktop and Claude Code). Set MCP_TRANSPORT=streamable-http for HTTP mode. Invalid values cause a clean exit with an error message.
Multi-stage build with uv for fast dependency resolution. docker-compose pre-configures streamable-http transport on 0.0.0.0:8000.
The ark-sdk-python dependency is installed from a git URL, so the builder stage needs git available.
Sets resource_server_url so /.well-known/oauth-protected-resource returns the correct public URL behind Traefik reverse proxy.
…y proxy Adds RFC 8414 authorization server metadata endpoint using FastMCP custom_route(). Fetches CyberArk Identity's OIDC discovery, caches it, and maps to OAuth metadata format. Unblocks claude.ai OAuth flow which requires this endpoint for authorization server discovery.
…very claude.ai fetches /.well-known/oauth-authorization-server from the URL listed in authorization_servers (from oauth-protected-resource). Previously this pointed to CyberArk Identity which doesn't serve RFC 8414 metadata. Now points to our server since we serve the proxied metadata there.
claude.ai requires RFC 7591 DCR to obtain client credentials before starting the OAuth authorization code flow. Adds /register endpoint that returns the pre-configured CyberArk Identity OIDC app ID (__idaptive_cybr_user_oidc) as a public client (no secret, PKCE). Also adds registration_endpoint to the authorization server metadata.
The built-in __idaptive_cybr_user_oidc app doesn't allow adding redirect URIs. Use custom 'mcpprivilegecloud' app which can be configured with claude.ai's callback URL. App ID is now configurable via CYBERARK_OIDC_APP_ID env var.
The generic /Oauth/Openid endpoint from OIDC discovery doesn't resolve
the app context, causing "contact your system administrator" errors.
Use app-specific /OAuth2/Authorize/{app_id} and /OAuth2/Token/{app_id}
endpoints which correctly bind to the mcpprivilegecloud OIDC app.
Return CYBERARK_CLIENT_ID and CYBERARK_CLIENT_SECRET from env in the DCR response so claude.ai sends client credentials during token exchange. Falls back to public client (no secret) when env vars unset.
Documents the full investigation of connecting as a Remote MCP Server in claude.ai, including what worked (discovery, DCR, auth redirect), the blocking issue (CyberArk Identity invalid_client after user auth), and potential next steps.
Without ctx, execute_tool() cannot access the MCP auth context, so get_access_token() returns None and falls through to the legacy get_server() path using the shared service account. This meant OAuth per-user sessions were broken for 48 of 53 tools. Each tool now receives ctx: Optional[Context[ServerSession, AppContext]] and passes ctx=ctx to execute_tool(). Existing callers are unaffected since ctx defaults to None.
The token verifier was constructing the JWKS URI as
{tenant}/OAuth2/Keys/{app_id}, which returns 404 for OAuth 2.0
Client app types in CyberArk Identity. Now lazily resolves the
correct jwks_uri from the OIDC discovery document on first token
verification, with fallback to the constructed URI.
CyberArk Identity tokens set `aud` to the auto-generated OAuth2 Client ID (a UUID), not the app name. The verifier now reads CYBERARK_CLIENT_ID env var for audience validation, falling back to CYBERARK_OIDC_APP_ID if not set.
CYBERARK_CLIENT_ID holds the service account username for DCR, but CyberArk Identity tokens use the app's auto-generated OAuth2 Client ID (UUID) as the aud claim. New env var priority: CYBERARK_OAUTH_AUDIENCE > CYBERARK_CLIENT_ID > CYBERARK_OIDC_APP_ID
The SDK derives the Privilege Cloud URL from JWT claims (subdomain, platform_domain, unique_name). Log these claims during token auth initialization to diagnose URL resolution mismatches.
- Add CYBERARK_OAUTH_CLIENT_ID/SECRET as optional overrides for DCR and JWT audience (priority over CYBERARK_CLIENT_ID/SECRET) - DCR client_id chain: OAUTH_CLIENT_ID > CLIENT_ID > OIDC_APP_ID - JWT audience chain: OAUTH_CLIENT_ID > OAUTH_AUDIENCE > CLIENT_ID > OIDC_APP_ID - Remove dead DEPLOY_ENV code from token_auth.py - Clarify that credentials come from service user marked "OAuth 2.0 confidential client", not from the OAuth2 Client app itself - Add tests/test_env_var_resolution.py (10 new tests) - Update docs: setup guide, investigation report, README, .env.example - 315 tests passing
OAuth JWTs from CyberArk Identity authorization_code flow lack the subdomain/platform_domain claims that the SDK uses to resolve the PCloud API URL. Without these claims, the SDK falls back to parsing unique_name and extracts the wrong subdomain (e.g., 'cyberark' from '[email protected]' instead of the correct 'cyberiam'). CYBERARK_SUBDOMAIN env var provides the correct PCloud tenant subdomain. After service initialization, from_token() patches each service client's base URL to use the correct subdomain. - Add _override_pcloud_base_url() static method to CyberArkMCPServer - Apply override in from_token() for all PCloud services - Add tests/test_pcloud_url_resolution.py (4 tests) - Update test_token_auth.py with subdomain override tests - 321 tests passing
PCloud rejects CyberArk Identity OIDC JWTs (CAJWT001E). OAuth mode now creates a service account server alongside the session manager. execute_tool() verifies user identity from the OIDC JWT, then routes all PCloud API calls through the service account's platform token. Unauthenticated requests in OAuth mode raise PermissionError. Separates OIDC app credentials (CYBERARK_OAUTH_CLIENT_ID/SECRET for DCR + JWT audience) from service account credentials (CYBERARK_CLIENT_ID/SECRET for PCloud access). Removes CYBERARK_SUBDOMAIN and CYBERARK_OAUTH_AUDIENCE from .env (code still supports them as backward-compat fallbacks). 326 tests passing with zero regression.
The OIDC app's Trust tab client_id (CYBERARK_OAUTH_CLIENT_ID) differs from the JWT aud claim. Swap audience resolution priority so CYBERARK_OAUTH_AUDIENCE (explicit override) > CYBERARK_OAUTH_CLIENT_ID (Trust tab) > CYBERARK_CLIENT_ID > CYBERARK_OIDC_APP_ID.
…ject files - Delete session_manager.py, token_auth.py and their tests (dead code from abandoned per-user session architecture) - Remove from_token(), _build_api_url(), get_available_tools(), reinitialize_services(), clear_cache(), shutdown() from server.py - Remove all 3 HTTP bypass blocks (rotationalgroup, ExpirationDate) from server.py - Remove AuthenticationError, SessionExpiredError from exceptions.py - Remove create_sdk_authenticator() from sdk_auth.py - Simplify AppContext: replace session_manager with is_oauth bool flag - Remove global server/get_server()/reset_server() from mcp_server.py - Clean up imports, add sys import, add OIDC discovery cache TTL - Fix token_verifier.py: separate bare Exception from pyjwt exception tuple - Delete poc_bearer_test.py, poc_token_test.py (results in docs) - Clean .gitignore: remove serena, overly broad patterns, setuptools cruft - Fix docker-compose.yml: parameterize MCP_SERVER_URL, add OAUTH env vars - Fix .env.example: correct credential descriptions, add OIDC app fields - Update all docs (ARCHITECTURE, TESTING, DEVELOPMENT, API_REFERENCE, CLAUDE.md, README) with accurate module list, test count (292), OAuth flow description, and removed stale references 292 tests passing, zero regression.
- Delete unused services/ package (BaseService never imported) - Remove OAuthError class from exceptions.py (never raised) - Remove unused OAuthError import from test_token_verifier.py - Remove unused TokenVerifier import from mcp_server.py - Merge mid-module CYBERARK_OIDC_APP_ID import to top-level - Remove 4 unused fixtures from conftest.py (mock_env_vars, mock_context, mock_context_with_server, mock_oauth_context) 292 tests passing, -127 lines.
Use `or` instead of getenv default so empty string falls back to
the computed http://{host}:{port} URL instead of failing Pydantic
AnyHttpUrl validation.
Two issues prevented MCP clients from completing the OAuth flow: 1. The /.well-known/oauth-authorization-server metadata returned CyberArk Identity's issuer URL, but the client fetched it from our server. RFC 8414 requires issuer to match the fetch URL. Changed to use our server_url as issuer. 2. Clients try path-specific metadata first per RFC 8414 section 3.1 (e.g., /.well-known/oauth-authorization-server/mcp) and got 404. Added catch-all route for path-suffixed requests.
The MCP SDK normalizes URLs through Pydantic AnyHttpUrl, which adds a trailing slash to bare domains (e.g. https://example.com -> https://example.com/). The authorization_servers array in protected resource metadata contained the normalized URL with trailing slash, but our issuer in authorization server metadata stripped it via rstrip("/"). Per RFC 8414 section 3.3, the issuer MUST be identical to the authorization server URL the client used for discovery. The mismatch caused clients to silently reject the metadata and loop. Fix: use AnyHttpUrl normalization for the issuer to match the SDK behavior.
The MCP client constructs /.well-known/oauth-authorization-server/mcp from the endpoint path, then validates the issuer against the derived URL per RFC 8414 section 3.3. With just the base URL as issuer, the client expected https://host/mcp but got https://host/ — mismatch. Include /mcp in both issuer_url and resource_server_url so: - authorization_servers matches the derived issuer from well-known URL - resource in protected resource metadata matches the actual endpoint - Protected resource route is at /.well-known/oauth-protected-resource/mcp - registration_endpoint stays at server root (/register)
jwks_uri is a REQUIRED field per RFC 8414 for authorization_code grants. Forward it from the CyberArk Identity OIDC discovery response. Clients may reject metadata missing this field.
Copilot Studio (and likely other clients) reject AS metadata where authorization_endpoint and token_endpoint are on a different domain from the issuer. Previously these pointed directly to CyberArk Identity (aao4818.id.cyberark.cloud) while the issuer was our server. Add /authorize (302 redirect) and /token (reverse proxy) routes so all endpoints in the metadata are same-origin. The /authorize route passes all query parameters to CyberArk Identity, and /token forwards the POST body and returns CyberArk's response.
CyberArk Identity sets the JWT aud claim to the client_id used in the authorization request. When DCR returns CYBERARK_OAUTH_CLIENT_ID, the token aud will be that value, which differs from CYBERARK_OAUTH_AUDIENCE (the internal app ID). Accept all configured audience values instead of using a priority chain that picks only one.
MCP clients like Copilot Studio POST to /mcp/ (trailing slash), which triggers a Starlette 307 redirect to /mcp. HTTP clients strip the Authorization header on redirect, causing OAuth Bearer tokens to be lost and resulting in repeated 401 Unauthorized responses. Document the required reverse proxy configuration (Traefik replacePathRegex middleware) that strips trailing slashes at the proxy level, preventing the redirect and preserving auth headers.
MCP clients (e.g. Copilot Studio) POST to /mcp/ (trailing slash). Starlette's default redirect_slashes returns a 307 to /mcp, causing HTTP clients to strip the Authorization header (per RFC 9110). This breaks OAuth Bearer token authentication. Add TrailingSlashMiddleware that normalizes paths at the ASGI level before Starlette's router sees them — no redirect, no header loss. The middleware wraps the Starlette app for streamable-http transport.
Remove unused source code: - Delete models.py (12 Pydantic classes never imported by source) - Delete test_response_models.py and test_typed_tools.py (tested dead code) - Remove SDKAuthenticationError (replaced with CyberArkAPIError) - Remove unused is_authenticated() method from sdk_auth.py - Remove _SDK_AVAILABLE flag from exceptions.py (set but never read) - Remove unused conftest.py mock_server fixture (shadowed everywhere) - Remove misleading new_password param from change_account_password tool - Remove 4 unused SDK type imports and 2 redundant local imports from server.py - Delete stale MCP_SERVER_MODERNIZATION_PLAN.md Fix documentation: - Fix tool counts: safe 11->10, platform parenthetical 12->10, account 17->18 - Remove documented health_check tool (not exposed as @mcp.tool) - Add CYBERARK_SUBDOMAIN to README, ARCHITECTURE, CYBERARK_IDENTITY_SETUP - Rewrite CYBERARK_IDENTITY_SETUP.md with accurate shared service account architecture, OIDC app config reference from live API, Trust tab credential location, and tenant OIDC discovery details - Fix CyberArkAuthenticator typo in DEVELOPMENT.md diagnostic snippet - Remove dead INSTRUCTIONS.md reference from CLAUDE.md - Update test counts from 292 to 269 across all docs 269 tests passing, zero regression.
CYBERARK_SUBDOMAIN was documented and referenced in config files but never read by any source code. Remove from .env.example, README, docker-compose.yml, ARCHITECTURE.md, CYBERARK_IDENTITY_SETUP.md, and delete the orphaned test file.
Deduplicate the _make_jwt() test helper that was copy-pasted across test_token_verifier.py and test_oauth_integration.py. Add pythonpath=tests to pytest.ini so the shared module is importable.
Security improvements to the OAuth flow: - DCR no longer returns client_secret; secrets are injected server-side by the /token proxy via _get_oauth_credentials() - Remove CYBERARK_CLIENT_ID (service account username) from DCR fallback chain and JWT audience validation to prevent credential exposure - Token proxy now parses form body, injects credentials, and forwards with improved error logging (sensitive values redacted) - token_endpoint_auth_methods_supported narrowed to ["none"] since clients never handle secrets directly
- Dockerfile: add non-root appuser for security - sdk_auth.py: remove verbose env var debug logging - exceptions.py: remove stale pass statement - CLAUDE.md: update test count references to 276+
- Delete test_token_bridge.py (5 tests), relocate 1 unique test to test_oauth_integration.py - Remove TestTokenVerifierAudienceResolution from test_env_var_resolution.py (4 tests duplicated in test_token_verifier.py) - Remove 3 duplicate DCR tests from test_oauth_metadata.py (covered by test_env_var_resolution.py) - Remove TestAppLifespanOAuthMode from test_oauth_integration.py (3 tests duplicated in test_lifespan.py) - Extract _default_claims() to shared tests/helpers.py
CyberArk Identity always sets the JWT `aud` claim to the Trust tab client_id (CYBERARK_OAUTH_CLIENT_ID). The separate CYBERARK_OAUTH_AUDIENCE override was speculative and never needed, adding unnecessary config complexity. Simplifies setup from 6 to 5 required env vars.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Adds OAuth per-user authentication and Streamable HTTP transport to the MCP server, enabling browser-based MCP clients (claude.ai, Copilot Studio) to connect with their own CyberArk Identity credentials.
MCP_TRANSPORT=streamable-http, with Docker deployment support (Dockerfile, docker-compose.yml)./register),/authorizeand/tokenproxies to CyberArk Identity, RFC 8414 metadata endpoint (/.well-known/oauth-authorization-server)./tokenproxy, trailing slash middleware prevents OAuth header loss on 307 redirects.models.py(288 lines),session_manager.py,token_auth.py,MCP_SERVER_MODERNIZATION_PLAN.md, and 29 tests that only covered dead code. Removed speculativeCYBERARK_OAUTH_AUDIENCEenv var (simplifies config from 6 to 5 required vars).CYBERARK_IDENTITY_SETUP.md(full setup guide),CLAUDE_AI_OAUTH_INVESTIGATION.md, updated all existing docs.Production-proven: working Copilot Studio integration deployed via Docker + Traefik.
Test plan
.envgitignored