Files
GearBox/docs/superpowers/plans/2026-04-04-mcp-oauth.md
2026-04-04 09:09:30 +02:00

42 KiB

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:

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
bun run db:generate

Expected: A new migration SQL file in drizzle/ creating the three tables.

  • Step 3: Apply migration
bun run db:push

Expected: Migration applies cleanly.

  • Step 4: Commit
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:

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<typeof createTestDb>;

	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
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:

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
bun test tests/services/oauth.service.test.ts

Expected: All tests PASS.

  • Step 5: Commit
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:

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<typeof createTestDb>;

	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("<form");
			expect(html).toContain("password");
			expect(html).toContain("GearBox");
		});

		it("rejects authorize with invalid client_id", async () => {
			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
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:

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 `<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Authorize — GearBox</title>
	<style>
		* { margin: 0; padding: 0; box-sizing: border-box; }
		body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8f9fa; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
		.card { background: white; border-radius: 12px; padding: 2rem; width: 100%; max-width: 400px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
		h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; }
		.subtitle { color: #6b7280; margin-bottom: 1.5rem; font-size: 0.875rem; }
		label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; color: #374151; }
		input[type="text"], input[type="password"] { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.875rem; margin-bottom: 1rem; outline: none; }
		input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
		button { width: 100%; padding: 0.625rem; background: #111827; color: white; border: none; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
		button:hover { background: #1f2937; }
		.error { background: #fef2f2; color: #dc2626; padding: 0.75rem; border-radius: 8px; font-size: 0.875rem; margin-bottom: 1rem; }
	</style>
</head>
<body>
	<div class="card">
		<h1>GearBox</h1>
		<p class="subtitle">Authorize <strong>${escapeHtml(params.clientName)}</strong> to access your data</p>
		${params.error ? `<div class="error">${escapeHtml(params.error)}</div>` : ""}
		<form method="POST">
			<label for="username">Username</label>
			<input type="text" id="username" name="username" required autocomplete="username" />
			<label for="password">Password</label>
			<input type="password" id="password" name="password" required autocomplete="current-password" />
			<input type="hidden" name="client_id" value="${escapeHtml(params.clientId)}" />
			<input type="hidden" name="redirect_uri" value="${escapeHtml(params.redirectUri)}" />
			<input type="hidden" name="code_challenge" value="${escapeHtml(params.codeChallenge)}" />
			<input type="hidden" name="code_challenge_method" value="${escapeHtml(params.codeChallengeMethod)}" />
			<input type="hidden" name="state" value="${escapeHtml(params.state)}" />
			<button type="submit">Authorize</button>
		</form>
	</div>
</body>
</html>`;
}

function escapeHtml(str: string): string {
	return str
		.replace(/&/g, "&amp;")
		.replace(/</g, "&lt;")
		.replace(/>/g, "&gt;")
		.replace(/"/g, "&quot;");
}
  • Step 4: Mount routes in src/server/index.ts

Add imports at the top of src/server/index.ts:

import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";

Add route mounting after the health check and before the API db middleware:

// 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:

app.use("/oauth/*", async (c, next) => {
	c.set("db", prodDb);
	return next();
});
  • Step 5: Run tests to verify they pass
bun test tests/routes/oauth.test.ts

Expected: All tests PASS.

  • Step 6: Run all existing tests to verify no regressions
bun test

Expected: All tests PASS.

  • Step 7: Commit
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:

import { verifyAccessToken } from "../services/oauth.service.ts";

Replace the auth middleware (lines 89-105) with:

// 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
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:

curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <your-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
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
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:

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
bun test

Expected: All tests PASS, including the new E2E flow test.

  • Step 3: Commit
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:

### 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
bun run lint

Expected: No lint errors.

  • Step 4: Commit
git add CLAUDE.md
git commit -m "docs: add MCP OAuth documentation to CLAUDE.md"