Skip to content

Harden hosted MCP OAuth docs, challenge semantics, and e2e smoke tooling - #179

Closed
dodeja wants to merge 0 commit into
mainfrom
codex/data-8540-workos-only-mcp-auth
Closed

Harden hosted MCP OAuth docs, challenge semantics, and e2e smoke tooling#179
dodeja wants to merge 0 commit into
mainfrom
codex/data-8540-workos-only-mcp-auth

Conversation

@dodeja

@dodeja dodeja commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements OAuth 2.1 authentication for the hosted MCP server while maintaining backward compatibility with legacy API token authentication. The implementation adds JWT verification with WorkOS, proper WWW-Authenticate challenge semantics per RFC standards, and comprehensive e2e smoke testing tooling.

Key Changes:

  • Added workos-jwt.ts module with RS256 signature verification, JWKS caching with key rotation support, and comprehensive claim validation (issuer, audience, scope, exp, nbf)
  • Updated MCP handler to detect JWT-like Bearer tokens and verify them via local WorkOS JWT validation or remote internal principal verification fallback
  • Implemented RFC-compliant OAuth challenge response with WWW-Authenticate: Bearer resource_metadata=... headers on 401s
  • Updated TypeScript SDK client to preserve Bearer/Token prefixes for OAuth and legacy tokens
  • Added comprehensive test coverage for OAuth scenarios including challenge headers and internal verification fallback
  • Created bash script for OAuth E2E smoke testing covering discovery, registration, and authenticated MCP calls
  • Added three new documentation files: OAuth requirements specification, test plan with traceability matrix, and E2E smoke test runbook
  • Updated existing docs to position OAuth as the preferred authentication method while documenting legacy compatibility

Security & Quality:

  • JWT verification follows security best practices: algorithm validation (RS256 only), kid validation, signature verification with public keys from JWKS, claim validation (exp, nbf, iss, aud, scope)
  • Dual-mode authentication allows gradual migration from API tokens to OAuth
  • Comprehensive test coverage added for new OAuth flows
  • Proper error handling throughout with graceful fallbacks

The implementation is solid, well-tested, and follows OAuth 2.1 and RFC standards for MCP authentication.

Confidence Score: 5/5

  • This PR is safe to merge with high confidence
  • The implementation follows OAuth 2.1 and JWT security best practices, includes comprehensive test coverage for new scenarios, maintains backward compatibility during migration, and adds extensive documentation. No critical bugs or security vulnerabilities identified. The code is well-structured with proper error handling and follows established patterns.
  • No files require special attention

Important Files Changed

Filename Overview
api/mcp.ts adds OAuth JWT verification with local and remote fallback, WWW-Authenticate challenge headers, and maintains backward compatibility with legacy API tokens
packages/mcp/src/auth/workos-jwt.ts new JWT verification module with RS256 signature validation, JWKS caching, comprehensive claim validation (exp, nbf, iss, aud, scope), and proper error handling
sdks/typescript-sdk/src/client.ts updated to preserve Bearer-prefixed tokens (OAuth JWTs) alongside Token-prefixed legacy API tokens
packages/mcp/tests/api-handler.test.ts added OAuth challenge header validation tests and internal fallback verification test coverage
scripts/mcp-oauth-e2e-smoke.sh new bash script for OAuth smoke testing covering discovery, registration, challenge semantics, and authenticated MCP calls

Sequence Diagram

sequenceDiagram
    participant Client as MCP Client
    participant MCP as MCP Handler
    participant WorkOS as WorkOS JWT
    participant Internal as Internal Verify
    participant Upstream as Terminal49 API

    Note over Client,Upstream: OAuth Flow (JWT Bearer Token)
    Client->>MCP: POST /mcp (no auth)
    MCP->>Client: 401 + WWW-Authenticate header
    Note right of Client: Discovery + OAuth flow happens<br/>(external to this PR)
    Client->>MCP: POST /mcp (Bearer JWT)
    MCP->>MCP: Detect JWT-like Bearer token
    MCP->>WorkOS: Verify JWT locally
    alt Local JWT valid
        WorkOS->>MCP: Valid (user_id, account_id)
        MCP->>Upstream: Call API with Bearer token
        Upstream->>MCP: Response
        MCP->>Client: 200 OK
    else Local JWT invalid
        WorkOS->>MCP: Invalid
        MCP->>Internal: Verify via internal endpoint
        alt Internal verify succeeds
            Internal->>MCP: Valid (active, user_id, account_id)
            MCP->>Upstream: Call API with Bearer token
            Upstream->>MCP: Response
            MCP->>Client: 200 OK
        else Both verifications fail
            Internal->>MCP: Invalid
            MCP->>Client: 401 + WWW-Authenticate
        end
    end

    Note over Client,Upstream: Legacy Flow (API Token)
    Client->>MCP: POST /mcp (Token <api_token>)
    MCP->>MCP: Not JWT-like, check client secret
    MCP->>Upstream: Call API with configured token
    Upstream->>MCP: Response
    MCP->>Client: 200 OK
Loading

Last reviewed commit: d740f6c

@vercel

vercel Bot commented Feb 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview, Comment Feb 28, 2026 4:50pm

Request Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d740f6c30f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread api/mcp.ts Outdated
let authSource: 'authorization' | 'environment' | 'oauth_local' | 'oauth_remote' =
resolvedAuth.source ?? 'authorization';

const jwtLikeBearerToken = callerScheme === 'bearer' && looksLikeJwt(callerToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Accept opaque Bearer tokens in OAuth validation path

The OAuth branch is gated on looksLikeJwt, so only Bearer tokens with exactly three dot-separated segments are treated as OAuth tokens. Any valid opaque OAuth access token will skip this path and be handled as legacy auth, which means it is rejected as Invalid client credentials whenever T49_API_TOKEN/T49_MCP_CLIENT_SECRET is configured (or forwarded upstream as a Token scheme when not configured). This breaks hosted OAuth clients that receive opaque access tokens.

Useful? React with 👍 / 👎.

Comment thread packages/mcp/src/auth/workos-jwt.ts Outdated
}
}

const response = await fetch(jwksUrl, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle JWKS fetch failures as auth failures

fetchJwks performs network I/O without error handling, so a transient JWKS network error (DNS, timeout, connection reset) throws out of verifyWorkosJwt instead of returning null. In api/mcp.ts, that propagates to the top-level catch and returns HTTP 500, which both skips internal token verification fallback and turns a token-validation issue into a server error for clients.

Useful? React with 👍 / 👎.

@dodeja dodeja closed this Jul 1, 2026
@dodeja
dodeja force-pushed the codex/data-8540-workos-only-mcp-auth branch from d740f6c to 8f6e499 Compare July 1, 2026 20:45
@dodeja

dodeja commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebase result: this PR is fully superseded by #240 (already merged to main)

I rebased this branch onto current main to resolve the DIRTY merge state and the failing mcp CI check. After careful review, every change in this PR's diff is now obsolete — main shipped a different (and RFC-compliant) OAuth architecture in #240 ("Add WorkOS MCP auth gateway") back in June, well after this PR (Feb 28) was opened. Specifically:

  • This PR's core mechanism — local WorkOS JWT verification in the gateway (packages/mcp/src/auth/workos-jwt.ts, WORKOS_MCP_ISSUER/WORKOS_MCP_AUDIENCE/WORKOS_MCP_JWKS_URL, plus a Rails "internal token principal" fallback) — conflicts with the shipped design, where token validation is delegated to the Terminal49 backend via /connected-clients/resolve (see AGENTS.md/CLAUDE.md: "Token validation is delegated to the Terminal49 backend... the backend must enforce the token audience"). Reintroducing local verification would create two divergent trust paths for the same bearer token.
  • This PR's oauthResourceMetadataUrl() derives its own OAuth resource-metadata URL per-file (defaulting to https://api.terminal49.com/.well-known/oauth-authorization-server). Main's shipped fix in Add WorkOS MCP auth gateway #240 explicitly centralized this in packages/mcp/src/resource.ts as the single source of truth specifically so the PRM document and the WWW-Authenticate challenge can never diverge (RFC 9728) — the repo instructions explicitly say not to reintroduce per-file resource derivation.
  • The three new docs (hosted-http-oauth-requirements.mdx, hosted-http-oauth-test-plan.mdx, oauth-e2e-smoke.mdx) and scripts/mcp-oauth-e2e-smoke.sh describe a Rails-hosted OAuth Authorization Server with dynamic client registration (POST /oauth/register) and a Rails-served /.well-known/oauth-authorization-server. That's not what got built — WorkOS is the external Authorization Server, there's no DCR, and the AS metadata is discovered directly from WorkOS via the PRM's authorization_servers field. Publishing these docs as-is would describe a system that doesn't exist.
  • The SDK client changes (preserving Bearer/Token prefixes) and their tests are already implemented equivalently (and more completely) on main.

Failing mcp CI check root cause: the added tests in tests/api-handler.test.ts asserted the old resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server" challenge value and an "Invalid OAuth bearer token" message — both from the superseded local-JWT code path. Main's current behavior only advertises resource_metadata via protectedResourceMetadataUrl(req) (resource.ts) when AuthKit + WorkOS are configured, so these assertions no longer match reality.

Review comments: both Greptile findings (opaque-token rejection in the JWT branch, and unhandled JWKS fetch failures) are on packages/mcp/src/auth/workos-jwt.ts, which no longer exists after this reconciliation — the code path they flagged is gone rather than patched.

What I did: rebased onto origin/main, resolved every conflict in favor of main's shipped implementation, and dropped the PR's now-obsolete additions rather than reintroducing a competing/incorrect auth path or misleading docs. The resulting diff against main is empty — npm run test/build/lint --workspace @terminal49/mcp and tsc --noEmit are all green (130/130 tests passing).

Recommendation: close this PR as superseded by #240. Happy to reopen/redo it scoped to anything from here that's still genuinely missing (e.g., dedicated OAuth e2e smoke tooling for the current WorkOS-based flow) if that's still wanted — but it should be written against the shipped architecture, not the original Rails-AS design.

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