feat(15-01): add Logto service to Docker Compose and create init script
- Add Logto OIDC provider to docker-compose.yml and docker-compose.dev.yml - Create docker/init-logto-db.sql to initialize separate Logto database on Postgres - Add OIDC env vars (issuer, client ID/secret, auth secret) to app service - Document all required env vars in .env.example
This commit is contained in:
555
.planning/phases/15-external-authentication/15-02-PLAN.md
Normal file
555
.planning/phases/15-external-authentication/15-02-PLAN.md
Normal file
@@ -0,0 +1,555 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Current auth service exports that will be modified -->
|
||||
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<boolean>
|
||||
export async function listApiKeys(db: Db): Promise<{...}[]>
|
||||
export async function deleteApiKey(db: Db, id: number): Promise<void>
|
||||
```
|
||||
|
||||
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<number>
|
||||
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<boolean>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install OIDC dependencies and rewrite auth middleware + service</name>
|
||||
<files>package.json, src/server/middleware/auth.ts, src/server/services/auth.service.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
**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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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`
|
||||
</acceptance_criteria>
|
||||
<done>@hono/oidc-auth installed, middleware does three-way auth check, service only has API key functions</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Rewrite auth routes for OIDC login/callback/logout + API key CRUD</name>
|
||||
<files>src/server/routes/auth.ts, src/server/index.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
**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<Env>();
|
||||
|
||||
// ── 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>Auth routes serve OIDC login/callback/logout at root, /me returns OIDC claims, API key CRUD preserved</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Update MCP OAuth authorize and MCP auth middleware for OIDC</name>
|
||||
<files>src/server/routes/oauth.ts, src/server/mcp/index.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
**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"`,
|
||||
});
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>! 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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>MCP OAuth uses OIDC session for authorization, MCP middleware has no getUserCount bypass, both auth domains coexist cleanly</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/15-external-authentication/15-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user