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/api-reference/search/search.mdx b/docs/api-docs/api-reference/search/search.mdx new file mode 100644 index 00000000..6c4f9545 --- /dev/null +++ b/docs/api-docs/api-reference/search/search.mdx @@ -0,0 +1,5 @@ +--- +og:title: Search | Terminal49 API Documentation +og:description: Full-text search across shipments, containers, and tracking requests using Terminal49's API. +openapi: get /search +--- 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/docs.json b/docs/docs.json index 383340d8..c60015cb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -122,6 +122,13 @@ "api-docs/api-reference/containers/get-a-containers-transport-events" ] }, + { + "group": "Search", + "icon": "magnifying-glass", + "pages": [ + "api-docs/api-reference/search/search" + ] + }, { "group": "Webhooks", "icon": "webhook", @@ -357,4 +364,4 @@ "og:site_name": "Terminal49 Docs" } } -} +} \ No newline at end of file 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/docs/openapi.json b/docs/openapi.json index ddecd529..4406c747 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1604,7 +1604,9 @@ "example": "WHLU1234560" } }, - "required": ["number"] + "required": [ + "number" + ] }, "examples": { "Container Number": { @@ -1645,7 +1647,11 @@ "properties": { "number_type": { "type": "string", - "enum": ["container", "bill_of_lading", "booking"] + "enum": [ + "container", + "bill_of_lading", + "booking" + ] }, "validation": { "type": "object", @@ -1656,7 +1662,10 @@ }, "type": { "type": "string", - "enum": ["container", "shipment"] + "enum": [ + "container", + "shipment" + ] }, "check_digit_passed": { "type": "boolean", @@ -1677,7 +1686,11 @@ "properties": { "decision": { "type": "string", - "enum": ["auto_select", "needs_confirmation", "no_prediction"] + "enum": [ + "auto_select", + "needs_confirmation", + "no_prediction" + ] }, "selected": { "type": "object", @@ -5624,7 +5637,9 @@ "properties": { "type": { "type": "string", - "enum": ["Point"] + "enum": [ + "Point" + ] }, "coordinates": { "type": "array", @@ -5633,10 +5648,16 @@ }, "minItems": 2, "maxItems": 2, - "example": [100.896831042, 13.065302386] + "example": [ + 100.896831042, + 13.065302386 + ] } }, - "required": ["type", "coordinates"] + "required": [ + "type", + "coordinates" + ] }, { "title": "LineString", @@ -5644,7 +5665,9 @@ "properties": { "type": { "type": "string", - "enum": ["LineString"] + "enum": [ + "LineString" + ] }, "coordinates": { "type": "array", @@ -5658,12 +5681,21 @@ }, "minItems": 2, "example": [ - [100.868768333, 13.07306], - [100.839155, 13.079318333] + [ + 100.868768333, + 13.07306 + ], + [ + 100.839155, + 13.079318333 + ] ] } }, - "required": ["type", "coordinates"] + "required": [ + "type", + "coordinates" + ] } ] }, @@ -8277,6 +8309,216 @@ "Parties" ] } + }, + "/search": { + "get": { + "summary": "Search shipments, containers, and tracking requests", + "tags": [ + "Search" + ], + "operationId": "searchAll", + "description": "Full-text search across shipments, containers (cargos), and tracking requests within your account. Results are ranked by type (shipments first, then containers, then tracking requests) and recency. Returns up to 25 results. Duplicate tracking requests (where a shipment exists with the same BL number) are automatically filtered out.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "query", + "required": true, + "description": "Search term to match against BL numbers, container numbers, reference numbers, and other indexed fields. Supports full-text search and partial (ILIKE) matching." + } + ], + "responses": { + "200": { + "description": "Successful search results", + "content": { + "application/vnd.api+json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the matched resource" + }, + "type": { + "type": "string", + "description": "Resource type", + "enum": [ + "shipment", + "cargo", + "tracking_request" + ] + }, + "attributes": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "description": "Type of the matched entity: `shipment`, `cargo`, or `tracking_request`" + }, + "number": { + "type": "string", + "description": "BL number (shipments), container number (cargos), or request number (tracking requests)" + }, + "shipment_id": { + "type": "string", + "nullable": true, + "description": "Associated shipment ID (for cargos and tracking requests only)" + }, + "scac": { + "type": "string", + "nullable": true, + "description": "Standard Carrier Alpha Code of the shipping line" + }, + "port_of_lading_name": { + "type": "string", + "nullable": true, + "description": "Port of lading name (shipments only)" + }, + "port_of_discharge_name": { + "type": "string", + "nullable": true, + "description": "Port of discharge name (shipments only)" + }, + "containers_count": { + "type": "integer", + "nullable": true, + "description": "Number of containers on the shipment (shipments only)" + }, + "tracking_stopped": { + "type": "boolean", + "nullable": true, + "description": "Whether tracking has been stopped (shipments only)" + }, + "tracking_stopped_reason": { + "type": "string", + "nullable": true, + "description": "Reason tracking was stopped (shipments only)" + }, + "status": { + "type": "string", + "nullable": true, + "description": "Tracking request status (tracking requests only)" + }, + "failed_reason": { + "type": "string", + "nullable": true, + "description": "Reason the tracking request failed (tracking requests only)" + }, + "ref_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Customer reference numbers" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the resource was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the resource was last updated" + } + } + } + } + } + } + } + }, + "example": { + "data": [ + { + "id": "abc12345-6789-0def-ghij-klmnopqrstuv", + "type": "shipment", + "attributes": { + "entity_type": "shipment", + "number": "MEDUA1234567", + "shipment_id": null, + "scac": "MSCU", + "port_of_lading_name": "Shanghai", + "port_of_discharge_name": "Los Angeles", + "containers_count": 3, + "tracking_stopped": false, + "tracking_stopped_reason": null, + "status": null, + "failed_reason": null, + "ref_numbers": [ + "PO-2026-001" + ], + "created_at": "2026-01-15T10:30:00.000Z", + "updated_at": "2026-03-01T14:22:00.000Z" + } + }, + { + "id": "def45678-9012-3abc-defg-hijklmnopqrs", + "type": "cargo", + "attributes": { + "entity_type": "cargo", + "number": "MSCU1234567", + "shipment_id": "abc12345-6789-0def-ghij-klmnopqrstuv", + "scac": "MSCU", + "port_of_lading_name": null, + "port_of_discharge_name": null, + "containers_count": null, + "tracking_stopped": null, + "tracking_stopped_reason": null, + "status": null, + "failed_reason": null, + "ref_numbers": [], + "created_at": "2026-01-15T10:30:00.000Z", + "updated_at": "2026-03-01T14:22:00.000Z" + } + } + ] + } + } + } + }, + "400": { + "description": "Bad request \u2014 missing required `query` parameter", + "content": { + "application/vnd.api+json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + } + } + } + }, + "example": { + "errors": [ + { + "detail": "Query parameter is required" + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid API token" + } + } + } } }, "x-tagGroups": [ @@ -11120,7 +11362,9 @@ "properties": { "feature_type": { "type": "string", - "enum": ["port"] + "enum": [ + "port" + ] }, "ports_sequence": { "type": "integer", @@ -11136,7 +11380,9 @@ }, "location_type": { "type": "string", - "enum": ["Port"] + "enum": [ + "Port" + ] }, "name": { "type": "string", @@ -11199,7 +11445,9 @@ "description": "Last update timestamp from the shipment (ISO 8601)" } }, - "required": ["feature_type"] + "required": [ + "feature_type" + ] }, "currentVesselFeatureProperties": { "title": "Current Vessel", @@ -11207,7 +11455,9 @@ "properties": { "feature_type": { "type": "string", - "enum": ["current_vessel"] + "enum": [ + "current_vessel" + ] }, "ports_sequence": { "type": "integer", @@ -11332,7 +11582,9 @@ "description": "Timezone of arrival port" } }, - "required": ["feature_type"] + "required": [ + "feature_type" + ] }, "pastVesselLocationsFeatureProperties": { "title": "Past Vessel Locations", @@ -11340,7 +11592,9 @@ "properties": { "feature_type": { "type": "string", - "enum": ["past_vessel_locations"] + "enum": [ + "past_vessel_locations" + ] }, "ports_sequence": { "type": "integer", @@ -11383,7 +11637,9 @@ "description": "Estimated time of arrival at the destination port (ISO 8601)" } }, - "required": ["feature_type"] + "required": [ + "feature_type" + ] }, "estimatedFullLegFeatureProperties": { "title": "Estimated Full Leg", @@ -11391,7 +11647,9 @@ "properties": { "feature_type": { "type": "string", - "enum": ["estimated_full_legs"] + "enum": [ + "estimated_full_legs" + ] }, "ports_sequence": { "type": "integer", @@ -11410,7 +11668,9 @@ "description": "Number of coordinate points in the LineString" } }, - "required": ["feature_type"] + "required": [ + "feature_type" + ] }, "estimatedPartialLegFeatureProperties": { "title": "Estimated Partial Leg", @@ -11418,7 +11678,9 @@ "properties": { "feature_type": { "type": "string", - "enum": ["estimated_partial_leg"] + "enum": [ + "estimated_partial_leg" + ] }, "ports_sequence": { "type": "integer", @@ -11437,7 +11699,9 @@ "description": "Number of coordinate points in the LineString" } }, - "required": ["feature_type"] + "required": [ + "feature_type" + ] }, "pointGeometry": { "title": "Point", @@ -11445,7 +11709,9 @@ "properties": { "type": { "type": "string", - "enum": ["Point"] + "enum": [ + "Point" + ] }, "coordinates": { "type": "array", @@ -11454,10 +11720,16 @@ }, "minItems": 2, "maxItems": 2, - "example": [100.896831042, 13.065302386] + "example": [ + 100.896831042, + 13.065302386 + ] } }, - "required": ["type", "coordinates"] + "required": [ + "type", + "coordinates" + ] }, "custom_field_value": { "description": "Raw custom field value (type depends on definition)", @@ -11698,7 +11970,9 @@ "properties": { "type": { "type": "string", - "enum": ["LineString"] + "enum": [ + "LineString" + ] }, "coordinates": { "type": "array", @@ -11712,12 +11986,21 @@ }, "minItems": 2, "example": [ - [100.868768333, 13.07306], - [100.839155, 13.079318333] + [ + 100.868768333, + 13.07306 + ], + [ + 100.839155, + 13.079318333 + ] ] } }, - "required": ["type", "coordinates"] + "required": [ + "type", + "coordinates" + ] } }, "securitySchemes": { @@ -11778,4 +12061,4 @@ "authorization": [] } ] -} +} \ No newline at end of file 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')) {