Skip to content

feat: Oauth2 resource server#63

Open
Coolomina wants to merge 10 commits intomainfrom
feat/oauth-resource-server
Open

feat: Oauth2 resource server#63
Coolomina wants to merge 10 commits intomainfrom
feat/oauth-resource-server

Conversation

@Coolomina
Copy link
Contributor

@Coolomina Coolomina commented Mar 3, 2026

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, setupOAuth middleware wiring; AbortSignal.timeout(10 s) on discovery fetches to prevent hanging at startup
  • src/mcp_server/tools/auth_status.ts — new tool, only registered when MCP_OAUTH_ENABLED=true: returns client ID, scopes, and token expiry; never leaks the token value or server config
  • src/mcp_server/server.ts — stateless transport, per-request SdkServer, Origin header validation, localhost-only binding; withLogging threads extra (including authInfo) through to all tool handlers; logging trimmed to actionable output only
  • src/config.tsoauthConfig with all MCP_OAUTH_* env vars including MCP_OAUTH_ALLOWED_CLIENT_IDS
  • tests/ — 74 tests across 11 suites; console.error suppressed globally via setupFilesAfterEnv

Key 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). mcpAuthMetadataRouter from the MCP SDK handles the RFC 9728 /.well-known/oauth-protected-resource endpoint automatically.

/.well-known requires a dedicated domain

RFC 8615 section 3 mandates well-known URIs at the domain root. RFC 9728 section 3 inserts /.well-known/oauth-protected-resource between 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 both MCP_OAUTH_CLIENT_ID and MCP_OAUTH_CLIENT_SECRET): calls the provider's RFC 7662 endpoint on every request. Use introspection for opaque tokens or real-time revocation.

InvalidTokenError wrapping (500 -> 401 fix)

jose throws its own types (JWTExpired, JWSSignatureVerificationFailed, etc.) which are not SDK error types. The MCP SDK's requireBearerAuth only converts SDK error types to HTTP status codes; anything else becomes 500. buildJwksVerifier wraps all non-InvalidTokenError exceptions in InvalidTokenError so expired/invalid tokens return 401 instead of 500.

MCP_OAUTH_ALLOWED_CLIENT_IDS — client allowlist

Without 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_IDS adds an allowlist check against the client_id/azp/cid claim. Empty = allow any client from the issuer (default). Set to your specific app's client ID to restrict access.

Okta cid claim — client ID extraction fallback

Okta access tokens don't use the standard client_id or azp claims — they use a non-standard cid claim. Without this, MCP_OAUTH_ALLOWED_CLIENT_IDS would reject all Okta tokens with client_id "" is not in the allowlist. The JWKS verifier extracts client ID with priority: client_id (standard) -> azp (authorized-party) -> cid (Okta-specific).

withLogging threads extra through all tool handlers

Tool handlers receive a second extra argument from the MCP SDK containing authInfo and the abort signal. withLogging is generic over the extra type so any tool can access authInfo without being registered outside the wrapper.

Origin header validation (DNS rebinding protection)

The MCP spec (2025-03-26, Transports Security Warning) requires validating the Origin header. MCP clients are not browsers and do not send Origin. When a browser-originated request includes Origin that does not match the server's own origin, the request is rejected with 403.

Localhost-only binding in local mode

When MCP_SERVER_URL is unset or points to localhost, the server binds to 127.0.0.1 only. When MCP_SERVER_URL is a non-localhost URL, the server binds to 0.0.0.0 for container deployments.

Stateless transport

Every POST gets a fresh SdkServer + StreamableHTTPServerTransport with sessionIdGenerator: undefined. No session state, no sticky sessions, horizontally scalable. The MCP SDK's single _transport slot design (inherited from stdio) means sharing one SdkServer across 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

  • Server must be reachable at a dedicated (sub)domain
  • Okta: use SPA app type (not Web) — MCP clients use PKCE with no client secret
  • Set MCP_OAUTH_REQUIRED_SCOPES to at least openid for Okta
  • Set MCP_OAUTH_ALLOWED_CLIENT_IDS to your app's client ID to prevent other apps in the same tenant from accessing the server
  • See README for full operator guide

RFC / spec compliance checklist

  • RFC 9728 — Protected Resource Metadata (/.well-known/oauth-protected-resource)
  • RFC 8615 — Well-known URIs at domain root
  • RFC 7662 — Token Introspection (opt-in verifier)
  • RFC 8707 — Resource Indicators / checkResourceAllowed for audience validation
  • RFC 7636 — PKCE (client-side; clients handle this)
  • RFC 8414 — Authorization Server Metadata (OIDC discovery)
  • MCP spec, Transports Security Warning — Origin header validation
  • MCP spec, Transports Security Warning — localhost-only binding in local mode
  • MCP spec, Transports Security Warning — authentication via requireBearerAuth

Coolomina and others added 8 commits March 3, 2026 15:00
…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]>
…_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]>
@Coolomina Coolomina self-assigned this Mar 5, 2026
Coolomina and others added 2 commits March 6, 2026 11:24
…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]>
@Coolomina Coolomina marked this pull request as ready for review March 6, 2026 17:40
Copilot AI review requested due to automatic review settings March 6, 2026 17:40
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and setupOAuth wiring
  • New auth_status tool (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.

Comment on lines +100 to +107
if (audiences.length > 0) {
const allowed = audiences.some((aud) =>
checkResourceAllowed({ requestedResource: aud, configuredResource: serverUrl })
);
if (!allowed) {
throw new InvalidTokenError('Token audience mismatch');
}
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.

3 participants