# 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" ```