Skip to content

feat: add OAuth per-user auth and Streamable HTTP transport - #32

Merged
aaearon merged 48 commits into
mainfrom
feature/oauth-streamable-http
Mar 7, 2026
Merged

feat: add OAuth per-user auth and Streamable HTTP transport#32
aaearon merged 48 commits into
mainfrom
feature/oauth-streamable-http

Conversation

@aaearon

@aaearon aaearon commented Mar 7, 2026

Copy link
Copy Markdown
Owner

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.

  • OAuth per-user authentication: OIDC JWT verification against CyberArk Identity JWKS, with service account token bridge for PCloud API calls. Each user authenticates individually; their identity is logged for audit.
  • Streamable HTTP transport: Configurable via MCP_TRANSPORT=streamable-http, with Docker deployment support (Dockerfile, docker-compose.yml).
  • MCP OAuth protocol: Dynamic Client Registration (/register), /authorize and /token proxies to CyberArk Identity, RFC 8414 metadata endpoint (/.well-known/oauth-authorization-server).
  • Security hardening: DCR never leaks secrets (public client only), credentials injected server-side by /token proxy, trailing slash middleware prevents OAuth header loss on 307 redirects.
  • Dead code cleanup: Removed models.py (288 lines), session_manager.py, token_auth.py, MCP_SERVER_MODERNIZATION_PLAN.md, and 29 tests that only covered dead code. Removed speculative CYBERARK_OAUTH_AUDIENCE env var (simplifies config from 6 to 5 required vars).
  • Documentation: Added 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

  • 260 tests passing (was 276 on main — 29 dead-code tests removed, 13 new test files added)
  • OAuth flow verified: metadata, DCR, authorize redirect, token proxy
  • Streamable HTTP transport starts and responds correctly
  • Docker build and deployment tested on remote host
  • Copilot Studio end-to-end integration verified in production
  • Legacy stdio mode unaffected (default transport unchanged)
  • No secrets in tracked files, .env gitignored

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.
@aaearon
aaearon merged commit 4ba079a into main Mar 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant