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, "&")
.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:
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 metadataPOST /oauth/register— Dynamic Client RegistrationGET/POST /oauth/authorize— Authorization with login formPOST /oauth/token— Token exchange and refresh
Both auth methods work simultaneously:
- API key (
X-API-Keyheader) — Claude Code, scripts, programmatic access - OAuth Bearer token (
Authorization: Bearerheader) — 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"