Conversation
…verage - Validate Origin header on /mcp to prevent DNS rebinding attacks (MCP spec 2025-03-26 §Transports Security Warning MUST requirement) - Bind to 127.0.0.1 in local mode, 0.0.0.0 when MCP_SERVER_URL is a non-localhost URL (SHOULD requirement from same spec section) - Export discoverOidcEndpoints and add 8 new tests: jose error → InvalidTokenError wrapping (the 500→401 fix path), re-throw of existing InvalidTokenError, and 6 tests for OIDC discovery logic - Fix latent test bug: "passes the token" test was leaving an unconsumed mockJwt() in the queue, causing subsequent tests to get wrong mocks - Add OAuth deployment section to README covering both verifier modes, Docker example, Okta SPA requirement, and dedicated domain requirement Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…_IDS Without this, any Okta app in the same tenant with the same Authorization Server could produce tokens that pass validation, since we only check signature, issuer, audience, and scopes — not which app issued the token. MCP_OAUTH_ALLOWED_CLIENT_IDS (space-separated) adds an allowlist check against the client_id / azp claim in the token. When empty (default), behaviour is unchanged. When set, tokens from any client not in the list are rejected with 401. Both verifiers enforce the check: - buildJwksVerifier: checks client_id, falls back to azp - buildIntrospectionVerifier: checks client_id from introspection response Also suppress console.error in tests via setupFilesAfterEnv to keep test output clean. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Returns authenticated client ID, granted scopes, and token expiry. Never includes the token value or any server-side config. Only registered when MCP_OAUTH_ENABLED=true. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Okta access tokens use a non-standard `cid` claim instead of `client_id` or `azp`. Without this, MCP_OAUTH_ALLOWED_CLIENT_IDS would reject all Okta tokens with "client_id \"\" is not in the allowlist". Priority order: client_id (standard) -> azp (authorized-party) -> cid (Okta). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…at, docs - discoverOidcEndpoints: add AbortSignal.timeout(10s) to prevent hanging at startup - withLogging: thread extra through so all tools receive authInfo (fixes latent bug where any future tool needing extra.authInfo would silently get undefined) - auth_status: registered via withLogging for consistency; formatDuration now shows hours for tokens with >= 1h remaining (e.g. "1h 5m 3s" instead of "65m 3s") - README: fix literal \n in auth_status section, add MCP_OAUTH_ALLOWED_CLIENT_IDS docs and client_id/azp/cid note, add auth_status to tools table, add all OAuth env vars to configuration table Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Logging: keep only actionable output (security rejections, tool errors, unhandled HTTP errors, OAuth startup config). Remove per-request HTTP logs, RPC method logs, and tool start/ok traces. Magic numbers: document 10s OIDC discovery timeout, and the reason headersTimeout (66s) must exceed keepAliveTimeout (65s). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR adds OAuth 2.0 bearer token authentication to the MCP HTTP server, implementing RFC 9728 (OAuth Protected Resource Metadata) so that clients can auto-discover the authorization server. The server acts as a pure resource server — it validates tokens via JWKS (default) or token introspection (opt-in), never issuing them. Related refactoring also makes the HTTP server stateless (per-request SdkServer), adds Origin header validation, and wires extra (including authInfo) through all tool handlers.
Changes:
- New OAuth middleware (
src/mcp_server/auth/oauth.ts) with OIDC discovery, JWKS verifier, introspection verifier, andsetupOAuthwiring - New
auth_statustool (src/mcp_server/tools/auth_status.ts) that exposes auth metadata to clients when OAuth is enabled - HTTP server refactored to stateless transport, per-request
SdkServer, Origin validation, and localhost-only binding for local mode
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/mcp_server/auth/oauth.ts |
Core OAuth logic: OIDC discovery, JWKS/introspection verifiers, and Express middleware setup |
src/mcp_server/tools/auth_status.ts |
New tool returning auth status to MCP clients; never exposes token value |
src/mcp_server/server.ts |
HTTP server refactored to stateless transport, auth middleware wiring, origin validation |
src/mcp_server/bin.ts |
Passes MCP_SERVER_URL into startHttp |
src/config.ts |
New oauthConfig with all MCP_OAUTH_* env vars |
src/mcp_server/tools/list_indices.ts |
Adds per-operation timing logs (Elasticsearch queries) |
tests/mcp_server/auth/oauth.test.ts |
32 new tests for OIDC discovery, JWKS verifier, and introspection verifier |
tests/mcp_server/auth_status.test.ts |
9 new tests for the auth_status tool handler |
tests/mcp_server/symbol_analysis.test.ts |
Adds missing jest.mock for ../../src/config |
tests/setup.ts |
Globally suppresses console.error in all tests |
jest.config.js |
Registers tests/setup.ts via setupFilesAfterEnv |
package.json / package-lock.json |
Adds jose ^5.10.0 dependency |
README.md |
Documents OAuth configuration, env vars, and deployment guide |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (audiences.length > 0) { | ||
| const allowed = audiences.some((aud) => | ||
| checkResourceAllowed({ requestedResource: aud, configuredResource: serverUrl }) | ||
| ); | ||
| if (!allowed) { | ||
| throw new InvalidTokenError('Token audience mismatch'); | ||
| } | ||
| } |
There was a problem hiding this comment.
The buildIntrospectionVerifier audience check (lines 100-107) calls checkResourceAllowed directly without any try/catch. If an opaque provider returns a non-URL audience (like Okta's api://default), checkResourceAllowed may throw — the error will propagate as an unhandled exception rather than being converted to an InvalidTokenError (which would return 401). The JWKS verifier already handles this case by catching any throw and falling back to an equality comparison (lines 156-162 in the same file). The introspection verifier should apply the same pattern.
Summary
Adds OAuth 2.0 bearer token authentication to the HTTP server. The server acts as an OAuth Resource Server (RFC 9728): it validates tokens but never issues them. The authorization server (Okta, Auth0, Keycloak, etc.) is configured via environment variables and auto-discovered at startup.
MCP clients (Claude Code, VS Code, Cursor) handle the full auth flow — they discover the AS from
/.well-known/oauth-protected-resource, obtain a token via Authorization Code + PKCE (RFC 7636), and present it as a bearer token on every request.What changed
src/mcp_server/auth/oauth.ts— OIDC discovery, JWKS verifier, introspection verifier,setupOAuthmiddleware wiring;AbortSignal.timeout(10 s)on discovery fetches to prevent hanging at startupsrc/mcp_server/tools/auth_status.ts— new tool, only registered whenMCP_OAUTH_ENABLED=true: returns client ID, scopes, and token expiry; never leaks the token value or server configsrc/mcp_server/server.ts— stateless transport, per-requestSdkServer, Origin header validation, localhost-only binding;withLoggingthreadsextra(includingauthInfo) through to all tool handlers; logging trimmed to actionable output onlysrc/config.ts—oauthConfigwith allMCP_OAUTH_*env vars includingMCP_OAUTH_ALLOWED_CLIENT_IDStests/— 74 tests across 11 suites;console.errorsuppressed globally viasetupFilesAfterEnvKey decisions
Resource Server only — no Authorization Server code
The server validates tokens; Okta/Auth0/Keycloak issues them. No database, no redirect URIs, no PKCE state, no client secret storage (in JWKS mode).
mcpAuthMetadataRouterfrom the MCP SDK handles the RFC 9728/.well-known/oauth-protected-resourceendpoint automatically./.well-knownrequires a dedicated domainRFC 8615 section 3 mandates well-known URIs at the domain root. RFC 9728 section 3 inserts
/.well-known/oauth-protected-resourcebetween the host and path. The MCP SDK always strips any subpath when constructing this URL. The server must have its own (sub)domain.JWKS vs introspection
JWKS is default: validates token signatures locally using the provider's public keys. No network call per request; keys are cached by
jose. Introspection is opt-in (set bothMCP_OAUTH_CLIENT_IDandMCP_OAUTH_CLIENT_SECRET): calls the provider's RFC 7662 endpoint on every request. Use introspection for opaque tokens or real-time revocation.InvalidTokenErrorwrapping (500 -> 401 fix)josethrows its own types (JWTExpired,JWSSignatureVerificationFailed, etc.) which are not SDK error types. The MCP SDK'srequireBearerAuthonly converts SDK error types to HTTP status codes; anything else becomes 500.buildJwksVerifierwraps all non-InvalidTokenErrorexceptions inInvalidTokenErrorso expired/invalid tokens return 401 instead of 500.MCP_OAUTH_ALLOWED_CLIENT_IDS— client allowlistWithout this, any OAuth app in the same Okta tenant using the same Authorization Server can produce tokens that pass all checks (valid signature, correct issuer, correct audience).
MCP_OAUTH_ALLOWED_CLIENT_IDSadds an allowlist check against theclient_id/azp/cidclaim. Empty = allow any client from the issuer (default). Set to your specific app's client ID to restrict access.Okta
cidclaim — client ID extraction fallbackOkta access tokens don't use the standard
client_idorazpclaims — they use a non-standardcidclaim. Without this,MCP_OAUTH_ALLOWED_CLIENT_IDSwould reject all Okta tokens withclient_id "" is not in the allowlist. The JWKS verifier extracts client ID with priority:client_id(standard) ->azp(authorized-party) ->cid(Okta-specific).withLoggingthreadsextrathrough all tool handlersTool handlers receive a second
extraargument from the MCP SDK containingauthInfoand the abort signal.withLoggingis generic over the extra type so any tool can accessauthInfowithout being registered outside the wrapper.Origin header validation (DNS rebinding protection)
The MCP spec (2025-03-26, Transports Security Warning) requires validating the
Originheader. MCP clients are not browsers and do not sendOrigin. When a browser-originated request includesOriginthat does not match the server's own origin, the request is rejected with 403.Localhost-only binding in local mode
When
MCP_SERVER_URLis unset or points to localhost, the server binds to127.0.0.1only. WhenMCP_SERVER_URLis a non-localhost URL, the server binds to0.0.0.0for container deployments.Stateless transport
Every POST gets a fresh
SdkServer+StreamableHTTPServerTransportwithsessionIdGenerator: undefined. No session state, no sticky sessions, horizontally scalable. The MCP SDK's single_transportslot design (inherited from stdio) means sharing oneSdkServeracross HTTP clients causes response misrouting — per-request creation eliminates this.Logging — actionable only
Only failures and startup configuration are logged: security rejections (Origin mismatch, allowlist hits), auth errors, tool errors, unhandled transport errors, and OAuth startup config. Per-request HTTP traces, RPC method logs, and tool start/ok lines are not logged.
Deployment requirements
MCP_OAUTH_REQUIRED_SCOPESto at leastopenidfor OktaMCP_OAUTH_ALLOWED_CLIENT_IDSto your app's client ID to prevent other apps in the same tenant from accessing the serverRFC / spec compliance checklist
/.well-known/oauth-protected-resource)checkResourceAllowedfor audience validationrequireBearerAuth