1373 lines
42 KiB
Markdown
1373 lines
42 KiB
Markdown
# 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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 `<!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`:
|
|
|
|
```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: <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**
|
|
|
|
```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"
|
|
```
|