diff --git a/.env.sample b/.env.sample index dc10ecf9..b247e9ce 100644 --- a/.env.sample +++ b/.env.sample @@ -1,10 +1,22 @@ -# Terminal49 MCP Server - Environment Variables -# Copy this file to .env.local and fill in your credentials - -# Terminal49 API Token -# Get your token from: https://app.terminal49.com/settings/api -T49_API_TOKEN=your_api_token_here - -# Terminal49 API Base URL (optional) -# Default: https://api.terminal49.com/v2 -T49_API_BASE_URL=https://api.terminal49.com/v2 +# Terminal49 MCP Server - Environment Variables +# Copy this file to .env.local and fill in your credentials + +# Legacy compatibility auth (still supported during migration) +# Get your token from: https://app.terminal49.com/settings/api +T49_API_TOKEN=your_api_token_here + +# Upstream Terminal49 API base URL (optional) +# Default: https://api.terminal49.com/v2 +T49_API_BASE_URL=https://api.terminal49.com/v2 + +# OAuth challenge metadata URL returned via WWW-Authenticate +T49_MCP_RESOURCE_METADATA_URL=https://api.terminal49.com/.well-known/oauth-authorization-server + +# Internal fallback token verification endpoint (server-to-server) +T49_MCP_TOKEN_VERIFY_URL=https://api.terminal49.com/internal/mcp/token_principal +T49_MCP_INTERNAL_AUTH_TOKEN=replace-with-shared-internal-secret + +# WorkOS MCP JWT verification settings +WORKOS_MCP_ISSUER=https://api.workos.com +WORKOS_MCP_AUDIENCE=replace-with-your-workos-audience +WORKOS_MCP_JWKS_URL=https://api.workos.com/sso/jwks/your-jwks-id diff --git a/api/mcp.ts b/api/mcp.ts index 11ac66b7..4fcefc34 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -9,6 +9,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { randomUUID, timingSafeEqual } from 'node:crypto'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { looksLikeJwt, verifyWorkosJwt } from '../packages/mcp/src/auth/workos-jwt.js'; import { createTerminal49McpServer } from '../packages/mcp/src/server.js'; type RequestLike = { @@ -42,14 +43,18 @@ function getHeaderValue(value: string | string[] | undefined): string | undefine function extractAuthorizationToken( authorizationHeader: string | undefined, -): { token?: string; source?: 'authorization' } { +): { token?: string; source?: 'authorization'; scheme?: 'bearer' | 'token' } { if (authorizationHeader?.trim()) { const trimmed = authorizationHeader.trim(); const authMatch = trimmed.match(/^(bearer|token)\s+(.+)$/i); if (authMatch?.[2]) { const token = authMatch[2].trim(); if (token.length > 0) { - return { token, source: 'authorization' }; + return { + token, + source: 'authorization', + scheme: authMatch[1]?.toLowerCase() === 'bearer' ? 'bearer' : 'token', + }; } } } @@ -176,6 +181,57 @@ function validateRequestSecurity(req: RequestLike, res: ResponseLike): boolean { return true; } +function oauthResourceMetadataUrl(): string { + return ( + process.env.T49_MCP_RESOURCE_METADATA_URL?.trim() ?? + 'https://api.terminal49.com/.well-known/oauth-authorization-server' + ); +} + +function setOAuthChallengeHeader(res: ResponseLike): void { + res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${oauthResourceMetadataUrl()}"`); +} + +function unauthorized(res: ResponseLike, payload: { error: string; message: string }): void { + setCorsHeaders(res); + setOAuthChallengeHeader(res); + res.status(401).json(payload); +} + +async function verifyViaInternalPrincipal(token: string): Promise { + const verifyUrl = process.env.T49_MCP_TOKEN_VERIFY_URL?.trim(); + const internalAuthToken = process.env.T49_MCP_INTERNAL_AUTH_TOKEN?.trim(); + if (!verifyUrl || !internalAuthToken) { + return false; + } + + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), 2000); + + try { + const response = await fetch(verifyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${internalAuthToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + signal: abortController.signal, + }); + + if (!response.ok) { + return false; + } + + const payload = (await response.json()) as { active?: boolean; user_id?: string; account_id?: string }; + return Boolean(payload?.active && payload?.user_id && payload?.account_id); + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + /** * Main handler for Vercel serverless function */ @@ -259,10 +315,10 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom const authHeader = getHeaderValue(req.headers.authorization); const resolvedAuth = extractAuthorizationToken(authHeader); const callerToken = resolvedAuth.token; + const callerScheme = resolvedAuth.scheme; if (!callerToken) { - setCorsHeaders(res); - res.status(401).json({ + unauthorized(res, { error: 'Unauthorized', message: 'Missing valid Authorization header. Use `Authorization: Bearer ` or `Authorization: Token `.', @@ -274,9 +330,29 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom const configuredApiToken = process.env.T49_API_TOKEN?.trim(); const configuredClientSecret = process.env.T49_MCP_CLIENT_SECRET?.trim(); let apiToken = callerToken; - let authSource: 'authorization' | 'environment' = resolvedAuth.source ?? 'authorization'; + let authSource: 'authorization' | 'environment' | 'oauth_local' | 'oauth_remote' = + resolvedAuth.source ?? 'authorization'; + + const jwtLikeBearerToken = callerScheme === 'bearer' && looksLikeJwt(callerToken); + if (jwtLikeBearerToken) { + const localVerificationPayload = await verifyWorkosJwt(callerToken); + if (localVerificationPayload) { + apiToken = `Bearer ${callerToken}`; + authSource = 'oauth_local'; + } else if (await verifyViaInternalPrincipal(callerToken)) { + apiToken = `Bearer ${callerToken}`; + authSource = 'oauth_remote'; + } else { + unauthorized(res, { + error: 'Unauthorized', + message: 'Invalid OAuth bearer token.', + }); + logLifecycle('mcp.request.complete', requestId, { reason: 'invalid_oauth_bearer' }); + return; + } + } - if (configuredApiToken) { + if (configuredApiToken && authSource !== 'oauth_local' && authSource !== 'oauth_remote') { if (!configuredClientSecret) { setCorsHeaders(res); res.status(500).json({ @@ -288,8 +364,7 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom } if (!isMatchingClientSecret(callerToken, configuredClientSecret)) { - setCorsHeaders(res); - res.status(401).json({ + unauthorized(res, { error: 'Unauthorized', message: 'Invalid client credentials.', }); diff --git a/docs/api-docs/in-depth-guides/mcp.mdx b/docs/api-docs/in-depth-guides/mcp.mdx index 00597060..a7c73c16 100644 --- a/docs/api-docs/in-depth-guides/mcp.mdx +++ b/docs/api-docs/in-depth-guides/mcp.mdx @@ -19,8 +19,8 @@ Before you begin, make sure you have: A Terminal49 account with API access - - A `T49_API_TOKEN` from the [dashboard](https://app.terminal49.com/developers/api-keys) + + Discovery + registration + browser auth for hosted MCP (preferred) Required if running the MCP server locally @@ -43,23 +43,30 @@ Before you begin, make sure you have: |-----------|----------|----------| | HTTP (streamable) | `POST /api/mcp` or `POST /mcp` | Serverless, short-lived requests | -**Authentication**: API token only (OAuth not required for this release). -Pass `Authorization: Bearer ` or `Authorization: Token `. -Server falls back to `T49_API_TOKEN` environment variable. +**Authentication**: OAuth 2.1 for hosted MCP (preferred). +Discover OAuth metadata from `GET /.well-known/oauth-authorization-server`, register via `POST /oauth/register`, then complete auth and call MCP with bearer tokens. +On unauthenticated requests, server returns `401` with: +`WWW-Authenticate: Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"`. + +Legacy compatibility mode still accepts: +`Authorization: Bearer ` or `Authorization: Token `. Claude Desktop and Cursor use the HTTP transport. For hosted production usage, use Streamable HTTP at `/mcp`. -For hosted OAuth implementation planning, see [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements) and [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan). +For hosted OAuth requirements and validation: +- [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements) +- [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan) +- [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke) --- ## Configure Your MCP Client -### Claude Desktop +### Claude Desktop (legacy compatibility example) @@ -112,7 +119,7 @@ For hosted OAuth implementation planning, see [Hosted HTTP OAuth Requirements](/ -### Cursor IDE +### Cursor IDE (legacy compatibility example) Add to your Cursor settings: diff --git a/docs/mcp/home.mdx b/docs/mcp/home.mdx index 356dd086..9f822e98 100644 --- a/docs/mcp/home.mdx +++ b/docs/mcp/home.mdx @@ -10,14 +10,15 @@ Use the Terminal49 MCP server to let Claude or Cursor answer questions with live ## TL;DR – Get Started in 5 Minutes - - Go to the [Terminal49 dashboard](https://app.terminal49.com/developers/api-keys) → Settings → API Tokens and create a `T49_API_TOKEN`. + + Preferred: OAuth 2.1 (discovery + registration + browser authorization). + Compatibility: API token header is still supported during migration. - **Claude Desktop** (macOS / Windows / Linux) - **Cursor IDE** - + Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json @@ -59,6 +60,10 @@ Use the Terminal49 MCP server to let Claude or Cursor answer questions with live + +For OAuth-native hosted setup and validation, use [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke). + + Need test container numbers? See [Test Numbers](/api-docs/useful-info/test-numbers) for containers you can use during development. @@ -74,16 +79,23 @@ For the full walkthrough (including local stdio dev, deployment, and SDK example | HTTP (streamable) | `POST /api/mcp` or `POST /mcp` | Serverless, short-lived requests | **Authentication**: -- API token only (OAuth not required for this release) -- Header: `Authorization: Bearer ` or `Authorization: Token ` -- Or set `T49_API_TOKEN` environment variable when self-hosting +- OAuth 2.1 for hosted MCP (preferred) +- Discovery: `GET /.well-known/oauth-authorization-server` +- Dynamic registration: `POST /oauth/register` (public clients, no secret) +- On unauthenticated calls, MCP responds with: +`WWW-Authenticate: Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"` +- Legacy compatibility mode still accepts API tokens during migration: +`Authorization: Bearer ` or `Authorization: Token ` Claude Desktop and Cursor use the HTTP transport at `https://mcp.terminal49.com/mcp`. -Planning OAuth for hosted MCP? Use the [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements) and [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan). +For implementation details and validation, use: +- [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements) +- [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan) +- [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke) The same [rate limits](/api-docs/in-depth-guides/rate-limiting) apply to MCP endpoints as the REST API. diff --git a/docs/mcp/hosted-http-oauth-requirements.mdx b/docs/mcp/hosted-http-oauth-requirements.mdx new file mode 100644 index 00000000..340945ac --- /dev/null +++ b/docs/mcp/hosted-http-oauth-requirements.mdx @@ -0,0 +1,175 @@ +--- +title: Hosted HTTP OAuth Requirements +description: Phase 1 requirements and protocol contract for OAuth 2.1 on Terminal49 hosted MCP +--- + +# Hosted HTTP OAuth Requirements + + +This is a delivery specification for the Terminal49 monorepo team (Rails + UI). It defines required OAuth behavior for hosted MCP over HTTP. + + +## Current State + +Hosted MCP now supports OAuth 2.1 bootstrap/discovery and bearer-token auth semantics for MCP clients. + +Current deployed behavior: +- OAuth discovery metadata at `GET /.well-known/oauth-authorization-server` +- Dynamic registration at `POST /oauth/register` +- OAuth challenge headers on unauthenticated `POST /mcp` requests +- Temporary dual-mode compatibility for legacy API-token clients + +## Scope + +### In + +- Hosted MCP HTTP auth for `POST /mcp` +- OAuth Authorization Server in TMT API Rails +- Consent flow and OAuth app management UI in monorepo +- Multi-client support via static OAuth app registry +- Compatibility certification for Claude/local agents and ChatGPT apps +- Dual-mode migration from API token auth to OAuth + +### Out + +- stdio auth flow changes +- Dynamic Client Registration +- Per-tool scopes (phase 1 uses a single scope) +- Migration of non-MCP REST endpoints to OAuth + +## Phase 1 Decisions (Locked) + +- Authorization Server location: Rails in Terminal49 monorepo +- Client onboarding: static admin-managed OAuth applications +- Grant profile: Authorization Code + PKCE only +- Access token format: opaque tokens with server-side validation +- Scope model: single `mcp` scope +- Consent behavior: prompt once per `user + client + scope`, skip repeat consent +- Rollout mode: dual auth (`oauth` and legacy API token), then deprecate API token + +## Normative Requirements + +| ID | Requirement | +|----|-------------| +| `MCP-OAUTH-001` | Hosted MCP `POST /mcp` MUST accept OAuth bearer access tokens and reject invalid tokens before MCP method execution. | +| `MCP-OAUTH-002` | Authorization Server MUST support multiple OAuth applications with per-client redirect URI allowlists and enable/disable controls. | +| `MCP-OAUTH-003` | Phase 1 MUST support `authorization_code` grant with PKCE (`S256`) and MUST reject non-PKCE requests. | +| `MCP-OAUTH-004` | Access tokens MUST be opaque and validated server-side in Rails. | +| `MCP-OAUTH-005` | Authorization decisions for MCP MUST require scope `mcp` and resolve user/account context. | +| `MCP-OAUTH-006` | Consent MUST be shown on first grant and MAY be skipped for repeat grants of same `user + client + scope`. | +| `MCP-OAUTH-007` | Authorization code MUST be one-time use, short-lived, and protected against replay. | +| `MCP-OAUTH-008` | Refresh tokens MUST rotate on use and revoke chain on replay detection. | +| `MCP-OAUTH-009` | Authorization endpoint MUST enforce exact redirect URI matching against registered client URIs. | +| `MCP-OAUTH-010` | OAuth metadata/discovery endpoint(s) needed by MCP OAuth clients MUST be exposed and accurate. | +| `MCP-OAUTH-011` | On auth failure, MCP endpoint MUST return `401` with an RFC-compliant `WWW-Authenticate` header suitable for OAuth bootstrapping. | +| `MCP-OAUTH-012` | Legacy API token auth MAY remain temporarily in dual-mode but MUST emit telemetry tagged as legacy auth. | +| `MCP-OAUTH-013` | Auth endpoints MUST be rate-limited and audited (authorize, token, revoke, failed/blocked auth). | +| `MCP-OAUTH-014` | Before GA, end-to-end compatibility MUST pass for Claude/local-agent path and ChatGPT app path. | +| `MCP-OAUTH-015` | GA MUST include a dated deprecation plan for legacy API token auth on hosted MCP. | + +## OAuth Endpoint Contract (Monorepo) + +### `GET /oauth/authorize` + +- Validates client, redirect URI, scope, session, and PKCE challenge. +- Presents consent when needed. +- Redirects with authorization code and `state`. + +### `POST /oauth/token` + +- Supports `authorization_code` and `refresh_token`. +- Validates code verifier against stored PKCE challenge. +- Issues opaque access token and rotating refresh token. + +### `POST /oauth/revoke` + +- Revokes access or refresh token. +- Returns success semantics for valid and already-revoked tokens. + +### `GET /.well-known/oauth-authorization-server` + +- Provides authorization server metadata used by OAuth-capable MCP clients. +- MUST include authorization endpoint, token endpoint, supported grant types, and supported code challenge methods. + +## MCP Endpoint Auth Contract + +### `POST /mcp` + +- Accepts bearer tokens from OAuth flow. +- Resolves auth context: + - `user_id` + - `account_id` + - `client_id` + - `scopes` + - `auth_type` (`oauth` or `legacy_api_token`) +- Rejects missing/invalid/expired/revoked tokens with `401`. + +### Required auth error shape + +- `HTTP 401` +- `WWW-Authenticate: Bearer ...` +- Response body may include JSON-RPC error payload, but transport-level auth semantics must remain standards-compliant. + +## Data Model Contract (Monorepo) + +Minimum entities for phase 1: + +- `oauth_applications` + - `name` + - `client_id` + - `client_secret` (nullable for public clients) + - `redirect_uris` (array) + - `scopes` (includes `mcp`) + - `is_first_party` + - `active` +- `oauth_authorization_codes` + - `user_id` + - `account_id` + - `oauth_application_id` + - `scope` + - `code_challenge` + - `expires_at` + - `consumed_at` +- `oauth_access_tokens` + - `token_hash` + - `user_id` + - `account_id` + - `oauth_application_id` + - `scope` + - `expires_at` + - `revoked_at` +- `oauth_refresh_tokens` + - `token_hash` + - `oauth_access_token_id` or chain pointer + - `expires_at` + - `revoked_at` + - `rotated_from_id` + - `compromised` + +## Security Requirements + +- PKCE `S256` required for public clients +- Strict redirect URI matching +- CSRF/state verification on authorize callback +- Code replay protection and single-use codes +- Refresh token rotation and replay revocation +- Token hashes at rest +- Structured audit logs for auth events +- Rate limits on auth endpoints and abusive-client protections + +## Rollout Requirements + +1. Internal enablement with OAuth-only clients. +2. Dual-mode beta: OAuth + legacy API token at `/mcp`. +3. Public migration documentation with target dates. +4. Deprecation notice and cutoff date for legacy API token. +5. Legacy auth removal after adoption and stability gates pass. + +## Handoff Deliverables + +- Rails OAuth endpoint implementation per contract above +- UI consent and app-management screens +- MCP auth middleware using opaque token validation +- Telemetry dashboard for legacy vs OAuth usage +- Certification evidence for Claude and ChatGPT tracks +- Migration runbook and deprecation timeline diff --git a/docs/mcp/hosted-http-oauth-test-plan.mdx b/docs/mcp/hosted-http-oauth-test-plan.mdx new file mode 100644 index 00000000..cbd7c163 --- /dev/null +++ b/docs/mcp/hosted-http-oauth-test-plan.mdx @@ -0,0 +1,124 @@ +--- +title: Hosted HTTP OAuth Test Plan +description: Requirement-driven test and certification plan for OAuth 2.1 on hosted MCP HTTP +--- + +# Hosted HTTP OAuth Test Plan + +Use this plan to validate the hosted MCP OAuth implementation in the Terminal49 monorepo. + +## Test Objectives + +- Verify all phase 1 OAuth requirements for hosted MCP. +- Prove compatibility with both target client tracks: + - Claude/local-agent path + - ChatGPT app path +- Validate safe migration from legacy API token auth to OAuth. +- Validate RFC-compliant OAuth challenge semantics (`WWW-Authenticate` + `resource_metadata`). + +## Test Tracks + +| Track | Purpose | +|-------|---------| +| Unit | PKCE, token lifecycle, scope checks, replay protections | +| Integration (Rails) | OAuth endpoint behavior and error handling | +| Integration (MCP) | `/mcp` bearer validation and auth semantics | +| End-to-End Client | Real OAuth flow and MCP calls from target clients | +| Security | Abuse/replay/redirect/state attack resistance | +| Rollout/Operations | Metrics, deprecation readiness, regression watch | + +## Requirement Traceability Matrix + +| Requirement | Unit | Integration | E2E | Security | +|-------------|------|-------------|-----|----------| +| `MCP-OAUTH-001` | - | Yes | Yes | - | +| `MCP-OAUTH-002` | - | Yes | Yes | - | +| `MCP-OAUTH-003` | Yes | Yes | Yes | Yes | +| `MCP-OAUTH-004` | Yes | Yes | Yes | Yes | +| `MCP-OAUTH-005` | Yes | Yes | Yes | - | +| `MCP-OAUTH-006` | - | Yes | Yes | - | +| `MCP-OAUTH-007` | Yes | Yes | - | Yes | +| `MCP-OAUTH-008` | Yes | Yes | - | Yes | +| `MCP-OAUTH-009` | - | Yes | Yes | Yes | +| `MCP-OAUTH-010` | - | Yes | Yes | - | +| `MCP-OAUTH-011` | - | Yes | Yes | - | +| `MCP-OAUTH-012` | - | Yes | Yes | - | +| `MCP-OAUTH-013` | - | Yes | - | Yes | +| `MCP-OAUTH-014` | - | - | Yes | - | +| `MCP-OAUTH-015` | - | Yes | - | - | + +## Core Test Cases + +### OAuth Endpoint Integration + +1. `AUTH-001` Valid auth code + PKCE exchange returns access and refresh tokens. +2. `AUTH-002` Missing PKCE verifier fails token exchange. +3. `AUTH-003` Reused authorization code is rejected. +4. `AUTH-004` Invalid redirect URI is rejected. +5. `AUTH-005` Invalid `state` is rejected. +6. `AUTH-006` Refresh token rotates and old refresh token is invalidated. +7. `AUTH-007` Revoked token cannot be used at `/mcp`. +8. `AUTH-008` Discovery metadata endpoints return expected values. + +### MCP Auth Integration + +1. `MCP-001` Valid OAuth token succeeds for `initialize`. +2. `MCP-002` Valid OAuth token succeeds for `tools/list`. +3. `MCP-003` Valid OAuth token succeeds for `tools/call`. +4. `MCP-004` Expired token returns `401` with `WWW-Authenticate`. +5. `MCP-005` Missing token returns `401` with `WWW-Authenticate`. +6. `MCP-006` Wrong scope returns `401` or `403` per contract and is logged. +7. `MCP-007` Legacy API token succeeds during dual-mode and emits `auth_type=legacy_api_token`. + +### Security Tests + +1. `SEC-001` Authorization code replay attempt is blocked. +2. `SEC-002` Refresh token replay marks token chain compromised and blocks reuse. +3. `SEC-003` Redirect URI tampering attempt is blocked. +4. `SEC-004` CSRF/state mismatch is blocked. +5. `SEC-005` PKCE downgrade/non-`S256` is blocked. +6. `SEC-006` Auth endpoint rate limits trigger under abuse load. + +## Client Certification Scenarios + +### Claude / Local-Agent Certification + +1. Configure client with registered OAuth app and redirect URI. +2. Complete OAuth sign-in and consent. +3. Run MCP handshake (`initialize`, `tools/list`). +4. Execute one read-only tool and one write-like tracking tool. +5. Force token expiry and verify refresh path succeeds. +6. Confirm no fallback to legacy token was required. + +### ChatGPT App Certification + +1. Configure ChatGPT app OAuth settings with registered Terminal49 client. +2. Complete OAuth sign-in and consent. +3. Confirm app can call hosted MCP endpoint with issued access token. +4. Validate refresh and revoke behavior in app session lifecycle. +5. Verify auth failures are actionable and standards-compliant. + +## Rollout Validation + +Track these metrics during dual-mode: + +- OAuth auth success rate +- OAuth token issuance and refresh success +- `/mcp` auth failures by reason +- Legacy-token request share over time +- Per-client error rate (Claude app vs ChatGPT app) +- Median and p95 auth latency for `/oauth/token` and `/mcp` + +## Quick Smoke Validation + +For repeatable smoke checks, run the [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke) runbook and script. + +## Exit Criteria (GA Gate) + +All must pass: + +1. All `AUTH-*`, `MCP-*`, and `SEC-*` critical tests green. +2. Claude/local-agent and ChatGPT certifications completed and documented. +3. Legacy-token migration adoption target met. +4. No open critical vulnerabilities in OAuth or auth middleware. +5. Deprecation date for legacy API token auth communicated and approved. diff --git a/docs/mcp/oauth-e2e-smoke.mdx b/docs/mcp/oauth-e2e-smoke.mdx new file mode 100644 index 00000000..f5f76ab4 --- /dev/null +++ b/docs/mcp/oauth-e2e-smoke.mdx @@ -0,0 +1,74 @@ +--- +title: OAuth E2E Smoke Test +description: End-to-end validation for hosted MCP OAuth discovery, registration, challenge, and authenticated calls +--- + +# OAuth E2E Smoke Test + +Use this runbook to validate the hosted MCP OAuth 2.1 integration. + +## Prerequisites + +1. `curl` and `jq` installed. +2. Reachable API and MCP endpoints. +3. A redirect URI for client registration (for example `http://localhost:5050/callback`). +4. Optional: a valid OAuth access token to run authenticated MCP calls. + +## Environment variables + +```bash +export API_BASE_URL="https://api.terminal49.com" +export MCP_BASE_URL="https://mcp.terminal49.com/mcp" +export MCP_REDIRECT_URI="http://localhost:5050/callback" +export MCP_CLIENT_NAME="Terminal49 MCP Smoke Client" + +# Optional for authenticated phase +export MCP_ACCESS_TOKEN="" +``` + +## Run + +From the `API` repo root: + +```bash +bash scripts/mcp-oauth-e2e-smoke.sh +``` + +## What this validates + +1. Discovery endpoint returns required metadata: +- `issuer` +- `authorization_endpoint` +- `token_endpoint` +- `revocation_endpoint` +- `code_challenge_methods_supported` + +2. Dynamic registration succeeds: +- `POST /oauth/register` returns `client_id` +- no `client_secret` is returned + +3. Unauthenticated MCP request is standards-compliant: +- `POST /mcp` returns `401` +- `WWW-Authenticate` includes: +`Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"` + +4. Authenticated MCP calls (if `MCP_ACCESS_TOKEN` provided): +- `initialize` returns success +- `tools/list` returns a non-empty tools list + +## Troubleshooting + +1. `401` with missing `resource_metadata`: +- Verify `T49_MCP_RESOURCE_METADATA_URL` in the MCP deployment. + +2. Registration fails with `invalid_client_metadata`: +- Validate `redirect_uris` format. +- Use `https://` callbacks or localhost http callbacks. + +3. OAuth bearer token rejected: +- Confirm token contains `mcp` scope. +- Confirm issuer/audience/JWKS env vars: +`WORKOS_MCP_ISSUER`, `WORKOS_MCP_AUDIENCE`, `WORKOS_MCP_JWKS_URL`. + +4. Internal fallback verification fails: +- Confirm `T49_MCP_TOKEN_VERIFY_URL` and `T49_MCP_INTERNAL_AUTH_TOKEN` match Rails internal endpoint config. diff --git a/packages/mcp/.env.example b/packages/mcp/.env.example index 0c46b7e1..8fbde2d9 100644 --- a/packages/mcp/.env.example +++ b/packages/mcp/.env.example @@ -1,7 +1,20 @@ # Terminal49 API Configuration +# Legacy compatibility auth (still supported while OAuth rollout completes) T49_API_TOKEN=your_api_token_here T49_API_BASE_URL=https://api.terminal49.com/v2 +# OAuth challenge metadata advertised to MCP clients +T49_MCP_RESOURCE_METADATA_URL=https://api.terminal49.com/.well-known/oauth-authorization-server + +# Internal fallback token principal verification (server-to-server) +T49_MCP_TOKEN_VERIFY_URL=https://api.terminal49.com/internal/mcp/token_principal +T49_MCP_INTERNAL_AUTH_TOKEN=replace-with-shared-internal-secret + +# WorkOS MCP JWT verification settings +WORKOS_MCP_ISSUER=https://api.workos.com +WORKOS_MCP_AUDIENCE=replace-with-your-workos-audience +WORKOS_MCP_JWKS_URL=https://api.workos.com/sso/jwks/your-jwks-id + # MCP Server Configuration NODE_ENV=development LOG_LEVEL=info diff --git a/packages/mcp/src/auth/workos-jwt.ts b/packages/mcp/src/auth/workos-jwt.ts new file mode 100644 index 00000000..783b4035 --- /dev/null +++ b/packages/mcp/src/auth/workos-jwt.ts @@ -0,0 +1,286 @@ +import { createPublicKey, createVerify } from 'node:crypto'; + +const ACCOUNT_ID_CLAIM = 'urn:terminal49:account_id'; +const LEGACY_ACCOUNT_ID_CLAIM = 'account_id'; +const DEFAULT_REQUIRED_SCOPE = 'mcp'; +const EXPECTED_ALGORITHM = 'RS256'; +const DEFAULT_JWKS_CACHE_MS = 10 * 60 * 1000; + +type Jwk = { + kid?: string; + kty?: string; + n?: string; + e?: string; + alg?: string; + use?: string; +}; + +type JwksResponse = { + keys?: Jwk[]; +}; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +type JwtPayload = { [key: string]: JsonValue }; + +type VerificationConfig = { + issuer?: string; + audience?: string; + jwksUrl?: string; + requiredScope?: string; +}; + +type VerifiedPayload = JwtPayload & { + t49_user_id: string; + t49_account_id: string; +}; + +const jwksCache = new Map(); + +export function looksLikeJwt(token: string): boolean { + return token.split('.').length === 3; +} + +export async function verifyWorkosJwt( + token: string, + config: VerificationConfig = {}, +): Promise { + if (!looksLikeJwt(token)) { + return null; + } + + const issuer = (config.issuer ?? process.env.WORKOS_MCP_ISSUER ?? '').trim(); + const audience = (config.audience ?? process.env.WORKOS_MCP_AUDIENCE ?? '').trim(); + const jwksUrl = (config.jwksUrl ?? process.env.WORKOS_MCP_JWKS_URL ?? '').trim(); + const requiredScope = (config.requiredScope ?? DEFAULT_REQUIRED_SCOPE).trim(); + + if (!issuer || !audience || !jwksUrl) { + return null; + } + + const [encodedHeader, encodedPayload, encodedSignature] = token.split('.'); + if (!encodedHeader || !encodedPayload || !encodedSignature) { + return null; + } + + const header = decodeSegment(encodedHeader) as Record | null; + const payload = decodeSegment(encodedPayload) as JwtPayload | null; + if (!header || !payload) { + return null; + } + + if (header.alg !== EXPECTED_ALGORITHM) { + return null; + } + + const kid = typeof header.kid === 'string' ? header.kid : ''; + if (!kid) { + return null; + } + + const jwk = (await findJwk(kid, jwksUrl)) ?? (await findJwk(kid, jwksUrl, true)); + if (!jwk || jwk.kty !== 'RSA' || !jwk.n || !jwk.e) { + return null; + } + + const isSignatureValid = verifySignature({ + encodedHeader, + encodedPayload, + encodedSignature, + jwk, + }); + if (!isSignatureValid) { + return null; + } + + if (!validClaims(payload, { issuer, audience, requiredScope })) { + return null; + } + + const userId = resolveUserId(payload); + const accountId = resolveAccountId(payload); + if (!userId || !accountId) { + return null; + } + + return { + ...payload, + t49_user_id: userId, + t49_account_id: accountId, + }; +} + +async function findJwk(kid: string, jwksUrl: string, forceRefresh = false): Promise { + const keys = await fetchJwks(jwksUrl, forceRefresh); + return keys.find((key) => key.kid === kid) ?? null; +} + +async function fetchJwks(jwksUrl: string, forceRefresh: boolean): Promise { + if (!forceRefresh) { + const cached = jwksCache.get(jwksUrl); + if (cached && cached.expiresAt > Date.now()) { + return cached.keys; + } + } + + const response = await fetch(jwksUrl, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return []; + } + + const body = (await response.json()) as JwksResponse; + const keys = Array.isArray(body.keys) + ? body.keys.filter((key) => typeof key?.kid === 'string') + : []; + + jwksCache.set(jwksUrl, { + keys, + expiresAt: Date.now() + DEFAULT_JWKS_CACHE_MS, + }); + + return keys; +} + +function verifySignature(params: { + encodedHeader: string; + encodedPayload: string; + encodedSignature: string; + jwk: Jwk; +}): boolean { + const signingInput = `${params.encodedHeader}.${params.encodedPayload}`; + const signature = decodeBase64UrlBuffer(params.encodedSignature); + if (!signature) { + return false; + } + + const publicKey = createPublicKey({ + key: { + kty: params.jwk.kty, + n: params.jwk.n, + e: params.jwk.e, + }, + format: 'jwk', + }); + const verifier = createVerify('RSA-SHA256'); + verifier.update(signingInput); + verifier.end(); + + return verifier.verify(publicKey, signature); +} + +function validClaims( + payload: JwtPayload, + options: { issuer: string; audience: string; requiredScope: string }, +): boolean { + if (payload.iss !== options.issuer) { + return false; + } + + if (!audienceMatches(payload.aud, options.audience)) { + return false; + } + + const now = Math.floor(Date.now() / 1000); + const exp = numericClaim(payload.exp); + if (!exp || exp <= now) { + return false; + } + + const nbf = numericClaim(payload.nbf); + if (nbf && nbf > now) { + return false; + } + + const scope = typeof payload.scope === 'string' ? payload.scope : ''; + const scopeParts = scope.split(/\s+/).filter(Boolean); + if (!scopeParts.includes(options.requiredScope)) { + return false; + } + + return true; +} + +function audienceMatches(audClaim: JsonValue | undefined, expectedAudience: string): boolean { + if (typeof audClaim === 'string') { + return audClaim === expectedAudience; + } + + if (Array.isArray(audClaim)) { + return audClaim.includes(expectedAudience); + } + + return false; +} + +function resolveUserId(payload: JwtPayload): string | null { + if (typeof payload.sub === 'string' && payload.sub.trim()) { + return payload.sub; + } + + if (typeof payload.user_id === 'string' && payload.user_id.trim()) { + return payload.user_id; + } + + return null; +} + +function resolveAccountId(payload: JwtPayload): string | null { + const accountIdClaim = payload[ACCOUNT_ID_CLAIM]; + if (typeof accountIdClaim === 'string' && accountIdClaim.trim()) { + return accountIdClaim; + } + + const legacyAccountIdClaim = payload[LEGACY_ACCOUNT_ID_CLAIM]; + if (typeof legacyAccountIdClaim === 'string' && legacyAccountIdClaim.trim()) { + return legacyAccountIdClaim; + } + + if (typeof payload.t49_account_id === 'string' && payload.t49_account_id.trim()) { + return payload.t49_account_id; + } + + return null; +} + +function numericClaim(value: JsonValue | undefined): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + return null; +} + +function decodeSegment(segment: string): Record | null { + const decoded = decodeBase64UrlString(segment); + if (!decoded) { + return null; + } + + try { + return JSON.parse(decoded) as Record; + } catch { + return null; + } +} + +function decodeBase64UrlString(value: string): string | null { + const buffer = decodeBase64UrlBuffer(value); + if (!buffer) { + return null; + } + + return buffer.toString('utf-8'); +} + +function decodeBase64UrlBuffer(value: string): Buffer | null { + try { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4; + const padded = padding === 0 ? normalized : `${normalized}${'='.repeat(4 - padding)}`; + return Buffer.from(padded, 'base64'); + } catch { + return null; + } +} diff --git a/packages/mcp/tests/api-handler.test.ts b/packages/mcp/tests/api-handler.test.ts index 532bfeb3..87fdf518 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -87,6 +87,7 @@ describe('api/mcp handler lifecycle', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + vi.unstubAllGlobals(); mockState.servers.length = 0; mockState.transports.length = 0; mockState.serverCreateArgs.length = 0; @@ -96,6 +97,12 @@ describe('api/mcp handler lifecycle', () => { delete process.env.T49_API_BASE_URL; delete process.env.T49_MCP_ALLOWED_HOSTS; delete process.env.T49_MCP_ALLOWED_ORIGINS; + delete process.env.T49_MCP_RESOURCE_METADATA_URL; + delete process.env.T49_MCP_TOKEN_VERIFY_URL; + delete process.env.T49_MCP_INTERNAL_AUTH_TOKEN; + delete process.env.WORKOS_MCP_ISSUER; + delete process.env.WORKOS_MCP_AUDIENCE; + delete process.env.WORKOS_MCP_JWKS_URL; }); it('closes transport and server after successful request handling', async () => { @@ -207,6 +214,9 @@ describe('api/mcp handler lifecycle', () => { expect(res.payload).toMatchObject({ error: 'Unauthorized', }); + expect(res.headers['WWW-Authenticate']).toBe( + 'Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"', + ); expect(mockState.servers).toHaveLength(0); expect(mockState.transports).toHaveLength(0); }); @@ -249,6 +259,9 @@ describe('api/mcp handler lifecycle', () => { error: 'Unauthorized', message: 'Invalid client credentials.', }); + expect(res.headers['WWW-Authenticate']).toBe( + 'Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"', + ); expect(mockState.servers).toHaveLength(0); expect(mockState.transports).toHaveLength(0); }); @@ -290,7 +303,65 @@ describe('api/mcp handler lifecycle', () => { expect(res.payload).toMatchObject({ error: 'Unauthorized', }); + expect(res.headers['WWW-Authenticate']).toBe( + 'Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"', + ); expect(mockState.servers).toHaveLength(0); expect(mockState.transports).toHaveLength(0); }); + + it('returns 401 with OAuth challenge for invalid JWT-like bearer tokens', async () => { + const { default: handler } = await import('../../../api/mcp.ts'); + const req = createRequest({ + headers: { + host: 'localhost', + authorization: 'Bearer header.payload.signature', + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(res.statusCode).toBe(401); + expect(res.payload).toMatchObject({ + error: 'Unauthorized', + message: 'Invalid OAuth bearer token.', + }); + expect(res.headers['WWW-Authenticate']).toBe( + 'Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"', + ); + expect(mockState.servers).toHaveLength(0); + expect(mockState.transports).toHaveLength(0); + }); + + it('accepts JWT-like OAuth bearer token when internal fallback verification succeeds', async () => { + process.env.T49_MCP_TOKEN_VERIFY_URL = 'https://api.terminal49.com/internal/mcp/token_principal'; + process.env.T49_MCP_INTERNAL_AUTH_TOKEN = 'internal-auth-token'; + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ active: true, user_id: 'user-1', account_id: 'account-1' }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const { default: handler } = await import('../../../api/mcp.ts'); + const req = createRequest({ + headers: { + host: 'localhost', + authorization: 'Bearer header.payload.signature', + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(res.statusCode).toBe(200); + expect(mockState.serverCreateArgs[0]?.apiToken).toBe('Bearer header.payload.signature'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/scripts/mcp-oauth-e2e-smoke.sh b/scripts/mcp-oauth-e2e-smoke.sh new file mode 100755 index 00000000..5e9d958f --- /dev/null +++ b/scripts/mcp-oauth-e2e-smoke.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd curl +require_cmd jq + +API_BASE_URL="${API_BASE_URL:-https://api.terminal49.com}" +MCP_BASE_URL="${MCP_BASE_URL:-https://mcp.terminal49.com/mcp}" +MCP_CLIENT_NAME="${MCP_CLIENT_NAME:-Terminal49 MCP Smoke Client}" +MCP_REDIRECT_URI="${MCP_REDIRECT_URI:-}" +MCP_ACCESS_TOKEN="${MCP_ACCESS_TOKEN:-}" + +if [[ -z "${MCP_REDIRECT_URI}" ]]; then + echo "Set MCP_REDIRECT_URI before running this script." >&2 + exit 1 +fi + +echo "==> Step 1: discovery metadata" +DISCOVERY_PAYLOAD="$(curl -fsS "${API_BASE_URL}/.well-known/oauth-authorization-server")" +echo "${DISCOVERY_PAYLOAD}" | jq . + +echo "${DISCOVERY_PAYLOAD}" | jq -e '.issuer and .authorization_endpoint and .token_endpoint and .revocation_endpoint and .code_challenge_methods_supported' >/dev/null + +RESOURCE_METADATA_URL="${API_BASE_URL}/.well-known/oauth-authorization-server" +echo "Discovery checks passed." + +echo +echo "==> Step 2: dynamic client registration" +REGISTER_PAYLOAD="$(curl -fsS -X POST "${API_BASE_URL}/oauth/register" \ + -H 'Content-Type: application/json' \ + --data "{ + \"client_name\": \"${MCP_CLIENT_NAME}\", + \"redirect_uris\": [\"${MCP_REDIRECT_URI}\"], + \"grant_types\": [\"authorization_code\", \"refresh_token\"], + \"response_types\": [\"code\"], + \"token_endpoint_auth_method\": \"none\" + }")" +echo "${REGISTER_PAYLOAD}" | jq . +CLIENT_ID="$(echo "${REGISTER_PAYLOAD}" | jq -r '.client_id')" +if [[ -z "${CLIENT_ID}" || "${CLIENT_ID}" == "null" ]]; then + echo "Registration failed: missing client_id" >&2 + exit 1 +fi +echo "Client registration checks passed." + +echo +echo "==> Step 3: unauthenticated MCP request challenge" +CHALLENGE_HEADERS="$(mktemp)" +CHALLENGE_BODY="$(mktemp)" +CHALLENGE_STATUS="$( + curl -sS -o "${CHALLENGE_BODY}" -D "${CHALLENGE_HEADERS}" \ + -w '%{http_code}' \ + -X POST "${MCP_BASE_URL}" \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' +)" + +if [[ "${CHALLENGE_STATUS}" != "401" ]]; then + echo "Expected 401 for unauthenticated MCP request, got ${CHALLENGE_STATUS}" >&2 + cat "${CHALLENGE_BODY}" >&2 + exit 1 +fi + +WWW_AUTH="$(grep -i '^WWW-Authenticate:' "${CHALLENGE_HEADERS}" | tr -d '\r' || true)" +echo "WWW-Authenticate: ${WWW_AUTH}" +if [[ "${WWW_AUTH}" != *"resource_metadata=\"${RESOURCE_METADATA_URL}\""* ]]; then + echo "Challenge header does not include expected resource_metadata URL." >&2 + exit 1 +fi +echo "Challenge checks passed." + +echo +if [[ -z "${MCP_ACCESS_TOKEN}" ]]; then + echo "==> Step 4 skipped: no MCP_ACCESS_TOKEN provided." + echo "Provide MCP_ACCESS_TOKEN to validate authenticated initialize/tools/list." + exit 0 +fi + +echo "==> Step 4: authenticated MCP initialize" +INIT_BODY="$(curl -fsS -X POST "${MCP_BASE_URL}" \ + -H "Authorization: Bearer ${MCP_ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0.0"}}}')" +echo "${INIT_BODY}" | jq . +echo "${INIT_BODY}" | jq -e 'has("result") and (.error|not)' >/dev/null + +echo +echo "==> Step 5: authenticated MCP tools/list" +TOOLS_BODY="$(curl -fsS -X POST "${MCP_BASE_URL}" \ + -H "Authorization: Bearer ${MCP_ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}')" +echo "${TOOLS_BODY}" | jq . +echo "${TOOLS_BODY}" | jq -e '.result.tools | length >= 1' >/dev/null + +echo +echo "OAuth MCP smoke test complete." diff --git a/sdks/typescript-sdk/src/client.test.ts b/sdks/typescript-sdk/src/client.test.ts index 5ac12b9b..cb54292a 100644 --- a/sdks/typescript-sdk/src/client.test.ts +++ b/sdks/typescript-sdk/src/client.test.ts @@ -75,6 +75,24 @@ describe('Terminal49Client', () => { ); }); + it('preserves Bearer-prefixed tokens when building auth header', async () => { + const { fetchImpl, calls } = createMockFetch({ + '/containers/abc?include=shipment,pod_terminal': () => + jsonResponse({ data: { id: 'abc', attributes: {} } }), + }); + + const client = new Terminal49Client({ + apiToken: 'Bearer jwt-token-value', + apiBaseUrl: baseUrl, + fetchImpl, + }); + + await client.getContainer('abc'); + + const headers = new Headers(calls[0].init?.headers); + expect(headers.get('Authorization')).toBe('Bearer jwt-token-value'); + }); + it('sets include params on shipment and lists shipping lines with search', async () => { const { fetchImpl, calls } = createMockFetch({ '/shipments/ship-1?include=containers,pod_terminal,port_of_lading,port_of_discharge,destination,destination_terminal': diff --git a/sdks/typescript-sdk/src/client.ts b/sdks/typescript-sdk/src/client.ts index f63617f5..09af2331 100644 --- a/sdks/typescript-sdk/src/client.ts +++ b/sdks/typescript-sdk/src/client.ts @@ -640,9 +640,10 @@ export class Terminal49Client { init?: RequestInit, ): Promise => { const headers = new Headers(init?.headers); - const authHeader = this.apiToken.startsWith('Token ') - ? this.apiToken - : `Token ${this.apiToken}`; + const rawToken = this.apiToken.trim(); + const authHeader = /^(Token|Bearer)\s+/i.test(rawToken) + ? rawToken + : `Token ${rawToken}`; headers.set('Authorization', authHeader); headers.set('Accept', 'application/json'); if (init?.body !== undefined && !headers.has('Content-Type')) {