From c04b9b0e090aa5ec607dbb4a0d91d3cc893a51a5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 4 Apr 2026 09:09:30 +0200 Subject: [PATCH] docs: add MCP OAuth 2.1 implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../superpowers/plans/2026-04-04-mcp-oauth.md | 1372 +++++++++++++++++ 1 file changed, 1372 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-mcp-oauth.md diff --git a/docs/superpowers/plans/2026-04-04-mcp-oauth.md b/docs/superpowers/plans/2026-04-04-mcp-oauth.md new file mode 100644 index 0000000..b20d643 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-mcp-oauth.md @@ -0,0 +1,1372 @@ +# MCP OAuth 2.1 Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OAuth 2.1 Authorization Code + PKCE support to the MCP server so it works with Claude mobile app and claude.ai remote MCP connectors, while keeping existing API key auth working. + +**Architecture:** Four OAuth endpoints (`/.well-known/oauth-authorization-server`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) as Hono routes, backed by an `oauth.service.ts` with 3 new SQLite tables. The MCP auth middleware gains Bearer token support alongside existing API key auth. The authorize flow reuses the existing `verifyPassword()` function and renders a server-side HTML login form. + +**Tech Stack:** Hono, Drizzle ORM (SQLite), Node.js crypto, existing auth.service.ts + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/db/schema.ts` | Modify | Add `oauthClients`, `oauthCodes`, `oauthTokens` tables | +| `src/server/services/oauth.service.ts` | Create | OAuth business logic: client registration, PKCE, code exchange, token management | +| `src/server/routes/oauth.ts` | Create | Hono routes for all OAuth endpoints + authorize HTML form | +| `src/server/index.ts` | Modify | Mount `/.well-known/oauth-authorization-server` and `/oauth` routes | +| `src/server/mcp/index.ts` | Modify | Add Bearer token auth alongside API key auth | +| `tests/services/oauth.service.test.ts` | Create | Service-level tests for OAuth logic | +| `tests/routes/oauth.test.ts` | Create | Route-level integration tests for OAuth endpoints | + +--- + +### Task 1: Schema — Add OAuth tables + +**Files:** +- Modify: `src/db/schema.ts` + +- [ ] **Step 1: Add oauthClients table to schema** + +Add after the `apiKeys` table in `src/db/schema.ts`: + +```typescript +export const oauthClients = sqliteTable("oauth_clients", { + id: integer("id").primaryKey({ autoIncrement: true }), + clientId: text("client_id").notNull().unique(), + clientName: text("client_name"), + redirectUris: text("redirect_uris").notNull(), // JSON array + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const oauthCodes = sqliteTable("oauth_codes", { + id: integer("id").primaryKey({ autoIncrement: true }), + code: text("code").notNull().unique(), + clientId: text("client_id").notNull(), + codeChallenge: text("code_challenge").notNull(), + codeChallengeMethod: text("code_challenge_method").notNull().default("S256"), + redirectUri: text("redirect_uri").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + used: integer("used").notNull().default(0), +}); + +export const oauthTokens = sqliteTable("oauth_tokens", { + id: integer("id").primaryKey({ autoIncrement: true }), + accessTokenHash: text("access_token_hash").notNull().unique(), + refreshTokenHash: text("refresh_token_hash").notNull().unique(), + clientId: text("client_id").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry + refreshExpiresAt: integer("refresh_expires_at", { mode: "timestamp" }).notNull(), // refresh token expiry + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); +``` + +- [ ] **Step 2: Generate migration** + +```bash +bun run db:generate +``` + +Expected: A new migration SQL file in `drizzle/` creating the three tables. + +- [ ] **Step 3: Apply migration** + +```bash +bun run db:push +``` + +Expected: Migration applies cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add src/db/schema.ts drizzle/ +git commit -m "feat: add OAuth tables (clients, codes, tokens) to schema" +``` + +--- + +### Task 2: OAuth service — Client registration and PKCE utilities + +**Files:** +- Create: `src/server/services/oauth.service.ts` +- Create: `tests/services/oauth.service.test.ts` + +- [ ] **Step 1: Write failing tests for client registration and PKCE** + +Create `tests/services/oauth.service.test.ts`: + +```typescript +import { beforeEach, describe, expect, it } from "bun:test"; +import { createTestDb } from "../helpers/db.ts"; +import { + registerClient, + getClient, + createAuthorizationCode, + exchangeCode, + verifyAccessToken, + refreshAccessToken, +} from "../../src/server/services/oauth.service.ts"; + +describe("OAuth Service", () => { + let db: ReturnType; + + beforeEach(() => { + db = createTestDb(); + }); + + describe("Client Registration", () => { + it("registers a client and returns clientId", () => { + const result = registerClient(db, "Test App", ["http://localhost:3000/callback"]); + + expect(result.clientId).toBeDefined(); + expect(typeof result.clientId).toBe("string"); + expect(result.clientId.length).toBeGreaterThan(0); + }); + + it("getClient returns registered client", () => { + const { clientId } = registerClient(db, "Test App", ["http://localhost:3000/callback"]); + const client = getClient(db, clientId); + + expect(client).not.toBeNull(); + expect(client!.clientName).toBe("Test App"); + expect(JSON.parse(client!.redirectUris)).toEqual(["http://localhost:3000/callback"]); + }); + + it("getClient returns null for unknown client", () => { + const client = getClient(db, "nonexistent"); + expect(client).toBeNull(); + }); + }); + + describe("Authorization Code + PKCE", () => { + // Helper to generate a valid PKCE pair + function generatePkce() { + const { createHash, randomBytes } = require("node:crypto"); + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; + } + + it("creates an authorization code", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { challenge } = generatePkce(); + + const result = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + + expect(result.code).toBeDefined(); + expect(result.code.length).toBeGreaterThan(0); + }); + + it("exchanges code for tokens with valid PKCE verifier", async () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + const tokens = exchangeCode(db, code, verifier, clientId, "http://localhost/cb"); + + expect(tokens).not.toBeNull(); + expect(tokens!.accessToken).toBeDefined(); + expect(tokens!.refreshToken).toBeDefined(); + expect(tokens!.expiresIn).toBe(3600); + }); + + it("rejects code exchange with wrong PKCE verifier", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { challenge } = generatePkce(); + + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + const tokens = exchangeCode(db, code, "wrong-verifier", clientId, "http://localhost/cb"); + + expect(tokens).toBeNull(); + }); + + it("rejects code exchange with wrong redirect_uri", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + const tokens = exchangeCode(db, code, verifier, clientId, "http://other.com/cb"); + + expect(tokens).toBeNull(); + }); + + it("rejects replayed code (single use)", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + exchangeCode(db, code, verifier, clientId, "http://localhost/cb"); + const second = exchangeCode(db, code, verifier, clientId, "http://localhost/cb"); + + expect(second).toBeNull(); + }); + }); + + describe("Token Verification", () => { + function generatePkce() { + const { createHash, randomBytes } = require("node:crypto"); + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; + } + + it("verifies a valid access token", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { verifier, challenge } = generatePkce(); + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + const tokens = exchangeCode(db, code, verifier, clientId, "http://localhost/cb"); + + const valid = verifyAccessToken(db, tokens!.accessToken); + expect(valid).toBe(true); + }); + + it("rejects an unknown token", () => { + const valid = verifyAccessToken(db, "bogus-token"); + expect(valid).toBe(false); + }); + }); + + describe("Token Refresh", () => { + function generatePkce() { + const { createHash, randomBytes } = require("node:crypto"); + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; + } + + it("refreshes a valid refresh token and returns new tokens", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { verifier, challenge } = generatePkce(); + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + const tokens = exchangeCode(db, code, verifier, clientId, "http://localhost/cb"); + + const refreshed = refreshAccessToken(db, tokens!.refreshToken, clientId); + expect(refreshed).not.toBeNull(); + expect(refreshed!.accessToken).toBeDefined(); + expect(refreshed!.accessToken).not.toBe(tokens!.accessToken); + }); + + it("rejects refresh with wrong clientId", () => { + const { clientId } = registerClient(db, "Test", ["http://localhost/cb"]); + const { verifier, challenge } = generatePkce(); + const { code } = createAuthorizationCode(db, clientId, challenge, "S256", "http://localhost/cb"); + const tokens = exchangeCode(db, code, verifier, clientId, "http://localhost/cb"); + + const refreshed = refreshAccessToken(db, tokens!.refreshToken, "wrong-client"); + expect(refreshed).toBeNull(); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bun test tests/services/oauth.service.test.ts +``` + +Expected: FAIL — module `oauth.service.ts` does not exist. + +- [ ] **Step 3: Implement oauth.service.ts** + +Create `src/server/services/oauth.service.ts`: + +```typescript +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { eq, and, lt } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { oauthClients, oauthCodes, oauthTokens } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +// ── Client Registration ───────────────────────────────────────────── + +export function registerClient( + db: Db, + name: string | null, + redirectUris: string[], +): { clientId: string } { + const clientId = randomUUID(); + db.insert(oauthClients) + .values({ + clientId, + clientName: name, + redirectUris: JSON.stringify(redirectUris), + }) + .run(); + return { clientId }; +} + +export function getClient(db: Db, clientId: string) { + return ( + db + .select() + .from(oauthClients) + .where(eq(oauthClients.clientId, clientId)) + .get() ?? null + ); +} + +// ── Authorization Codes ───────────────────────────────────────────── + +export function createAuthorizationCode( + db: Db, + clientId: string, + codeChallenge: string, + codeChallengeMethod: string, + redirectUri: string, +): { code: string } { + const code = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + db.insert(oauthCodes) + .values({ + code, + clientId, + codeChallenge, + codeChallengeMethod, + redirectUri, + expiresAt, + }) + .run(); + + return { code }; +} + +// ── Code Exchange (PKCE) ──────────────────────────────────────────── + +function verifyPkce(verifier: string, challenge: string): boolean { + const computed = createHash("sha256").update(verifier).digest("base64url"); + return computed === challenge; +} + +function generateTokens(db: Db, clientId: string) { + const accessToken = randomBytes(32).toString("hex"); + const refreshToken = randomBytes(32).toString("hex"); + const accessTokenHash = createHash("sha256") + .update(accessToken) + .digest("hex"); + const refreshTokenHash = createHash("sha256") + .update(refreshToken) + .digest("hex"); + const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour + const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); // 30 days + + db.insert(oauthTokens) + .values({ accessTokenHash, refreshTokenHash, clientId, expiresAt, refreshExpiresAt }) + .run(); + + return { accessToken, refreshToken, expiresIn: 3600 }; +} + +export function exchangeCode( + db: Db, + code: string, + codeVerifier: string, + clientId: string, + redirectUri: string, +): { accessToken: string; refreshToken: string; expiresIn: number } | null { + const record = db + .select() + .from(oauthCodes) + .where(eq(oauthCodes.code, code)) + .get(); + + if (!record) return null; + if (record.used) return null; + if (record.clientId !== clientId) return null; + if (record.redirectUri !== redirectUri) return null; + if (record.expiresAt < new Date()) return null; + if (!verifyPkce(codeVerifier, record.codeChallenge)) return null; + + // Mark code as used + db.update(oauthCodes).set({ used: 1 }).where(eq(oauthCodes.id, record.id)).run(); + + return generateTokens(db, clientId); +} + +// ── Token Verification ────────────────────────────────────────────── + +export function verifyAccessToken(db: Db, token: string): boolean { + const hash = createHash("sha256").update(token).digest("hex"); + const record = db + .select() + .from(oauthTokens) + .where(eq(oauthTokens.accessTokenHash, hash)) + .get(); + + if (!record) return false; + if (record.expiresAt < new Date()) return false; + + return true; +} + +// ── Token Refresh ─────────────────────────────────────────────────── + +export function refreshAccessToken( + db: Db, + refreshToken: string, + clientId: string, +): { accessToken: string; refreshToken: string; expiresIn: number } | null { + const hash = createHash("sha256").update(refreshToken).digest("hex"); + const record = db + .select() + .from(oauthTokens) + .where( + and( + eq(oauthTokens.refreshTokenHash, hash), + eq(oauthTokens.clientId, clientId), + ), + ) + .get(); + + if (!record) return null; + if (record.refreshExpiresAt < new Date()) return null; + + // Delete old token pair + db.delete(oauthTokens).where(eq(oauthTokens.id, record.id)).run(); + + // Issue new pair + return generateTokens(db, clientId); +} + +// ── Cleanup ───────────────────────────────────────────────────────── + +export function cleanExpiredOAuthData(db: Db) { + const now = new Date(); + db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now)).run(); + db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now)).run(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +bun test tests/services/oauth.service.test.ts +``` + +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/oauth.service.ts tests/services/oauth.service.test.ts +git commit -m "feat: add OAuth service with PKCE, token management, and tests" +``` + +--- + +### Task 3: OAuth routes — Registration, metadata, and token endpoint + +**Files:** +- Create: `src/server/routes/oauth.ts` +- Modify: `src/server/index.ts` +- Create: `tests/routes/oauth.test.ts` + +- [ ] **Step 1: Write failing route tests** + +Create `tests/routes/oauth.test.ts`: + +```typescript +import { createHash, randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { createUser } from "../../src/server/services/auth.service.ts"; +import { oauthRoutes, wellKnownRoute } from "../../src/server/routes/oauth.ts"; +import { createTestDb } from "../helpers/db.ts"; + +function generatePkce() { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +function createTestApp() { + const db = createTestDb(); + const app = new Hono<{ Variables: { db?: any } }>(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/.well-known", wellKnownRoute); + app.route("/oauth", oauthRoutes); + return { app, db }; +} + +describe("OAuth Routes", () => { + let app: Hono; + let db: ReturnType; + + beforeEach(async () => { + const testApp = createTestApp(); + app = testApp.app; + db = testApp.db; + // Create a user so auth works + await createUser(db, "admin", "secret123"); + }); + + describe("GET /.well-known/oauth-authorization-server", () => { + it("returns OAuth metadata", async () => { + const res = await app.request("/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authorization_endpoint).toContain("/oauth/authorize"); + expect(body.token_endpoint).toContain("/oauth/token"); + expect(body.registration_endpoint).toContain("/oauth/register"); + expect(body.response_types_supported).toEqual(["code"]); + expect(body.code_challenge_methods_supported).toEqual(["S256"]); + }); + }); + + describe("POST /oauth/register", () => { + it("registers a client and returns client_id", async () => { + const res = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Claude Mobile", + redirect_uris: ["https://claude.ai/callback"], + }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.client_id).toBeDefined(); + expect(body.client_name).toBe("Claude Mobile"); + expect(body.redirect_uris).toEqual(["https://claude.ai/callback"]); + }); + + it("rejects registration without redirect_uris", async () => { + const res = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_name: "Test" }), + }); + + expect(res.status).toBe(400); + }); + }); + + describe("POST /oauth/token (authorization_code)", () => { + it("exchanges code for tokens with valid PKCE", async () => { + const { verifier, challenge } = generatePkce(); + + // Register client + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test", + redirect_uris: ["http://localhost/cb"], + }), + }); + const { client_id } = await regRes.json(); + + // Get authorize page (this creates the code internally via form POST) + // Simulate the form POST to /oauth/authorize + const authorizeRes = await app.request( + `/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost/cb")}&code_challenge=${challenge}&code_challenge_method=S256&state=teststate`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + username: "admin", + password: "secret123", + client_id, + redirect_uri: "http://localhost/cb", + code_challenge: challenge, + code_challenge_method: "S256", + state: "teststate", + }).toString(), + }, + ); + + expect(authorizeRes.status).toBe(302); + const location = authorizeRes.headers.get("location")!; + const url = new URL(location); + const code = url.searchParams.get("code")!; + expect(code).toBeDefined(); + expect(url.searchParams.get("state")).toBe("teststate"); + + // Exchange code for tokens + const tokenRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id, + redirect_uri: "http://localhost/cb", + }).toString(), + }); + + expect(tokenRes.status).toBe(200); + const tokens = await tokenRes.json(); + expect(tokens.access_token).toBeDefined(); + expect(tokens.refresh_token).toBeDefined(); + expect(tokens.token_type).toBe("Bearer"); + expect(tokens.expires_in).toBe(3600); + }); + }); + + describe("POST /oauth/token (refresh_token)", () => { + it("refreshes an access token", async () => { + const { verifier, challenge } = generatePkce(); + + // Register + authorize + exchange (full flow) + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test", + redirect_uris: ["http://localhost/cb"], + }), + }); + const { client_id } = await regRes.json(); + + const authorizeRes = await app.request( + `/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost/cb")}&code_challenge=${challenge}&code_challenge_method=S256&state=s`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + username: "admin", + password: "secret123", + client_id, + redirect_uri: "http://localhost/cb", + code_challenge: challenge, + code_challenge_method: "S256", + state: "s", + }).toString(), + }, + ); + + const location = authorizeRes.headers.get("location")!; + const code = new URL(location).searchParams.get("code")!; + + const tokenRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id, + redirect_uri: "http://localhost/cb", + }).toString(), + }); + const tokens = await tokenRes.json(); + + // Now refresh + const refreshRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + client_id, + }).toString(), + }); + + expect(refreshRes.status).toBe(200); + const refreshed = await refreshRes.json(); + expect(refreshed.access_token).toBeDefined(); + expect(refreshed.access_token).not.toBe(tokens.access_token); + }); + }); + + describe("GET /oauth/authorize", () => { + it("returns HTML login form for valid params", async () => { + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test", + redirect_uris: ["http://localhost/cb"], + }), + }); + const { client_id } = await regRes.json(); + const { challenge } = generatePkce(); + + const res = await app.request( + `/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost/cb")}&code_challenge=${challenge}&code_challenge_method=S256&state=s`, + ); + + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain(" { + const { challenge } = generatePkce(); + const res = await app.request( + `/oauth/authorize?response_type=code&client_id=bogus&redirect_uri=http://localhost/cb&code_challenge=${challenge}&code_challenge_method=S256`, + ); + expect(res.status).toBe(400); + }); + }); + + describe("POST /oauth/authorize", () => { + it("rejects wrong password", async () => { + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test", + redirect_uris: ["http://localhost/cb"], + }), + }); + const { client_id } = await regRes.json(); + const { challenge } = generatePkce(); + + const res = await app.request( + `/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost/cb")}&code_challenge=${challenge}&code_challenge_method=S256`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + username: "admin", + password: "wrongpassword", + client_id, + redirect_uri: "http://localhost/cb", + code_challenge: challenge, + code_challenge_method: "S256", + state: "", + }).toString(), + }, + ); + + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Invalid"); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bun test tests/routes/oauth.test.ts +``` + +Expected: FAIL — module `oauth.ts` does not exist. + +- [ ] **Step 3: Implement OAuth routes** + +Create `src/server/routes/oauth.ts`: + +```typescript +import { Hono } from "hono"; +import { db as prodDb } from "../../db/index.ts"; +import { verifyPassword, getUserCount } from "../services/auth.service.ts"; +import { + registerClient, + getClient, + createAuthorizationCode, + exchangeCode, + refreshAccessToken, + cleanExpiredOAuthData, +} from "../services/oauth.service.ts"; + +type Db = typeof prodDb; + +// ── Well-Known Metadata ───────────────────────────────────────────── + +export const wellKnownRoute = new Hono(); + +wellKnownRoute.get("/oauth-authorization-server", (c) => { + const base = process.env.GEARBOX_URL || new URL(c.req.url).origin; + return c.json({ + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + registration_endpoint: `${base}/oauth/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); +}); + +// ── OAuth Routes ──────────────────────────────────────────────────── + +export const oauthRoutes = new Hono(); + +// Dynamic Client Registration (RFC 7591) +oauthRoutes.post("/register", async (c) => { + const db: Db = c.get("db") ?? prodDb; + const body = await c.req.json(); + + if (!body.redirect_uris || !Array.isArray(body.redirect_uris) || body.redirect_uris.length === 0) { + return c.json({ error: "redirect_uris is required" }, 400); + } + + const { clientId } = registerClient(db, body.client_name ?? null, body.redirect_uris); + + return c.json( + { + client_id: clientId, + client_name: body.client_name ?? null, + redirect_uris: body.redirect_uris, + }, + 201, + ); +}); + +// Authorization Endpoint — GET shows login form +oauthRoutes.get("/authorize", async (c) => { + const db: Db = c.get("db") ?? prodDb; + const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state } = + c.req.query(); + + if (response_type !== "code") { + return c.json({ error: "unsupported_response_type" }, 400); + } + if (!client_id || !redirect_uri || !code_challenge) { + return c.json({ error: "invalid_request", error_description: "Missing required parameters" }, 400); + } + + const client = getClient(db, client_id); + if (!client) { + return c.json({ error: "invalid_client" }, 400); + } + + const allowedUris: string[] = JSON.parse(client.redirectUris); + if (!allowedUris.includes(redirect_uri)) { + return c.json({ error: "invalid_redirect_uri" }, 400); + } + + return c.html(renderLoginForm({ + clientName: client.clientName ?? "Unknown App", + clientId: client_id, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + codeChallengeMethod: code_challenge_method ?? "S256", + state: state ?? "", + error: null, + })); +}); + +// Authorization Endpoint — POST processes login +oauthRoutes.post("/authorize", async (c) => { + const db: Db = c.get("db") ?? prodDb; + + const formData = await c.req.parseBody(); + const username = formData.username as string; + const password = formData.password as string; + const clientId = formData.client_id as string; + const redirectUri = formData.redirect_uri as string; + const codeChallenge = formData.code_challenge as string; + const codeChallengeMethod = formData.code_challenge_method as string; + const state = formData.state as string; + + // Verify credentials + const user = await verifyPassword(db, username, password); + if (!user) { + return c.html( + renderLoginForm({ + clientName: "", + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod, + state, + error: "Invalid username or password", + }), + ); + } + + // Generate auth code + const { code } = createAuthorizationCode( + db, + clientId, + codeChallenge, + codeChallengeMethod, + redirectUri, + ); + + // Redirect back with code + const url = new URL(redirectUri); + url.searchParams.set("code", code); + if (state) url.searchParams.set("state", state); + + return c.redirect(url.toString(), 302); +}); + +// Token Endpoint +oauthRoutes.post("/token", async (c) => { + const db: Db = c.get("db") ?? prodDb; + + const body = await c.req.parseBody(); + const grantType = body.grant_type as string; + + // Opportunistic cleanup + cleanExpiredOAuthData(db); + + if (grantType === "authorization_code") { + const code = body.code as string; + const codeVerifier = body.code_verifier as string; + const clientId = body.client_id as string; + const redirectUri = body.redirect_uri as string; + + if (!code || !codeVerifier || !clientId || !redirectUri) { + return c.json({ error: "invalid_request" }, 400); + } + + const tokens = exchangeCode(db, code, codeVerifier, clientId, redirectUri); + if (!tokens) { + return c.json({ error: "invalid_grant" }, 400); + } + + return c.json({ + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + token_type: "Bearer", + expires_in: tokens.expiresIn, + }); + } + + if (grantType === "refresh_token") { + const refreshToken = body.refresh_token as string; + const clientId = body.client_id as string; + + if (!refreshToken || !clientId) { + return c.json({ error: "invalid_request" }, 400); + } + + const tokens = refreshAccessToken(db, refreshToken, clientId); + if (!tokens) { + return c.json({ error: "invalid_grant" }, 400); + } + + return c.json({ + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + token_type: "Bearer", + expires_in: tokens.expiresIn, + }); + } + + return c.json({ error: "unsupported_grant_type" }, 400); +}); + +// ── Login Form HTML ───────────────────────────────────────────────── + +function renderLoginForm(params: { + clientName: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + codeChallengeMethod: string; + state: string; + error: string | null; +}): string { + return ` + + + + + Authorize — GearBox + + + +
+

GearBox

+

Authorize ${escapeHtml(params.clientName)} to access your data

+ ${params.error ? `
${escapeHtml(params.error)}
` : ""} +
+ + + + + + + + + + +
+
+ +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +``` + +- [ ] **Step 4: Mount routes in src/server/index.ts** + +Add imports at the top of `src/server/index.ts`: + +```typescript +import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; +``` + +Add route mounting after the health check and before the API db middleware: + +```typescript +// OAuth well-known metadata (must be at root, not under /api) +app.route("/.well-known", wellKnownRoute); + +// OAuth routes (must be at root, not under /api) +app.route("/oauth", oauthRoutes); +``` + +The `.well-known` and `/oauth` routes need the DB context. Add a db middleware for them before the routes: + +```typescript +app.use("/oauth/*", async (c, next) => { + c.set("db", prodDb); + return next(); +}); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +bun test tests/routes/oauth.test.ts +``` + +Expected: All tests PASS. + +- [ ] **Step 6: Run all existing tests to verify no regressions** + +```bash +bun test +``` + +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/server/routes/oauth.ts src/server/index.ts tests/routes/oauth.test.ts +git commit -m "feat: add OAuth 2.1 endpoints (register, authorize, token)" +``` + +--- + +### Task 4: MCP auth middleware — Add Bearer token support + +**Files:** +- Modify: `src/server/mcp/index.ts` + +- [ ] **Step 1: Add Bearer token import and update auth middleware** + +In `src/server/mcp/index.ts`, add the import: + +```typescript +import { verifyAccessToken } from "../services/oauth.service.ts"; +``` + +Replace the auth middleware (lines 89-105) with: + +```typescript +// Auth middleware for all MCP requests +mcpRoutes.use("/*", async (c, next) => { + const db = c.get("db") ?? prodDb; + + // Skip auth if no users exist + if (getUserCount(db) <= 0) { + return next(); + } + + // Try Bearer token first (OAuth) + const authHeader = c.req.header("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + if (verifyAccessToken(db, token)) { + return next(); + } + return c.json({ error: "invalid_token" }, 401); + } + + // Try API key (existing flow) + const apiKey = c.req.header("X-API-Key"); + if (apiKey) { + const valid = await verifyApiKey(db, apiKey); + if (valid) { + return next(); + } + return c.json({ error: "Invalid API key" }, 401); + } + + // No auth provided — return 401 with WWW-Authenticate to trigger OAuth flow + return c.text("Unauthorized", 401, { + "WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-authorization-server"', + }); +}); +``` + +- [ ] **Step 2: Run all tests to verify no regressions** + +```bash +bun test +``` + +Expected: All tests PASS. + +- [ ] **Step 3: Manually verify MCP still works with API key** + +Start the dev server and test with an existing API key: + +```bash +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "X-API-Key: " \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' +``` + +Expected: MCP initialization response (not 401). + +- [ ] **Step 4: Verify 401 with WWW-Authenticate when no auth provided** + +```bash +curl -v -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' +``` + +Expected: 401 with `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"` header. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/mcp/index.ts +git commit -m "feat: add Bearer token auth to MCP alongside API key auth" +``` + +--- + +### Task 5: End-to-end OAuth flow test + +**Files:** +- Modify: `tests/routes/oauth.test.ts` + +- [ ] **Step 1: Add E2E integration test for full OAuth → MCP flow** + +Add this test to `tests/routes/oauth.test.ts`. It needs a different test app that also mounts the MCP routes: + +```typescript +import { mcpRoutes } from "../../src/server/mcp/index.ts"; + +function createFullTestApp() { + const db = createTestDb(); + const app = new Hono<{ Variables: { db?: any } }>(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/.well-known", wellKnownRoute); + app.route("/oauth", oauthRoutes); + app.route("/mcp", mcpRoutes); + return { app, db }; +} + +describe("Full OAuth → MCP Flow", () => { + it("complete flow: register → authorize → token → MCP call", async () => { + const { app, db } = createFullTestApp(); + await createUser(db, "admin", "secret123"); + + const { verifier, challenge } = generatePkce(); + + // 1. Register client + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Claude", + redirect_uris: ["http://localhost/cb"], + }), + }); + const { client_id } = await regRes.json(); + + // 2. Authorize (simulate form POST) + const authRes = await app.request( + `/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost/cb")}&code_challenge=${challenge}&code_challenge_method=S256&state=test`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + username: "admin", + password: "secret123", + client_id, + redirect_uri: "http://localhost/cb", + code_challenge: challenge, + code_challenge_method: "S256", + state: "test", + }).toString(), + }, + ); + const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!; + + // 3. Exchange code for tokens + const tokenRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id, + redirect_uri: "http://localhost/cb", + }).toString(), + }); + const { access_token } = await tokenRes.json(); + + // 4. Use token to call MCP + const mcpRes = await app.request("/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${access_token}`, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + }, + id: 1, + }), + }); + + expect(mcpRes.status).toBe(200); + }); + + it("rejects MCP call without auth when user exists", async () => { + const { app, db } = createFullTestApp(); + await createUser(db, "admin", "secret123"); + + const mcpRes = await app.request("/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + }, + id: 1, + }), + }); + + expect(mcpRes.status).toBe(401); + expect(mcpRes.headers.get("www-authenticate")).toContain("Bearer"); + }); +}); +``` + +- [ ] **Step 2: Run all tests** + +```bash +bun test +``` + +Expected: All tests PASS, including the new E2E flow test. + +- [ ] **Step 3: Commit** + +```bash +git add tests/routes/oauth.test.ts +git commit -m "test: add end-to-end OAuth to MCP flow integration test" +``` + +--- + +### Task 6: Update documentation + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update MCP server section in CLAUDE.md** + +Add to the MCP Server section in `CLAUDE.md`, after the existing configuration examples: + +```markdown +### OAuth Authentication (Claude Mobile / claude.ai) + +GearBox supports OAuth 2.1 Authorization Code + PKCE for MCP connections from Claude mobile app and claude.ai. The OAuth flow is automatic — Claude handles discovery and token exchange. + +**Required environment variable for production:** +```bash +GEARBOX_URL=https://your-gearbox-domain.com # Used as OAuth issuer URL +``` + +**OAuth endpoints:** +- `GET /.well-known/oauth-authorization-server` — Discovery metadata +- `POST /oauth/register` — Dynamic Client Registration +- `GET/POST /oauth/authorize` — Authorization with login form +- `POST /oauth/token` — Token exchange and refresh + +**Both auth methods work simultaneously:** +- **API key** (`X-API-Key` header) — Claude Code, scripts, programmatic access +- **OAuth Bearer token** (`Authorization: Bearer` header) — Claude mobile, claude.ai +``` + +- [ ] **Step 2: Update the Authentication section** + +Add a bullet to the Authentication section: + +```markdown +- **MCP OAuth**: OAuth 2.1 + PKCE for Claude mobile/web. Endpoints at `/oauth/*`. Uses existing GearBox credentials. +``` + +- [ ] **Step 3: Run lint to verify formatting** + +```bash +bun run lint +``` + +Expected: No lint errors. + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add MCP OAuth documentation to CLAUDE.md" +```