From f09477f293f829ac1a28970b7e2652cf8b0f5625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Wed, 3 Dec 2025 10:56:38 +0300 Subject: [PATCH 1/7] docs plan --- docs/oauth-architecture-plan.md | 1269 ++++++++++++++++++++++++++++ docs/sentry-mcp-oauth-reference.md | 755 +++++++++++++++++ 2 files changed, 2024 insertions(+) create mode 100644 docs/oauth-architecture-plan.md create mode 100644 docs/sentry-mcp-oauth-reference.md diff --git a/docs/oauth-architecture-plan.md b/docs/oauth-architecture-plan.md new file mode 100644 index 00000000..29c3690a --- /dev/null +++ b/docs/oauth-architecture-plan.md @@ -0,0 +1,1269 @@ +# OAuth Architecture Plan for Context7 MCP + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Detailed OAuth Flow](#detailed-oauth-flow) +4. [Implementation](#implementation) +5. [Database Schema](#database-schema) +6. [File Structure](#file-structure) +7. [Implementation Checklist](#implementation-checklist) +8. [References](#references) + +--- + +## Overview + +This document outlines the OAuth 2.1 implementation for the Context7 MCP server, enabling MCP clients (Claude Desktop, Cursor, VS Code) to authenticate users via the Context7app Next.js backend. + +### Key Decisions + +| Decision | Choice | Rationale | +| ------------------------ | --------------------------- | ---------------------------------------------------- | +| **Authorization Server** | context7app (Next.js) | Already has Clerk auth and Supabase | +| **Resource Server** | context7 MCP | Validates API keys, serves MCP tools | +| **Token Type** | API Key (not JWT) | Zero changes to existing API validation | +| **Client Registration** | Dynamic (RFC 7591) | Required by MCP spec | +| **User Authentication** | Clerk | Existing auth system | +| **Storage** | Supabase | Existing database | + +### Why API Keys Instead of JWT? + +The OAuth flow returns an **API key** as the `access_token`: + +- Reuses existing `member_api_keys` table and validation logic +- No changes needed to the MCP server's API key validation +- Consistent behavior whether user gets key via OAuth or dashboard +- Simple revocation (delete the key from database) + +### API Key Regeneration on OAuth Flow + +Since we only store **hashed** API keys (not the originals), a **new API key is generated each OAuth flow**. This is handled safely using a `source` column: + +| Scenario | Behavior | +|----------|----------| +| First OAuth connection | Creates new API key with `source: "mcp-oauth"` | +| Subsequent OAuth connections | Regenerates only the `mcp-oauth` key | +| Manual API keys (dashboard) | **Unaffected** - they have `source: "manual"` | + +**Why this is acceptable:** +- OAuth access tokens are typically regenerated on each authorization - this is expected behavior +- Each reconnection only invalidates the previous OAuth-issued key +- Manual API keys created via dashboard remain valid +- Clear separation between OAuth-issued and manually-created keys + +--- + +## Architecture + +### System Components + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MCP Client │ +│ (Claude Desktop, Cursor, VS Code) │ +└─────────────────────────────────────┬───────────────────────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ +│ Context7 MCP │ │ Context7app │ │ Clerk │ +│ (Resource Server) │ │ (Auth Server) │ │ (Identity Provider) │ +│ │ │ │ │ │ +│ • Serves MCP tools │ │ • OAuth endpoints │ │ • User login UI │ +│ • Validates API keys │ │ • Issues API keys │ │ • Session management │ +│ • Returns 401 if │ │ • Client registration │ │ • User database │ +│ unauthenticated │ │ • PKCE verification │ │ │ +│ │ │ │ │ │ +│ packages/mcp/ │ │ context7app/ │ │ clerk.com │ +└───────────────────────┘ └───────────────────────┘ └───────────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Supabase │ +│ │ +│ • member_api_keys (existing) • mcp_oauth_clients (new) │ +│ • users (existing) • mcp_auth_codes (new) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Endpoint Overview + +| Component | Endpoint | Purpose | +| ------------- | ----------------------------------------- | -------------------------------- | +| context7app | `/.well-known/oauth-protected-resource` | RFC 9728 resource metadata | +| context7app | `/.well-known/oauth-authorization-server` | RFC 8414 auth server metadata | +| context7app | `/api/mcp-auth/register` | Dynamic client registration | +| context7app | `/api/mcp-auth/authorize` | Authorization endpoint | +| context7app | `/api/mcp-auth/token` | Token exchange (returns API key) | +| context7 MCP | `/mcp` | MCP protocol endpoint | + +--- + +## Detailed OAuth Flow + +### Sequence Diagram + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│MCP Client│ │Context7 │ │Context7 │ │ Clerk │ +│(Claude) │ │ MCP │ │ app │ │ │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + │ ══════════════════════════════════════════════════════════════════════ + │ PHASE 1: DISCOVERY + │ ══════════════════════════════════════════════════════════════════════ + │ │ │ │ + │ 1. Connect (no token) │ │ + ├────────────────────►│ │ │ + │ │ │ │ + │ 2. 401 Unauthorized │ │ + │ WWW-Authenticate: Bearer │ │ + │ resource_metadata="/.well-known/..." │ │ + │◄────────────────────┤ │ │ + │ │ │ │ + │ 3. GET /.well-known/oauth-protected-resource │ + ├──────────────────────────────────────────►│ │ + │ │ │ │ + │ 4. { authorization_servers: ["https://context7.com"] } │ + │◄──────────────────────────────────────────┤ │ + │ │ │ │ + │ 5. GET /.well-known/oauth-authorization-server │ + ├──────────────────────────────────────────►│ │ + │ │ │ │ + │ 6. { authorization_endpoint, token_endpoint, ... } │ + │◄──────────────────────────────────────────┤ │ + │ │ │ │ + │ ══════════════════════════════════════════════════════════════════════ + │ PHASE 2: CLIENT REGISTRATION (first time only) + │ ══════════════════════════════════════════════════════════════════════ + │ │ │ │ + │ 7. POST /api/mcp-auth/register │ │ + │ { redirect_uris, client_name } │ │ + ├──────────────────────────────────────────►│ │ + │ │ │ │ + │ │ │ Store in │ + │ │ │ mcp_oauth_clients │ + │ │ ├──┐ │ + │ │ │ │ │ + │ │ │◄─┘ │ + │ │ │ │ + │ 8. { client_id: "uuid-xxx" } │ │ + │◄──────────────────────────────────────────┤ │ + │ │ │ │ + │ ══════════════════════════════════════════════════════════════════════ + │ PHASE 3: AUTHORIZATION (PKCE) + │ ══════════════════════════════════════════════════════════════════════ + │ │ │ │ + │ Generate code_verifier (random) │ │ + │ code_challenge = SHA256(code_verifier) │ │ + ├──┐ │ │ │ + │ │ │ │ │ + │◄─┘ │ │ │ + │ │ │ │ + │ 9. Open browser: /api/mcp-auth/authorize │ │ + │ ?client_id=xxx │ │ + │ &redirect_uri=http://127.0.0.1:xxx │ │ + │ &code_challenge=yyy │ │ + │ &code_challenge_method=S256 │ │ + │ &state=random-state │ │ + ├──────────────────────────────────────────►│ │ + │ │ │ │ + │ │ │ 10. Check session │ + │ │ ├────────────────────►│ + │ │ │ │ + │ │ │ 11. No session │ + │ │ │◄────────────────────┤ + │ │ │ │ + │ 12. Redirect to /sign-in?redirect_url=...│ │ + │◄──────────────────────────────────────────┤ │ + │ │ │ │ + │ 13. User enters credentials │ │ + ├─────────────────────────────────────────────────────────────────► + │ │ │ │ + │ 14. Session created │ │ + │◄───────────────────────────────────────────────────────────────── + │ │ │ │ + │ 15. Redirect back to /api/mcp-auth/authorize (with session) │ + ├──────────────────────────────────────────►│ │ + │ │ │ │ + │ │ │ 16. Verify session │ + │ │ ├────────────────────►│ + │ │ │ │ + │ │ │ 17. userId │ + │ │ │◄────────────────────┤ + │ │ │ │ + │ │ │ 18. Generate auth │ + │ │ │ code, store │ + │ │ │ with PKCE │ + │ │ │ challenge │ + │ │ ├──┐ │ + │ │ │ │ │ + │ │ │◄─┘ │ + │ │ │ │ + │ 19. Redirect to redirect_uri?code=xxx&state=yyy │ + │◄──────────────────────────────────────────┤ │ + │ │ │ │ + │ ══════════════════════════════════════════════════════════════════════ + │ PHASE 4: TOKEN EXCHANGE + │ ══════════════════════════════════════════════════════════════════════ + │ │ │ │ + │ 20. POST /api/mcp-auth/token │ │ + │ grant_type=authorization_code │ │ + │ code=xxx │ │ + │ code_verifier=original-verifier │ │ + │ redirect_uri=http://127.0.0.1:xxx │ │ + ├──────────────────────────────────────────►│ │ + │ │ │ │ + │ │ │ 21. Validate code │ + │ │ │ Verify PKCE: │ + │ │ │ SHA256(verifier)│ + │ │ │ == challenge │ + │ │ ├──┐ │ + │ │ │ │ │ + │ │ │◄─┘ │ + │ │ │ │ + │ │ │ 22. Get/create │ + │ │ │ API key for │ + │ │ │ user │ + │ │ ├──┐ │ + │ │ │ │ │ + │ │ │◄─┘ │ + │ │ │ │ + │ 23. { access_token: "ctx7sk-xxx", token_type: "Bearer" } │ + │◄──────────────────────────────────────────┤ │ + │ │ │ │ + │ Store API key │ │ │ + ├──┐ │ │ │ + │ │ │ │ │ + │◄─┘ │ │ │ + │ │ │ │ + │ ══════════════════════════════════════════════════════════════════════ + │ PHASE 5: AUTHENTICATED MCP REQUESTS + │ ══════════════════════════════════════════════════════════════════════ + │ │ │ │ + │ 24. MCP Request │ │ │ + │ Authorization: Bearer ctx7sk-xxx │ │ + ├────────────────────►│ │ │ + │ │ │ │ + │ │ 25. Validate API key│ │ + │ │ (existing logic)│ │ + │ ├──┐ │ │ + │ │ │ │ │ + │ │◄─┘ │ │ + │ │ │ │ + │ 26. MCP Response │ │ │ + │◄────────────────────┤ │ │ + │ │ │ │ +``` + +### Step-by-Step Breakdown + +--- + +#### Phase 1: Discovery + +**What happens:** The MCP client learns where and how to authenticate. + +**Step 1 — MCP Client connects without credentials** + +When the user adds the Context7 MCP server to their client (Cursor, Claude Desktop), the client automatically attempts to connect: + +``` +GET https://mcp.context7.com/mcp +(no Authorization header) +``` + +**Step 2 — MCP Server returns 401 with OAuth metadata location** + +Since there's no token, the MCP server rejects the request but tells the client where to find authentication info: + +``` +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer resource_metadata="https://context7.com/.well-known/oauth-protected-resource" +``` + +**Step 3 — MCP Client fetches Protected Resource Metadata** + +The client reads the URL from the `WWW-Authenticate` header and fetches it: + +``` +GET https://context7.com/.well-known/oauth-protected-resource +``` + +**Step 4 — Context7app returns resource metadata** + +This tells the client which authorization server to use: + +```json +{ + "resource": "https://mcp.context7.com/mcp", + "authorization_servers": ["https://context7.com"] +} +``` + +**Step 5 — MCP Client fetches Authorization Server Metadata** + +Now the client knows to look at `context7.com`, so it fetches the OAuth configuration: + +``` +GET https://context7.com/.well-known/oauth-authorization-server +``` + +**Step 6 — Context7app returns OAuth endpoints** + +This tells the client all the URLs it needs for the OAuth flow: + +```json +{ + "authorization_endpoint": "https://context7.com/api/mcp-auth/authorize", + "token_endpoint": "https://context7.com/api/mcp-auth/token", + "registration_endpoint": "https://context7.com/api/mcp-auth/register", + "code_challenge_methods_supported": ["S256"] +} +``` + +--- + +#### Phase 2: Client Registration (First Time Only) + +**What happens:** The MCP client registers itself to get a `client_id`. This only happens once per client. + +**Step 7 — MCP Client registers itself** + +The client sends its information to get a unique identifier: + +``` +POST https://context7.com/api/mcp-auth/register +Content-Type: application/json + +{ + "client_name": "Cursor", + "redirect_uris": ["http://127.0.0.1:54321/callback"] +} +``` + +**Step 8 — Context7app stores client and returns `client_id`** + +The server creates a record in `mcp_oauth_clients` and responds: + +```json +{ + "client_id": "550e8400-e29b-41d4-a716-446655440000", + "client_name": "Cursor", + "redirect_uris": ["http://127.0.0.1:54321/callback"] +} +``` + +The client saves this `client_id` for future use. + +--- + +#### Phase 3: Authorization (PKCE Flow) + +**What happens:** The user logs in via browser, and the client gets an authorization code. + +**Step 9 — MCP Client generates PKCE values and opens browser** + +The client creates a random `code_verifier` and computes its SHA256 hash (`code_challenge`). Then it opens the user's browser: + +``` +https://context7.com/api/mcp-auth/authorize + ?client_id=550e8400-e29b-41d4-a716-446655440000 + &redirect_uri=http://127.0.0.1:54321/callback + &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + &code_challenge_method=S256 + &state=xyz123 +``` + +**Step 10-11 — Context7app checks for Clerk session** + +The authorize endpoint checks if the user is already logged in with Clerk. If not, there's no session. + +**Step 12 — Context7app redirects to sign-in** + +Since the user isn't logged in, they're redirected to the Clerk sign-in page: + +``` +HTTP/1.1 302 Found +Location: https://context7.com/sign-in?redirect_url=https://context7.com/api/mcp-auth/authorize?client_id=... +``` + +**Step 13-14 — User logs in with Clerk** + +The user sees the Context7 login page and enters their credentials (or uses Google/GitHub SSO). Clerk authenticates them and creates a session. + +**Step 15 — Browser redirects back to authorize endpoint** + +After successful login, Clerk redirects back to the original authorize URL, now with a valid session cookie. + +**Step 16-17 — Context7app verifies session and gets user info** + +The authorize endpoint now detects the Clerk session and retrieves the `userId`. + +**Step 18 — Context7app generates authorization code** + +A random authorization code is generated and stored in `mcp_auth_codes` along with: +- The `client_id` +- The `user_id` +- The `code_challenge` (for PKCE verification later) +- Expiration time (10 minutes) + +**Step 19 — Context7app redirects to client with code** + +The browser is redirected to the client's callback URL: + +``` +HTTP/1.1 302 Found +Location: http://127.0.0.1:54321/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz123 +``` + +The MCP client (listening on localhost) receives this callback. + +--- + +#### Phase 4: Token Exchange + +**What happens:** The client exchanges the authorization code for an API key. + +**Step 20 — MCP Client sends token request** + +The client sends the code along with the original `code_verifier` (not the hash): + +``` +POST https://context7.com/api/mcp-auth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code +&code=SplxlOBeZQQYbYS6WxSbIA +&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk +&redirect_uri=http://127.0.0.1:54321/callback +``` + +**Step 21 — Context7app validates code and PKCE** + +The server: +1. Looks up the code in `mcp_auth_codes` +2. Checks it hasn't expired or been used +3. Computes `SHA256(code_verifier)` and verifies it matches the stored `code_challenge` + +This proves the same client that started the flow is completing it (prevents code interception attacks). + +**Step 22 — Context7app creates/regenerates API key** + +The server checks if this user already has an OAuth-issued API key (`source: "mcp-oauth"`): +- **If yes:** Regenerates it (updates the hash in database) +- **If no:** Creates a new API key with `source: "mcp-oauth"` + +Manual API keys (`source: "manual"`) are never affected. + +**Step 23 — Context7app returns the API key as access_token** + +```json +{ + "access_token": "ctx7sk-abc123def456...", + "token_type": "Bearer", + "expires_in": 31536000, + "scope": "mcp:read" +} +``` + +The MCP client stores this token securely. + +--- + +#### Phase 5: Authenticated Requests + +**What happens:** The client can now use Context7 MCP tools. + +**Step 24 — MCP Client sends authenticated requests** + +All subsequent MCP requests include the API key: + +``` +POST https://mcp.context7.com/mcp +Authorization: Bearer ctx7sk-abc123def456... +Content-Type: application/json + +{"method": "tools/call", "params": {"name": "get-library-docs", ...}} +``` + +**Step 25 — MCP Server validates API key** + +The server extracts the token from the `Authorization` header and validates it against `member_api_keys` using the existing API key validation logic. No changes needed here. + +**Step 26 — MCP Server returns response** + +```json +{ + "result": { + "content": [{"type": "text", "text": "Documentation for react..."}] + } +} +``` + +The user can now use all Context7 MCP tools normally. + +--- + +### User Experience Summary + +From the user's perspective, the entire flow looks like: + +1. **Add server to config** (one-time) +2. **Restart client** (Cursor/Claude Desktop) +3. **Browser opens** → Login with existing Context7 account +4. **Done** — tools work automatically + +Subsequent sessions reuse the stored token. Re-authentication only happens if the token is revoked or the user removes the server config. + +--- + +## Implementation + +### Context7app Routes + +#### 1. Protected Resource Metadata + +**File:** `app/.well-known/oauth-protected-resource/route.ts` + +```typescript +import { NextResponse } from "next/server"; + +export async function GET() { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; + + return NextResponse.json({ + resource: `${baseUrl}/mcp`, + authorization_servers: [baseUrl], + scopes_supported: ["mcp:read", "mcp:write"], + bearer_methods_supported: ["header"], + }); +} +``` + +#### 2. Authorization Server Metadata + +**File:** `app/.well-known/oauth-authorization-server/route.ts` + +```typescript +import { NextResponse } from "next/server"; + +export async function GET() { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; + + return NextResponse.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/api/mcp-auth/authorize`, + token_endpoint: `${baseUrl}/api/mcp-auth/token`, + registration_endpoint: `${baseUrl}/api/mcp-auth/register`, + scopes_supported: ["mcp:read", "mcp:write"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); +} +``` + +#### 3. Client Registration + +**File:** `app/api/mcp-auth/register/route.ts` + +```typescript +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/serverClient"; +import crypto from "crypto"; + +export async function POST(request: Request) { + const body = await request.json(); + const { redirect_uris, client_name, client_uri } = body; + + // Validate redirect URIs + if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { + return NextResponse.json( + { error: "invalid_client_metadata", error_description: "redirect_uris required" }, + { status: 400 } + ); + } + + // Validate each URI + for (const uri of redirect_uris) { + try { + const url = new URL(uri); + // Allow localhost and 127.0.0.1 for development + if (!["http:", "https:"].includes(url.protocol)) { + return NextResponse.json( + { error: "invalid_redirect_uri", error_description: "Invalid protocol" }, + { status: 400 } + ); + } + } catch { + return NextResponse.json( + { error: "invalid_redirect_uri", error_description: "Invalid URL" }, + { status: 400 } + ); + } + } + + const supabase = createClient(); + const clientId = crypto.randomUUID(); + + const { error } = await supabase.from("mcp_oauth_clients").insert({ + client_id: clientId, + client_name: client_name || null, + client_uri: client_uri || null, + redirect_uris: redirect_uris, + }); + + if (error) { + console.error("Failed to register client:", error); + return NextResponse.json( + { error: "server_error", error_description: "Failed to register client" }, + { status: 500 } + ); + } + + return NextResponse.json( + { + client_id: clientId, + client_name: client_name, + redirect_uris: redirect_uris, + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + { status: 201 } + ); +} +``` + +#### 4. Authorization Endpoint + +**File:** `app/api/mcp-auth/authorize/route.ts` + +```typescript +import { auth } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/serverClient"; +import crypto from "crypto"; + +export async function GET(request: Request) { + const url = new URL(request.url); + + // Extract OAuth parameters + const client_id = url.searchParams.get("client_id"); + const redirect_uri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + const code_challenge = url.searchParams.get("code_challenge"); + const code_challenge_method = url.searchParams.get("code_challenge_method"); + const scope = url.searchParams.get("scope") || "mcp:read"; + + // Validate required parameters + if (!client_id) { + return NextResponse.json( + { error: "invalid_request", error_description: "client_id required" }, + { status: 400 } + ); + } + + if (!redirect_uri) { + return NextResponse.json( + { error: "invalid_request", error_description: "redirect_uri required" }, + { status: 400 } + ); + } + + // Validate PKCE (required by MCP spec) + if (!code_challenge || code_challenge_method !== "S256") { + return NextResponse.json( + { error: "invalid_request", error_description: "PKCE with S256 required" }, + { status: 400 } + ); + } + + // Validate client exists and redirect_uri is registered + const supabase = createClient(); + const { data: client } = await supabase + .from("mcp_oauth_clients") + .select("*") + .eq("client_id", client_id) + .single(); + + if (!client) { + return NextResponse.json( + { error: "invalid_client", error_description: "Unknown client_id" }, + { status: 400 } + ); + } + + if (!client.redirect_uris.includes(redirect_uri)) { + return NextResponse.json( + { error: "invalid_request", error_description: "redirect_uri not registered" }, + { status: 400 } + ); + } + + // Check if user is authenticated with Clerk + const { userId } = await auth(); + + if (!userId) { + // Redirect to sign-in, then back here + const returnUrl = encodeURIComponent(request.url); + return redirect(`/sign-in?redirect_url=${returnUrl}`); + } + + // User is authenticated - generate authorization code + const authCode = crypto.randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + // Store auth code with PKCE challenge + const { error: insertError } = await supabase.from("mcp_auth_codes").insert({ + code: authCode, + client_id: client_id, + user_id: userId, + redirect_uri: redirect_uri, + code_challenge: code_challenge, + scope: scope, + expires_at: expiresAt.toISOString(), + }); + + if (insertError) { + console.error("Failed to store auth code:", insertError); + return NextResponse.json( + { error: "server_error", error_description: "Failed to generate code" }, + { status: 500 } + ); + } + + // Redirect back to client with code + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set("code", authCode); + if (state) { + redirectUrl.searchParams.set("state", state); + } + + return redirect(redirectUrl.toString()); +} +``` + +#### 5. Token Endpoint + +**File:** `app/api/mcp-auth/token/route.ts` + +```typescript +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/serverClient"; +import { generateApiKey, hashApiKey } from "@/lib/dashboard/apiKey"; +import crypto from "crypto"; + +export async function POST(request: Request) { + // Parse form data (OAuth spec requires application/x-www-form-urlencoded) + const contentType = request.headers.get("content-type") || ""; + let params: Record = {}; + + if (contentType.includes("application/x-www-form-urlencoded")) { + const formData = await request.formData(); + formData.forEach((value, key) => { + params[key] = value.toString(); + }); + } else if (contentType.includes("application/json")) { + params = await request.json(); + } else { + return NextResponse.json( + { error: "invalid_request", error_description: "Invalid content type" }, + { status: 400 } + ); + } + + const { grant_type, code, code_verifier, redirect_uri } = params; + + // Only support authorization_code grant + if (grant_type !== "authorization_code") { + return NextResponse.json( + { error: "unsupported_grant_type" }, + { status: 400 } + ); + } + + if (!code || !code_verifier || !redirect_uri) { + return NextResponse.json( + { error: "invalid_request", error_description: "Missing required parameters" }, + { status: 400 } + ); + } + + const supabase = createClient(); + + // Fetch and validate auth code + const { data: authCode } = await supabase + .from("mcp_auth_codes") + .select("*") + .eq("code", code) + .eq("consumed", false) + .single(); + + if (!authCode) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Invalid or expired code" }, + { status: 400 } + ); + } + + // Check expiration + if (new Date(authCode.expires_at) < new Date()) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Code expired" }, + { status: 400 } + ); + } + + // Verify redirect_uri matches + if (authCode.redirect_uri !== redirect_uri) { + return NextResponse.json( + { error: "invalid_grant", error_description: "redirect_uri mismatch" }, + { status: 400 } + ); + } + + // Verify PKCE: SHA256(code_verifier) must equal stored code_challenge + const computedChallenge = crypto + .createHash("sha256") + .update(code_verifier) + .digest("base64url"); + + if (computedChallenge !== authCode.code_challenge) { + return NextResponse.json( + { error: "invalid_grant", error_description: "PKCE verification failed" }, + { status: 400 } + ); + } + + // Mark code as consumed (one-time use) + await supabase + .from("mcp_auth_codes") + .update({ consumed: true }) + .eq("code", code); + + // Get or create API key for user + const userId = authCode.user_id; + + // Check for existing OAuth-issued API key (by source, not name) + // This ensures manual API keys are never affected by OAuth flows + const { data: existingKey } = await supabase + .from("member_api_keys") + .select("id") + .eq("user_id", userId) + .eq("source", "mcp-oauth") // Only look for OAuth-issued keys + .single(); + + let apiKey: string; + + if (existingKey) { + // Regenerate only the OAuth key (manual keys remain untouched) + // This is expected behavior - OAuth tokens are typically regenerated on each auth + apiKey = generateApiKey(); + const keyHash = hashApiKey(apiKey); + + await supabase + .from("member_api_keys") + .update({ + key_hash: keyHash, + updated_at: new Date().toISOString(), + }) + .eq("id", existingKey.id); + } else { + // Create new OAuth-specific API key + apiKey = generateApiKey(); + const keyHash = hashApiKey(apiKey); + + // Get user's default project + const { data: membership } = await supabase + .from("project_members") + .select("project_id") + .eq("user_id", userId) + .limit(1) + .single(); + + await supabase.from("member_api_keys").insert({ + user_id: userId, + project_id: membership?.project_id, + name: "MCP OAuth Token", + key_hash: keyHash, + source: "mcp-oauth", // Tag the source for future lookups + }); + } + + // Return token response + return NextResponse.json({ + access_token: apiKey, + token_type: "Bearer", + expires_in: 31536000, // 1 year (API keys don't expire) + scope: authCode.scope, + }); +} +``` + +### Context7 MCP Server Updates + +#### OAuth Challenge Response + +**File:** `packages/mcp/src/lib/oauth.ts` + +```typescript +import type { Response } from "express"; + +const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || "https://context7.com"; + +export function sendOAuthChallenge(res: Response): void { + res.set( + "WWW-Authenticate", + `Bearer resource_metadata="${AUTH_SERVER_URL}/.well-known/oauth-protected-resource"` + ); + res.status(401).json({ + error: "unauthorized", + error_description: "Authentication required", + authorization_server: AUTH_SERVER_URL, + }); +} +``` + +#### Updated MCP Endpoint + +**File:** `packages/mcp/src/index.ts` (modification) + +```typescript +import { sendOAuthChallenge } from "./lib/oauth.js"; + +// In the HTTP transport setup: +app.all("/mcp", async (req, res) => { + // Extract API key from headers (existing logic) + const apiKey = extractApiKey(req); + + // If no API key provided, send OAuth challenge + if (!apiKey) { + return sendOAuthChallenge(res); + } + + // Continue with existing MCP handling... +}); + +function extractApiKey(req: Request): string | undefined { + // Check Authorization header + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice(7); + } + + // Check custom headers (existing logic) + return ( + req.headers["x-api-key"] || + req.headers["context7-api-key"] || + req.headers["x-context7-api-key"] + ) as string | undefined; +} +``` + +--- + +## Database Schema + +### New Tables + +```sql +-- ============================================ +-- MCP OAuth Clients (Dynamic Registration) +-- ============================================ +CREATE TABLE mcp_oauth_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id TEXT UNIQUE NOT NULL, + client_name TEXT, + client_uri TEXT, + redirect_uris TEXT[] NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Index for client lookups +CREATE INDEX idx_mcp_oauth_clients_client_id ON mcp_oauth_clients(client_id); + +-- ============================================ +-- Authorization Codes (Short-lived) +-- ============================================ +CREATE TABLE mcp_auth_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL REFERENCES mcp_oauth_clients(client_id), + user_id TEXT NOT NULL, -- Clerk user ID + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, -- PKCE challenge + scope TEXT DEFAULT 'mcp:read', + expires_at TIMESTAMPTZ NOT NULL, + consumed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes for code validation +CREATE INDEX idx_mcp_auth_codes_code ON mcp_auth_codes(code); +CREATE INDEX idx_mcp_auth_codes_expires ON mcp_auth_codes(expires_at); + +-- ============================================ +-- Cleanup Function (Optional) +-- ============================================ +-- Run periodically to clean up expired codes +CREATE OR REPLACE FUNCTION cleanup_expired_mcp_auth_codes() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM mcp_auth_codes + WHERE expires_at < NOW() - INTERVAL '1 hour' + OR consumed = TRUE; + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- Update existing member_api_keys table +-- ============================================ +-- Add 'source' column to differentiate OAuth keys from manual keys +-- This is critical for API key regeneration on OAuth flows: +-- - OAuth flows regenerate only keys with source='mcp-oauth' +-- - Manual keys (source='manual') are never affected by OAuth + +ALTER TABLE member_api_keys +ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'manual'; + +COMMENT ON COLUMN member_api_keys.source IS + 'Key source: manual (dashboard), mcp-oauth (OAuth flow), api (programmatic)'; + +-- Index for efficient OAuth key lookups +CREATE INDEX IF NOT EXISTS idx_member_api_keys_source +ON member_api_keys(user_id, source); +``` + +--- + +## File Structure + +### Context7app (Next.js) + +``` +context7app/ +├── app/ +│ ├── .well-known/ +│ │ ├── oauth-protected-resource/ +│ │ │ └── route.ts # RFC 9728 metadata +│ │ └── oauth-authorization-server/ +│ │ └── route.ts # RFC 8414 metadata +│ └── api/ +│ └── mcp-auth/ +│ ├── register/ +│ │ └── route.ts # Client registration +│ ├── authorize/ +│ │ └── route.ts # Authorization endpoint +│ └── token/ +│ └── route.ts # Token exchange +└── lib/ + └── mcp-auth/ # (Optional) Shared utilities + ├── validation.ts # Input validation + └── types.ts # Type definitions +``` + +### Context7 MCP Server + +``` +packages/mcp/ +└── src/ + ├── lib/ + │ ├── oauth.ts # OAuth challenge response (NEW) + │ ├── api.ts # Existing API functions + │ ├── encryption.ts # Existing encryption + │ └── types.ts # Existing types + └── index.ts # Updated with OAuth challenge +``` + +--- + +## Implementation Checklist + +### Phase 1: Database Setup +- [ ] Create `mcp_oauth_clients` table in Supabase +- [ ] Create `mcp_auth_codes` table in Supabase +- [ ] Add `source` column to `member_api_keys` (default: 'manual') +- [ ] Add index on `(user_id, source)` for efficient OAuth key lookups +- [ ] Test table creation and indexes + +### Phase 2: Discovery Endpoints +- [ ] Implement `/.well-known/oauth-protected-resource` +- [ ] Implement `/.well-known/oauth-authorization-server` +- [ ] Test with `curl` or browser +- [ ] Verify JSON responses are correct + +### Phase 3: Client Registration +- [ ] Implement `/api/mcp-auth/register` +- [ ] Add redirect URI validation +- [ ] Test registration with sample client +- [ ] Verify client stored in database + +### Phase 4: Authorization Flow +- [ ] Implement `/api/mcp-auth/authorize` +- [ ] Add PKCE validation +- [ ] Integrate with Clerk auth check +- [ ] Test redirect to sign-in when not authenticated +- [ ] Test redirect back with auth code + +### Phase 5: Token Exchange +- [ ] Implement `/api/mcp-auth/token` +- [ ] Add PKCE verification +- [ ] Integrate with API key generation +- [ ] Test full token exchange flow +- [ ] Verify API key returned correctly + +### Phase 6: MCP Server Updates +- [ ] Add `oauth.ts` with challenge response +- [ ] Update `/mcp` endpoint to return 401 +- [ ] Test 401 response includes correct headers +- [ ] Verify existing API key auth still works + +### Phase 7: End-to-End Testing +- [ ] Test with Claude Desktop +- [ ] Test with Cursor +- [ ] Test with VS Code + MCP extension +- [ ] Verify token persistence across sessions + +### Phase 8: Production Readiness +- [ ] Add rate limiting to OAuth endpoints +- [ ] Add logging for OAuth events +- [ ] Set up monitoring/alerts +- [ ] Update documentation + +--- + +## Alternative: Proactive OAuth Discovery (No 401 Required) + +If your MCP server accepts **anonymous requests** (no API key required), the 401 challenge flow won't trigger automatically. Instead, MCP clients can use **proactive discovery** to find OAuth support. + +### How It Works + +``` +┌──────────────┐ ┌──────────────┐ +│ MCP Client │ │ Context7app │ +│ (Cursor) │ │ │ +└──────┬───────┘ └──────┬───────┘ + │ │ + │ 1. GET /.well-known/oauth-protected-resource + ├────────────────────────────────────────────►│ + │ │ + │ 2. { authorization_servers: [...] } │ + │◄────────────────────────────────────────────┤ + │ │ + │ Client discovers OAuth is available │ + │ Shows "Authorize" button in UI │ + ├──┐ │ + │ │ │ + │◄─┘ │ + │ │ + │ 3. User clicks "Authorize" │ + │ → OAuth flow begins (Phase 2-4) │ + │ │ +``` + +### Flow Comparison + +| Approach | Trigger | Anonymous Support | User Experience | +|----------|---------|-------------------|-----------------| +| **401 Challenge** | Client connects without token | No - returns 401 | Auto-popup on first connect | +| **Proactive Discovery** | Client checks `.well-known` | Yes - works anonymously | User clicks "Authorize" button | + +### When to Use Proactive Discovery + +Use this approach when: +- Your server accepts anonymous requests (current Context7 behavior) +- You want users to optionally authenticate for additional features +- You don't want to break existing anonymous users + +### Implementation + +**No changes needed to the MCP server.** Just implement the `.well-known` endpoints in context7app: + +1. `/.well-known/oauth-protected-resource` - Already in the plan +2. `/.well-known/oauth-authorization-server` - Already in the plan + +MCP clients (Cursor, Claude Code) will: +1. Fetch these endpoints when the server is added +2. Detect that OAuth is available +3. Show an "Authorize" or "Sign in" button in the MCP server listing +4. User clicks when ready → OAuth flow starts +5. After auth, client uses the token for subsequent requests + +### Server Behavior + +The MCP server accepts both anonymous and authenticated requests: + +```typescript +app.all("/mcp", async (req, res) => { + const apiKey = extractApiKey(req); + + if (apiKey) { + // Authenticated request - validate and get user context + const user = await validateApiKey(apiKey); + if (!user) { + return res.status(401).json({ error: "Invalid API key" }); + } + req.user = user; + } + // If no apiKey, continue as anonymous (existing behavior) + + // Handle MCP request... + // Tools can check req.user to provide personalized responses +}); +``` + +### Benefits of This Approach + +1. **No breaking changes** - Anonymous users continue working +2. **User-initiated auth** - Users choose when to authenticate +3. **Graceful upgrade** - Same tools work for both anonymous and authenticated users +4. **Simple implementation** - Only need the `.well-known` endpoints + +### Optional: Enhanced Features for Authenticated Users + +You can provide additional features for authenticated users: + +```typescript +// Example: Personalized documentation recommendations +if (req.user) { + // Return docs based on user's favorite libraries + const favorites = await getUserFavorites(req.user.id); + // ... +} else { + // Return generic results for anonymous users +} +``` + +--- + +## References + +### Specifications +- [MCP Authorization Spec (2025-03-26)](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) +- [RFC 9728 - OAuth Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [RFC 8414 - OAuth Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) +- [RFC 7591 - OAuth Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) +- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636) + +### Reference Implementations +- [Sentry MCP OAuth](https://github.com/getsentry/sentry-mcp/tree/main/packages/mcp-cloudflare/src/server/oauth) - Production implementation +- [Cloudflare MCP GitHub OAuth Demo](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-github-oauth) - Complete example +- [Cloudflare Workers OAuth Provider](https://github.com/cloudflare/workers-oauth-provider) - OAuth 2.1 library + +### Articles +- [Auth0: MCP Specs Update June 2025](https://auth0.com/blog/mcp-specs-update-all-about-auth/) +- [Aaron Parecki: OAuth for MCP](https://aaronparecki.com/2025/04/03/15/oauth-for-model-context-protocol) +- [Cloudflare: MCP Authorization Guide](https://developers.cloudflare.com/agents/model-context-protocol/authorization/) diff --git a/docs/sentry-mcp-oauth-reference.md b/docs/sentry-mcp-oauth-reference.md new file mode 100644 index 00000000..f105bfd9 --- /dev/null +++ b/docs/sentry-mcp-oauth-reference.md @@ -0,0 +1,755 @@ +# Sentry MCP OAuth Implementation Reference + +This document contains the actual source code from the Sentry MCP server's OAuth implementation for reference. + +**Source Repository**: [getsentry/sentry-mcp](https://github.com/getsentry/sentry-mcp) +**OAuth Directory**: [packages/mcp-cloudflare/src/server/oauth](https://github.com/getsentry/sentry-mcp/tree/main/packages/mcp-cloudflare/src/server/oauth) + +--- + +## File Structure + +``` +packages/mcp-cloudflare/src/server/oauth/ +├── index.ts # Main export (re-exports routes and helpers) +├── constants.ts # OAuth URLs and Zod schemas +├── state.ts # HMAC-signed state management +├── helpers.ts # Token exchange and refresh logic +└── routes/ + ├── index.ts # Hono router combining authorize + callback + ├── authorize.ts # Authorization endpoint + └── callback.ts # OAuth callback handler +``` + +--- + +## constants.ts + +Defines Sentry OAuth endpoints and token response schema. + +```typescript +import { z } from "zod"; + +// Sentry OAuth endpoints +export const SENTRY_AUTH_URL = "/oauth/authorize/"; +export const SENTRY_TOKEN_URL = "/oauth/token/"; + +export const TokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + token_type: z.string(), // should be "bearer" + expires_in: z.number(), + expires_at: z.string().datetime(), + user: z.object({ + email: z.string().email(), + id: z.string(), + name: z.string().nullable(), + }), + scope: z.string(), +}); +``` + +--- + +## state.ts + +HMAC-signed stateless OAuth state management using Web Crypto API. + +```typescript +import { z } from "zod"; +import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; + +// Schema for OAuth state payload +const OAuthStateSchema = z.object({ + req: z.any(), // The downstream auth request data + iat: z.number(), // Issued at timestamp + exp: z.number(), // Expiration timestamp +}); + +export type OAuthState = z.infer; + +// State token TTL (10 minutes) +const STATE_TTL_MS = 10 * 60 * 1000; + +/** + * Import a secret string as a CryptoKey for HMAC-SHA256 + */ +async function importKey(secret: string): Promise { + const encoder = new TextEncoder(); + return crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); +} + +/** + * Sign data and return hex-encoded signature + */ +async function signHex(data: string, secret: string): Promise { + const key = await importKey(secret); + const encoder = new TextEncoder(); + const signatureBuffer = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); + return Array.from(new Uint8Array(signatureBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Verify a hex-encoded signature + */ +async function verifyHex(signatureHex: string, data: string, secret: string): Promise { + const key = await importKey(secret); + const encoder = new TextEncoder(); + try { + const signatureBytes = new Uint8Array( + signatureHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)) + ); + return await crypto.subtle.verify("HMAC", key, signatureBytes.buffer, encoder.encode(data)); + } catch { + return false; + } +} + +/** + * Create a signed state token + * Format: ${signatureHex}.${base64(payload)} + */ +export async function signState(req: AuthRequest, secret: string): Promise { + const now = Date.now(); + const payload: OAuthState = { + req, + iat: now, + exp: now + STATE_TTL_MS, + }; + const data = JSON.stringify(payload); + const signature = await signHex(data, secret); + return `${signature}.${btoa(data)}`; +} + +/** + * Verify and parse a signed state token + */ +export async function verifyAndParseState(token: string, secret: string): Promise { + const [signatureHex, base64Payload] = token.split("."); + + if (!signatureHex || !base64Payload) { + throw new Error("Invalid state format"); + } + + const data = atob(base64Payload); + + const isValid = await verifyHex(signatureHex, data, secret); + if (!isValid) { + throw new Error("Invalid state signature"); + } + + const parsed = OAuthStateSchema.parse(JSON.parse(data)); + + if (parsed.exp < Date.now()) { + throw new Error("State expired"); + } + + return parsed; +} +``` + +--- + +## helpers.ts + +Token exchange and refresh logic with upstream Sentry OAuth. + +```typescript +import { z } from "zod"; +import { TokenResponseSchema, SENTRY_TOKEN_URL } from "./constants"; +import { logError, logWarn } from "@sentry/mcp-core/telem/logging"; + +type TokenResponse = z.infer; + +/** + * Constructs an authorization URL for Sentry OAuth + */ +export function getUpstreamAuthorizeUrl(options: { + upstream_url: string; + client_id: string; + redirect_uri: string; + scope: string; + state?: string; +}): string { + const url = new URL(options.upstream_url); + url.searchParams.set("client_id", options.client_id); + url.searchParams.set("redirect_uri", options.redirect_uri); + url.searchParams.set("scope", options.scope); + url.searchParams.set("response_type", "code"); + if (options.state) { + url.searchParams.set("state", options.state); + } + return url.href; +} + +/** + * Exchange authorization code for access token + */ +export async function exchangeCodeForAccessToken(options: { + upstream_url: string; + client_id: string; + client_secret: string; + code: string | undefined; + redirect_uri: string; +}): Promise<[TokenResponse, null] | [null, Response]> { + const { upstream_url, client_id, client_secret, code, redirect_uri } = options; + + if (!code) { + return [null, new Response("Missing authorization code", { status: 400 })]; + } + + try { + const response = await fetch(upstream_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id, + client_secret, + code, + redirect_uri, + }).toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + logError("Failed to exchange code for token", { + loggerScope: ["cloudflare", "oauth", "token"], + extra: { status: response.status, error: errorText }, + }); + return [null, new Response("Failed to exchange authorization code", { status: 500 })]; + } + + const data = await response.json(); + const parsed = TokenResponseSchema.parse(data); + return [parsed, null]; + } catch (error) { + logError("Token exchange error", { + loggerScope: ["cloudflare", "oauth", "token"], + extra: { error: String(error) }, + }); + return [null, new Response("Token exchange failed", { status: 500 })]; + } +} + +/** + * Refresh an access token using a refresh token + */ +export async function refreshAccessToken(options: { + upstream_url: string; + client_id: string; + client_secret: string; + refresh_token: string; +}): Promise<[TokenResponse, null] | [null, Response]> { + const { upstream_url, client_id, client_secret, refresh_token } = options; + + try { + const response = await fetch(upstream_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id, + client_secret, + refresh_token, + }).toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + logError("Failed to refresh token", { + loggerScope: ["cloudflare", "oauth", "refresh"], + extra: { status: response.status, error: errorText }, + }); + return [null, new Response("Failed to refresh token", { status: 500 })]; + } + + const data = await response.json(); + const parsed = TokenResponseSchema.parse(data); + return [parsed, null]; + } catch (error) { + logError("Token refresh error", { + loggerScope: ["cloudflare", "oauth", "refresh"], + extra: { error: String(error) }, + }); + return [null, new Response("Token refresh failed", { status: 500 })]; + } +} + +/** + * Token exchange callback for the OAuth provider + * Called when tokens need to be refreshed + */ +export const tokenExchangeCallback = async (options: { + grantType: string; + props: { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: number; + [key: string]: unknown; + }; + env: { + SENTRY_HOST: string; + SENTRY_CLIENT_ID: string; + SENTRY_CLIENT_SECRET: string; + }; +}) => { + if (options.grantType === "refresh_token") { + const cachedExpiry = options.props.accessTokenExpiresAt; + + // Skip refresh if token still valid (with 2 minute buffer) + if (cachedExpiry && cachedExpiry > Date.now() + 2 * 60 * 1000) { + return {}; // Use cached token + } + + // Refresh the upstream token + const [payload, err] = await refreshAccessToken({ + upstream_url: new URL( + SENTRY_TOKEN_URL, + `https://${options.env.SENTRY_HOST || "sentry.io"}` + ).href, + client_id: options.env.SENTRY_CLIENT_ID, + client_secret: options.env.SENTRY_CLIENT_SECRET, + refresh_token: options.props.refreshToken, + }); + + if (err) { + logWarn("Failed to refresh upstream token", { + loggerScope: ["cloudflare", "oauth", "callback"], + }); + return {}; + } + + return { + accessTokenTTL: payload.expires_in, + newProps: { + ...options.props, + accessToken: payload.access_token, + refreshToken: payload.refresh_token, + accessTokenExpiresAt: Date.now() + payload.expires_in * 1000, + }, + }; + } + + return {}; +}; +``` + +--- + +## routes/index.ts + +Hono router combining authorize and callback routes. + +```typescript +import { Hono } from "hono"; +import type { Env } from "../../types"; +import authorizeApp from "./authorize"; +import callbackApp from "./callback"; + +// Compose and export the main OAuth Hono app +export default new Hono<{ Bindings: Env }>() + .route("/authorize", authorizeApp) + .route("/callback", callbackApp); +``` + +--- + +## routes/authorize.ts + +Authorization endpoint - shows approval dialog and redirects to Sentry. + +```typescript +import { Hono } from "hono"; +import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; +import type { Env } from "../../types"; +import { SENTRY_AUTH_URL } from "../constants"; +import { signState } from "../state"; +import { getUpstreamAuthorizeUrl } from "../helpers"; +import { renderApprovalDialog, addApprovedClient } from "../../lib/approval-dialog"; +import { logWarn } from "@sentry/mcp-core/telem/logging"; + +export default new Hono<{ Bindings: Env }>() + // GET /authorize - Show approval dialog + .get("/", async (c) => { + // Parse the OAuth request from the MCP client + const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); + + if (!oauthReqInfo.clientId) { + return c.text("Missing client_id", 400); + } + + // Validate redirect URI + if (!oauthReqInfo.redirectUri) { + logWarn("Missing redirect_uri in authorization request", { + loggerScope: ["cloudflare", "oauth", "authorize"], + extra: { clientId: oauthReqInfo.clientId }, + }); + return c.text("Missing redirect_uri", 400); + } + + // Look up client metadata + const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); + + // Validate redirect URI is registered for this client + if (!client?.redirectUris?.includes(oauthReqInfo.redirectUri)) { + logWarn("Redirect URI not registered for client", { + loggerScope: ["cloudflare", "oauth", "authorize"], + extra: { + clientId: oauthReqInfo.clientId, + redirectUri: oauthReqInfo.redirectUri, + registeredUris: client?.redirectUris, + }, + }); + return c.text("Invalid redirect_uri", 400); + } + + // Render approval dialog for user consent + // Note: We always show the dialog to allow choosing permissions + return renderApprovalDialog(c.req.raw, { + client, + oauthReqInfo, + serverName: "Sentry MCP", + serverDescription: "Connect your Sentry account to enable AI-powered issue management.", + }); + }) + + // POST /authorize - Handle approval form submission + .post("/", async (c) => { + const formData = await c.req.raw.formData(); + + // Extract state from form (contains original OAuth request) + const encodedState = formData.get("state"); + if (!encodedState || typeof encodedState !== "string") { + return c.text("Missing state in form data", 400); + } + + let state: { oauthReqInfo?: AuthRequest; skills?: string[] }; + try { + state = JSON.parse(atob(encodedState)); + } catch { + return c.text("Invalid state data", 400); + } + + const oauthReqInfo = state.oauthReqInfo; + if (!oauthReqInfo?.clientId) { + return c.text("Invalid OAuth request in state", 400); + } + + // Validate redirect URI again + const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); + if (!client?.redirectUris?.includes(oauthReqInfo.redirectUri!)) { + return c.text("Invalid redirect_uri", 400); + } + + // Add client to user's approved list (stored in encrypted cookie) + const approvedClientCookie = await addApprovedClient( + c.req.raw, + oauthReqInfo.clientId, + c.env.COOKIE_SECRET + ); + + // Create signed state token for the callback + const stateToken = await signState( + { ...oauthReqInfo, skills: state.skills } as AuthRequest, + c.env.COOKIE_SECRET + ); + + // Build callback URL for Sentry to redirect back to + const callbackUrl = new URL("/oauth/callback", c.req.url).href; + + // Redirect to Sentry's authorization endpoint + const sentryAuthUrl = getUpstreamAuthorizeUrl({ + upstream_url: new URL(SENTRY_AUTH_URL, `https://${c.env.SENTRY_HOST || "sentry.io"}`).href, + client_id: c.env.SENTRY_CLIENT_ID, + redirect_uri: callbackUrl, + scope: "openid profile email", + state: stateToken, + }); + + return new Response(null, { + status: 302, + headers: { + Location: sentryAuthUrl, + "Set-Cookie": approvedClientCookie, + }, + }); + }); +``` + +--- + +## routes/callback.ts + +OAuth callback handler - exchanges code for token and completes authorization. + +```typescript +import { Hono } from "hono"; +import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; +import type { Env, WorkerProps } from "../../types"; +import { SENTRY_TOKEN_URL } from "../constants"; +import { exchangeCodeForAccessToken } from "../helpers"; +import { verifyAndParseState, type OAuthState } from "../state"; +import { clientIdAlreadyApproved } from "../../lib/approval-dialog"; +import { logWarn } from "@sentry/mcp-core/telem/logging"; +import { parseSkills, getScopesForSkills } from "@sentry/mcp-core/skills"; + +interface AuthRequestWithSkills extends AuthRequest { + skills?: unknown; +} + +export default new Hono<{ Bindings: Env }>().get("/", async (c) => { + // 1. Verify and parse the state token + let parsedState: OAuthState; + try { + const rawState = c.req.query("state") ?? ""; + parsedState = await verifyAndParseState(rawState, c.env.COOKIE_SECRET); + } catch (err) { + logWarn("Invalid state received on OAuth callback", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { error: String(err) }, + }); + return c.text("Invalid state", 400); + } + + const oauthReqInfo = parsedState.req as unknown as AuthRequestWithSkills; + + // 2. Validate required fields + if (!oauthReqInfo.clientId) { + logWarn("Missing clientId in OAuth state", { + loggerScope: ["cloudflare", "oauth", "callback"], + }); + return c.text("Invalid state", 400); + } + + if (!oauthReqInfo.redirectUri) { + logWarn("Missing redirectUri in OAuth state", { + loggerScope: ["cloudflare", "oauth", "callback"], + }); + return c.text("Authorization failed: No redirect URL provided", 400); + } + + // 3. Validate redirect URI format + try { + new URL(oauthReqInfo.redirectUri); + } catch { + logWarn(`Invalid redirectUri in OAuth state: ${oauthReqInfo.redirectUri}`, { + loggerScope: ["cloudflare", "oauth", "callback"], + }); + return c.text("Authorization failed: Invalid redirect URL", 400); + } + + // 4. Verify client was approved by user + const isApproved = await clientIdAlreadyApproved( + c.req.raw, + oauthReqInfo.clientId, + c.env.COOKIE_SECRET + ); + if (!isApproved) { + return c.text("Authorization failed: Client not approved", 403); + } + + // 5. Validate redirect URI is registered for client + try { + const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); + const uriIsAllowed = + Array.isArray(client?.redirectUris) && + client.redirectUris.includes(oauthReqInfo.redirectUri); + if (!uriIsAllowed) { + logWarn("Redirect URI not registered for client on callback", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { + clientId: oauthReqInfo.clientId, + redirectUri: oauthReqInfo.redirectUri, + }, + }); + return c.text("Authorization failed: Invalid redirect URL", 400); + } + } catch (lookupErr) { + logWarn("Failed to validate client redirect URI on callback", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { error: String(lookupErr) }, + }); + return c.text("Authorization failed: Invalid redirect URL", 400); + } + + // 6. Exchange authorization code for access token with Sentry + const sentryCallbackUrl = new URL("/oauth/callback", c.req.url).href; + const [payload, errResponse] = await exchangeCodeForAccessToken({ + upstream_url: new URL( + SENTRY_TOKEN_URL, + `https://${c.env.SENTRY_HOST || "sentry.io"}` + ).href, + client_id: c.env.SENTRY_CLIENT_ID, + client_secret: c.env.SENTRY_CLIENT_SECRET, + code: c.req.query("code"), + redirect_uri: sentryCallbackUrl, + }); + + if (errResponse) { + return errResponse; + } + + // 7. Parse and validate requested skills/scopes + const { valid: validSkills, invalid: invalidSkills } = parseSkills(oauthReqInfo.skills); + + if (invalidSkills.length > 0) { + logWarn("OAuth callback received invalid skill names", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { + clientId: oauthReqInfo.clientId, + invalidSkills, + }, + }); + } + + if (validSkills.size === 0) { + logWarn("OAuth authorization rejected: No valid skills selected", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { + clientId: oauthReqInfo.clientId, + receivedSkills: oauthReqInfo.skills, + }, + }); + return c.text( + "Authorization failed: You must select at least one valid permission to continue.", + 400 + ); + } + + const grantedScopes = await getScopesForSkills(validSkills); + const grantedSkills = Array.from(validSkills); + + // 8. Complete authorization - this issues the MCP access token + const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ + request: oauthReqInfo, + userId: payload.user.id, + metadata: { + label: payload.user.name, + }, + scope: oauthReqInfo.scope, + props: { + // These props are encrypted and stored with the token + id: payload.user.id, + accessToken: payload.access_token, + refreshToken: payload.refresh_token, + accessTokenExpiresAt: Date.now() + payload.expires_in * 1000, + clientId: oauthReqInfo.clientId, + scope: oauthReqInfo.scope.join(" "), + grantedScopes: Array.from(grantedScopes), + grantedSkills, + } as WorkerProps, + }); + + // 9. Redirect back to MCP client with the access token + return c.redirect(redirectTo); +}); +``` + +--- + +## Main Server Setup (app.ts) + +How the OAuth routes are integrated into the main server. + +```typescript +import { Hono } from "hono"; +import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; +import type { Env } from "./types"; +import oauthApp from "./oauth"; +import { tokenExchangeCallback } from "./oauth/helpers"; +import { McpServer } from "./mcp-server"; + +// Create the main Hono app +const app = new Hono<{ Bindings: Env }>(); + +// Mount OAuth routes +app.route("/oauth", oauthApp); + +// Health check +app.get("/health", (c) => c.json({ status: "ok" })); + +// Export with OAuth provider wrapper +export default new OAuthProvider({ + // API routes that require authentication + apiRoute: ["/mcp", "/sse"], + apiHandler: McpServer, + + // Default handler for non-API routes (OAuth, health, etc.) + defaultHandler: app, + + // OAuth endpoint configuration + authorizeEndpoint: "/oauth/authorize", + tokenEndpoint: "/oauth/token", + clientRegistrationEndpoint: "/oauth/register", + + // Supported scopes + scopesSupported: ["openid", "profile", "email"], + + // Token refresh callback + tokenExchangeCallback, +}); +``` + +--- + +## Environment Variables Required + +```bash +# Sentry OAuth App credentials +SENTRY_CLIENT_ID=your-client-id +SENTRY_CLIENT_SECRET=your-client-secret +SENTRY_HOST=sentry.io # or your self-hosted instance + +# Cookie signing secret (32+ random characters) +COOKIE_SECRET=your-random-secret-key + +# Cloudflare KV namespace for token storage +# (configured in wrangler.toml) +``` + +--- + +## Key Takeaways for Context7 + +1. **State Management**: Sentry uses HMAC-signed stateless tokens instead of storing state in a database. This is simpler but requires a `COOKIE_SECRET`. + +2. **Upstream OAuth**: Sentry acts as an OAuth proxy - it authenticates with Sentry's OAuth, then issues its own MCP tokens. Context7 would authenticate with Clerk, then issue API keys. + +3. **Cookie-based Approval**: Users' approved clients are stored in an encrypted cookie, avoiding database lookups on repeat authorizations. + +4. **Token Refresh**: The `tokenExchangeCallback` handles refreshing upstream tokens when they expire. + +5. **Cloudflare Workers OAuth Provider**: Sentry uses `@cloudflare/workers-oauth-provider` which handles most of the OAuth protocol. For Next.js, you'll implement these endpoints manually. + +--- + +## Adapting for Context7 (Next.js) + +The main differences for your implementation: + +| Sentry (Cloudflare) | Context7 (Next.js) | +|---------------------|---------------------| +| `@cloudflare/workers-oauth-provider` | Manual route handlers | +| Hono framework | Next.js App Router | +| Cloudflare KV for storage | Supabase for storage | +| Sentry OAuth upstream | Clerk for user auth | +| Issues MCP tokens | Issues API keys | +| `COOKIE_SECRET` for state | Can use Supabase for state | + +Your implementation will be simpler because: +- No upstream OAuth (Clerk handles user auth) +- Return API keys instead of MCP tokens +- Existing API key validation logic From 41c79fecc78d61f55f9ed580dc90e1ee9ef2afe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Thu, 4 Dec 2025 12:32:12 +0300 Subject: [PATCH 2/7] oauth poc --- docs/oauth-architecture-plan.md | 112 ++++++++++++++++++++++++++------ packages/mcp/src/index.ts | 55 +++++++++++++++- 2 files changed, 146 insertions(+), 21 deletions(-) diff --git a/docs/oauth-architecture-plan.md b/docs/oauth-architecture-plan.md index 29c3690a..31b5684c 100644 --- a/docs/oauth-architecture-plan.md +++ b/docs/oauth-architecture-plan.md @@ -24,9 +24,10 @@ This document outlines the OAuth 2.1 implementation for the Context7 MCP server, | **Authorization Server** | context7app (Next.js) | Already has Clerk auth and Supabase | | **Resource Server** | context7 MCP | Validates API keys, serves MCP tools | | **Token Type** | API Key (not JWT) | Zero changes to existing API validation | -| **Client Registration** | Dynamic (RFC 7591) | Required by MCP spec | +| **Client Registration** | Dynamic (RFC 7591) or Client ID Metadata Documents | MCP spec supports both | | **User Authentication** | Clerk | Existing auth system | | **Storage** | Supabase | Existing database | +| **Resource Parameter** | Required (RFC 8707) | Token audience binding per MCP spec | ### Why API Keys Instead of JWT? @@ -360,6 +361,27 @@ The server creates a record in `mcp_oauth_clients` and responds: The client saves this `client_id` for future use. +**Alternative: Client ID Metadata Documents** + +Instead of Dynamic Client Registration, clients can use **Client ID Metadata Documents** (recommended by MCP spec for clients without prior relationship): + +1. Client hosts a metadata JSON at an HTTPS URL (e.g., `https://cursor.com/oauth/client.json`) +2. Client uses this URL as its `client_id` in the authorization request +3. Server fetches the metadata document to validate redirect URIs + +Example metadata document hosted by the client: +```json +{ + "client_id": "https://cursor.com/oauth/client.json", + "client_name": "Cursor", + "redirect_uris": ["http://127.0.0.1:54321/callback"], + "grant_types": ["authorization_code"], + "token_endpoint_auth_method": "none" +} +``` + +This approach eliminates the need for the `/register` endpoint when the client supports it. + --- #### Phase 3: Authorization (PKCE Flow) @@ -377,6 +399,7 @@ https://context7.com/api/mcp-auth/authorize &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &state=xyz123 + &resource=https://mcp.context7.com ← RFC 8707 resource indicator (REQUIRED) ``` **Step 10-11 — Context7app checks for Clerk session** @@ -410,6 +433,7 @@ A random authorization code is generated and stored in `mcp_auth_codes` along wi - The `client_id` - The `user_id` - The `code_challenge` (for PKCE verification later) +- The `resource` (for token endpoint validation) - Expiration time (10 minutes) **Step 19 — Context7app redirects to client with code** @@ -441,16 +465,19 @@ grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk &redirect_uri=http://127.0.0.1:54321/callback +&resource=https://mcp.context7.com ← Must match the authorization request ``` -**Step 21 — Context7app validates code and PKCE** +**Step 21 — Context7app validates code, PKCE, and resource** The server: 1. Looks up the code in `mcp_auth_codes` 2. Checks it hasn't expired or been used -3. Computes `SHA256(code_verifier)` and verifies it matches the stored `code_challenge` +3. Verifies `redirect_uri` matches the stored value +4. Verifies `resource` matches the stored value (RFC 8707) +5. Computes `SHA256(code_verifier)` and verifies it matches the stored `code_challenge` -This proves the same client that started the flow is completing it (prevents code interception attacks). +This proves the same client that started the flow is completing it (prevents code interception attacks) and that the token is bound to the intended resource. **Step 22 — Context7app creates/regenerates API key** @@ -563,8 +590,9 @@ export async function GET() { scopes_supported: ["mcp:read", "mcp:write"], response_types_supported: ["code"], grant_types_supported: ["authorization_code"], - code_challenge_methods_supported: ["S256"], + code_challenge_methods_supported: ["S256"], // REQUIRED by MCP spec token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, // Support Client ID Metadata Documents }); } ``` @@ -662,6 +690,7 @@ export async function GET(request: Request) { const code_challenge = url.searchParams.get("code_challenge"); const code_challenge_method = url.searchParams.get("code_challenge_method"); const scope = url.searchParams.get("scope") || "mcp:read"; + const resource = url.searchParams.get("resource"); // RFC 8707 resource indicator // Validate required parameters if (!client_id) { @@ -686,22 +715,57 @@ export async function GET(request: Request) { ); } - // Validate client exists and redirect_uri is registered + // Validate client - supports both Dynamic Registration and Client ID Metadata Documents const supabase = createClient(); - const { data: client } = await supabase - .from("mcp_oauth_clients") - .select("*") - .eq("client_id", client_id) - .single(); + let clientRedirectUris: string[]; - if (!client) { - return NextResponse.json( - { error: "invalid_client", error_description: "Unknown client_id" }, - { status: 400 } - ); + // Check if client_id is a URL (Client ID Metadata Document) + if (client_id.startsWith("https://")) { + // Fetch client metadata from the URL + try { + const metadataResponse = await fetch(client_id); + if (!metadataResponse.ok) { + return NextResponse.json( + { error: "invalid_client", error_description: "Failed to fetch client metadata" }, + { status: 400 } + ); + } + const metadata = await metadataResponse.json(); + + // Validate client_id in metadata matches the URL + if (metadata.client_id !== client_id) { + return NextResponse.json( + { error: "invalid_client", error_description: "client_id mismatch in metadata" }, + { status: 400 } + ); + } + + clientRedirectUris = metadata.redirect_uris || []; + } catch { + return NextResponse.json( + { error: "invalid_client", error_description: "Failed to fetch client metadata" }, + { status: 400 } + ); + } + } else { + // Look up in registered clients (Dynamic Registration) + const { data: client } = await supabase + .from("mcp_oauth_clients") + .select("*") + .eq("client_id", client_id) + .single(); + + if (!client) { + return NextResponse.json( + { error: "invalid_client", error_description: "Unknown client_id" }, + { status: 400 } + ); + } + + clientRedirectUris = client.redirect_uris; } - if (!client.redirect_uris.includes(redirect_uri)) { + if (!clientRedirectUris.includes(redirect_uri)) { return NextResponse.json( { error: "invalid_request", error_description: "redirect_uri not registered" }, { status: 400 } @@ -721,7 +785,7 @@ export async function GET(request: Request) { const authCode = crypto.randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes - // Store auth code with PKCE challenge + // Store auth code with PKCE challenge and resource const { error: insertError } = await supabase.from("mcp_auth_codes").insert({ code: authCode, client_id: client_id, @@ -729,6 +793,7 @@ export async function GET(request: Request) { redirect_uri: redirect_uri, code_challenge: code_challenge, scope: scope, + resource: resource, // Store resource for token endpoint validation expires_at: expiresAt.toISOString(), }); @@ -780,7 +845,7 @@ export async function POST(request: Request) { ); } - const { grant_type, code, code_verifier, redirect_uri } = params; + const { grant_type, code, code_verifier, redirect_uri, resource } = params; // Only support authorization_code grant if (grant_type !== "authorization_code") { @@ -830,6 +895,14 @@ export async function POST(request: Request) { ); } + // Verify resource matches (RFC 8707) + if (authCode.resource && authCode.resource !== resource) { + return NextResponse.json( + { error: "invalid_grant", error_description: "resource mismatch" }, + { status: 400 } + ); + } + // Verify PKCE: SHA256(code_verifier) must equal stored code_challenge const computedChallenge = crypto .createHash("sha256") @@ -1002,6 +1075,7 @@ CREATE TABLE mcp_auth_codes ( redirect_uri TEXT NOT NULL, code_challenge TEXT NOT NULL, -- PKCE challenge scope TEXT DEFAULT 'mcp:read', + resource TEXT, -- RFC 8707 resource indicator (MCP server URI) expires_at TIMESTAMPTZ NOT NULL, consumed BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 3ad7d122..63b6dcc4 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -317,10 +317,34 @@ async function main() { ); }; - app.all("/mcp", async (req: express.Request, res: express.Response) => { + // Shared MCP request handler + const handleMcpRequest = async ( + req: express.Request, + res: express.Response, + requireAuth: boolean + ) => { try { const clientIp = getClientIp(req); const apiKey = extractApiKey(req); + const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; + + // Always add WWW-Authenticate header with OAuth discovery info + res.set( + "WWW-Authenticate", + `Bearer resource_metadata="${resourceUrl}/.well-known/oauth-protected-resource"` + ); + + // If auth required and no API key, return 401 to trigger OAuth flow + if (requireAuth && !apiKey) { + return res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Authentication required. Please authenticate to use this MCP server.", + }, + id: null, + }); + } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, @@ -345,12 +369,39 @@ async function main() { }); } } - }); + }; + + // Anonymous access endpoint - no authentication required + app.all("/mcp", (req, res) => handleMcpRequest(req, res, false)); + + // OAuth-protected endpoint - requires authentication (returns 401 if no API key) + app.all("/mcp/oauth", (req, res) => handleMcpRequest(req, res, true)); app.get("/ping", (_req: express.Request, res: express.Response) => { res.json({ status: "ok", message: "pong" }); }); + // OAuth 2.0 Protected Resource Metadata (RFC 9728) + // This enables MCP clients to discover the authorization server + app.get( + "/.well-known/oauth-protected-resource", + (_req: express.Request, res: express.Response) => { + // Use environment variables or defaults + // For local testing: AUTH_SERVER_URL=http://localhost:3000 + // For production: AUTH_SERVER_URL=https://context7.com + console.log("WELL KNOWN OAUTH PROTECTED RESOURCE", _req.body); + const authServerUrl = process.env.AUTH_SERVER_URL || "http://localhost:3000"; + const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; + + res.json({ + resource: resourceUrl, + authorization_servers: [authServerUrl], + scopes_supported: ["mcp:read", "mcp:write"], + bearer_methods_supported: ["header"], + }); + } + ); + // Catch-all 404 handler - must be after all other routes app.use((_req: express.Request, res: express.Response) => { res.status(404).json({ From e639e287dd359bd6479e24b0942edb01733488c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Thu, 4 Dec 2025 20:40:15 +0300 Subject: [PATCH 3/7] stash --- docs/jwt-oauth-plan.md | 922 +++++++++++++++++++++++++++++++++++++ docs/oauth-redis-schema.md | 183 ++++++++ 2 files changed, 1105 insertions(+) create mode 100644 docs/jwt-oauth-plan.md create mode 100644 docs/oauth-redis-schema.md diff --git a/docs/jwt-oauth-plan.md b/docs/jwt-oauth-plan.md new file mode 100644 index 00000000..172ed5d1 --- /dev/null +++ b/docs/jwt-oauth-plan.md @@ -0,0 +1,922 @@ +# JWT OAuth Implementation Plan + +## Overview + +This document outlines the changes needed to implement JWT-based OAuth flow for Context7 MCP, replacing the current API key approach with short-lived JWT access tokens and long-lived refresh tokens. + +## Architecture Decision + +| Component | Choice | Details | +|-----------|--------|---------| +| **Access Token** | JWT | 3-hour expiry, signed with RS256 | +| **Refresh Token** | Opaque | 30-day expiry, stored hashed in DB | +| **Signing Algorithm** | RS256 | Asymmetric keys for distributed validation | +| **Caching** | Redis (Upstash) | Cache validated tokens + usage tracking | + +### Why RS256 (Asymmetric) vs HS256 (Symmetric)? + +- **RS256**: Private key signs (context7app), public key verifies (MCP server) + - MCP server only needs public key (can't forge tokens) + - Easy key rotation + - Better for distributed systems + +- **HS256**: Single shared secret + - Simpler but less secure for distributed systems + - Anyone with the secret can create tokens + +**Recommendation**: Use RS256 for production security. + +--- + +## Token Structure + +### JWT Access Token + +```json +{ + "header": { + "alg": "RS256", + "typ": "JWT", + "kid": "key-2025-01" + }, + "payload": { + "iss": "https://context7.com", + "sub": "user_clerk_id", + "aud": "https://mcp.context7.com", + "exp": 1733410800, + "iat": 1733400000, + "jti": "unique-token-id", + "scope": "mcp:read mcp:write", + "project_id": "proj_xxx", + "client_id": "oauth-client-id" + } +} +``` + +### Token Prefixes + +``` +Access Token: ctx7jwt_eyJhbGciOiJSUzI1NiIs... +Refresh Token: ctx7rt_a1b2c3d4e5f6... +``` + +--- + +## Changes Required + +### Repository: context7 (MCP Server) + +#### 1. Add Dependencies + +**File:** `packages/mcp/package.json` + +```json +{ + "dependencies": { + "jose": "^5.2.0", + "@upstash/redis": "^1.28.0" + } +} +``` + +> Using `jose` instead of `jsonwebtoken` - it's more modern, has better TypeScript support, and works in edge environments. + +#### 2. Create JWT Validation Library + +**File:** `packages/mcp/src/lib/jwt.ts` + +```typescript +import * as jose from "jose"; +import { Redis } from "@upstash/redis"; + +// Environment variables +const JWKS_URL = process.env.JWKS_URL || "https://context7.com/.well-known/jwks.json"; +const JWT_ISSUER = process.env.JWT_ISSUER || "https://context7.com"; +const JWT_AUDIENCE = process.env.JWT_AUDIENCE || "https://mcp.context7.com"; + +// Redis for caching +const redis = process.env.UPSTASH_REDIS_URL + ? new Redis({ + url: process.env.UPSTASH_REDIS_URL, + token: process.env.UPSTASH_REDIS_TOKEN!, + }) + : null; + +const TOKEN_CACHE_TTL = 300; // 5 minutes + +// JWKS client with caching +let jwksClient: jose.JWTVerifyGetKey | null = null; + +async function getJwksClient(): Promise { + if (!jwksClient) { + jwksClient = jose.createRemoteJWKSet(new URL(JWKS_URL), { + cacheMaxAge: 600000, // 10 minutes + }); + } + return jwksClient; +} + +export interface TokenPayload { + sub: string; // user_id + project_id: string; + scope: string; + client_id: string; + jti: string; // token ID for revocation check +} + +export interface ValidationResult { + valid: boolean; + payload?: TokenPayload; + error?: string; +} + +export async function validateAccessToken(token: string): Promise { + // Strip prefix if present + const jwt = token.startsWith("ctx7jwt_") ? token.slice(8) : token; + + // 1. Check cache first + if (redis) { + const cacheKey = `jwt:${jose.base64url.encode(new TextEncoder().encode(jwt.slice(-32)))}`; + const cached = await redis.get(cacheKey); + if (cached) { + return { valid: true, payload: cached }; + } + } + + try { + // 2. Verify JWT signature and claims + const jwks = await getJwksClient(); + const { payload } = await jose.jwtVerify(jwt, jwks, { + issuer: JWT_ISSUER, + audience: JWT_AUDIENCE, + }); + + // 3. Check if token is revoked (optional - for immediate revocation) + if (redis && payload.jti) { + const revoked = await redis.get(`revoked:${payload.jti}`); + if (revoked) { + return { valid: false, error: "Token revoked" }; + } + } + + const tokenPayload: TokenPayload = { + sub: payload.sub as string, + project_id: payload.project_id as string, + scope: payload.scope as string, + client_id: payload.client_id as string, + jti: payload.jti as string, + }; + + // 4. Cache the validated token + if (redis) { + const cacheKey = `jwt:${jose.base64url.encode(new TextEncoder().encode(jwt.slice(-32)))}`; + const ttl = Math.min(TOKEN_CACHE_TTL, (payload.exp as number) - Math.floor(Date.now() / 1000)); + if (ttl > 0) { + await redis.set(cacheKey, tokenPayload, { ex: ttl }); + } + } + + return { valid: true, payload: tokenPayload }; + } catch (error) { + if (error instanceof jose.errors.JWTExpired) { + return { valid: false, error: "Token expired" }; + } + if (error instanceof jose.errors.JWTClaimValidationFailed) { + return { valid: false, error: "Invalid token claims" }; + } + return { valid: false, error: "Invalid token" }; + } +} + +// Revoke a token (called when refresh token is used or user logs out) +export async function revokeToken(jti: string, expiresIn: number): Promise { + if (redis) { + await redis.set(`revoked:${jti}`, "1", { ex: expiresIn }); + } +} +``` + +#### 3. Create Usage Tracking Library + +**File:** `packages/mcp/src/lib/usage.ts` + +```typescript +import { Redis } from "@upstash/redis"; + +const redis = process.env.UPSTASH_REDIS_URL + ? new Redis({ + url: process.env.UPSTASH_REDIS_URL, + token: process.env.UPSTASH_REDIS_TOKEN!, + }) + : null; + +export async function trackUsage(projectId: string): Promise { + if (!redis) return; + + const today = new Date().toISOString().split("T")[0]; + const key = `usage:${projectId}:${today}`; + + // Atomic increment, expires after 48 hours + await redis.incr(key); + await redis.expire(key, 48 * 60 * 60); +} + +export async function getUsage(projectId: string): Promise { + if (!redis) return 0; + + const today = new Date().toISOString().split("T")[0]; + const key = `usage:${projectId}:${today}`; + return (await redis.get(key)) || 0; +} + +export async function checkRateLimit(projectId: string, limit: number): Promise { + const usage = await getUsage(projectId); + return usage < limit; +} +``` + +#### 4. Update MCP Server Authentication + +**File:** `packages/mcp/src/index.ts` + +Update the authentication middleware to handle JWT: + +```typescript +import { validateAccessToken, type TokenPayload } from "./lib/jwt.js"; +import { trackUsage, checkRateLimit } from "./lib/usage.js"; + +// Add to request context +const requestContext = new AsyncLocalStorage<{ + clientIp?: string; + apiKey?: string; + user?: TokenPayload; +}>(); + +// Updated authentication function +async function authenticateRequest( + req: express.Request, + res: express.Response, + requireAuth: boolean +): Promise<{ authenticated: boolean; user?: TokenPayload }> { + const token = extractApiKey(req); // Reuse existing extraction logic + + if (!token) { + if (requireAuth) { + const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; + res.set( + "WWW-Authenticate", + `Bearer resource_metadata="${resourceUrl}/.well-known/oauth-protected-resource"` + ); + res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Authentication required", + }, + id: null, + }); + } + return { authenticated: false }; + } + + // Check if it's a JWT (has prefix or looks like JWT) + if (token.startsWith("ctx7jwt_") || token.split(".").length === 3) { + const result = await validateAccessToken(token); + + if (!result.valid) { + res.set( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="${result.error}"` + ); + res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: result.error || "Invalid token", + }, + id: null, + }); + return { authenticated: false }; + } + + // Check rate limit + const withinLimit = await checkRateLimit(result.payload!.project_id, 10000); + if (!withinLimit) { + res.status(429).json({ + jsonrpc: "2.0", + error: { + code: -32002, + message: "Rate limit exceeded", + }, + id: null, + }); + return { authenticated: false }; + } + + // Track usage (fire and forget) + trackUsage(result.payload!.project_id).catch(console.error); + + return { authenticated: true, user: result.payload }; + } + + // Fall back to legacy API key validation (for backwards compatibility) + // This allows existing API keys to continue working + return { authenticated: true }; +} +``` + +#### 5. Environment Variables + +**File:** `packages/mcp/.env.example` + +```env +# JWT Configuration +JWKS_URL=https://context7.com/.well-known/jwks.json +JWT_ISSUER=https://context7.com +JWT_AUDIENCE=https://mcp.context7.com + +# Redis (Upstash) +UPSTASH_REDIS_URL=https://xxx.upstash.io +UPSTASH_REDIS_TOKEN=xxx + +# OAuth +AUTH_SERVER_URL=https://context7.com +RESOURCE_URL=https://mcp.context7.com +``` + +--- + +### Repository: context7app (Next.js Backend) + +#### 1. Add Dependencies + +**File:** `package.json` + +```json +{ + "dependencies": { + "jose": "^5.2.0", + "@upstash/redis": "^1.28.0" + } +} +``` + +#### 2. Generate RSA Key Pair + +Run once to generate keys: + +```bash +# Generate private key +openssl genrsa -out private.pem 2048 + +# Extract public key +openssl rsa -in private.pem -pubout -out public.pem + +# Convert to JWK format (use a tool or jose library) +``` + +Store in environment: +- `JWT_PRIVATE_KEY`: Base64-encoded private key (keep secret!) +- `JWT_PUBLIC_KEY`: Base64-encoded public key (can be public) +- `JWT_KEY_ID`: Unique key identifier (e.g., "key-2025-01") + +#### 3. Create JWT Utilities + +**File:** `lib/mcp-auth/jwt.ts` + +```typescript +import * as jose from "jose"; +import crypto from "crypto"; + +const JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!; +const JWT_KEY_ID = process.env.JWT_KEY_ID || "key-2025-01"; +const JWT_ISSUER = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; +const JWT_AUDIENCE = process.env.MCP_RESOURCE_URL || "https://mcp.context7.com"; + +const ACCESS_TOKEN_EXPIRY = "3h"; +const REFRESH_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days + +let privateKey: jose.KeyLike | null = null; + +async function getPrivateKey(): Promise { + if (!privateKey) { + const keyData = Buffer.from(JWT_PRIVATE_KEY, "base64").toString("utf-8"); + privateKey = await jose.importPKCS8(keyData, "RS256"); + } + return privateKey; +} + +export interface TokenClaims { + userId: string; + projectId: string; + scope: string; + clientId: string; +} + +export async function generateAccessToken(claims: TokenClaims): Promise { + const key = await getPrivateKey(); + const jti = crypto.randomUUID(); + + const jwt = await new jose.SignJWT({ + project_id: claims.projectId, + scope: claims.scope, + client_id: claims.clientId, + }) + .setProtectedHeader({ alg: "RS256", typ: "JWT", kid: JWT_KEY_ID }) + .setIssuer(JWT_ISSUER) + .setSubject(claims.userId) + .setAudience(JWT_AUDIENCE) + .setIssuedAt() + .setExpirationTime(ACCESS_TOKEN_EXPIRY) + .setJti(jti) + .sign(key); + + return `ctx7jwt_${jwt}`; +} + +export function generateRefreshToken(): string { + const randomBytes = crypto.randomBytes(32).toString("base64url"); + return `ctx7rt_${randomBytes}`; +} + +export function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export const REFRESH_TOKEN_EXPIRY = REFRESH_TOKEN_EXPIRY_SECONDS; +export const ACCESS_TOKEN_EXPIRY_SECONDS = 3 * 60 * 60; // 3 hours +``` + +#### 4. Create JWKS Endpoint + +**File:** `app/.well-known/jwks.json/route.ts` + +```typescript +import { NextResponse } from "next/server"; +import * as jose from "jose"; + +const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!; +const JWT_KEY_ID = process.env.JWT_KEY_ID || "key-2025-01"; + +let publicKeyJwk: jose.JWK | null = null; + +async function getPublicKeyJwk(): Promise { + if (!publicKeyJwk) { + const keyData = Buffer.from(JWT_PUBLIC_KEY, "base64").toString("utf-8"); + const publicKey = await jose.importSPKI(keyData, "RS256"); + publicKeyJwk = await jose.exportJWK(publicKey); + publicKeyJwk.kid = JWT_KEY_ID; + publicKeyJwk.use = "sig"; + publicKeyJwk.alg = "RS256"; + } + return publicKeyJwk; +} + +export async function GET() { + const jwk = await getPublicKeyJwk(); + + return NextResponse.json( + { + keys: [jwk], + }, + { + headers: { + "Cache-Control": "public, max-age=3600", // Cache for 1 hour + }, + } + ); +} +``` + +#### 5. Update Token Endpoint + +**File:** `app/api/mcp-auth/token/route.ts` + +```typescript +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/serverClient"; +import { + generateAccessToken, + generateRefreshToken, + hashToken, + ACCESS_TOKEN_EXPIRY_SECONDS, + REFRESH_TOKEN_EXPIRY, +} from "@/lib/mcp-auth/jwt"; +import crypto from "crypto"; + +export async function POST(request: Request) { + const contentType = request.headers.get("content-type") || ""; + let params: Record = {}; + + if (contentType.includes("application/x-www-form-urlencoded")) { + const formData = await request.formData(); + formData.forEach((value, key) => { + params[key] = value.toString(); + }); + } else if (contentType.includes("application/json")) { + params = await request.json(); + } else { + return NextResponse.json( + { error: "invalid_request", error_description: "Invalid content type" }, + { status: 400 } + ); + } + + const { grant_type } = params; + + if (grant_type === "authorization_code") { + return handleAuthorizationCode(params); + } else if (grant_type === "refresh_token") { + return handleRefreshToken(params); + } else { + return NextResponse.json({ error: "unsupported_grant_type" }, { status: 400 }); + } +} + +async function handleAuthorizationCode(params: Record) { + const { code, code_verifier, redirect_uri } = params; + + if (!code || !code_verifier || !redirect_uri) { + return NextResponse.json( + { error: "invalid_request", error_description: "Missing required parameters" }, + { status: 400 } + ); + } + + const supabase = createClient(); + + // Fetch and validate auth code + const { data: authCode } = await supabase + .from("mcp_auth_codes") + .select("*") + .eq("code", code) + .eq("consumed", false) + .single(); + + if (!authCode) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Invalid or expired code" }, + { status: 400 } + ); + } + + // Check expiration + if (new Date(authCode.expires_at) < new Date()) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Code expired" }, + { status: 400 } + ); + } + + // Verify redirect_uri matches + if (authCode.redirect_uri !== redirect_uri) { + return NextResponse.json( + { error: "invalid_grant", error_description: "redirect_uri mismatch" }, + { status: 400 } + ); + } + + // Verify PKCE + const computedChallenge = crypto + .createHash("sha256") + .update(code_verifier) + .digest("base64url"); + + if (computedChallenge !== authCode.code_challenge) { + return NextResponse.json( + { error: "invalid_grant", error_description: "PKCE verification failed" }, + { status: 400 } + ); + } + + // Mark code as consumed + await supabase.from("mcp_auth_codes").update({ consumed: true }).eq("code", code); + + // Get user's default project + const { data: membership } = await supabase + .from("project_members") + .select("project_id") + .eq("user_id", authCode.user_id) + .limit(1) + .single(); + + const projectId = membership?.project_id || "default"; + + // Generate JWT access token + const accessToken = await generateAccessToken({ + userId: authCode.user_id, + projectId: projectId, + scope: authCode.scope || "mcp:read", + clientId: authCode.client_id, + }); + + // Generate refresh token + const refreshToken = generateRefreshToken(); + const refreshTokenHash = hashToken(refreshToken); + const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_EXPIRY * 1000); + + // Revoke any existing refresh tokens for this user/client + await supabase + .from("mcp_refresh_tokens") + .update({ revoked: true }) + .eq("user_id", authCode.user_id) + .eq("client_id", authCode.client_id); + + // Store new refresh token + await supabase.from("mcp_refresh_tokens").insert({ + token_hash: refreshTokenHash, + user_id: authCode.user_id, + client_id: authCode.client_id, + project_id: projectId, + scope: authCode.scope || "mcp:read", + expires_at: refreshTokenExpiry.toISOString(), + }); + + return NextResponse.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, + refresh_token: refreshToken, + scope: authCode.scope || "mcp:read", + }); +} + +async function handleRefreshToken(params: Record) { + const { refresh_token } = params; + + if (!refresh_token) { + return NextResponse.json( + { error: "invalid_request", error_description: "refresh_token required" }, + { status: 400 } + ); + } + + const supabase = createClient(); + const refreshTokenHash = hashToken(refresh_token); + + // Look up refresh token + const { data: storedToken } = await supabase + .from("mcp_refresh_tokens") + .select("*") + .eq("token_hash", refreshTokenHash) + .eq("revoked", false) + .single(); + + if (!storedToken) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Invalid refresh token" }, + { status: 400 } + ); + } + + // Check expiration + if (new Date(storedToken.expires_at) < new Date()) { + await supabase + .from("mcp_refresh_tokens") + .update({ revoked: true }) + .eq("id", storedToken.id); + + return NextResponse.json( + { error: "invalid_grant", error_description: "Refresh token expired" }, + { status: 400 } + ); + } + + // Update last_used_at + await supabase + .from("mcp_refresh_tokens") + .update({ last_used_at: new Date().toISOString() }) + .eq("id", storedToken.id); + + // Generate new JWT access token + const accessToken = await generateAccessToken({ + userId: storedToken.user_id, + projectId: storedToken.project_id, + scope: storedToken.scope, + clientId: storedToken.client_id, + }); + + return NextResponse.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, + scope: storedToken.scope, + }); +} +``` + +#### 6. Update Authorization Server Metadata + +**File:** `app/.well-known/oauth-authorization-server/route.ts` + +```typescript +import { NextResponse } from "next/server"; + +export async function GET() { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; + + return NextResponse.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/api/mcp-auth/authorize`, + token_endpoint: `${baseUrl}/api/mcp-auth/token`, + registration_endpoint: `${baseUrl}/api/mcp-auth/register`, + jwks_uri: `${baseUrl}/.well-known/jwks.json`, // NEW + scopes_supported: ["mcp:read", "mcp:write"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); +} +``` + +--- + +### Database Schema Changes + +#### New Table: mcp_refresh_tokens + +```sql +CREATE TABLE mcp_refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + client_id TEXT NOT NULL, + project_id TEXT, + scope TEXT DEFAULT 'mcp:read', + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_mcp_refresh_tokens_hash ON mcp_refresh_tokens(token_hash); +CREATE INDEX idx_mcp_refresh_tokens_user ON mcp_refresh_tokens(user_id); +CREATE INDEX idx_mcp_refresh_tokens_expires ON mcp_refresh_tokens(expires_at); +``` + +#### Optional: Token Revocation Table (for immediate JWT revocation) + +```sql +-- Only needed if you want instant JWT revocation +-- Otherwise, just wait for JWT to expire (3 hours max) +CREATE TABLE mcp_revoked_tokens ( + jti TEXT PRIMARY KEY, + revoked_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL -- Auto-delete after JWT would have expired +); + +-- Auto-cleanup (run periodically) +CREATE OR REPLACE FUNCTION cleanup_revoked_tokens() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM mcp_revoked_tokens WHERE expires_at < NOW(); + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; +``` + +--- + +## Implementation Checklist + +### Phase 1: Setup (context7app) + +- [ ] Generate RSA key pair +- [ ] Add `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, `JWT_KEY_ID` to environment +- [ ] Install `jose` package +- [ ] Create `lib/mcp-auth/jwt.ts` +- [ ] Create `app/.well-known/jwks.json/route.ts` +- [ ] Create `mcp_refresh_tokens` table in Supabase + +### Phase 2: Token Endpoint (context7app) + +- [ ] Update `app/api/mcp-auth/token/route.ts` for JWT generation +- [ ] Update `app/.well-known/oauth-authorization-server/route.ts` with `jwks_uri` +- [ ] Test authorization_code grant returns JWT +- [ ] Test refresh_token grant returns new JWT + +### Phase 3: MCP Server (context7) + +- [ ] Install `jose` and `@upstash/redis` packages +- [ ] Create `packages/mcp/src/lib/jwt.ts` +- [ ] Create `packages/mcp/src/lib/usage.ts` +- [ ] Update `packages/mcp/src/index.ts` authentication logic +- [ ] Add environment variables for JWKS URL and Redis +- [ ] Test JWT validation works +- [ ] Test expired token returns proper error + +### Phase 4: Integration Testing + +- [ ] Test full OAuth flow with Cursor/Claude Desktop +- [ ] Test token refresh when access token expires +- [ ] Test rate limiting works +- [ ] Test usage tracking in Redis +- [ ] Verify backwards compatibility with existing API keys + +### Phase 5: Production + +- [ ] Deploy context7app changes +- [ ] Deploy MCP server changes +- [ ] Monitor for errors +- [ ] Set up Redis usage flush job (if using batched DB writes) + +--- + +## Security Considerations + +1. **Private Key Protection**: Never expose `JWT_PRIVATE_KEY`. Use secrets management. + +2. **Key Rotation**: Plan for periodic key rotation: + - Generate new key pair with new `kid` + - Add new public key to JWKS (keep old one) + - Switch signing to new private key + - Remove old public key after all old JWTs expire + +3. **Refresh Token Security**: + - Always hash before storing + - Consider refresh token rotation (issue new refresh token on each use) + - Implement token family tracking to detect theft + +4. **Rate Limiting**: Protect token endpoint from brute force. + +5. **HTTPS Only**: Never transmit tokens over HTTP. + +--- + +## Flow Diagram + +``` +Authorization Code Flow: +┌────────────┐ ┌─────���──────┐ +│ MCP Client │ │ context7app│ +└─────┬──────┘ └─────┬──────┘ + │ │ + │ POST /api/mcp-auth/token │ + │ grant_type=authorization_code │ + │ code=xxx, code_verifier=yyy │ + ├──────────────────────────────────────────►│ + │ │ + │ Verify PKCE │ + │ Generate JWT │ + │ Store refresh│ + │ │ + │ { access_token: "ctx7jwt_xxx", │ + │ refresh_token: "ctx7rt_yyy", │ + │ expires_in: 10800 } │ + │◄──────────────────────────────────────────┤ + │ │ + +Token Refresh Flow: +┌────────────┐ ┌────────────┐ +│ MCP Client │ │ context7app│ +└─────┬──────┘ └─────┬──────┘ + │ │ + │ POST /api/mcp-auth/token │ + │ grant_type=refresh_token │ + │ refresh_token=ctx7rt_yyy │ + ├──────────────────────────────────────────►│ + │ │ + │ Verify refresh hash│ + │ Generate JWT │ + │ │ + │ { access_token: "ctx7jwt_zzz", │ + │ expires_in: 10800 } │ + │◄──────────────────────────────────────────┤ + │ │ + +API Request Flow: +┌────────────┐ ┌────────────┐ +│ MCP Client │ │ MCP Server │ +└─────┬──────┘ └─────┬──────┘ + │ │ + │ POST /mcp/oauth │ + │ Authorization: Bearer ctx7jwt_xxx │ + ├──────────────────────────────────────────►│ + │ │ + │ 1. Check Redis cache │ + │ 2. If miss: verify JWT│ + │ via JWKS │ + │ 3. Check rate limit │ + │ 4. Track usage │ + │ │ + │ { result: ... } │ + │◄──────────────────────────────────────────┤ + │ │ +``` + +--- + +## Backwards Compatibility + +The implementation maintains backwards compatibility: + +1. **Existing API keys continue to work**: The MCP server checks token format and falls back to legacy validation for non-JWT tokens. + +2. **Gradual migration**: Users can continue using dashboard-created API keys while new OAuth users get JWTs. + +3. **No client changes needed**: MCP clients just see a Bearer token - they don't care if it's JWT or opaque. diff --git a/docs/oauth-redis-schema.md b/docs/oauth-redis-schema.md new file mode 100644 index 00000000..491fca68 --- /dev/null +++ b/docs/oauth-redis-schema.md @@ -0,0 +1,183 @@ +# OAuth Redis Schema + +All OAuth data stored in Upstash Redis with appropriate TTLs. + +## Key Patterns + +### 1. OAuth Clients (Dynamic Registration) + +``` +Key: oauth:client:{client_id} +Type: Hash +TTL: No expiry (persistent) or 1 year +Fields: + - client_name: string + - client_uri: string + - redirect_uris: JSON array string + - grant_types: JSON array string + - created_at: ISO timestamp +``` + +Example: +``` +HSET oauth:client:550e8400-e29b-41d4-a716-446655440000 + client_name "Cursor" + client_uri "https://cursor.com" + redirect_uris '["http://127.0.0.1:8080/callback"]' + grant_types '["authorization_code"]' + created_at "2025-12-04T10:00:00Z" +``` + +### 2. Authorization Codes (Short-lived) + +``` +Key: oauth:code:{code} +Type: Hash +TTL: 600 seconds (10 minutes) +Fields: + - client_id: string + - user_id: string (Clerk ID) + - redirect_uri: string + - code_challenge: string (PKCE S256) + - scope: string + - resource: string (optional) + - state: string (optional) + - created_at: ISO timestamp +``` + +Example: +``` +HSET oauth:code:SplxlOBeZQQYbYS6WxSbIA + client_id "550e8400-e29b-41d4-a716-446655440000" + user_id "user_2abc123" + redirect_uri "http://127.0.0.1:8080/callback" + code_challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + scope "mcp:read" + created_at "2025-12-04T10:00:00Z" +EXPIRE oauth:code:SplxlOBeZQQYbYS6WxSbIA 600 +``` + +### 3. Refresh Tokens + +``` +Key: oauth:refresh:{token_hash} +Type: Hash +TTL: 2592000 seconds (30 days) +Fields: + - user_id: string + - client_id: string + - project_id: string + - scope: string + - created_at: ISO timestamp + - last_used_at: ISO timestamp +``` + +Additionally, maintain a set of refresh tokens per user for revocation: +``` +Key: oauth:user_tokens:{user_id} +Type: Set +TTL: No expiry (cleaned up when tokens expire) +Members: token_hash values +``` + +Example: +``` +HSET oauth:refresh:a1b2c3d4e5f6... + user_id "user_2abc123" + client_id "550e8400-e29b-41d4-a716-446655440000" + project_id "proj_xyz" + scope "mcp:read" + created_at "2025-12-04T10:00:00Z" + last_used_at "2025-12-04T10:00:00Z" +EXPIRE oauth:refresh:a1b2c3d4e5f6... 2592000 + +SADD oauth:user_tokens:user_2abc123 "a1b2c3d4e5f6..." +``` + +### 4. Revoked JWTs (for immediate revocation) + +``` +Key: oauth:revoked:{jti} +Type: String +TTL: Same as JWT expiry (3 hours max) +Value: "1" or timestamp +``` + +Example: +``` +SET oauth:revoked:jwt-unique-id "1" +EXPIRE oauth:revoked:jwt-unique-id 10800 +``` + +### 5. JWT Validation Cache + +``` +Key: oauth:jwt_cache:{token_hash_suffix} +Type: Hash +TTL: 300 seconds (5 minutes) +Fields: + - sub: user_id + - project_id: string + - scope: string + - client_id: string + - exp: expiration timestamp +``` + +### 6. Usage Tracking + +``` +Key: usage:{project_id}:{date} +Type: String (counter) +TTL: 172800 seconds (48 hours) +Value: Integer count +``` + +Example: +``` +INCR usage:proj_xyz:2025-12-04 +EXPIRE usage:proj_xyz:2025-12-04 172800 +``` + +### 7. Rate Limiting + +``` +Key: ratelimit:{project_id}:{window} +Type: String (counter) +TTL: Window duration (e.g., 60 seconds for per-minute) +Value: Integer count +``` + +## Operations Summary + +| Operation | Redis Command | TTL | +|-----------|---------------|-----| +| Register client | HSET + (optional EXPIRE) | Persistent or 1 year | +| Store auth code | HSET + EXPIRE | 10 minutes | +| Consume auth code | DEL | - | +| Store refresh token | HSET + EXPIRE + SADD | 30 days | +| Validate refresh token | HGETALL | - | +| Revoke refresh token | DEL + SREM | - | +| Revoke all user tokens | SMEMBERS + DEL | - | +| Revoke JWT | SET + EXPIRE | 3 hours | +| Check JWT revoked | GET | - | +| Cache JWT validation | HSET + EXPIRE | 5 minutes | +| Track usage | INCR + EXPIRE | 48 hours | +| Check rate limit | INCR + EXPIRE | Window duration | + +## Cleanup + +Redis TTLs handle most cleanup automatically. For user token sets: + +```lua +-- Periodic cleanup script (optional) +-- Remove expired token hashes from user sets +local user_keys = redis.call('KEYS', 'oauth:user_tokens:*') +for _, user_key in ipairs(user_keys) do + local tokens = redis.call('SMEMBERS', user_key) + for _, token_hash in ipairs(tokens) do + if redis.call('EXISTS', 'oauth:refresh:' .. token_hash) == 0 then + redis.call('SREM', user_key, token_hash) + end + end +end +``` From 21a8f8f1bd628c90ca1dd544c62f6e8d568a658b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sat, 6 Dec 2025 00:29:22 +0300 Subject: [PATCH 4/7] add jwt validation --- docs/jwt-oauth-plan.md | 922 ------------------- docs/oauth-architecture-plan.md | 1343 ---------------------------- docs/oauth-redis-schema.md | 183 ---- docs/sentry-mcp-oauth-reference.md | 755 ---------------- packages/mcp/package.json | 1 + packages/mcp/src/index.ts | 17 +- packages/mcp/src/lib/api.ts | 2 +- packages/mcp/src/lib/jwt.ts | 66 ++ pnpm-lock.yaml | 8 + 9 files changed, 92 insertions(+), 3205 deletions(-) delete mode 100644 docs/jwt-oauth-plan.md delete mode 100644 docs/oauth-architecture-plan.md delete mode 100644 docs/oauth-redis-schema.md delete mode 100644 docs/sentry-mcp-oauth-reference.md create mode 100644 packages/mcp/src/lib/jwt.ts diff --git a/docs/jwt-oauth-plan.md b/docs/jwt-oauth-plan.md deleted file mode 100644 index 172ed5d1..00000000 --- a/docs/jwt-oauth-plan.md +++ /dev/null @@ -1,922 +0,0 @@ -# JWT OAuth Implementation Plan - -## Overview - -This document outlines the changes needed to implement JWT-based OAuth flow for Context7 MCP, replacing the current API key approach with short-lived JWT access tokens and long-lived refresh tokens. - -## Architecture Decision - -| Component | Choice | Details | -|-----------|--------|---------| -| **Access Token** | JWT | 3-hour expiry, signed with RS256 | -| **Refresh Token** | Opaque | 30-day expiry, stored hashed in DB | -| **Signing Algorithm** | RS256 | Asymmetric keys for distributed validation | -| **Caching** | Redis (Upstash) | Cache validated tokens + usage tracking | - -### Why RS256 (Asymmetric) vs HS256 (Symmetric)? - -- **RS256**: Private key signs (context7app), public key verifies (MCP server) - - MCP server only needs public key (can't forge tokens) - - Easy key rotation - - Better for distributed systems - -- **HS256**: Single shared secret - - Simpler but less secure for distributed systems - - Anyone with the secret can create tokens - -**Recommendation**: Use RS256 for production security. - ---- - -## Token Structure - -### JWT Access Token - -```json -{ - "header": { - "alg": "RS256", - "typ": "JWT", - "kid": "key-2025-01" - }, - "payload": { - "iss": "https://context7.com", - "sub": "user_clerk_id", - "aud": "https://mcp.context7.com", - "exp": 1733410800, - "iat": 1733400000, - "jti": "unique-token-id", - "scope": "mcp:read mcp:write", - "project_id": "proj_xxx", - "client_id": "oauth-client-id" - } -} -``` - -### Token Prefixes - -``` -Access Token: ctx7jwt_eyJhbGciOiJSUzI1NiIs... -Refresh Token: ctx7rt_a1b2c3d4e5f6... -``` - ---- - -## Changes Required - -### Repository: context7 (MCP Server) - -#### 1. Add Dependencies - -**File:** `packages/mcp/package.json` - -```json -{ - "dependencies": { - "jose": "^5.2.0", - "@upstash/redis": "^1.28.0" - } -} -``` - -> Using `jose` instead of `jsonwebtoken` - it's more modern, has better TypeScript support, and works in edge environments. - -#### 2. Create JWT Validation Library - -**File:** `packages/mcp/src/lib/jwt.ts` - -```typescript -import * as jose from "jose"; -import { Redis } from "@upstash/redis"; - -// Environment variables -const JWKS_URL = process.env.JWKS_URL || "https://context7.com/.well-known/jwks.json"; -const JWT_ISSUER = process.env.JWT_ISSUER || "https://context7.com"; -const JWT_AUDIENCE = process.env.JWT_AUDIENCE || "https://mcp.context7.com"; - -// Redis for caching -const redis = process.env.UPSTASH_REDIS_URL - ? new Redis({ - url: process.env.UPSTASH_REDIS_URL, - token: process.env.UPSTASH_REDIS_TOKEN!, - }) - : null; - -const TOKEN_CACHE_TTL = 300; // 5 minutes - -// JWKS client with caching -let jwksClient: jose.JWTVerifyGetKey | null = null; - -async function getJwksClient(): Promise { - if (!jwksClient) { - jwksClient = jose.createRemoteJWKSet(new URL(JWKS_URL), { - cacheMaxAge: 600000, // 10 minutes - }); - } - return jwksClient; -} - -export interface TokenPayload { - sub: string; // user_id - project_id: string; - scope: string; - client_id: string; - jti: string; // token ID for revocation check -} - -export interface ValidationResult { - valid: boolean; - payload?: TokenPayload; - error?: string; -} - -export async function validateAccessToken(token: string): Promise { - // Strip prefix if present - const jwt = token.startsWith("ctx7jwt_") ? token.slice(8) : token; - - // 1. Check cache first - if (redis) { - const cacheKey = `jwt:${jose.base64url.encode(new TextEncoder().encode(jwt.slice(-32)))}`; - const cached = await redis.get(cacheKey); - if (cached) { - return { valid: true, payload: cached }; - } - } - - try { - // 2. Verify JWT signature and claims - const jwks = await getJwksClient(); - const { payload } = await jose.jwtVerify(jwt, jwks, { - issuer: JWT_ISSUER, - audience: JWT_AUDIENCE, - }); - - // 3. Check if token is revoked (optional - for immediate revocation) - if (redis && payload.jti) { - const revoked = await redis.get(`revoked:${payload.jti}`); - if (revoked) { - return { valid: false, error: "Token revoked" }; - } - } - - const tokenPayload: TokenPayload = { - sub: payload.sub as string, - project_id: payload.project_id as string, - scope: payload.scope as string, - client_id: payload.client_id as string, - jti: payload.jti as string, - }; - - // 4. Cache the validated token - if (redis) { - const cacheKey = `jwt:${jose.base64url.encode(new TextEncoder().encode(jwt.slice(-32)))}`; - const ttl = Math.min(TOKEN_CACHE_TTL, (payload.exp as number) - Math.floor(Date.now() / 1000)); - if (ttl > 0) { - await redis.set(cacheKey, tokenPayload, { ex: ttl }); - } - } - - return { valid: true, payload: tokenPayload }; - } catch (error) { - if (error instanceof jose.errors.JWTExpired) { - return { valid: false, error: "Token expired" }; - } - if (error instanceof jose.errors.JWTClaimValidationFailed) { - return { valid: false, error: "Invalid token claims" }; - } - return { valid: false, error: "Invalid token" }; - } -} - -// Revoke a token (called when refresh token is used or user logs out) -export async function revokeToken(jti: string, expiresIn: number): Promise { - if (redis) { - await redis.set(`revoked:${jti}`, "1", { ex: expiresIn }); - } -} -``` - -#### 3. Create Usage Tracking Library - -**File:** `packages/mcp/src/lib/usage.ts` - -```typescript -import { Redis } from "@upstash/redis"; - -const redis = process.env.UPSTASH_REDIS_URL - ? new Redis({ - url: process.env.UPSTASH_REDIS_URL, - token: process.env.UPSTASH_REDIS_TOKEN!, - }) - : null; - -export async function trackUsage(projectId: string): Promise { - if (!redis) return; - - const today = new Date().toISOString().split("T")[0]; - const key = `usage:${projectId}:${today}`; - - // Atomic increment, expires after 48 hours - await redis.incr(key); - await redis.expire(key, 48 * 60 * 60); -} - -export async function getUsage(projectId: string): Promise { - if (!redis) return 0; - - const today = new Date().toISOString().split("T")[0]; - const key = `usage:${projectId}:${today}`; - return (await redis.get(key)) || 0; -} - -export async function checkRateLimit(projectId: string, limit: number): Promise { - const usage = await getUsage(projectId); - return usage < limit; -} -``` - -#### 4. Update MCP Server Authentication - -**File:** `packages/mcp/src/index.ts` - -Update the authentication middleware to handle JWT: - -```typescript -import { validateAccessToken, type TokenPayload } from "./lib/jwt.js"; -import { trackUsage, checkRateLimit } from "./lib/usage.js"; - -// Add to request context -const requestContext = new AsyncLocalStorage<{ - clientIp?: string; - apiKey?: string; - user?: TokenPayload; -}>(); - -// Updated authentication function -async function authenticateRequest( - req: express.Request, - res: express.Response, - requireAuth: boolean -): Promise<{ authenticated: boolean; user?: TokenPayload }> { - const token = extractApiKey(req); // Reuse existing extraction logic - - if (!token) { - if (requireAuth) { - const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; - res.set( - "WWW-Authenticate", - `Bearer resource_metadata="${resourceUrl}/.well-known/oauth-protected-resource"` - ); - res.status(401).json({ - jsonrpc: "2.0", - error: { - code: -32001, - message: "Authentication required", - }, - id: null, - }); - } - return { authenticated: false }; - } - - // Check if it's a JWT (has prefix or looks like JWT) - if (token.startsWith("ctx7jwt_") || token.split(".").length === 3) { - const result = await validateAccessToken(token); - - if (!result.valid) { - res.set( - "WWW-Authenticate", - `Bearer error="invalid_token", error_description="${result.error}"` - ); - res.status(401).json({ - jsonrpc: "2.0", - error: { - code: -32001, - message: result.error || "Invalid token", - }, - id: null, - }); - return { authenticated: false }; - } - - // Check rate limit - const withinLimit = await checkRateLimit(result.payload!.project_id, 10000); - if (!withinLimit) { - res.status(429).json({ - jsonrpc: "2.0", - error: { - code: -32002, - message: "Rate limit exceeded", - }, - id: null, - }); - return { authenticated: false }; - } - - // Track usage (fire and forget) - trackUsage(result.payload!.project_id).catch(console.error); - - return { authenticated: true, user: result.payload }; - } - - // Fall back to legacy API key validation (for backwards compatibility) - // This allows existing API keys to continue working - return { authenticated: true }; -} -``` - -#### 5. Environment Variables - -**File:** `packages/mcp/.env.example` - -```env -# JWT Configuration -JWKS_URL=https://context7.com/.well-known/jwks.json -JWT_ISSUER=https://context7.com -JWT_AUDIENCE=https://mcp.context7.com - -# Redis (Upstash) -UPSTASH_REDIS_URL=https://xxx.upstash.io -UPSTASH_REDIS_TOKEN=xxx - -# OAuth -AUTH_SERVER_URL=https://context7.com -RESOURCE_URL=https://mcp.context7.com -``` - ---- - -### Repository: context7app (Next.js Backend) - -#### 1. Add Dependencies - -**File:** `package.json` - -```json -{ - "dependencies": { - "jose": "^5.2.0", - "@upstash/redis": "^1.28.0" - } -} -``` - -#### 2. Generate RSA Key Pair - -Run once to generate keys: - -```bash -# Generate private key -openssl genrsa -out private.pem 2048 - -# Extract public key -openssl rsa -in private.pem -pubout -out public.pem - -# Convert to JWK format (use a tool or jose library) -``` - -Store in environment: -- `JWT_PRIVATE_KEY`: Base64-encoded private key (keep secret!) -- `JWT_PUBLIC_KEY`: Base64-encoded public key (can be public) -- `JWT_KEY_ID`: Unique key identifier (e.g., "key-2025-01") - -#### 3. Create JWT Utilities - -**File:** `lib/mcp-auth/jwt.ts` - -```typescript -import * as jose from "jose"; -import crypto from "crypto"; - -const JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!; -const JWT_KEY_ID = process.env.JWT_KEY_ID || "key-2025-01"; -const JWT_ISSUER = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; -const JWT_AUDIENCE = process.env.MCP_RESOURCE_URL || "https://mcp.context7.com"; - -const ACCESS_TOKEN_EXPIRY = "3h"; -const REFRESH_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days - -let privateKey: jose.KeyLike | null = null; - -async function getPrivateKey(): Promise { - if (!privateKey) { - const keyData = Buffer.from(JWT_PRIVATE_KEY, "base64").toString("utf-8"); - privateKey = await jose.importPKCS8(keyData, "RS256"); - } - return privateKey; -} - -export interface TokenClaims { - userId: string; - projectId: string; - scope: string; - clientId: string; -} - -export async function generateAccessToken(claims: TokenClaims): Promise { - const key = await getPrivateKey(); - const jti = crypto.randomUUID(); - - const jwt = await new jose.SignJWT({ - project_id: claims.projectId, - scope: claims.scope, - client_id: claims.clientId, - }) - .setProtectedHeader({ alg: "RS256", typ: "JWT", kid: JWT_KEY_ID }) - .setIssuer(JWT_ISSUER) - .setSubject(claims.userId) - .setAudience(JWT_AUDIENCE) - .setIssuedAt() - .setExpirationTime(ACCESS_TOKEN_EXPIRY) - .setJti(jti) - .sign(key); - - return `ctx7jwt_${jwt}`; -} - -export function generateRefreshToken(): string { - const randomBytes = crypto.randomBytes(32).toString("base64url"); - return `ctx7rt_${randomBytes}`; -} - -export function hashToken(token: string): string { - return crypto.createHash("sha256").update(token).digest("hex"); -} - -export const REFRESH_TOKEN_EXPIRY = REFRESH_TOKEN_EXPIRY_SECONDS; -export const ACCESS_TOKEN_EXPIRY_SECONDS = 3 * 60 * 60; // 3 hours -``` - -#### 4. Create JWKS Endpoint - -**File:** `app/.well-known/jwks.json/route.ts` - -```typescript -import { NextResponse } from "next/server"; -import * as jose from "jose"; - -const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!; -const JWT_KEY_ID = process.env.JWT_KEY_ID || "key-2025-01"; - -let publicKeyJwk: jose.JWK | null = null; - -async function getPublicKeyJwk(): Promise { - if (!publicKeyJwk) { - const keyData = Buffer.from(JWT_PUBLIC_KEY, "base64").toString("utf-8"); - const publicKey = await jose.importSPKI(keyData, "RS256"); - publicKeyJwk = await jose.exportJWK(publicKey); - publicKeyJwk.kid = JWT_KEY_ID; - publicKeyJwk.use = "sig"; - publicKeyJwk.alg = "RS256"; - } - return publicKeyJwk; -} - -export async function GET() { - const jwk = await getPublicKeyJwk(); - - return NextResponse.json( - { - keys: [jwk], - }, - { - headers: { - "Cache-Control": "public, max-age=3600", // Cache for 1 hour - }, - } - ); -} -``` - -#### 5. Update Token Endpoint - -**File:** `app/api/mcp-auth/token/route.ts` - -```typescript -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/serverClient"; -import { - generateAccessToken, - generateRefreshToken, - hashToken, - ACCESS_TOKEN_EXPIRY_SECONDS, - REFRESH_TOKEN_EXPIRY, -} from "@/lib/mcp-auth/jwt"; -import crypto from "crypto"; - -export async function POST(request: Request) { - const contentType = request.headers.get("content-type") || ""; - let params: Record = {}; - - if (contentType.includes("application/x-www-form-urlencoded")) { - const formData = await request.formData(); - formData.forEach((value, key) => { - params[key] = value.toString(); - }); - } else if (contentType.includes("application/json")) { - params = await request.json(); - } else { - return NextResponse.json( - { error: "invalid_request", error_description: "Invalid content type" }, - { status: 400 } - ); - } - - const { grant_type } = params; - - if (grant_type === "authorization_code") { - return handleAuthorizationCode(params); - } else if (grant_type === "refresh_token") { - return handleRefreshToken(params); - } else { - return NextResponse.json({ error: "unsupported_grant_type" }, { status: 400 }); - } -} - -async function handleAuthorizationCode(params: Record) { - const { code, code_verifier, redirect_uri } = params; - - if (!code || !code_verifier || !redirect_uri) { - return NextResponse.json( - { error: "invalid_request", error_description: "Missing required parameters" }, - { status: 400 } - ); - } - - const supabase = createClient(); - - // Fetch and validate auth code - const { data: authCode } = await supabase - .from("mcp_auth_codes") - .select("*") - .eq("code", code) - .eq("consumed", false) - .single(); - - if (!authCode) { - return NextResponse.json( - { error: "invalid_grant", error_description: "Invalid or expired code" }, - { status: 400 } - ); - } - - // Check expiration - if (new Date(authCode.expires_at) < new Date()) { - return NextResponse.json( - { error: "invalid_grant", error_description: "Code expired" }, - { status: 400 } - ); - } - - // Verify redirect_uri matches - if (authCode.redirect_uri !== redirect_uri) { - return NextResponse.json( - { error: "invalid_grant", error_description: "redirect_uri mismatch" }, - { status: 400 } - ); - } - - // Verify PKCE - const computedChallenge = crypto - .createHash("sha256") - .update(code_verifier) - .digest("base64url"); - - if (computedChallenge !== authCode.code_challenge) { - return NextResponse.json( - { error: "invalid_grant", error_description: "PKCE verification failed" }, - { status: 400 } - ); - } - - // Mark code as consumed - await supabase.from("mcp_auth_codes").update({ consumed: true }).eq("code", code); - - // Get user's default project - const { data: membership } = await supabase - .from("project_members") - .select("project_id") - .eq("user_id", authCode.user_id) - .limit(1) - .single(); - - const projectId = membership?.project_id || "default"; - - // Generate JWT access token - const accessToken = await generateAccessToken({ - userId: authCode.user_id, - projectId: projectId, - scope: authCode.scope || "mcp:read", - clientId: authCode.client_id, - }); - - // Generate refresh token - const refreshToken = generateRefreshToken(); - const refreshTokenHash = hashToken(refreshToken); - const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_EXPIRY * 1000); - - // Revoke any existing refresh tokens for this user/client - await supabase - .from("mcp_refresh_tokens") - .update({ revoked: true }) - .eq("user_id", authCode.user_id) - .eq("client_id", authCode.client_id); - - // Store new refresh token - await supabase.from("mcp_refresh_tokens").insert({ - token_hash: refreshTokenHash, - user_id: authCode.user_id, - client_id: authCode.client_id, - project_id: projectId, - scope: authCode.scope || "mcp:read", - expires_at: refreshTokenExpiry.toISOString(), - }); - - return NextResponse.json({ - access_token: accessToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, - refresh_token: refreshToken, - scope: authCode.scope || "mcp:read", - }); -} - -async function handleRefreshToken(params: Record) { - const { refresh_token } = params; - - if (!refresh_token) { - return NextResponse.json( - { error: "invalid_request", error_description: "refresh_token required" }, - { status: 400 } - ); - } - - const supabase = createClient(); - const refreshTokenHash = hashToken(refresh_token); - - // Look up refresh token - const { data: storedToken } = await supabase - .from("mcp_refresh_tokens") - .select("*") - .eq("token_hash", refreshTokenHash) - .eq("revoked", false) - .single(); - - if (!storedToken) { - return NextResponse.json( - { error: "invalid_grant", error_description: "Invalid refresh token" }, - { status: 400 } - ); - } - - // Check expiration - if (new Date(storedToken.expires_at) < new Date()) { - await supabase - .from("mcp_refresh_tokens") - .update({ revoked: true }) - .eq("id", storedToken.id); - - return NextResponse.json( - { error: "invalid_grant", error_description: "Refresh token expired" }, - { status: 400 } - ); - } - - // Update last_used_at - await supabase - .from("mcp_refresh_tokens") - .update({ last_used_at: new Date().toISOString() }) - .eq("id", storedToken.id); - - // Generate new JWT access token - const accessToken = await generateAccessToken({ - userId: storedToken.user_id, - projectId: storedToken.project_id, - scope: storedToken.scope, - clientId: storedToken.client_id, - }); - - return NextResponse.json({ - access_token: accessToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, - scope: storedToken.scope, - }); -} -``` - -#### 6. Update Authorization Server Metadata - -**File:** `app/.well-known/oauth-authorization-server/route.ts` - -```typescript -import { NextResponse } from "next/server"; - -export async function GET() { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; - - return NextResponse.json({ - issuer: baseUrl, - authorization_endpoint: `${baseUrl}/api/mcp-auth/authorize`, - token_endpoint: `${baseUrl}/api/mcp-auth/token`, - registration_endpoint: `${baseUrl}/api/mcp-auth/register`, - jwks_uri: `${baseUrl}/.well-known/jwks.json`, // NEW - scopes_supported: ["mcp:read", "mcp:write"], - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); -} -``` - ---- - -### Database Schema Changes - -#### New Table: mcp_refresh_tokens - -```sql -CREATE TABLE mcp_refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token_hash TEXT UNIQUE NOT NULL, - user_id TEXT NOT NULL, - client_id TEXT NOT NULL, - project_id TEXT, - scope TEXT DEFAULT 'mcp:read', - expires_at TIMESTAMPTZ NOT NULL, - revoked BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW(), - last_used_at TIMESTAMPTZ DEFAULT NOW() -); - --- Indexes -CREATE INDEX idx_mcp_refresh_tokens_hash ON mcp_refresh_tokens(token_hash); -CREATE INDEX idx_mcp_refresh_tokens_user ON mcp_refresh_tokens(user_id); -CREATE INDEX idx_mcp_refresh_tokens_expires ON mcp_refresh_tokens(expires_at); -``` - -#### Optional: Token Revocation Table (for immediate JWT revocation) - -```sql --- Only needed if you want instant JWT revocation --- Otherwise, just wait for JWT to expire (3 hours max) -CREATE TABLE mcp_revoked_tokens ( - jti TEXT PRIMARY KEY, - revoked_at TIMESTAMPTZ DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL -- Auto-delete after JWT would have expired -); - --- Auto-cleanup (run periodically) -CREATE OR REPLACE FUNCTION cleanup_revoked_tokens() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM mcp_revoked_tokens WHERE expires_at < NOW(); - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; -``` - ---- - -## Implementation Checklist - -### Phase 1: Setup (context7app) - -- [ ] Generate RSA key pair -- [ ] Add `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, `JWT_KEY_ID` to environment -- [ ] Install `jose` package -- [ ] Create `lib/mcp-auth/jwt.ts` -- [ ] Create `app/.well-known/jwks.json/route.ts` -- [ ] Create `mcp_refresh_tokens` table in Supabase - -### Phase 2: Token Endpoint (context7app) - -- [ ] Update `app/api/mcp-auth/token/route.ts` for JWT generation -- [ ] Update `app/.well-known/oauth-authorization-server/route.ts` with `jwks_uri` -- [ ] Test authorization_code grant returns JWT -- [ ] Test refresh_token grant returns new JWT - -### Phase 3: MCP Server (context7) - -- [ ] Install `jose` and `@upstash/redis` packages -- [ ] Create `packages/mcp/src/lib/jwt.ts` -- [ ] Create `packages/mcp/src/lib/usage.ts` -- [ ] Update `packages/mcp/src/index.ts` authentication logic -- [ ] Add environment variables for JWKS URL and Redis -- [ ] Test JWT validation works -- [ ] Test expired token returns proper error - -### Phase 4: Integration Testing - -- [ ] Test full OAuth flow with Cursor/Claude Desktop -- [ ] Test token refresh when access token expires -- [ ] Test rate limiting works -- [ ] Test usage tracking in Redis -- [ ] Verify backwards compatibility with existing API keys - -### Phase 5: Production - -- [ ] Deploy context7app changes -- [ ] Deploy MCP server changes -- [ ] Monitor for errors -- [ ] Set up Redis usage flush job (if using batched DB writes) - ---- - -## Security Considerations - -1. **Private Key Protection**: Never expose `JWT_PRIVATE_KEY`. Use secrets management. - -2. **Key Rotation**: Plan for periodic key rotation: - - Generate new key pair with new `kid` - - Add new public key to JWKS (keep old one) - - Switch signing to new private key - - Remove old public key after all old JWTs expire - -3. **Refresh Token Security**: - - Always hash before storing - - Consider refresh token rotation (issue new refresh token on each use) - - Implement token family tracking to detect theft - -4. **Rate Limiting**: Protect token endpoint from brute force. - -5. **HTTPS Only**: Never transmit tokens over HTTP. - ---- - -## Flow Diagram - -``` -Authorization Code Flow: -┌────────────┐ ┌─────���──────┐ -│ MCP Client │ │ context7app│ -└─────┬──────┘ └─────┬──────┘ - │ │ - │ POST /api/mcp-auth/token │ - │ grant_type=authorization_code │ - │ code=xxx, code_verifier=yyy │ - ├──────────────────────────────────────────►│ - │ │ - │ Verify PKCE │ - │ Generate JWT │ - │ Store refresh│ - │ │ - │ { access_token: "ctx7jwt_xxx", │ - │ refresh_token: "ctx7rt_yyy", │ - │ expires_in: 10800 } │ - │◄──────────────────────────────────────────┤ - │ │ - -Token Refresh Flow: -┌────────────┐ ┌────────────┐ -│ MCP Client │ │ context7app│ -└─────┬──────┘ └─────┬──────┘ - │ │ - │ POST /api/mcp-auth/token │ - │ grant_type=refresh_token │ - │ refresh_token=ctx7rt_yyy │ - ├──────────────────────────────────────────►│ - │ │ - │ Verify refresh hash│ - │ Generate JWT │ - │ │ - │ { access_token: "ctx7jwt_zzz", │ - │ expires_in: 10800 } │ - │◄──────────────────────────────────────────┤ - │ │ - -API Request Flow: -┌────────────┐ ┌────────────┐ -│ MCP Client │ │ MCP Server │ -└─────┬──────┘ └─────┬──────┘ - │ │ - │ POST /mcp/oauth │ - │ Authorization: Bearer ctx7jwt_xxx │ - ├──────────────────────────────────────────►│ - │ │ - │ 1. Check Redis cache │ - │ 2. If miss: verify JWT│ - │ via JWKS │ - │ 3. Check rate limit │ - │ 4. Track usage │ - │ │ - │ { result: ... } │ - │◄──────────────────────────────────────────┤ - │ │ -``` - ---- - -## Backwards Compatibility - -The implementation maintains backwards compatibility: - -1. **Existing API keys continue to work**: The MCP server checks token format and falls back to legacy validation for non-JWT tokens. - -2. **Gradual migration**: Users can continue using dashboard-created API keys while new OAuth users get JWTs. - -3. **No client changes needed**: MCP clients just see a Bearer token - they don't care if it's JWT or opaque. diff --git a/docs/oauth-architecture-plan.md b/docs/oauth-architecture-plan.md deleted file mode 100644 index 31b5684c..00000000 --- a/docs/oauth-architecture-plan.md +++ /dev/null @@ -1,1343 +0,0 @@ -# OAuth Architecture Plan for Context7 MCP - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Detailed OAuth Flow](#detailed-oauth-flow) -4. [Implementation](#implementation) -5. [Database Schema](#database-schema) -6. [File Structure](#file-structure) -7. [Implementation Checklist](#implementation-checklist) -8. [References](#references) - ---- - -## Overview - -This document outlines the OAuth 2.1 implementation for the Context7 MCP server, enabling MCP clients (Claude Desktop, Cursor, VS Code) to authenticate users via the Context7app Next.js backend. - -### Key Decisions - -| Decision | Choice | Rationale | -| ------------------------ | --------------------------- | ---------------------------------------------------- | -| **Authorization Server** | context7app (Next.js) | Already has Clerk auth and Supabase | -| **Resource Server** | context7 MCP | Validates API keys, serves MCP tools | -| **Token Type** | API Key (not JWT) | Zero changes to existing API validation | -| **Client Registration** | Dynamic (RFC 7591) or Client ID Metadata Documents | MCP spec supports both | -| **User Authentication** | Clerk | Existing auth system | -| **Storage** | Supabase | Existing database | -| **Resource Parameter** | Required (RFC 8707) | Token audience binding per MCP spec | - -### Why API Keys Instead of JWT? - -The OAuth flow returns an **API key** as the `access_token`: - -- Reuses existing `member_api_keys` table and validation logic -- No changes needed to the MCP server's API key validation -- Consistent behavior whether user gets key via OAuth or dashboard -- Simple revocation (delete the key from database) - -### API Key Regeneration on OAuth Flow - -Since we only store **hashed** API keys (not the originals), a **new API key is generated each OAuth flow**. This is handled safely using a `source` column: - -| Scenario | Behavior | -|----------|----------| -| First OAuth connection | Creates new API key with `source: "mcp-oauth"` | -| Subsequent OAuth connections | Regenerates only the `mcp-oauth` key | -| Manual API keys (dashboard) | **Unaffected** - they have `source: "manual"` | - -**Why this is acceptable:** -- OAuth access tokens are typically regenerated on each authorization - this is expected behavior -- Each reconnection only invalidates the previous OAuth-issued key -- Manual API keys created via dashboard remain valid -- Clear separation between OAuth-issued and manually-created keys - ---- - -## Architecture - -### System Components - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ MCP Client │ -│ (Claude Desktop, Cursor, VS Code) │ -└─────────────────────────────────────┬───────────────────────────────────────┘ - │ - ┌─────────────────┼─────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ -│ Context7 MCP │ │ Context7app │ │ Clerk │ -│ (Resource Server) │ │ (Auth Server) │ │ (Identity Provider) │ -│ │ │ │ │ │ -│ • Serves MCP tools │ │ • OAuth endpoints │ │ • User login UI │ -│ • Validates API keys │ │ • Issues API keys │ │ • Session management │ -│ • Returns 401 if │ │ • Client registration │ │ • User database │ -│ unauthenticated │ │ • PKCE verification │ │ │ -│ │ │ │ │ │ -│ packages/mcp/ │ │ context7app/ │ │ clerk.com │ -└───────────────────────┘ └───────────────────────┘ └───────────────────────┘ - │ │ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Supabase │ -│ │ -│ • member_api_keys (existing) • mcp_oauth_clients (new) │ -│ • users (existing) • mcp_auth_codes (new) │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### Endpoint Overview - -| Component | Endpoint | Purpose | -| ------------- | ----------------------------------------- | -------------------------------- | -| context7app | `/.well-known/oauth-protected-resource` | RFC 9728 resource metadata | -| context7app | `/.well-known/oauth-authorization-server` | RFC 8414 auth server metadata | -| context7app | `/api/mcp-auth/register` | Dynamic client registration | -| context7app | `/api/mcp-auth/authorize` | Authorization endpoint | -| context7app | `/api/mcp-auth/token` | Token exchange (returns API key) | -| context7 MCP | `/mcp` | MCP protocol endpoint | - ---- - -## Detailed OAuth Flow - -### Sequence Diagram - -``` -┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ -│MCP Client│ │Context7 │ │Context7 │ │ Clerk │ -│(Claude) │ │ MCP │ │ app │ │ │ -└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ - │ │ │ │ - │ ══════════════════════════════════════════════════════════════════════ - │ PHASE 1: DISCOVERY - │ ══════════════════════════════════════════════════════════════════════ - │ │ │ │ - │ 1. Connect (no token) │ │ - ├────────────────────►│ │ │ - │ │ │ │ - │ 2. 401 Unauthorized │ │ - │ WWW-Authenticate: Bearer │ │ - │ resource_metadata="/.well-known/..." │ │ - │◄────────────────────┤ │ │ - │ │ │ │ - │ 3. GET /.well-known/oauth-protected-resource │ - ├──────────────────────────────────────────►│ │ - │ │ │ │ - │ 4. { authorization_servers: ["https://context7.com"] } │ - │◄──────────────────────────────────────────┤ │ - │ │ │ │ - │ 5. GET /.well-known/oauth-authorization-server │ - ├──────────────────────────────────────────►│ │ - │ │ │ │ - │ 6. { authorization_endpoint, token_endpoint, ... } │ - │◄──────────────────────────────────────────┤ │ - │ │ │ │ - │ ══════════════════════════════════════════════════════════════════════ - │ PHASE 2: CLIENT REGISTRATION (first time only) - │ ══════════════════════════════════════════════════════════════════════ - │ │ │ │ - │ 7. POST /api/mcp-auth/register │ │ - │ { redirect_uris, client_name } │ │ - ├──────────────────────────────────────────►│ │ - │ │ │ │ - │ │ │ Store in │ - │ │ │ mcp_oauth_clients │ - │ │ ├──┐ │ - │ │ │ │ │ - │ │ │◄─┘ │ - │ │ │ │ - │ 8. { client_id: "uuid-xxx" } │ │ - │◄──────────────────────────────────────────┤ │ - │ │ │ │ - │ ══════════════════════════════════════════════════════════════════════ - │ PHASE 3: AUTHORIZATION (PKCE) - │ ══════════════════════════════════════════════════════════════════════ - │ │ │ │ - │ Generate code_verifier (random) │ │ - │ code_challenge = SHA256(code_verifier) │ │ - ├──┐ │ │ │ - │ │ │ │ │ - │◄─┘ │ │ │ - │ │ │ │ - │ 9. Open browser: /api/mcp-auth/authorize │ │ - │ ?client_id=xxx │ │ - │ &redirect_uri=http://127.0.0.1:xxx │ │ - │ &code_challenge=yyy │ │ - │ &code_challenge_method=S256 │ │ - │ &state=random-state │ │ - ├──────────────────────────────────────────►│ │ - │ │ │ │ - │ │ │ 10. Check session │ - │ │ ├────────────────────►│ - │ │ │ │ - │ │ │ 11. No session │ - │ │ │◄────────────────────┤ - │ │ │ │ - │ 12. Redirect to /sign-in?redirect_url=...│ │ - │◄──────────────────────────────────────────┤ │ - │ │ │ │ - │ 13. User enters credentials │ │ - ├─────────────────────────────────────────────────────────────────► - │ │ │ │ - │ 14. Session created │ │ - │◄───────────────────────────────────────────────────────────────── - │ │ │ │ - │ 15. Redirect back to /api/mcp-auth/authorize (with session) │ - ├──────────────────────────────────────────►│ │ - │ │ │ │ - │ │ │ 16. Verify session │ - │ │ ├────────────────────►│ - │ │ │ │ - │ │ │ 17. userId │ - │ │ │◄────────────────────┤ - │ │ │ │ - │ │ │ 18. Generate auth │ - │ │ │ code, store │ - │ │ │ with PKCE │ - │ │ │ challenge │ - │ │ ├──┐ │ - │ │ │ │ │ - │ │ │◄─┘ │ - │ │ │ │ - │ 19. Redirect to redirect_uri?code=xxx&state=yyy │ - │◄──────────────────────────────────────────┤ │ - │ │ │ │ - │ ══════════════════════════════════════════════════════════════════════ - │ PHASE 4: TOKEN EXCHANGE - │ ══════════════════════════════════════════════════════════════════════ - │ │ │ │ - │ 20. POST /api/mcp-auth/token │ │ - │ grant_type=authorization_code │ │ - │ code=xxx │ │ - │ code_verifier=original-verifier │ │ - │ redirect_uri=http://127.0.0.1:xxx │ │ - ├──────────────────────────────────────────►│ │ - │ │ │ │ - │ │ │ 21. Validate code │ - │ │ │ Verify PKCE: │ - │ │ │ SHA256(verifier)│ - │ │ │ == challenge │ - │ │ ├──┐ │ - │ │ │ │ │ - │ │ │◄─┘ │ - │ │ │ │ - │ │ │ 22. Get/create │ - │ │ │ API key for │ - │ │ │ user │ - │ │ ├──┐ │ - │ │ │ │ │ - │ │ │◄─┘ │ - │ │ │ │ - │ 23. { access_token: "ctx7sk-xxx", token_type: "Bearer" } │ - │◄──────────────────────────────────────────┤ │ - │ │ │ │ - │ Store API key │ │ │ - ├──┐ │ │ │ - │ │ │ │ │ - │◄─┘ │ │ │ - │ │ │ │ - │ ══════════════════════════════════════════════════════════════════════ - │ PHASE 5: AUTHENTICATED MCP REQUESTS - │ ══════════════════════════════════════════════════════════════════════ - │ │ │ │ - │ 24. MCP Request │ │ │ - │ Authorization: Bearer ctx7sk-xxx │ │ - ├────────────────────►│ │ │ - │ │ │ │ - │ │ 25. Validate API key│ │ - │ │ (existing logic)│ │ - │ ├──┐ │ │ - │ │ │ │ │ - │ │◄─┘ │ │ - │ │ │ │ - │ 26. MCP Response │ │ │ - │◄────────────────────┤ │ │ - │ │ │ │ -``` - -### Step-by-Step Breakdown - ---- - -#### Phase 1: Discovery - -**What happens:** The MCP client learns where and how to authenticate. - -**Step 1 — MCP Client connects without credentials** - -When the user adds the Context7 MCP server to their client (Cursor, Claude Desktop), the client automatically attempts to connect: - -``` -GET https://mcp.context7.com/mcp -(no Authorization header) -``` - -**Step 2 — MCP Server returns 401 with OAuth metadata location** - -Since there's no token, the MCP server rejects the request but tells the client where to find authentication info: - -``` -HTTP/1.1 401 Unauthorized -WWW-Authenticate: Bearer resource_metadata="https://context7.com/.well-known/oauth-protected-resource" -``` - -**Step 3 — MCP Client fetches Protected Resource Metadata** - -The client reads the URL from the `WWW-Authenticate` header and fetches it: - -``` -GET https://context7.com/.well-known/oauth-protected-resource -``` - -**Step 4 — Context7app returns resource metadata** - -This tells the client which authorization server to use: - -```json -{ - "resource": "https://mcp.context7.com/mcp", - "authorization_servers": ["https://context7.com"] -} -``` - -**Step 5 — MCP Client fetches Authorization Server Metadata** - -Now the client knows to look at `context7.com`, so it fetches the OAuth configuration: - -``` -GET https://context7.com/.well-known/oauth-authorization-server -``` - -**Step 6 — Context7app returns OAuth endpoints** - -This tells the client all the URLs it needs for the OAuth flow: - -```json -{ - "authorization_endpoint": "https://context7.com/api/mcp-auth/authorize", - "token_endpoint": "https://context7.com/api/mcp-auth/token", - "registration_endpoint": "https://context7.com/api/mcp-auth/register", - "code_challenge_methods_supported": ["S256"] -} -``` - ---- - -#### Phase 2: Client Registration (First Time Only) - -**What happens:** The MCP client registers itself to get a `client_id`. This only happens once per client. - -**Step 7 — MCP Client registers itself** - -The client sends its information to get a unique identifier: - -``` -POST https://context7.com/api/mcp-auth/register -Content-Type: application/json - -{ - "client_name": "Cursor", - "redirect_uris": ["http://127.0.0.1:54321/callback"] -} -``` - -**Step 8 — Context7app stores client and returns `client_id`** - -The server creates a record in `mcp_oauth_clients` and responds: - -```json -{ - "client_id": "550e8400-e29b-41d4-a716-446655440000", - "client_name": "Cursor", - "redirect_uris": ["http://127.0.0.1:54321/callback"] -} -``` - -The client saves this `client_id` for future use. - -**Alternative: Client ID Metadata Documents** - -Instead of Dynamic Client Registration, clients can use **Client ID Metadata Documents** (recommended by MCP spec for clients without prior relationship): - -1. Client hosts a metadata JSON at an HTTPS URL (e.g., `https://cursor.com/oauth/client.json`) -2. Client uses this URL as its `client_id` in the authorization request -3. Server fetches the metadata document to validate redirect URIs - -Example metadata document hosted by the client: -```json -{ - "client_id": "https://cursor.com/oauth/client.json", - "client_name": "Cursor", - "redirect_uris": ["http://127.0.0.1:54321/callback"], - "grant_types": ["authorization_code"], - "token_endpoint_auth_method": "none" -} -``` - -This approach eliminates the need for the `/register` endpoint when the client supports it. - ---- - -#### Phase 3: Authorization (PKCE Flow) - -**What happens:** The user logs in via browser, and the client gets an authorization code. - -**Step 9 — MCP Client generates PKCE values and opens browser** - -The client creates a random `code_verifier` and computes its SHA256 hash (`code_challenge`). Then it opens the user's browser: - -``` -https://context7.com/api/mcp-auth/authorize - ?client_id=550e8400-e29b-41d4-a716-446655440000 - &redirect_uri=http://127.0.0.1:54321/callback - &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM - &code_challenge_method=S256 - &state=xyz123 - &resource=https://mcp.context7.com ← RFC 8707 resource indicator (REQUIRED) -``` - -**Step 10-11 — Context7app checks for Clerk session** - -The authorize endpoint checks if the user is already logged in with Clerk. If not, there's no session. - -**Step 12 — Context7app redirects to sign-in** - -Since the user isn't logged in, they're redirected to the Clerk sign-in page: - -``` -HTTP/1.1 302 Found -Location: https://context7.com/sign-in?redirect_url=https://context7.com/api/mcp-auth/authorize?client_id=... -``` - -**Step 13-14 — User logs in with Clerk** - -The user sees the Context7 login page and enters their credentials (or uses Google/GitHub SSO). Clerk authenticates them and creates a session. - -**Step 15 — Browser redirects back to authorize endpoint** - -After successful login, Clerk redirects back to the original authorize URL, now with a valid session cookie. - -**Step 16-17 — Context7app verifies session and gets user info** - -The authorize endpoint now detects the Clerk session and retrieves the `userId`. - -**Step 18 — Context7app generates authorization code** - -A random authorization code is generated and stored in `mcp_auth_codes` along with: -- The `client_id` -- The `user_id` -- The `code_challenge` (for PKCE verification later) -- The `resource` (for token endpoint validation) -- Expiration time (10 minutes) - -**Step 19 — Context7app redirects to client with code** - -The browser is redirected to the client's callback URL: - -``` -HTTP/1.1 302 Found -Location: http://127.0.0.1:54321/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz123 -``` - -The MCP client (listening on localhost) receives this callback. - ---- - -#### Phase 4: Token Exchange - -**What happens:** The client exchanges the authorization code for an API key. - -**Step 20 — MCP Client sends token request** - -The client sends the code along with the original `code_verifier` (not the hash): - -``` -POST https://context7.com/api/mcp-auth/token -Content-Type: application/x-www-form-urlencoded - -grant_type=authorization_code -&code=SplxlOBeZQQYbYS6WxSbIA -&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk -&redirect_uri=http://127.0.0.1:54321/callback -&resource=https://mcp.context7.com ← Must match the authorization request -``` - -**Step 21 — Context7app validates code, PKCE, and resource** - -The server: -1. Looks up the code in `mcp_auth_codes` -2. Checks it hasn't expired or been used -3. Verifies `redirect_uri` matches the stored value -4. Verifies `resource` matches the stored value (RFC 8707) -5. Computes `SHA256(code_verifier)` and verifies it matches the stored `code_challenge` - -This proves the same client that started the flow is completing it (prevents code interception attacks) and that the token is bound to the intended resource. - -**Step 22 — Context7app creates/regenerates API key** - -The server checks if this user already has an OAuth-issued API key (`source: "mcp-oauth"`): -- **If yes:** Regenerates it (updates the hash in database) -- **If no:** Creates a new API key with `source: "mcp-oauth"` - -Manual API keys (`source: "manual"`) are never affected. - -**Step 23 — Context7app returns the API key as access_token** - -```json -{ - "access_token": "ctx7sk-abc123def456...", - "token_type": "Bearer", - "expires_in": 31536000, - "scope": "mcp:read" -} -``` - -The MCP client stores this token securely. - ---- - -#### Phase 5: Authenticated Requests - -**What happens:** The client can now use Context7 MCP tools. - -**Step 24 — MCP Client sends authenticated requests** - -All subsequent MCP requests include the API key: - -``` -POST https://mcp.context7.com/mcp -Authorization: Bearer ctx7sk-abc123def456... -Content-Type: application/json - -{"method": "tools/call", "params": {"name": "get-library-docs", ...}} -``` - -**Step 25 — MCP Server validates API key** - -The server extracts the token from the `Authorization` header and validates it against `member_api_keys` using the existing API key validation logic. No changes needed here. - -**Step 26 — MCP Server returns response** - -```json -{ - "result": { - "content": [{"type": "text", "text": "Documentation for react..."}] - } -} -``` - -The user can now use all Context7 MCP tools normally. - ---- - -### User Experience Summary - -From the user's perspective, the entire flow looks like: - -1. **Add server to config** (one-time) -2. **Restart client** (Cursor/Claude Desktop) -3. **Browser opens** → Login with existing Context7 account -4. **Done** — tools work automatically - -Subsequent sessions reuse the stored token. Re-authentication only happens if the token is revoked or the user removes the server config. - ---- - -## Implementation - -### Context7app Routes - -#### 1. Protected Resource Metadata - -**File:** `app/.well-known/oauth-protected-resource/route.ts` - -```typescript -import { NextResponse } from "next/server"; - -export async function GET() { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; - - return NextResponse.json({ - resource: `${baseUrl}/mcp`, - authorization_servers: [baseUrl], - scopes_supported: ["mcp:read", "mcp:write"], - bearer_methods_supported: ["header"], - }); -} -``` - -#### 2. Authorization Server Metadata - -**File:** `app/.well-known/oauth-authorization-server/route.ts` - -```typescript -import { NextResponse } from "next/server"; - -export async function GET() { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://context7.com"; - - return NextResponse.json({ - issuer: baseUrl, - authorization_endpoint: `${baseUrl}/api/mcp-auth/authorize`, - token_endpoint: `${baseUrl}/api/mcp-auth/token`, - registration_endpoint: `${baseUrl}/api/mcp-auth/register`, - scopes_supported: ["mcp:read", "mcp:write"], - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - code_challenge_methods_supported: ["S256"], // REQUIRED by MCP spec - token_endpoint_auth_methods_supported: ["none"], - client_id_metadata_document_supported: true, // Support Client ID Metadata Documents - }); -} -``` - -#### 3. Client Registration - -**File:** `app/api/mcp-auth/register/route.ts` - -```typescript -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/serverClient"; -import crypto from "crypto"; - -export async function POST(request: Request) { - const body = await request.json(); - const { redirect_uris, client_name, client_uri } = body; - - // Validate redirect URIs - if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { - return NextResponse.json( - { error: "invalid_client_metadata", error_description: "redirect_uris required" }, - { status: 400 } - ); - } - - // Validate each URI - for (const uri of redirect_uris) { - try { - const url = new URL(uri); - // Allow localhost and 127.0.0.1 for development - if (!["http:", "https:"].includes(url.protocol)) { - return NextResponse.json( - { error: "invalid_redirect_uri", error_description: "Invalid protocol" }, - { status: 400 } - ); - } - } catch { - return NextResponse.json( - { error: "invalid_redirect_uri", error_description: "Invalid URL" }, - { status: 400 } - ); - } - } - - const supabase = createClient(); - const clientId = crypto.randomUUID(); - - const { error } = await supabase.from("mcp_oauth_clients").insert({ - client_id: clientId, - client_name: client_name || null, - client_uri: client_uri || null, - redirect_uris: redirect_uris, - }); - - if (error) { - console.error("Failed to register client:", error); - return NextResponse.json( - { error: "server_error", error_description: "Failed to register client" }, - { status: 500 } - ); - } - - return NextResponse.json( - { - client_id: clientId, - client_name: client_name, - redirect_uris: redirect_uris, - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - { status: 201 } - ); -} -``` - -#### 4. Authorization Endpoint - -**File:** `app/api/mcp-auth/authorize/route.ts` - -```typescript -import { auth } from "@clerk/nextjs/server"; -import { redirect } from "next/navigation"; -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/serverClient"; -import crypto from "crypto"; - -export async function GET(request: Request) { - const url = new URL(request.url); - - // Extract OAuth parameters - const client_id = url.searchParams.get("client_id"); - const redirect_uri = url.searchParams.get("redirect_uri"); - const state = url.searchParams.get("state"); - const code_challenge = url.searchParams.get("code_challenge"); - const code_challenge_method = url.searchParams.get("code_challenge_method"); - const scope = url.searchParams.get("scope") || "mcp:read"; - const resource = url.searchParams.get("resource"); // RFC 8707 resource indicator - - // Validate required parameters - if (!client_id) { - return NextResponse.json( - { error: "invalid_request", error_description: "client_id required" }, - { status: 400 } - ); - } - - if (!redirect_uri) { - return NextResponse.json( - { error: "invalid_request", error_description: "redirect_uri required" }, - { status: 400 } - ); - } - - // Validate PKCE (required by MCP spec) - if (!code_challenge || code_challenge_method !== "S256") { - return NextResponse.json( - { error: "invalid_request", error_description: "PKCE with S256 required" }, - { status: 400 } - ); - } - - // Validate client - supports both Dynamic Registration and Client ID Metadata Documents - const supabase = createClient(); - let clientRedirectUris: string[]; - - // Check if client_id is a URL (Client ID Metadata Document) - if (client_id.startsWith("https://")) { - // Fetch client metadata from the URL - try { - const metadataResponse = await fetch(client_id); - if (!metadataResponse.ok) { - return NextResponse.json( - { error: "invalid_client", error_description: "Failed to fetch client metadata" }, - { status: 400 } - ); - } - const metadata = await metadataResponse.json(); - - // Validate client_id in metadata matches the URL - if (metadata.client_id !== client_id) { - return NextResponse.json( - { error: "invalid_client", error_description: "client_id mismatch in metadata" }, - { status: 400 } - ); - } - - clientRedirectUris = metadata.redirect_uris || []; - } catch { - return NextResponse.json( - { error: "invalid_client", error_description: "Failed to fetch client metadata" }, - { status: 400 } - ); - } - } else { - // Look up in registered clients (Dynamic Registration) - const { data: client } = await supabase - .from("mcp_oauth_clients") - .select("*") - .eq("client_id", client_id) - .single(); - - if (!client) { - return NextResponse.json( - { error: "invalid_client", error_description: "Unknown client_id" }, - { status: 400 } - ); - } - - clientRedirectUris = client.redirect_uris; - } - - if (!clientRedirectUris.includes(redirect_uri)) { - return NextResponse.json( - { error: "invalid_request", error_description: "redirect_uri not registered" }, - { status: 400 } - ); - } - - // Check if user is authenticated with Clerk - const { userId } = await auth(); - - if (!userId) { - // Redirect to sign-in, then back here - const returnUrl = encodeURIComponent(request.url); - return redirect(`/sign-in?redirect_url=${returnUrl}`); - } - - // User is authenticated - generate authorization code - const authCode = crypto.randomBytes(32).toString("base64url"); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes - - // Store auth code with PKCE challenge and resource - const { error: insertError } = await supabase.from("mcp_auth_codes").insert({ - code: authCode, - client_id: client_id, - user_id: userId, - redirect_uri: redirect_uri, - code_challenge: code_challenge, - scope: scope, - resource: resource, // Store resource for token endpoint validation - expires_at: expiresAt.toISOString(), - }); - - if (insertError) { - console.error("Failed to store auth code:", insertError); - return NextResponse.json( - { error: "server_error", error_description: "Failed to generate code" }, - { status: 500 } - ); - } - - // Redirect back to client with code - const redirectUrl = new URL(redirect_uri); - redirectUrl.searchParams.set("code", authCode); - if (state) { - redirectUrl.searchParams.set("state", state); - } - - return redirect(redirectUrl.toString()); -} -``` - -#### 5. Token Endpoint - -**File:** `app/api/mcp-auth/token/route.ts` - -```typescript -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/serverClient"; -import { generateApiKey, hashApiKey } from "@/lib/dashboard/apiKey"; -import crypto from "crypto"; - -export async function POST(request: Request) { - // Parse form data (OAuth spec requires application/x-www-form-urlencoded) - const contentType = request.headers.get("content-type") || ""; - let params: Record = {}; - - if (contentType.includes("application/x-www-form-urlencoded")) { - const formData = await request.formData(); - formData.forEach((value, key) => { - params[key] = value.toString(); - }); - } else if (contentType.includes("application/json")) { - params = await request.json(); - } else { - return NextResponse.json( - { error: "invalid_request", error_description: "Invalid content type" }, - { status: 400 } - ); - } - - const { grant_type, code, code_verifier, redirect_uri, resource } = params; - - // Only support authorization_code grant - if (grant_type !== "authorization_code") { - return NextResponse.json( - { error: "unsupported_grant_type" }, - { status: 400 } - ); - } - - if (!code || !code_verifier || !redirect_uri) { - return NextResponse.json( - { error: "invalid_request", error_description: "Missing required parameters" }, - { status: 400 } - ); - } - - const supabase = createClient(); - - // Fetch and validate auth code - const { data: authCode } = await supabase - .from("mcp_auth_codes") - .select("*") - .eq("code", code) - .eq("consumed", false) - .single(); - - if (!authCode) { - return NextResponse.json( - { error: "invalid_grant", error_description: "Invalid or expired code" }, - { status: 400 } - ); - } - - // Check expiration - if (new Date(authCode.expires_at) < new Date()) { - return NextResponse.json( - { error: "invalid_grant", error_description: "Code expired" }, - { status: 400 } - ); - } - - // Verify redirect_uri matches - if (authCode.redirect_uri !== redirect_uri) { - return NextResponse.json( - { error: "invalid_grant", error_description: "redirect_uri mismatch" }, - { status: 400 } - ); - } - - // Verify resource matches (RFC 8707) - if (authCode.resource && authCode.resource !== resource) { - return NextResponse.json( - { error: "invalid_grant", error_description: "resource mismatch" }, - { status: 400 } - ); - } - - // Verify PKCE: SHA256(code_verifier) must equal stored code_challenge - const computedChallenge = crypto - .createHash("sha256") - .update(code_verifier) - .digest("base64url"); - - if (computedChallenge !== authCode.code_challenge) { - return NextResponse.json( - { error: "invalid_grant", error_description: "PKCE verification failed" }, - { status: 400 } - ); - } - - // Mark code as consumed (one-time use) - await supabase - .from("mcp_auth_codes") - .update({ consumed: true }) - .eq("code", code); - - // Get or create API key for user - const userId = authCode.user_id; - - // Check for existing OAuth-issued API key (by source, not name) - // This ensures manual API keys are never affected by OAuth flows - const { data: existingKey } = await supabase - .from("member_api_keys") - .select("id") - .eq("user_id", userId) - .eq("source", "mcp-oauth") // Only look for OAuth-issued keys - .single(); - - let apiKey: string; - - if (existingKey) { - // Regenerate only the OAuth key (manual keys remain untouched) - // This is expected behavior - OAuth tokens are typically regenerated on each auth - apiKey = generateApiKey(); - const keyHash = hashApiKey(apiKey); - - await supabase - .from("member_api_keys") - .update({ - key_hash: keyHash, - updated_at: new Date().toISOString(), - }) - .eq("id", existingKey.id); - } else { - // Create new OAuth-specific API key - apiKey = generateApiKey(); - const keyHash = hashApiKey(apiKey); - - // Get user's default project - const { data: membership } = await supabase - .from("project_members") - .select("project_id") - .eq("user_id", userId) - .limit(1) - .single(); - - await supabase.from("member_api_keys").insert({ - user_id: userId, - project_id: membership?.project_id, - name: "MCP OAuth Token", - key_hash: keyHash, - source: "mcp-oauth", // Tag the source for future lookups - }); - } - - // Return token response - return NextResponse.json({ - access_token: apiKey, - token_type: "Bearer", - expires_in: 31536000, // 1 year (API keys don't expire) - scope: authCode.scope, - }); -} -``` - -### Context7 MCP Server Updates - -#### OAuth Challenge Response - -**File:** `packages/mcp/src/lib/oauth.ts` - -```typescript -import type { Response } from "express"; - -const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || "https://context7.com"; - -export function sendOAuthChallenge(res: Response): void { - res.set( - "WWW-Authenticate", - `Bearer resource_metadata="${AUTH_SERVER_URL}/.well-known/oauth-protected-resource"` - ); - res.status(401).json({ - error: "unauthorized", - error_description: "Authentication required", - authorization_server: AUTH_SERVER_URL, - }); -} -``` - -#### Updated MCP Endpoint - -**File:** `packages/mcp/src/index.ts` (modification) - -```typescript -import { sendOAuthChallenge } from "./lib/oauth.js"; - -// In the HTTP transport setup: -app.all("/mcp", async (req, res) => { - // Extract API key from headers (existing logic) - const apiKey = extractApiKey(req); - - // If no API key provided, send OAuth challenge - if (!apiKey) { - return sendOAuthChallenge(res); - } - - // Continue with existing MCP handling... -}); - -function extractApiKey(req: Request): string | undefined { - // Check Authorization header - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - return authHeader.slice(7); - } - - // Check custom headers (existing logic) - return ( - req.headers["x-api-key"] || - req.headers["context7-api-key"] || - req.headers["x-context7-api-key"] - ) as string | undefined; -} -``` - ---- - -## Database Schema - -### New Tables - -```sql --- ============================================ --- MCP OAuth Clients (Dynamic Registration) --- ============================================ -CREATE TABLE mcp_oauth_clients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - client_id TEXT UNIQUE NOT NULL, - client_name TEXT, - client_uri TEXT, - redirect_uris TEXT[] NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Index for client lookups -CREATE INDEX idx_mcp_oauth_clients_client_id ON mcp_oauth_clients(client_id); - --- ============================================ --- Authorization Codes (Short-lived) --- ============================================ -CREATE TABLE mcp_auth_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code TEXT UNIQUE NOT NULL, - client_id TEXT NOT NULL REFERENCES mcp_oauth_clients(client_id), - user_id TEXT NOT NULL, -- Clerk user ID - redirect_uri TEXT NOT NULL, - code_challenge TEXT NOT NULL, -- PKCE challenge - scope TEXT DEFAULT 'mcp:read', - resource TEXT, -- RFC 8707 resource indicator (MCP server URI) - expires_at TIMESTAMPTZ NOT NULL, - consumed BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Indexes for code validation -CREATE INDEX idx_mcp_auth_codes_code ON mcp_auth_codes(code); -CREATE INDEX idx_mcp_auth_codes_expires ON mcp_auth_codes(expires_at); - --- ============================================ --- Cleanup Function (Optional) --- ============================================ --- Run periodically to clean up expired codes -CREATE OR REPLACE FUNCTION cleanup_expired_mcp_auth_codes() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM mcp_auth_codes - WHERE expires_at < NOW() - INTERVAL '1 hour' - OR consumed = TRUE; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - --- ============================================ --- Update existing member_api_keys table --- ============================================ --- Add 'source' column to differentiate OAuth keys from manual keys --- This is critical for API key regeneration on OAuth flows: --- - OAuth flows regenerate only keys with source='mcp-oauth' --- - Manual keys (source='manual') are never affected by OAuth - -ALTER TABLE member_api_keys -ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'manual'; - -COMMENT ON COLUMN member_api_keys.source IS - 'Key source: manual (dashboard), mcp-oauth (OAuth flow), api (programmatic)'; - --- Index for efficient OAuth key lookups -CREATE INDEX IF NOT EXISTS idx_member_api_keys_source -ON member_api_keys(user_id, source); -``` - ---- - -## File Structure - -### Context7app (Next.js) - -``` -context7app/ -├── app/ -│ ├── .well-known/ -│ │ ├── oauth-protected-resource/ -│ │ │ └── route.ts # RFC 9728 metadata -│ │ └── oauth-authorization-server/ -│ │ └── route.ts # RFC 8414 metadata -│ └── api/ -│ └── mcp-auth/ -│ ├── register/ -│ │ └── route.ts # Client registration -│ ├── authorize/ -│ │ └── route.ts # Authorization endpoint -│ └── token/ -│ └── route.ts # Token exchange -└── lib/ - └── mcp-auth/ # (Optional) Shared utilities - ├── validation.ts # Input validation - └── types.ts # Type definitions -``` - -### Context7 MCP Server - -``` -packages/mcp/ -└── src/ - ├── lib/ - │ ├── oauth.ts # OAuth challenge response (NEW) - │ ├── api.ts # Existing API functions - │ ├── encryption.ts # Existing encryption - │ └── types.ts # Existing types - └── index.ts # Updated with OAuth challenge -``` - ---- - -## Implementation Checklist - -### Phase 1: Database Setup -- [ ] Create `mcp_oauth_clients` table in Supabase -- [ ] Create `mcp_auth_codes` table in Supabase -- [ ] Add `source` column to `member_api_keys` (default: 'manual') -- [ ] Add index on `(user_id, source)` for efficient OAuth key lookups -- [ ] Test table creation and indexes - -### Phase 2: Discovery Endpoints -- [ ] Implement `/.well-known/oauth-protected-resource` -- [ ] Implement `/.well-known/oauth-authorization-server` -- [ ] Test with `curl` or browser -- [ ] Verify JSON responses are correct - -### Phase 3: Client Registration -- [ ] Implement `/api/mcp-auth/register` -- [ ] Add redirect URI validation -- [ ] Test registration with sample client -- [ ] Verify client stored in database - -### Phase 4: Authorization Flow -- [ ] Implement `/api/mcp-auth/authorize` -- [ ] Add PKCE validation -- [ ] Integrate with Clerk auth check -- [ ] Test redirect to sign-in when not authenticated -- [ ] Test redirect back with auth code - -### Phase 5: Token Exchange -- [ ] Implement `/api/mcp-auth/token` -- [ ] Add PKCE verification -- [ ] Integrate with API key generation -- [ ] Test full token exchange flow -- [ ] Verify API key returned correctly - -### Phase 6: MCP Server Updates -- [ ] Add `oauth.ts` with challenge response -- [ ] Update `/mcp` endpoint to return 401 -- [ ] Test 401 response includes correct headers -- [ ] Verify existing API key auth still works - -### Phase 7: End-to-End Testing -- [ ] Test with Claude Desktop -- [ ] Test with Cursor -- [ ] Test with VS Code + MCP extension -- [ ] Verify token persistence across sessions - -### Phase 8: Production Readiness -- [ ] Add rate limiting to OAuth endpoints -- [ ] Add logging for OAuth events -- [ ] Set up monitoring/alerts -- [ ] Update documentation - ---- - -## Alternative: Proactive OAuth Discovery (No 401 Required) - -If your MCP server accepts **anonymous requests** (no API key required), the 401 challenge flow won't trigger automatically. Instead, MCP clients can use **proactive discovery** to find OAuth support. - -### How It Works - -``` -┌──────────────┐ ┌──────────────┐ -│ MCP Client │ │ Context7app │ -│ (Cursor) │ │ │ -└──────┬───────┘ └──────┬───────┘ - │ │ - │ 1. GET /.well-known/oauth-protected-resource - ├────────────────────────────────────────────►│ - │ │ - │ 2. { authorization_servers: [...] } │ - │◄────────────────────────────────────────────┤ - │ │ - │ Client discovers OAuth is available │ - │ Shows "Authorize" button in UI │ - ├──┐ │ - │ │ │ - │◄─┘ │ - │ │ - │ 3. User clicks "Authorize" │ - │ → OAuth flow begins (Phase 2-4) │ - │ │ -``` - -### Flow Comparison - -| Approach | Trigger | Anonymous Support | User Experience | -|----------|---------|-------------------|-----------------| -| **401 Challenge** | Client connects without token | No - returns 401 | Auto-popup on first connect | -| **Proactive Discovery** | Client checks `.well-known` | Yes - works anonymously | User clicks "Authorize" button | - -### When to Use Proactive Discovery - -Use this approach when: -- Your server accepts anonymous requests (current Context7 behavior) -- You want users to optionally authenticate for additional features -- You don't want to break existing anonymous users - -### Implementation - -**No changes needed to the MCP server.** Just implement the `.well-known` endpoints in context7app: - -1. `/.well-known/oauth-protected-resource` - Already in the plan -2. `/.well-known/oauth-authorization-server` - Already in the plan - -MCP clients (Cursor, Claude Code) will: -1. Fetch these endpoints when the server is added -2. Detect that OAuth is available -3. Show an "Authorize" or "Sign in" button in the MCP server listing -4. User clicks when ready → OAuth flow starts -5. After auth, client uses the token for subsequent requests - -### Server Behavior - -The MCP server accepts both anonymous and authenticated requests: - -```typescript -app.all("/mcp", async (req, res) => { - const apiKey = extractApiKey(req); - - if (apiKey) { - // Authenticated request - validate and get user context - const user = await validateApiKey(apiKey); - if (!user) { - return res.status(401).json({ error: "Invalid API key" }); - } - req.user = user; - } - // If no apiKey, continue as anonymous (existing behavior) - - // Handle MCP request... - // Tools can check req.user to provide personalized responses -}); -``` - -### Benefits of This Approach - -1. **No breaking changes** - Anonymous users continue working -2. **User-initiated auth** - Users choose when to authenticate -3. **Graceful upgrade** - Same tools work for both anonymous and authenticated users -4. **Simple implementation** - Only need the `.well-known` endpoints - -### Optional: Enhanced Features for Authenticated Users - -You can provide additional features for authenticated users: - -```typescript -// Example: Personalized documentation recommendations -if (req.user) { - // Return docs based on user's favorite libraries - const favorites = await getUserFavorites(req.user.id); - // ... -} else { - // Return generic results for anonymous users -} -``` - ---- - -## References - -### Specifications -- [MCP Authorization Spec (2025-03-26)](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) -- [RFC 9728 - OAuth Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) -- [RFC 8414 - OAuth Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) -- [RFC 7591 - OAuth Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) -- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636) - -### Reference Implementations -- [Sentry MCP OAuth](https://github.com/getsentry/sentry-mcp/tree/main/packages/mcp-cloudflare/src/server/oauth) - Production implementation -- [Cloudflare MCP GitHub OAuth Demo](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-github-oauth) - Complete example -- [Cloudflare Workers OAuth Provider](https://github.com/cloudflare/workers-oauth-provider) - OAuth 2.1 library - -### Articles -- [Auth0: MCP Specs Update June 2025](https://auth0.com/blog/mcp-specs-update-all-about-auth/) -- [Aaron Parecki: OAuth for MCP](https://aaronparecki.com/2025/04/03/15/oauth-for-model-context-protocol) -- [Cloudflare: MCP Authorization Guide](https://developers.cloudflare.com/agents/model-context-protocol/authorization/) diff --git a/docs/oauth-redis-schema.md b/docs/oauth-redis-schema.md deleted file mode 100644 index 491fca68..00000000 --- a/docs/oauth-redis-schema.md +++ /dev/null @@ -1,183 +0,0 @@ -# OAuth Redis Schema - -All OAuth data stored in Upstash Redis with appropriate TTLs. - -## Key Patterns - -### 1. OAuth Clients (Dynamic Registration) - -``` -Key: oauth:client:{client_id} -Type: Hash -TTL: No expiry (persistent) or 1 year -Fields: - - client_name: string - - client_uri: string - - redirect_uris: JSON array string - - grant_types: JSON array string - - created_at: ISO timestamp -``` - -Example: -``` -HSET oauth:client:550e8400-e29b-41d4-a716-446655440000 - client_name "Cursor" - client_uri "https://cursor.com" - redirect_uris '["http://127.0.0.1:8080/callback"]' - grant_types '["authorization_code"]' - created_at "2025-12-04T10:00:00Z" -``` - -### 2. Authorization Codes (Short-lived) - -``` -Key: oauth:code:{code} -Type: Hash -TTL: 600 seconds (10 minutes) -Fields: - - client_id: string - - user_id: string (Clerk ID) - - redirect_uri: string - - code_challenge: string (PKCE S256) - - scope: string - - resource: string (optional) - - state: string (optional) - - created_at: ISO timestamp -``` - -Example: -``` -HSET oauth:code:SplxlOBeZQQYbYS6WxSbIA - client_id "550e8400-e29b-41d4-a716-446655440000" - user_id "user_2abc123" - redirect_uri "http://127.0.0.1:8080/callback" - code_challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" - scope "mcp:read" - created_at "2025-12-04T10:00:00Z" -EXPIRE oauth:code:SplxlOBeZQQYbYS6WxSbIA 600 -``` - -### 3. Refresh Tokens - -``` -Key: oauth:refresh:{token_hash} -Type: Hash -TTL: 2592000 seconds (30 days) -Fields: - - user_id: string - - client_id: string - - project_id: string - - scope: string - - created_at: ISO timestamp - - last_used_at: ISO timestamp -``` - -Additionally, maintain a set of refresh tokens per user for revocation: -``` -Key: oauth:user_tokens:{user_id} -Type: Set -TTL: No expiry (cleaned up when tokens expire) -Members: token_hash values -``` - -Example: -``` -HSET oauth:refresh:a1b2c3d4e5f6... - user_id "user_2abc123" - client_id "550e8400-e29b-41d4-a716-446655440000" - project_id "proj_xyz" - scope "mcp:read" - created_at "2025-12-04T10:00:00Z" - last_used_at "2025-12-04T10:00:00Z" -EXPIRE oauth:refresh:a1b2c3d4e5f6... 2592000 - -SADD oauth:user_tokens:user_2abc123 "a1b2c3d4e5f6..." -``` - -### 4. Revoked JWTs (for immediate revocation) - -``` -Key: oauth:revoked:{jti} -Type: String -TTL: Same as JWT expiry (3 hours max) -Value: "1" or timestamp -``` - -Example: -``` -SET oauth:revoked:jwt-unique-id "1" -EXPIRE oauth:revoked:jwt-unique-id 10800 -``` - -### 5. JWT Validation Cache - -``` -Key: oauth:jwt_cache:{token_hash_suffix} -Type: Hash -TTL: 300 seconds (5 minutes) -Fields: - - sub: user_id - - project_id: string - - scope: string - - client_id: string - - exp: expiration timestamp -``` - -### 6. Usage Tracking - -``` -Key: usage:{project_id}:{date} -Type: String (counter) -TTL: 172800 seconds (48 hours) -Value: Integer count -``` - -Example: -``` -INCR usage:proj_xyz:2025-12-04 -EXPIRE usage:proj_xyz:2025-12-04 172800 -``` - -### 7. Rate Limiting - -``` -Key: ratelimit:{project_id}:{window} -Type: String (counter) -TTL: Window duration (e.g., 60 seconds for per-minute) -Value: Integer count -``` - -## Operations Summary - -| Operation | Redis Command | TTL | -|-----------|---------------|-----| -| Register client | HSET + (optional EXPIRE) | Persistent or 1 year | -| Store auth code | HSET + EXPIRE | 10 minutes | -| Consume auth code | DEL | - | -| Store refresh token | HSET + EXPIRE + SADD | 30 days | -| Validate refresh token | HGETALL | - | -| Revoke refresh token | DEL + SREM | - | -| Revoke all user tokens | SMEMBERS + DEL | - | -| Revoke JWT | SET + EXPIRE | 3 hours | -| Check JWT revoked | GET | - | -| Cache JWT validation | HSET + EXPIRE | 5 minutes | -| Track usage | INCR + EXPIRE | 48 hours | -| Check rate limit | INCR + EXPIRE | Window duration | - -## Cleanup - -Redis TTLs handle most cleanup automatically. For user token sets: - -```lua --- Periodic cleanup script (optional) --- Remove expired token hashes from user sets -local user_keys = redis.call('KEYS', 'oauth:user_tokens:*') -for _, user_key in ipairs(user_keys) do - local tokens = redis.call('SMEMBERS', user_key) - for _, token_hash in ipairs(tokens) do - if redis.call('EXISTS', 'oauth:refresh:' .. token_hash) == 0 then - redis.call('SREM', user_key, token_hash) - end - end -end -``` diff --git a/docs/sentry-mcp-oauth-reference.md b/docs/sentry-mcp-oauth-reference.md deleted file mode 100644 index f105bfd9..00000000 --- a/docs/sentry-mcp-oauth-reference.md +++ /dev/null @@ -1,755 +0,0 @@ -# Sentry MCP OAuth Implementation Reference - -This document contains the actual source code from the Sentry MCP server's OAuth implementation for reference. - -**Source Repository**: [getsentry/sentry-mcp](https://github.com/getsentry/sentry-mcp) -**OAuth Directory**: [packages/mcp-cloudflare/src/server/oauth](https://github.com/getsentry/sentry-mcp/tree/main/packages/mcp-cloudflare/src/server/oauth) - ---- - -## File Structure - -``` -packages/mcp-cloudflare/src/server/oauth/ -├── index.ts # Main export (re-exports routes and helpers) -├── constants.ts # OAuth URLs and Zod schemas -├── state.ts # HMAC-signed state management -├── helpers.ts # Token exchange and refresh logic -└── routes/ - ├── index.ts # Hono router combining authorize + callback - ├── authorize.ts # Authorization endpoint - └── callback.ts # OAuth callback handler -``` - ---- - -## constants.ts - -Defines Sentry OAuth endpoints and token response schema. - -```typescript -import { z } from "zod"; - -// Sentry OAuth endpoints -export const SENTRY_AUTH_URL = "/oauth/authorize/"; -export const SENTRY_TOKEN_URL = "/oauth/token/"; - -export const TokenResponseSchema = z.object({ - access_token: z.string(), - refresh_token: z.string(), - token_type: z.string(), // should be "bearer" - expires_in: z.number(), - expires_at: z.string().datetime(), - user: z.object({ - email: z.string().email(), - id: z.string(), - name: z.string().nullable(), - }), - scope: z.string(), -}); -``` - ---- - -## state.ts - -HMAC-signed stateless OAuth state management using Web Crypto API. - -```typescript -import { z } from "zod"; -import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; - -// Schema for OAuth state payload -const OAuthStateSchema = z.object({ - req: z.any(), // The downstream auth request data - iat: z.number(), // Issued at timestamp - exp: z.number(), // Expiration timestamp -}); - -export type OAuthState = z.infer; - -// State token TTL (10 minutes) -const STATE_TTL_MS = 10 * 60 * 1000; - -/** - * Import a secret string as a CryptoKey for HMAC-SHA256 - */ -async function importKey(secret: string): Promise { - const encoder = new TextEncoder(); - return crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"] - ); -} - -/** - * Sign data and return hex-encoded signature - */ -async function signHex(data: string, secret: string): Promise { - const key = await importKey(secret); - const encoder = new TextEncoder(); - const signatureBuffer = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); - return Array.from(new Uint8Array(signatureBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** - * Verify a hex-encoded signature - */ -async function verifyHex(signatureHex: string, data: string, secret: string): Promise { - const key = await importKey(secret); - const encoder = new TextEncoder(); - try { - const signatureBytes = new Uint8Array( - signatureHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)) - ); - return await crypto.subtle.verify("HMAC", key, signatureBytes.buffer, encoder.encode(data)); - } catch { - return false; - } -} - -/** - * Create a signed state token - * Format: ${signatureHex}.${base64(payload)} - */ -export async function signState(req: AuthRequest, secret: string): Promise { - const now = Date.now(); - const payload: OAuthState = { - req, - iat: now, - exp: now + STATE_TTL_MS, - }; - const data = JSON.stringify(payload); - const signature = await signHex(data, secret); - return `${signature}.${btoa(data)}`; -} - -/** - * Verify and parse a signed state token - */ -export async function verifyAndParseState(token: string, secret: string): Promise { - const [signatureHex, base64Payload] = token.split("."); - - if (!signatureHex || !base64Payload) { - throw new Error("Invalid state format"); - } - - const data = atob(base64Payload); - - const isValid = await verifyHex(signatureHex, data, secret); - if (!isValid) { - throw new Error("Invalid state signature"); - } - - const parsed = OAuthStateSchema.parse(JSON.parse(data)); - - if (parsed.exp < Date.now()) { - throw new Error("State expired"); - } - - return parsed; -} -``` - ---- - -## helpers.ts - -Token exchange and refresh logic with upstream Sentry OAuth. - -```typescript -import { z } from "zod"; -import { TokenResponseSchema, SENTRY_TOKEN_URL } from "./constants"; -import { logError, logWarn } from "@sentry/mcp-core/telem/logging"; - -type TokenResponse = z.infer; - -/** - * Constructs an authorization URL for Sentry OAuth - */ -export function getUpstreamAuthorizeUrl(options: { - upstream_url: string; - client_id: string; - redirect_uri: string; - scope: string; - state?: string; -}): string { - const url = new URL(options.upstream_url); - url.searchParams.set("client_id", options.client_id); - url.searchParams.set("redirect_uri", options.redirect_uri); - url.searchParams.set("scope", options.scope); - url.searchParams.set("response_type", "code"); - if (options.state) { - url.searchParams.set("state", options.state); - } - return url.href; -} - -/** - * Exchange authorization code for access token - */ -export async function exchangeCodeForAccessToken(options: { - upstream_url: string; - client_id: string; - client_secret: string; - code: string | undefined; - redirect_uri: string; -}): Promise<[TokenResponse, null] | [null, Response]> { - const { upstream_url, client_id, client_secret, code, redirect_uri } = options; - - if (!code) { - return [null, new Response("Missing authorization code", { status: 400 })]; - } - - try { - const response = await fetch(upstream_url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id, - client_secret, - code, - redirect_uri, - }).toString(), - }); - - if (!response.ok) { - const errorText = await response.text(); - logError("Failed to exchange code for token", { - loggerScope: ["cloudflare", "oauth", "token"], - extra: { status: response.status, error: errorText }, - }); - return [null, new Response("Failed to exchange authorization code", { status: 500 })]; - } - - const data = await response.json(); - const parsed = TokenResponseSchema.parse(data); - return [parsed, null]; - } catch (error) { - logError("Token exchange error", { - loggerScope: ["cloudflare", "oauth", "token"], - extra: { error: String(error) }, - }); - return [null, new Response("Token exchange failed", { status: 500 })]; - } -} - -/** - * Refresh an access token using a refresh token - */ -export async function refreshAccessToken(options: { - upstream_url: string; - client_id: string; - client_secret: string; - refresh_token: string; -}): Promise<[TokenResponse, null] | [null, Response]> { - const { upstream_url, client_id, client_secret, refresh_token } = options; - - try { - const response = await fetch(upstream_url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id, - client_secret, - refresh_token, - }).toString(), - }); - - if (!response.ok) { - const errorText = await response.text(); - logError("Failed to refresh token", { - loggerScope: ["cloudflare", "oauth", "refresh"], - extra: { status: response.status, error: errorText }, - }); - return [null, new Response("Failed to refresh token", { status: 500 })]; - } - - const data = await response.json(); - const parsed = TokenResponseSchema.parse(data); - return [parsed, null]; - } catch (error) { - logError("Token refresh error", { - loggerScope: ["cloudflare", "oauth", "refresh"], - extra: { error: String(error) }, - }); - return [null, new Response("Token refresh failed", { status: 500 })]; - } -} - -/** - * Token exchange callback for the OAuth provider - * Called when tokens need to be refreshed - */ -export const tokenExchangeCallback = async (options: { - grantType: string; - props: { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: number; - [key: string]: unknown; - }; - env: { - SENTRY_HOST: string; - SENTRY_CLIENT_ID: string; - SENTRY_CLIENT_SECRET: string; - }; -}) => { - if (options.grantType === "refresh_token") { - const cachedExpiry = options.props.accessTokenExpiresAt; - - // Skip refresh if token still valid (with 2 minute buffer) - if (cachedExpiry && cachedExpiry > Date.now() + 2 * 60 * 1000) { - return {}; // Use cached token - } - - // Refresh the upstream token - const [payload, err] = await refreshAccessToken({ - upstream_url: new URL( - SENTRY_TOKEN_URL, - `https://${options.env.SENTRY_HOST || "sentry.io"}` - ).href, - client_id: options.env.SENTRY_CLIENT_ID, - client_secret: options.env.SENTRY_CLIENT_SECRET, - refresh_token: options.props.refreshToken, - }); - - if (err) { - logWarn("Failed to refresh upstream token", { - loggerScope: ["cloudflare", "oauth", "callback"], - }); - return {}; - } - - return { - accessTokenTTL: payload.expires_in, - newProps: { - ...options.props, - accessToken: payload.access_token, - refreshToken: payload.refresh_token, - accessTokenExpiresAt: Date.now() + payload.expires_in * 1000, - }, - }; - } - - return {}; -}; -``` - ---- - -## routes/index.ts - -Hono router combining authorize and callback routes. - -```typescript -import { Hono } from "hono"; -import type { Env } from "../../types"; -import authorizeApp from "./authorize"; -import callbackApp from "./callback"; - -// Compose and export the main OAuth Hono app -export default new Hono<{ Bindings: Env }>() - .route("/authorize", authorizeApp) - .route("/callback", callbackApp); -``` - ---- - -## routes/authorize.ts - -Authorization endpoint - shows approval dialog and redirects to Sentry. - -```typescript -import { Hono } from "hono"; -import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; -import type { Env } from "../../types"; -import { SENTRY_AUTH_URL } from "../constants"; -import { signState } from "../state"; -import { getUpstreamAuthorizeUrl } from "../helpers"; -import { renderApprovalDialog, addApprovedClient } from "../../lib/approval-dialog"; -import { logWarn } from "@sentry/mcp-core/telem/logging"; - -export default new Hono<{ Bindings: Env }>() - // GET /authorize - Show approval dialog - .get("/", async (c) => { - // Parse the OAuth request from the MCP client - const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); - - if (!oauthReqInfo.clientId) { - return c.text("Missing client_id", 400); - } - - // Validate redirect URI - if (!oauthReqInfo.redirectUri) { - logWarn("Missing redirect_uri in authorization request", { - loggerScope: ["cloudflare", "oauth", "authorize"], - extra: { clientId: oauthReqInfo.clientId }, - }); - return c.text("Missing redirect_uri", 400); - } - - // Look up client metadata - const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); - - // Validate redirect URI is registered for this client - if (!client?.redirectUris?.includes(oauthReqInfo.redirectUri)) { - logWarn("Redirect URI not registered for client", { - loggerScope: ["cloudflare", "oauth", "authorize"], - extra: { - clientId: oauthReqInfo.clientId, - redirectUri: oauthReqInfo.redirectUri, - registeredUris: client?.redirectUris, - }, - }); - return c.text("Invalid redirect_uri", 400); - } - - // Render approval dialog for user consent - // Note: We always show the dialog to allow choosing permissions - return renderApprovalDialog(c.req.raw, { - client, - oauthReqInfo, - serverName: "Sentry MCP", - serverDescription: "Connect your Sentry account to enable AI-powered issue management.", - }); - }) - - // POST /authorize - Handle approval form submission - .post("/", async (c) => { - const formData = await c.req.raw.formData(); - - // Extract state from form (contains original OAuth request) - const encodedState = formData.get("state"); - if (!encodedState || typeof encodedState !== "string") { - return c.text("Missing state in form data", 400); - } - - let state: { oauthReqInfo?: AuthRequest; skills?: string[] }; - try { - state = JSON.parse(atob(encodedState)); - } catch { - return c.text("Invalid state data", 400); - } - - const oauthReqInfo = state.oauthReqInfo; - if (!oauthReqInfo?.clientId) { - return c.text("Invalid OAuth request in state", 400); - } - - // Validate redirect URI again - const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); - if (!client?.redirectUris?.includes(oauthReqInfo.redirectUri!)) { - return c.text("Invalid redirect_uri", 400); - } - - // Add client to user's approved list (stored in encrypted cookie) - const approvedClientCookie = await addApprovedClient( - c.req.raw, - oauthReqInfo.clientId, - c.env.COOKIE_SECRET - ); - - // Create signed state token for the callback - const stateToken = await signState( - { ...oauthReqInfo, skills: state.skills } as AuthRequest, - c.env.COOKIE_SECRET - ); - - // Build callback URL for Sentry to redirect back to - const callbackUrl = new URL("/oauth/callback", c.req.url).href; - - // Redirect to Sentry's authorization endpoint - const sentryAuthUrl = getUpstreamAuthorizeUrl({ - upstream_url: new URL(SENTRY_AUTH_URL, `https://${c.env.SENTRY_HOST || "sentry.io"}`).href, - client_id: c.env.SENTRY_CLIENT_ID, - redirect_uri: callbackUrl, - scope: "openid profile email", - state: stateToken, - }); - - return new Response(null, { - status: 302, - headers: { - Location: sentryAuthUrl, - "Set-Cookie": approvedClientCookie, - }, - }); - }); -``` - ---- - -## routes/callback.ts - -OAuth callback handler - exchanges code for token and completes authorization. - -```typescript -import { Hono } from "hono"; -import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; -import type { Env, WorkerProps } from "../../types"; -import { SENTRY_TOKEN_URL } from "../constants"; -import { exchangeCodeForAccessToken } from "../helpers"; -import { verifyAndParseState, type OAuthState } from "../state"; -import { clientIdAlreadyApproved } from "../../lib/approval-dialog"; -import { logWarn } from "@sentry/mcp-core/telem/logging"; -import { parseSkills, getScopesForSkills } from "@sentry/mcp-core/skills"; - -interface AuthRequestWithSkills extends AuthRequest { - skills?: unknown; -} - -export default new Hono<{ Bindings: Env }>().get("/", async (c) => { - // 1. Verify and parse the state token - let parsedState: OAuthState; - try { - const rawState = c.req.query("state") ?? ""; - parsedState = await verifyAndParseState(rawState, c.env.COOKIE_SECRET); - } catch (err) { - logWarn("Invalid state received on OAuth callback", { - loggerScope: ["cloudflare", "oauth", "callback"], - extra: { error: String(err) }, - }); - return c.text("Invalid state", 400); - } - - const oauthReqInfo = parsedState.req as unknown as AuthRequestWithSkills; - - // 2. Validate required fields - if (!oauthReqInfo.clientId) { - logWarn("Missing clientId in OAuth state", { - loggerScope: ["cloudflare", "oauth", "callback"], - }); - return c.text("Invalid state", 400); - } - - if (!oauthReqInfo.redirectUri) { - logWarn("Missing redirectUri in OAuth state", { - loggerScope: ["cloudflare", "oauth", "callback"], - }); - return c.text("Authorization failed: No redirect URL provided", 400); - } - - // 3. Validate redirect URI format - try { - new URL(oauthReqInfo.redirectUri); - } catch { - logWarn(`Invalid redirectUri in OAuth state: ${oauthReqInfo.redirectUri}`, { - loggerScope: ["cloudflare", "oauth", "callback"], - }); - return c.text("Authorization failed: Invalid redirect URL", 400); - } - - // 4. Verify client was approved by user - const isApproved = await clientIdAlreadyApproved( - c.req.raw, - oauthReqInfo.clientId, - c.env.COOKIE_SECRET - ); - if (!isApproved) { - return c.text("Authorization failed: Client not approved", 403); - } - - // 5. Validate redirect URI is registered for client - try { - const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); - const uriIsAllowed = - Array.isArray(client?.redirectUris) && - client.redirectUris.includes(oauthReqInfo.redirectUri); - if (!uriIsAllowed) { - logWarn("Redirect URI not registered for client on callback", { - loggerScope: ["cloudflare", "oauth", "callback"], - extra: { - clientId: oauthReqInfo.clientId, - redirectUri: oauthReqInfo.redirectUri, - }, - }); - return c.text("Authorization failed: Invalid redirect URL", 400); - } - } catch (lookupErr) { - logWarn("Failed to validate client redirect URI on callback", { - loggerScope: ["cloudflare", "oauth", "callback"], - extra: { error: String(lookupErr) }, - }); - return c.text("Authorization failed: Invalid redirect URL", 400); - } - - // 6. Exchange authorization code for access token with Sentry - const sentryCallbackUrl = new URL("/oauth/callback", c.req.url).href; - const [payload, errResponse] = await exchangeCodeForAccessToken({ - upstream_url: new URL( - SENTRY_TOKEN_URL, - `https://${c.env.SENTRY_HOST || "sentry.io"}` - ).href, - client_id: c.env.SENTRY_CLIENT_ID, - client_secret: c.env.SENTRY_CLIENT_SECRET, - code: c.req.query("code"), - redirect_uri: sentryCallbackUrl, - }); - - if (errResponse) { - return errResponse; - } - - // 7. Parse and validate requested skills/scopes - const { valid: validSkills, invalid: invalidSkills } = parseSkills(oauthReqInfo.skills); - - if (invalidSkills.length > 0) { - logWarn("OAuth callback received invalid skill names", { - loggerScope: ["cloudflare", "oauth", "callback"], - extra: { - clientId: oauthReqInfo.clientId, - invalidSkills, - }, - }); - } - - if (validSkills.size === 0) { - logWarn("OAuth authorization rejected: No valid skills selected", { - loggerScope: ["cloudflare", "oauth", "callback"], - extra: { - clientId: oauthReqInfo.clientId, - receivedSkills: oauthReqInfo.skills, - }, - }); - return c.text( - "Authorization failed: You must select at least one valid permission to continue.", - 400 - ); - } - - const grantedScopes = await getScopesForSkills(validSkills); - const grantedSkills = Array.from(validSkills); - - // 8. Complete authorization - this issues the MCP access token - const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ - request: oauthReqInfo, - userId: payload.user.id, - metadata: { - label: payload.user.name, - }, - scope: oauthReqInfo.scope, - props: { - // These props are encrypted and stored with the token - id: payload.user.id, - accessToken: payload.access_token, - refreshToken: payload.refresh_token, - accessTokenExpiresAt: Date.now() + payload.expires_in * 1000, - clientId: oauthReqInfo.clientId, - scope: oauthReqInfo.scope.join(" "), - grantedScopes: Array.from(grantedScopes), - grantedSkills, - } as WorkerProps, - }); - - // 9. Redirect back to MCP client with the access token - return c.redirect(redirectTo); -}); -``` - ---- - -## Main Server Setup (app.ts) - -How the OAuth routes are integrated into the main server. - -```typescript -import { Hono } from "hono"; -import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; -import type { Env } from "./types"; -import oauthApp from "./oauth"; -import { tokenExchangeCallback } from "./oauth/helpers"; -import { McpServer } from "./mcp-server"; - -// Create the main Hono app -const app = new Hono<{ Bindings: Env }>(); - -// Mount OAuth routes -app.route("/oauth", oauthApp); - -// Health check -app.get("/health", (c) => c.json({ status: "ok" })); - -// Export with OAuth provider wrapper -export default new OAuthProvider({ - // API routes that require authentication - apiRoute: ["/mcp", "/sse"], - apiHandler: McpServer, - - // Default handler for non-API routes (OAuth, health, etc.) - defaultHandler: app, - - // OAuth endpoint configuration - authorizeEndpoint: "/oauth/authorize", - tokenEndpoint: "/oauth/token", - clientRegistrationEndpoint: "/oauth/register", - - // Supported scopes - scopesSupported: ["openid", "profile", "email"], - - // Token refresh callback - tokenExchangeCallback, -}); -``` - ---- - -## Environment Variables Required - -```bash -# Sentry OAuth App credentials -SENTRY_CLIENT_ID=your-client-id -SENTRY_CLIENT_SECRET=your-client-secret -SENTRY_HOST=sentry.io # or your self-hosted instance - -# Cookie signing secret (32+ random characters) -COOKIE_SECRET=your-random-secret-key - -# Cloudflare KV namespace for token storage -# (configured in wrangler.toml) -``` - ---- - -## Key Takeaways for Context7 - -1. **State Management**: Sentry uses HMAC-signed stateless tokens instead of storing state in a database. This is simpler but requires a `COOKIE_SECRET`. - -2. **Upstream OAuth**: Sentry acts as an OAuth proxy - it authenticates with Sentry's OAuth, then issues its own MCP tokens. Context7 would authenticate with Clerk, then issue API keys. - -3. **Cookie-based Approval**: Users' approved clients are stored in an encrypted cookie, avoiding database lookups on repeat authorizations. - -4. **Token Refresh**: The `tokenExchangeCallback` handles refreshing upstream tokens when they expire. - -5. **Cloudflare Workers OAuth Provider**: Sentry uses `@cloudflare/workers-oauth-provider` which handles most of the OAuth protocol. For Next.js, you'll implement these endpoints manually. - ---- - -## Adapting for Context7 (Next.js) - -The main differences for your implementation: - -| Sentry (Cloudflare) | Context7 (Next.js) | -|---------------------|---------------------| -| `@cloudflare/workers-oauth-provider` | Manual route handlers | -| Hono framework | Next.js App Router | -| Cloudflare KV for storage | Supabase for storage | -| Sentry OAuth upstream | Clerk for user auth | -| Issues MCP tokens | Issues API keys | -| `COOKIE_SECRET` for state | Can use Supabase for state | - -Your implementation will be simpler because: -- No upstream OAuth (Clerk handles user auth) -- Return API keys instead of MCP tokens -- Existing API key validation logic diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8425abb8..6c7eb714 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -48,6 +48,7 @@ "@types/express": "^5.0.4", "commander": "^14.0.0", "express": "^5.1.0", + "jose": "^6.1.3", "undici": "^6.6.3", "zod": "^3.24.2" }, diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 63b6dcc4..aeeeb079 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { searchLibraries, fetchLibraryDocumentation } from "./lib/api.js"; import { formatSearchResults } from "./lib/utils.js"; import { SearchResponse, DOCUMENTATION_MODES } from "./lib/types.js"; +import { isJWT, validateJWT } from "./lib/jwt.js"; import express from "express"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { Command } from "commander"; @@ -346,6 +347,21 @@ async function main() { }); } + // If auth required and token is a JWT, validate it locally + if (requireAuth && apiKey && isJWT(apiKey)) { + const validationResult = await validateJWT(apiKey); + if (!validationResult.valid) { + return res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: validationResult.error || "Invalid token. Please re-authenticate.", + }, + id: null, + }); + } + } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, @@ -389,7 +405,6 @@ async function main() { // Use environment variables or defaults // For local testing: AUTH_SERVER_URL=http://localhost:3000 // For production: AUTH_SERVER_URL=https://context7.com - console.log("WELL KNOWN OAUTH PROTECTED RESOURCE", _req.body); const authServerUrl = process.env.AUTH_SERVER_URL || "http://localhost:3000"; const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 1b8174ca..01499229 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -3,7 +3,7 @@ import { generateHeaders } from "./encryption.js"; import { ProxyAgent, setGlobalDispatcher } from "undici"; import { DocumentationMode, DOCUMENTATION_MODES } from "./types.js"; -const CONTEXT7_API_BASE_URL = "https://context7.com/api"; +const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_BASE_URL || "https://context7.com/api"; const DEFAULT_TYPE = "txt"; /** diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts new file mode 100644 index 00000000..99603541 --- /dev/null +++ b/packages/mcp/src/lib/jwt.ts @@ -0,0 +1,66 @@ +import * as jose from "jose"; + +const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || "https://context7.com"; +const JWKS_URL = `${AUTH_SERVER_URL}/.well-known/jwks.json`; + +// we cache the jwks to avoid fetching it on every request +let jwksCache: jose.JWTVerifyGetKey | null = null; +let jwksCacheTime = 0; +const JWKS_CACHE_TTL = 60 * 60 * 1000; // 1 hour + +async function getJWKS(): Promise { + const now = Date.now(); + if (jwksCache && now - jwksCacheTime < JWKS_CACHE_TTL) { + return jwksCache; + } + + jwksCache = jose.createRemoteJWKSet(new URL(JWKS_URL)); + jwksCacheTime = now; + return jwksCache; +} + +export interface JWTValidationResult { + valid: boolean; + userId?: string; + clientId?: string; + scope?: string; + error?: string; +} + +export function isJWT(token: string): boolean { + const parts = token.split("."); + return parts.length === 3; +} + +export async function validateJWT(token: string): Promise { + if (!isJWT(token)) { + return { valid: false, error: "Not a valid JWT format" }; + } + + try { + const jwks = await getJWKS(); + const { payload } = await jose.jwtVerify(token, jwks, { + issuer: AUTH_SERVER_URL, + }); + + const result = { + valid: true, + userId: payload.sub, + clientId: payload.client_id as string, + scope: payload.scope as string, + }; + + return result; + } catch (error) { + if (error instanceof jose.errors.JWTExpired) { + return { valid: false, error: "Token expired" }; + } + if (error instanceof jose.errors.JWTClaimValidationFailed) { + return { valid: false, error: "Invalid token claims" }; + } + if (error instanceof jose.errors.JWSSignatureVerificationFailed) { + return { valid: false, error: "Invalid signature" }; + } + return { valid: false, error: "Invalid token" }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58cde648..03b7b4ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 + jose: + specifier: ^6.1.3 + version: 6.1.3 undici: specifier: ^6.6.3 version: 6.22.0 @@ -1359,6 +1362,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3279,6 +3285,8 @@ snapshots: isexe@2.0.0: {} + jose@6.1.3: {} + joycon@3.1.1: {} js-yaml@3.14.2: From e70d61da3b6abc51c4f137c87f6f70e050ba494f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 11:12:50 +0300 Subject: [PATCH 5/7] cleanup for review --- packages/mcp/src/index.ts | 47 +++++++++++++++++-------------------- packages/mcp/src/lib/api.ts | 2 +- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index aeeeb079..a4ba287f 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -329,37 +329,37 @@ async function main() { const apiKey = extractApiKey(req); const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; - // Always add WWW-Authenticate header with OAuth discovery info + // OAuth discovery info header, used by MCP clients to discover the authorization server res.set( "WWW-Authenticate", `Bearer resource_metadata="${resourceUrl}/.well-known/oauth-protected-resource"` ); - // If auth required and no API key, return 401 to trigger OAuth flow - if (requireAuth && !apiKey) { - return res.status(401).json({ - jsonrpc: "2.0", - error: { - code: -32001, - message: "Authentication required. Please authenticate to use this MCP server.", - }, - id: null, - }); - } - - // If auth required and token is a JWT, validate it locally - if (requireAuth && apiKey && isJWT(apiKey)) { - const validationResult = await validateJWT(apiKey); - if (!validationResult.valid) { + if (requireAuth) { + if (!apiKey) { return res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, - message: validationResult.error || "Invalid token. Please re-authenticate.", + message: "Authentication required. Please authenticate to use this MCP server.", }, id: null, }); } + + if (isJWT(apiKey)) { + const validationResult = await validateJWT(apiKey); + if (!validationResult.valid) { + return res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: validationResult.error || "Invalid token. Please re-authenticate.", + }, + id: null, + }); + } + } } const transport = new StreamableHTTPServerTransport({ @@ -390,7 +390,7 @@ async function main() { // Anonymous access endpoint - no authentication required app.all("/mcp", (req, res) => handleMcpRequest(req, res, false)); - // OAuth-protected endpoint - requires authentication (returns 401 if no API key) + // OAuth-protected endpoint - requires authentication app.all("/mcp/oauth", (req, res) => handleMcpRequest(req, res, true)); app.get("/ping", (_req: express.Request, res: express.Response) => { @@ -398,15 +398,12 @@ async function main() { }); // OAuth 2.0 Protected Resource Metadata (RFC 9728) - // This enables MCP clients to discover the authorization server + // Used by MCP clients to discover the authorization server app.get( "/.well-known/oauth-protected-resource", (_req: express.Request, res: express.Response) => { - // Use environment variables or defaults - // For local testing: AUTH_SERVER_URL=http://localhost:3000 - // For production: AUTH_SERVER_URL=https://context7.com - const authServerUrl = process.env.AUTH_SERVER_URL || "http://localhost:3000"; - const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; + const authServerUrl = process.env.AUTH_SERVER_URL || "https://context7.com"; + const resourceUrl = process.env.RESOURCE_URL || `https://mcp.context7.com/mcp`; res.json({ resource: resourceUrl, diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 01499229..1b8174ca 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -3,7 +3,7 @@ import { generateHeaders } from "./encryption.js"; import { ProxyAgent, setGlobalDispatcher } from "undici"; import { DocumentationMode, DOCUMENTATION_MODES } from "./types.js"; -const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_BASE_URL || "https://context7.com/api"; +const CONTEXT7_API_BASE_URL = "https://context7.com/api"; const DEFAULT_TYPE = "txt"; /** From eef2d53ba1190093c0da6127cad81c22ce7d0770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 11:19:13 +0300 Subject: [PATCH 6/7] ci: add changeset --- .changeset/yellow-chairs-study.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/yellow-chairs-study.md diff --git a/.changeset/yellow-chairs-study.md b/.changeset/yellow-chairs-study.md new file mode 100644 index 00000000..abf60976 --- /dev/null +++ b/.changeset/yellow-chairs-study.md @@ -0,0 +1,10 @@ +--- +"@upstash/context7-mcp": minor +--- + +Add OAuth 2.0 authentication support for MCP server + +- Add new `/mcp/oauth` endpoint requiring JWT authentication +- Implement JWT validation against authorization server JWKS +- Add OAuth Protected Resource Metadata endpoint (RFC 9728) at `/.well-known/oauth-protected-resource` +- Include `WWW-Authenticate` header for OAuth discovery From 47a627535730ef60c81682e64a8cd48a30c2703f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 12:15:00 +0300 Subject: [PATCH 7/7] feat: add audience --- packages/mcp/src/index.ts | 5 +++-- packages/mcp/src/lib/jwt.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index a4ba287f..a32dcc36 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -328,11 +328,12 @@ async function main() { const clientIp = getClientIp(req); const apiKey = extractApiKey(req); const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; + const baseUrl = new URL(resourceUrl).origin; // OAuth discovery info header, used by MCP clients to discover the authorization server res.set( "WWW-Authenticate", - `Bearer resource_metadata="${resourceUrl}/.well-known/oauth-protected-resource"` + `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` ); if (requireAuth) { @@ -403,7 +404,7 @@ async function main() { "/.well-known/oauth-protected-resource", (_req: express.Request, res: express.Response) => { const authServerUrl = process.env.AUTH_SERVER_URL || "https://context7.com"; - const resourceUrl = process.env.RESOURCE_URL || `https://mcp.context7.com/mcp`; + const resourceUrl = process.env.RESOURCE_URL || "https://mcp.context7.com"; res.json({ resource: resourceUrl, diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts index 99603541..f11ed452 100644 --- a/packages/mcp/src/lib/jwt.ts +++ b/packages/mcp/src/lib/jwt.ts @@ -3,6 +3,8 @@ import * as jose from "jose"; const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || "https://context7.com"; const JWKS_URL = `${AUTH_SERVER_URL}/.well-known/jwks.json`; +const RESOURCE_URL = (process.env.RESOURCE_URL || "https://mcp.context7.com").replace(/\/$/, ""); + // we cache the jwks to avoid fetching it on every request let jwksCache: jose.JWTVerifyGetKey | null = null; let jwksCacheTime = 0; @@ -41,6 +43,7 @@ export async function validateJWT(token: string): Promise { const jwks = await getJWKS(); const { payload } = await jose.jwtVerify(token, jwks, { issuer: AUTH_SERVER_URL, + audience: RESOURCE_URL, }); const result = {