--- phase: 15-external-authentication plan: 02 type: execute wave: 2 depends_on: ["15-01"] files_modified: - src/server/middleware/auth.ts - src/server/services/auth.service.ts - src/server/routes/auth.ts - src/server/routes/oauth.ts - src/server/mcp/index.ts - src/server/index.ts - package.json autonomous: true requirements: [AUTH-01, AUTH-02, AUTH-03] must_haves: truths: - "requireAuth middleware validates API keys, MCP Bearer tokens, and OIDC session cookies" - "GET /login redirects unauthenticated users to Logto" - "GET /callback processes the OIDC authorization code and sets a session cookie" - "GET /api/auth/me returns user identity from OIDC claims or null" - "API keys continue to authenticate programmatic requests without Logto" - "MCP OAuth Bearer tokens continue to work for Claude mobile/web" - "MCP OAuth /oauth/authorize validates via OIDC session instead of username/password" artifacts: - path: "src/server/middleware/auth.ts" provides: "Three-way auth middleware (API key, MCP Bearer, OIDC session)" exports: ["requireAuth"] - path: "src/server/services/auth.service.ts" provides: "API key CRUD only (user/session functions removed)" exports: ["createApiKey", "verifyApiKey", "listApiKeys", "deleteApiKey"] - path: "src/server/routes/auth.ts" provides: "OIDC login/callback/logout routes + API key CRUD routes" exports: ["authRoutes"] - path: "src/server/routes/oauth.ts" provides: "MCP OAuth with OIDC session validation instead of password" - path: "src/server/index.ts" provides: "Updated route registration with OIDC callback" key_links: - from: "src/server/middleware/auth.ts" to: "@hono/oidc-auth" via: "getAuth() for OIDC session check" pattern: "getAuth" - from: "src/server/middleware/auth.ts" to: "src/server/services/auth.service.ts" via: "verifyApiKey for API key path" pattern: "verifyApiKey" - from: "src/server/routes/auth.ts" to: "@hono/oidc-auth" via: "oidcAuthMiddleware for login redirect, processOAuthCallback for callback" pattern: "oidcAuthMiddleware|processOAuthCallback" - from: "src/server/routes/oauth.ts" to: "@hono/oidc-auth" via: "getAuth() replaces verifyPassword in authorize POST" pattern: "getAuth" --- Rewrite the server-side authentication layer to use OIDC via @hono/oidc-auth for browser sessions while preserving API key and MCP OAuth authentication paths. Purpose: This is the core auth integration -- replacing GearBox's custom user/session management with Logto OIDC. After this plan, browser users authenticate via Logto, API keys work unchanged, and MCP OAuth coexists cleanly. Output: Refactored middleware, routes, and services implementing three-way authentication. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/15-external-authentication/15-CONTEXT.md @.planning/phases/15-external-authentication/15-RESEARCH.md @.planning/phases/15-external-authentication/15-01-SUMMARY.md @src/server/middleware/auth.ts @src/server/services/auth.service.ts @src/server/routes/auth.ts @src/server/routes/oauth.ts @src/server/mcp/index.ts @src/server/index.ts From src/server/services/auth.service.ts (KEEP these): ```typescript export async function createApiKey(db: Db, name: string): Promise<{...}> export async function verifyApiKey(db: Db, rawKey: string): Promise export async function listApiKeys(db: Db): Promise<{...}[]> export async function deleteApiKey(db: Db, id: number): Promise ``` From src/server/services/auth.service.ts (REMOVE these): ```typescript export async function createUser(db: Db, username: string, password: string) export async function verifyPassword(db: Db, username: string, password: string) export async function getUserCount(db: Db): Promise export async function changePassword(db: Db, ...) export async function createSession(db: Db, userId: number, ...) export async function getSession(db: Db, sessionId: string) export async function deleteSession(db: Db, sessionId: string) export async function refreshSession(db: Db, sessionId: string, ...) ``` From src/server/services/oauth.service.ts (KEEP, used by MCP OAuth): ```typescript export async function verifyAccessToken(db: Db, token: string): Promise ``` From @hono/oidc-auth (NEW - to be installed): ```typescript import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from "@hono/oidc-auth"; // getAuth(c) returns { sub: string, email?: string, ... } | null // oidcAuthMiddleware() redirects to OIDC provider if no session // processOAuthCallback(c) handles the /callback redirect // revokeSession(c) clears the OIDC session ``` Task 1: Install OIDC dependencies and rewrite auth middleware + service package.json, src/server/middleware/auth.ts, src/server/services/auth.service.ts - src/server/middleware/auth.ts (current middleware with getUserCount, getSession, refreshSession) - src/server/services/auth.service.ts (current service with user/session/apiKey functions) - src/server/mcp/index.ts (imports getUserCount, verifyApiKey from auth.service) - .planning/phases/15-external-authentication/15-RESEARCH.md (Pattern 1: Auth Middleware, Pitfall 5: getUserCount, Pitfall 6: OIDC_AUTH_SECRET) **Install dependencies:** ```bash bun add @hono/oidc-auth jose ``` **Rewrite `src/server/services/auth.service.ts`:** - Remove ALL user management functions: `createUser`, `verifyPassword`, `getUserCount`, `changePassword` - Remove ALL session management functions: `createSession`, `getSession`, `deleteSession`, `refreshSession` - Remove imports of `users` and `sessions` from schema - Remove `count` from drizzle-orm imports (only needed by getUserCount) - KEEP all API key functions unchanged: `createApiKey`, `verifyApiKey`, `listApiKeys`, `deleteApiKey` - Keep `randomBytes` import (used by createApiKey) - Keep `eq` from drizzle-orm (used by API key functions) - Keep `apiKeys` schema import - Keep the `Db` type alias The file should export exactly: `createApiKey`, `verifyApiKey`, `listApiKeys`, `deleteApiKey`. **Rewrite `src/server/middleware/auth.ts`:** Per D-04, implement three-way auth check. Replace the entire file with: ```typescript import type { Context, Next } from "hono"; import { getAuth } from "@hono/oidc-auth"; import { verifyApiKey } from "../services/auth.service"; import { verifyAccessToken } from "../services/oauth.service"; export async function requireAuth(c: Context, next: Next) { const db = c.get("db"); // 1. Check API key (programmatic access) -- per D-10 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); } // 2. Check MCP OAuth Bearer token -- per D-12 const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); if (await verifyAccessToken(db, token)) return next(); return c.json({ error: "invalid_token" }, 401); } // 3. Check OIDC session (browser users) -- per D-02 const auth = await getAuth(c); if (auth) return next(); return c.json({ error: "Authentication required" }, 401); } ``` Key changes from old middleware: - Removed `getUserCount` check (Pitfall 5) -- first-run setup happens on Logto admin console - Removed `getCookie`/`getSession`/`refreshSession` -- replaced by `getAuth()` from @hono/oidc-auth - Added MCP OAuth Bearer token check (was only in MCP routes, now centralized) - No `hono/cookie` import needed grep -q "@hono/oidc-auth" package.json && grep -q "getAuth" src/server/middleware/auth.ts && ! grep -q "getUserCount" src/server/middleware/auth.ts && ! grep -q "getUserCount" src/server/services/auth.service.ts && grep -q "verifyApiKey" src/server/services/auth.service.ts && echo "PASS" || echo "FAIL" - package.json contains `@hono/oidc-auth` dependency - package.json contains `jose` dependency - src/server/middleware/auth.ts imports `getAuth` from `@hono/oidc-auth` - src/server/middleware/auth.ts imports `verifyAccessToken` from `../services/oauth.service` - src/server/middleware/auth.ts does NOT import `getUserCount`, `getSession`, `refreshSession` - src/server/middleware/auth.ts does NOT import from `hono/cookie` - src/server/services/auth.service.ts does NOT contain `export async function createUser` - src/server/services/auth.service.ts does NOT contain `export async function verifyPassword` - src/server/services/auth.service.ts does NOT contain `export async function getUserCount` - src/server/services/auth.service.ts does NOT contain `export async function createSession` - src/server/services/auth.service.ts does NOT contain `export async function getSession` - src/server/services/auth.service.ts does NOT import `users` or `sessions` from schema - src/server/services/auth.service.ts DOES contain `export async function verifyApiKey` - src/server/services/auth.service.ts DOES contain `export async function createApiKey` - src/server/services/auth.service.ts DOES contain `export async function listApiKeys` - src/server/services/auth.service.ts DOES contain `export async function deleteApiKey` @hono/oidc-auth installed, middleware does three-way auth check, service only has API key functions Task 2: Rewrite auth routes for OIDC login/callback/logout + API key CRUD src/server/routes/auth.ts, src/server/index.ts - src/server/routes/auth.ts (current routes with login form, setup, password change, API key CRUD) - src/server/index.ts (current route registration and middleware application order) - .planning/phases/15-external-authentication/15-RESEARCH.md (Code Examples: @hono/oidc-auth Configuration, Pattern 2: OIDC Middleware Selective Application) **Rewrite `src/server/routes/auth.ts`:** Per D-05, D-06, D-07: Replace credential-based auth routes with OIDC redirect flow. Remove: - `POST /login` (credential login) -- replaced by OIDC redirect - `POST /setup` (first-time account creation) -- happens on Logto now per D-06 - `PUT /password` (password change) -- managed by Logto now - All Zod schemas: `loginSchema`, `setupSchema`, `changePasswordSchema` - All cookie handling (`COOKIE_NAME`, `COOKIE_MAX_AGE`, `setCookie`, `getCookie`, `deleteCookie`) - Imports of `users` from schema, `verifyPassword`, `createUser`, `changePassword`, `createSession`, `getSession`, `deleteSession`, `getUserCount` Keep (with modifications): - `GET /me` -- rewrite to use `getAuth()` from @hono/oidc-auth - `GET /keys`, `POST /keys`, `DELETE /keys/:id` -- keep unchanged, still protected by requireAuth Add: - `GET /login` -- applies `oidcAuthMiddleware()` which redirects to Logto if no session; if session exists, redirects to `/` - `GET /callback` -- calls `processOAuthCallback(c)` to handle OIDC redirect back from Logto - `GET /logout` -- calls `revokeSession(c)` then redirects to `/login` New file structure: ```typescript import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback, } from "@hono/oidc-auth"; import { parseId } from "../lib/params.ts"; import { requireAuth } from "../middleware/auth.ts"; import { createApiKey, deleteApiKey, listApiKeys, } from "../services/auth.service.ts"; type Env = { Variables: { db?: any } }; const createKeySchema = z.object({ name: z.string().min(1) }); const app = new Hono(); // ── OIDC Browser Auth ──────────────────────────────────────────────── // Login: redirect to Logto if not authenticated app.get("/login", oidcAuthMiddleware(), async (c) => { // Middleware redirects to Logto if no session. If we reach here, user is authenticated. return c.redirect("/"); }); // Callback: process OIDC redirect from Logto app.get("/callback", async (c) => { return processOAuthCallback(c); }); // Logout: revoke OIDC session and redirect app.get("/logout", async (c) => { await revokeSession(c); return c.redirect("/login"); }); // ── Auth Status ────────────────────────────────────────────────────── app.get("/me", async (c) => { const auth = await getAuth(c); if (auth) { return c.json({ user: { id: auth.sub, email: auth.email }, authenticated: true, }); } return c.json({ user: null, authenticated: false }); }); // ── API Key Management (protected) ─────────────────────────────────── app.get("/keys", requireAuth, async (c) => { const db = c.get("db"); const keys = await listApiKeys(db); return c.json(keys); }); app.post("/keys", requireAuth, zValidator("json", createKeySchema), async (c) => { const db = c.get("db"); const { name } = c.req.valid("json"); const result = await createApiKey(db, name); return c.json({ id: result.id, name: result.name, key: result.rawKey, prefix: result.keyPrefix }, 201); }); app.delete("/keys/:id", requireAuth, async (c) => { const db = c.get("db"); const id = parseId(c.req.param("id")); if (!id) return c.json({ error: "Invalid key ID" }, 400); await deleteApiKey(db, id); return c.json({ ok: true }); }); export const authRoutes = app; ``` **Update `src/server/index.ts`:** The OIDC auth routes (`/login`, `/callback`, `/logout`) need to be accessible at the root level, not under `/api/auth`. But API key routes stay at `/api/auth/keys`. Changes to index.ts: 1. Add a new top-level route group for OIDC browser auth (login, callback, logout): ```typescript // OIDC browser auth routes (top-level, not under /api) app.get("/login", ...); // Delegate to authRoutes app.get("/callback", ...); // Delegate to authRoutes app.get("/logout", ...); // Delegate to authRoutes ``` Actually, simpler approach: mount authRoutes at root level for the OIDC routes AND at `/api/auth` for the API routes. But since Hono route() mounts all routes under a prefix, we need to split. Better approach: Keep authRoutes mounted at `/api/auth` for /me, /keys. Create separate top-level routes for /login, /callback, /logout: ```typescript import { oidcAuthMiddleware, processOAuthCallback, revokeSession } from "@hono/oidc-auth"; // OIDC browser auth (before /api/* middleware) app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/")); app.get("/callback", async (c) => processOAuthCallback(c)); app.get("/logout", async (c) => { await revokeSession(c); return c.redirect("/login"); }); ``` Then remove the /login, /callback, /logout routes from authRoutes (keep only /me and /keys/* in authRoutes). 2. Place these OIDC routes BEFORE the `/api/*` middleware blocks and BEFORE static file serving 3. Keep `app.route("/api/auth", authRoutes)` for /me and /keys endpoints 4. Ensure the auth middleware skip for `/api/auth` still works (it does -- /api/auth/me is GET, /api/auth/keys POST/DELETE go through requireAuth within the route handler) So the final authRoutes file should NOT contain /login, /callback, /logout. Those go directly in index.ts. authRoutes contains: GET /me, GET /keys, POST /keys, DELETE /keys/:id. **IMPORTANT (Pattern 2):** Do NOT apply `oidcAuthMiddleware()` globally. Only apply it to the `/login` route. The `/api/*` routes use the custom `requireAuth` middleware. grep -q "processOAuthCallback" src/server/index.ts && grep -q "oidcAuthMiddleware" src/server/index.ts && ! grep -q "verifyPassword" src/server/routes/auth.ts && ! grep -q "createUser" src/server/routes/auth.ts && grep -q "getAuth" src/server/routes/auth.ts && echo "PASS" || echo "FAIL" - src/server/routes/auth.ts does NOT contain `POST /login`, `POST /setup`, `PUT /password` handlers - src/server/routes/auth.ts does NOT import `verifyPassword`, `createUser`, `changePassword`, `createSession`, `deleteSession`, `getSession`, `getUserCount` - src/server/routes/auth.ts does NOT import `users` from schema - src/server/routes/auth.ts does NOT import `setCookie`, `getCookie`, `deleteCookie` from `hono/cookie` - src/server/routes/auth.ts DOES contain `GET /me` using `getAuth()` from @hono/oidc-auth - src/server/routes/auth.ts DOES contain API key CRUD routes (GET /keys, POST /keys, DELETE /keys/:id) - src/server/index.ts contains `app.get("/login"` with `oidcAuthMiddleware()` - src/server/index.ts contains `app.get("/callback"` with `processOAuthCallback` - src/server/index.ts contains `app.get("/logout"` with `revokeSession` - These OIDC routes appear BEFORE the `/api/*` middleware blocks in index.ts Auth routes serve OIDC login/callback/logout at root, /me returns OIDC claims, API key CRUD preserved Task 3: Update MCP OAuth authorize and MCP auth middleware for OIDC src/server/routes/oauth.ts, src/server/mcp/index.ts - src/server/routes/oauth.ts (current MCP OAuth with verifyPassword in POST /authorize) - src/server/mcp/index.ts (current MCP auth middleware with getUserCount check) - .planning/phases/15-external-authentication/15-RESEARCH.md (Pitfall 3: MCP OAuth POST /authorize, Pitfall 5: getUserCount) **Per D-12:** MCP OAuth coexists with Logto. These are separate auth domains. But the MCP OAuth authorize form currently uses `verifyPassword()` against the removed `users` table -- this must be fixed. **Update `src/server/routes/oauth.ts`:** 1. Remove `import { verifyPassword } from "../services/auth.service.ts"` -- this function no longer exists 2. Add `import { getAuth } from "@hono/oidc-auth"` 3. Replace the `POST /authorize` handler logic: - Instead of parsing username/password from the form and calling `verifyPassword()`, check for an active OIDC session using `getAuth(c)` - If the user has a valid OIDC session (`getAuth(c)` returns non-null), proceed with authorization code creation - If no OIDC session, redirect to `/login` with a return URL that brings them back to the authorize page after Logto login Updated POST /authorize: ```typescript oauthRoutes.post("/authorize", async (c) => { const db = c.get("db") ?? prodDb; // Check for OIDC session instead of username/password const auth = await getAuth(c); if (!auth) { // No session -- redirect to login, then back to authorize const currentUrl = c.req.url; return c.redirect(`/login?redirect=${encodeURIComponent(currentUrl)}`); } const body = await c.req.parseBody(); const clientId = body.client_id as string; const redirectUri = body.redirect_uri as string; const codeChallenge = body.code_challenge as string; const codeChallengeMethod = body.code_challenge_method as string; const state = (body.state as string) ?? ""; const client = await getClient(db, clientId); if (!client) { return c.json({ error: "Unknown client_id" }, 400); } const allowedUris: string[] = JSON.parse(client.redirectUris); if (!allowedUris.includes(redirectUri)) { return c.json({ error: "redirect_uri not allowed" }, 400); } const { code } = await createAuthorizationCode( db, clientId, codeChallenge, codeChallengeMethod, redirectUri, ); const url = new URL(redirectUri); url.searchParams.set("code", code); if (state) url.searchParams.set("state", state); return c.redirect(url.toString(), 302); }); ``` 4. Update the `GET /authorize` handler to also check for OIDC session: - If user has OIDC session, show a simplified consent screen (just an "Authorize" button, no login form) - If no OIDC session, redirect to `/login` with return URL Replace `renderLoginForm` with a simpler `renderConsentForm` that shows the client name and an "Authorize" button (no username/password fields). The consent form POSTs to `/oauth/authorize` with the hidden fields (client_id, redirect_uri, code_challenge, code_challenge_method, state). If no OIDC session on GET /authorize, redirect: ```typescript oauthRoutes.get("/authorize", async (c) => { const auth = await getAuth(c); if (!auth) { return c.redirect(`/login?redirect=${encodeURIComponent(c.req.url)}`); } // ... show consent form ... }); ``` 5. Keep all other oauth routes unchanged: POST /register, POST /token, well-known endpoints **Update `src/server/mcp/index.ts`:** Per Pitfall 5, remove the `getUserCount` check from MCP auth middleware. 1. Remove `import { getUserCount } from "../services/auth.service.ts"` (only keep `verifyApiKey`) 2. Remove the `if (getUserCount(db) <= 0) { return next(); }` block 3. The MCP auth middleware should now only check Bearer token and API key -- no "skip if no users" bypass Updated MCP auth middleware: ```typescript mcpRoutes.use("/*", async (c, next) => { const db = c.get("db") ?? prodDb; // Try Bearer token first (OAuth) const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); if (await verifyAccessToken(db, token)) { return next(); } return c.json({ error: "invalid_token" }, 401); } // Try API key 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 const baseUrl = (process.env.GEARBOX_URL || new URL(c.req.url).origin).replace(/\/$/, ""); return c.text("Unauthorized", 401, { "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`, }); }); ``` ! grep -q "verifyPassword" src/server/routes/oauth.ts && ! grep -q "getUserCount" src/server/mcp/index.ts && grep -q "getAuth" src/server/routes/oauth.ts && echo "PASS" || echo "FAIL" - src/server/routes/oauth.ts does NOT import `verifyPassword` - src/server/routes/oauth.ts DOES import `getAuth` from `@hono/oidc-auth` - src/server/routes/oauth.ts POST /authorize checks OIDC session via `getAuth(c)` instead of username/password - src/server/routes/oauth.ts GET /authorize redirects to `/login` if no OIDC session - src/server/routes/oauth.ts does NOT contain `renderLoginForm` with username/password fields - src/server/routes/oauth.ts DOES contain a consent form with just an "Authorize" button (no credential fields) - src/server/mcp/index.ts does NOT import `getUserCount` - src/server/mcp/index.ts does NOT contain `getUserCount` call - src/server/mcp/index.ts DOES still import `verifyApiKey` - src/server/mcp/index.ts DOES still import `verifyAccessToken` - All well-known routes, POST /register, POST /token remain unchanged MCP OAuth uses OIDC session for authorization, MCP middleware has no getUserCount bypass, both auth domains coexist cleanly - `bun run build` succeeds (TypeScript compiles without errors referencing removed functions/tables) - `grep -rn "getUserCount\|createUser\|verifyPassword\|createSession\|getSession\|deleteSession\|refreshSession" src/server/` returns NO matches - `grep -rn "getAuth" src/server/middleware/auth.ts src/server/routes/auth.ts src/server/routes/oauth.ts` shows usage in all three files - `grep "verifyApiKey" src/server/middleware/auth.ts` confirms API key path preserved - `grep "verifyAccessToken" src/server/middleware/auth.ts` confirms MCP Bearer path preserved - Three-way auth middleware works: API key, MCP Bearer, OIDC session - Browser auth flow: /login redirects to Logto, /callback processes return, /logout clears session - /api/auth/me returns OIDC user identity or null - API key CRUD at /api/auth/keys preserved and functional - MCP OAuth authorize uses OIDC session instead of removed password verification - MCP auth middleware has no getUserCount bypass - No references to removed user/session functions anywhere in src/server/ - TypeScript compiles cleanly After completion, create `.planning/phases/15-external-authentication/15-02-SUMMARY.md`