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:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# Logto OIDC (get from Logto Admin Console at http://localhost:3002)
|
||||
LOGTO_ENDPOINT=http://localhost:3001
|
||||
LOGTO_ADMIN_ENDPOINT=http://localhost:3002
|
||||
LOGTO_CLIENT_ID=your-app-client-id
|
||||
LOGTO_CLIENT_SECRET=your-app-client-secret
|
||||
OIDC_AUTH_SECRET=generate-a-random-32-char-string-here
|
||||
|
||||
# Derived (set in docker-compose.yml, not needed here):
|
||||
# OIDC_ISSUER=${LOGTO_ENDPOINT}/oidc
|
||||
|
||||
# GearBox
|
||||
GEARBOX_URL=http://localhost:3000
|
||||
220
.planning/phases/15-external-authentication/15-01-PLAN.md
Normal file
220
.planning/phases/15-external-authentication/15-01-PLAN.md
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
phase: 15-external-authentication
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- docker-compose.yml
|
||||
- docker-compose.dev.yml
|
||||
- docker/init-logto-db.sql
|
||||
- src/db/schema.ts
|
||||
- .env.example
|
||||
autonomous: true
|
||||
requirements: [AUTH-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Logto container starts alongside Postgres in docker-compose"
|
||||
- "Logto admin console is accessible at port 3002"
|
||||
- "Logto OIDC discovery endpoint responds at /oidc/.well-known/openid-configuration"
|
||||
- "GearBox schema no longer contains users or sessions tables"
|
||||
- "A separate logto database is created automatically on Postgres first boot"
|
||||
artifacts:
|
||||
- path: "docker-compose.yml"
|
||||
provides: "Production Logto service definition"
|
||||
contains: "svhd/logto"
|
||||
- path: "docker-compose.dev.yml"
|
||||
provides: "Dev Logto service definition"
|
||||
contains: "svhd/logto"
|
||||
- path: "docker/init-logto-db.sql"
|
||||
provides: "Postgres init script creating logto database"
|
||||
contains: "CREATE DATABASE logto"
|
||||
- path: "src/db/schema.ts"
|
||||
provides: "Schema without users/sessions tables"
|
||||
- path: ".env.example"
|
||||
provides: "Documentation of required OIDC env vars"
|
||||
contains: "OIDC_ISSUER"
|
||||
key_links:
|
||||
- from: "docker-compose.yml"
|
||||
to: "docker/init-logto-db.sql"
|
||||
via: "postgres volume mount to docker-entrypoint-initdb.d"
|
||||
pattern: "init-logto-db.sql:/docker-entrypoint-initdb.d"
|
||||
- from: "docker-compose.yml logto service"
|
||||
to: "docker-compose.yml postgres service"
|
||||
via: "depends_on with service_healthy"
|
||||
pattern: "condition: service_healthy"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add Logto as a Docker Compose service and remove the users/sessions tables from the GearBox schema.
|
||||
|
||||
Purpose: Establishes the infrastructure foundation for OIDC authentication -- Logto must be running before server-side auth code can be integrated. Schema changes remove the old auth tables that will be replaced by Logto-managed identity.
|
||||
|
||||
Output: Updated docker-compose files with Logto, cleaned schema, env var documentation.
|
||||
</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
|
||||
@src/db/schema.ts
|
||||
@docker-compose.yml
|
||||
@docker-compose.dev.yml
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add Logto service to Docker Compose and create init script</name>
|
||||
<files>docker-compose.yml, docker-compose.dev.yml, docker/init-logto-db.sql, .env.example</files>
|
||||
<read_first>
|
||||
- docker-compose.yml (current production compose)
|
||||
- docker-compose.dev.yml (current dev compose)
|
||||
- .planning/phases/15-external-authentication/15-RESEARCH.md (Pattern 4: Logto Docker Compose Integration, Pitfall 1: OIDC Issuer URL Mismatch)
|
||||
</read_first>
|
||||
<action>
|
||||
**Per D-13 and D-14:** Add Logto as a service in both docker-compose files.
|
||||
|
||||
1. Create `docker/init-logto-db.sql` with content:
|
||||
```sql
|
||||
-- Creates a separate database for Logto on the shared Postgres instance
|
||||
CREATE DATABASE logto;
|
||||
```
|
||||
|
||||
2. Update `docker-compose.yml` (production):
|
||||
- Add volume mount on postgres service: `./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql`
|
||||
- Add `logto` service:
|
||||
```yaml
|
||||
logto:
|
||||
image: svhd/logto:latest
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "3002:3002"
|
||||
environment:
|
||||
TRUST_PROXY_HEADER: "1"
|
||||
DB_URL: postgres://gearbox:${POSTGRES_PASSWORD}@postgres:5432/logto
|
||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
||||
```
|
||||
- Add to `app` service environment:
|
||||
```yaml
|
||||
OIDC_ISSUER: ${LOGTO_ENDPOINT:-http://localhost:3001}/oidc
|
||||
OIDC_CLIENT_ID: ${LOGTO_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${LOGTO_CLIENT_SECRET}
|
||||
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET}
|
||||
```
|
||||
- Add `depends_on` for app -> logto: `condition: service_started`
|
||||
|
||||
3. Update `docker-compose.dev.yml`:
|
||||
- Add the same postgres init volume mount
|
||||
- Add same `logto` service definition (ports 3001, 3002)
|
||||
- Logto environment uses hardcoded dev password: `DB_URL: postgres://gearbox:gearbox@postgres:5432/logto`
|
||||
|
||||
4. Create or update `.env.example` with all new OIDC env vars:
|
||||
```
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# Logto OIDC (get from Logto Admin Console at http://localhost:3002)
|
||||
LOGTO_ENDPOINT=http://localhost:3001
|
||||
LOGTO_ADMIN_ENDPOINT=http://localhost:3002
|
||||
LOGTO_CLIENT_ID=your-app-client-id
|
||||
LOGTO_CLIENT_SECRET=your-app-client-secret
|
||||
OIDC_AUTH_SECRET=generate-a-random-32-char-string-here
|
||||
|
||||
# GearBox
|
||||
GEARBOX_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
**IMPORTANT (Pitfall 1):** The `ENDPOINT` on Logto and `OIDC_ISSUER` on the app must both use the *externally accessible* URL (e.g., `http://localhost:3001`), NOT Docker-internal hostnames. The browser redirect and server-side JWT validation must agree on the issuer string.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "svhd/logto" docker-compose.yml && grep -q "svhd/logto" docker-compose.dev.yml && grep -q "CREATE DATABASE logto" docker/init-logto-db.sql && grep -q "OIDC_ISSUER" .env.example && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- docker-compose.yml contains `image: svhd/logto:latest`
|
||||
- docker-compose.yml logto service has `depends_on: postgres: condition: service_healthy`
|
||||
- docker-compose.yml logto service exposes ports 3001 and 3002
|
||||
- docker-compose.yml postgres service has volume mount containing `init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql`
|
||||
- docker-compose.yml app service has `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_AUTH_SECRET` env vars
|
||||
- docker-compose.dev.yml contains matching logto service definition
|
||||
- docker/init-logto-db.sql contains `CREATE DATABASE logto;`
|
||||
- .env.example contains `LOGTO_CLIENT_ID`, `LOGTO_CLIENT_SECRET`, `OIDC_AUTH_SECRET`, `LOGTO_ENDPOINT`
|
||||
</acceptance_criteria>
|
||||
<done>Both docker-compose files have Logto service, init SQL creates logto database, env vars documented</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Remove users and sessions tables from schema and generate migration</name>
|
||||
<files>src/db/schema.ts</files>
|
||||
<read_first>
|
||||
- src/db/schema.ts (current full schema with users, sessions, apiKeys, oauth* tables)
|
||||
- .planning/phases/15-external-authentication/15-CONTEXT.md (D-03: Remove users and sessions tables)
|
||||
</read_first>
|
||||
<action>
|
||||
**Per D-03:** Remove the `users` and `sessions` table definitions from `src/db/schema.ts`. Keep everything else: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `apiKeys`, `oauthClients`, `oauthCodes`, `oauthTokens`.
|
||||
|
||||
Specifically:
|
||||
1. Delete the `users` table definition (lines defining `export const users = pgTable("users", { ... })`)
|
||||
2. Delete the `sessions` table definition (lines defining `export const sessions = pgTable("sessions", { ... })`)
|
||||
3. Remove the `boolean` import from `drizzle-orm/pg-core` if no longer used (check: `oauthCodes` uses `boolean` for `used` field, so keep it)
|
||||
4. Do NOT remove `apiKeys` table -- it stays per D-10
|
||||
|
||||
After editing schema, run migration generation:
|
||||
```bash
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
This creates a Drizzle migration SQL file in `drizzle/` that drops the `users` and `sessions` tables. Review the generated migration to confirm it only drops `users` and `sessions` -- no other tables.
|
||||
|
||||
**Do NOT run `bun run db:push` yet** -- that will be done when the full auth refactor is ready.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>! grep -q "export const users" src/db/schema.ts && ! grep -q "export const sessions" src/db/schema.ts && grep -q "export const apiKeys" src/db/schema.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- src/db/schema.ts does NOT contain `export const users`
|
||||
- src/db/schema.ts does NOT contain `export const sessions`
|
||||
- src/db/schema.ts DOES contain `export const apiKeys`
|
||||
- src/db/schema.ts DOES contain `export const oauthClients`
|
||||
- src/db/schema.ts DOES contain `export const oauthCodes`
|
||||
- src/db/schema.ts DOES contain `export const oauthTokens`
|
||||
- A new migration file exists in drizzle/ directory
|
||||
</acceptance_criteria>
|
||||
<done>Users and sessions tables removed from schema, migration generated to drop them</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `grep -q "svhd/logto" docker-compose.yml` succeeds
|
||||
- `grep -q "svhd/logto" docker-compose.dev.yml` succeeds
|
||||
- `docker/init-logto-db.sql` exists with CREATE DATABASE logto
|
||||
- `src/db/schema.ts` has no `users` or `sessions` exports
|
||||
- `src/db/schema.ts` retains `apiKeys`, `oauthClients`, `oauthCodes`, `oauthTokens`
|
||||
- New Drizzle migration file exists in `drizzle/`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Logto service defined in both docker-compose files with correct ports, env vars, and Postgres dependency
|
||||
- Postgres init script creates the logto database
|
||||
- GearBox schema has users and sessions tables removed
|
||||
- Drizzle migration generated for the table drops
|
||||
- All OIDC-related environment variables documented in .env.example
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/15-external-authentication/15-01-SUMMARY.md`
|
||||
</output>
|
||||
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>
|
||||
423
.planning/phases/15-external-authentication/15-03-PLAN.md
Normal file
423
.planning/phases/15-external-authentication/15-03-PLAN.md
Normal file
@@ -0,0 +1,423 @@
|
||||
---
|
||||
phase: 15-external-authentication
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["15-02"]
|
||||
files_modified:
|
||||
- src/client/routes/login.tsx
|
||||
- src/client/hooks/useAuth.ts
|
||||
- e2e/seed.ts
|
||||
- tests/middleware/auth.test.ts
|
||||
- tests/services/auth.service.test.ts
|
||||
- tests/routes/auth.test.ts
|
||||
autonomous: false
|
||||
requirements: [AUTH-05, AUTH-01, AUTH-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Login page redirects users to Logto instead of showing a credential form"
|
||||
- "useAuth hook returns OIDC-based user identity (sub string, not integer id)"
|
||||
- "E2E seed script creates API keys directly without inserting into users table"
|
||||
- "E2E tests authenticate via API key header, not Logto"
|
||||
- "Unit tests for auth middleware and service pass without users/sessions tables"
|
||||
artifacts:
|
||||
- path: "src/client/routes/login.tsx"
|
||||
provides: "Login page that redirects to /login (OIDC redirect)"
|
||||
- path: "src/client/hooks/useAuth.ts"
|
||||
provides: "Auth hooks without useLogin, useSetup, useChangePassword"
|
||||
exports: ["useAuth", "useLogout", "useApiKeys", "useCreateApiKey", "useDeleteApiKey"]
|
||||
- path: "e2e/seed.ts"
|
||||
provides: "E2E seed without users table insert"
|
||||
- path: "tests/middleware/auth.test.ts"
|
||||
provides: "Middleware tests for three-way auth"
|
||||
- path: "tests/services/auth.service.test.ts"
|
||||
provides: "Service tests for API key functions only"
|
||||
- path: "tests/routes/auth.test.ts"
|
||||
provides: "Route tests for /me and /keys endpoints"
|
||||
key_links:
|
||||
- from: "src/client/hooks/useAuth.ts"
|
||||
to: "/api/auth/me"
|
||||
via: "apiGet fetch"
|
||||
pattern: "apiGet.*api/auth/me"
|
||||
- from: "src/client/routes/login.tsx"
|
||||
to: "/login"
|
||||
via: "window.location redirect to OIDC login"
|
||||
pattern: "window.location|/login"
|
||||
- from: "e2e/seed.ts"
|
||||
to: "apiKeys table"
|
||||
via: "direct insert"
|
||||
pattern: "apiKeys"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Update the client-side auth UI, auth hooks, E2E seed script, and all auth-related tests to work with the new OIDC-based authentication.
|
||||
|
||||
Purpose: The server-side auth was rewritten in Plan 02. This plan brings the client and tests into alignment -- login page redirects to Logto, hooks match new API responses, E2E tests use API keys per AUTH-05, and unit/integration tests validate the new auth architecture.
|
||||
|
||||
Output: Working client auth flow, passing unit tests, E2E-ready seed script.
|
||||
</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
|
||||
@.planning/phases/15-external-authentication/15-02-SUMMARY.md
|
||||
@src/client/routes/login.tsx
|
||||
@src/client/hooks/useAuth.ts
|
||||
@e2e/seed.ts
|
||||
@tests/middleware/auth.test.ts
|
||||
@tests/services/auth.service.test.ts
|
||||
@tests/routes/auth.test.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- New server API contracts from Plan 02 -->
|
||||
|
||||
GET /api/auth/me response (new shape):
|
||||
```typescript
|
||||
// Authenticated (OIDC session):
|
||||
{ user: { id: string, email?: string }, authenticated: true }
|
||||
// Not authenticated:
|
||||
{ user: null, authenticated: false }
|
||||
```
|
||||
Note: user.id is now a string (Logto sub claim), NOT an integer.
|
||||
|
||||
GET /login behavior: Redirects to Logto OIDC provider (server-side redirect via @hono/oidc-auth)
|
||||
GET /callback behavior: Processes OIDC callback, sets session cookie, redirects to /
|
||||
GET /logout behavior: Revokes OIDC session, redirects to /login
|
||||
|
||||
API key routes unchanged:
|
||||
GET /api/auth/keys -> ApiKeyListItem[]
|
||||
POST /api/auth/keys { name: string } -> { id, name, key, prefix }
|
||||
DELETE /api/auth/keys/:id -> { ok: true }
|
||||
|
||||
Auth middleware (from Plan 02):
|
||||
```typescript
|
||||
export async function requireAuth(c: Context, next: Next)
|
||||
// Checks: X-API-Key header -> Bearer token -> OIDC session cookie
|
||||
```
|
||||
|
||||
Auth service exports (from Plan 02):
|
||||
```typescript
|
||||
export async function createApiKey(db, name): Promise<{id, name, keyHash, keyPrefix, createdAt, rawKey}>
|
||||
export async function verifyApiKey(db, rawKey): Promise<boolean>
|
||||
export async function listApiKeys(db): Promise<{id, name, keyPrefix, createdAt}[]>
|
||||
export async function deleteApiKey(db, id): Promise<void>
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Rewrite login page and auth hooks for OIDC</name>
|
||||
<files>src/client/routes/login.tsx, src/client/hooks/useAuth.ts</files>
|
||||
<read_first>
|
||||
- src/client/routes/login.tsx (current login form with username/password)
|
||||
- src/client/hooks/useAuth.ts (current hooks: useAuth, useLogin, useSetup, useChangePassword, useLogout, useApiKeys, useCreateApiKey, useDeleteApiKey)
|
||||
- .planning/phases/15-external-authentication/15-CONTEXT.md (D-07: /login becomes redirect trigger, D-06: registration on Logto)
|
||||
</read_first>
|
||||
<action>
|
||||
**Rewrite `src/client/hooks/useAuth.ts`:**
|
||||
|
||||
Per D-07 and D-06, remove hooks that relied on credential-based auth:
|
||||
- Remove `useLogin` (no more POST /api/auth/login)
|
||||
- Remove `useSetup` (no more POST /api/auth/setup)
|
||||
- Remove `useChangePassword` (no more PUT /api/auth/password)
|
||||
|
||||
Update `useAuth`:
|
||||
- Change `AuthState` interface: `user` is now `{ id: string; email?: string } | null` (id changed from number to string per Logto sub claim)
|
||||
- Remove `setupRequired` field -- first-run setup is on Logto admin console
|
||||
- New interface:
|
||||
```typescript
|
||||
interface AuthState {
|
||||
user: { id: string; email?: string } | null;
|
||||
authenticated: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Update `useLogout`:
|
||||
- Change from `apiPost("/api/auth/logout", {})` to `window.location.href = "/logout"` (server-side OIDC logout via redirect)
|
||||
- Since this is a redirect (not an API call), use a simple function instead of useMutation:
|
||||
```typescript
|
||||
export function useLogout() {
|
||||
const logout = () => {
|
||||
window.location.href = "/logout";
|
||||
};
|
||||
return { logout };
|
||||
}
|
||||
```
|
||||
|
||||
Keep unchanged: `useApiKeys`, `useCreateApiKey`, `useDeleteApiKey` (API key CRUD routes are the same).
|
||||
|
||||
Final exports: `useAuth`, `useLogout`, `useApiKeys`, `useCreateApiKey`, `useDeleteApiKey`.
|
||||
|
||||
**Rewrite `src/client/routes/login.tsx`:**
|
||||
|
||||
Per D-07: The login page becomes a redirect trigger to Logto, not a credential form.
|
||||
|
||||
Replace the entire form with a simple page that:
|
||||
1. On mount, checks if user is already authenticated via `useAuth()`
|
||||
2. If authenticated, redirects to `/` via TanStack Router `navigate`
|
||||
3. If not authenticated, shows a centered card with "Sign in to GearBox" heading and a "Sign in" button
|
||||
4. The "Sign in" button sets `window.location.href = "/login"` which triggers the server-side OIDC redirect to Logto
|
||||
|
||||
```typescript
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: auth, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth?.authenticated) {
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
}, [auth, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-gray-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
|
||||
Sign in to GearBox
|
||||
</h1>
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
You will be redirected to sign in with your account.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = "/login"; }}
|
||||
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: The client route is `/login` (TanStack Router) and the server route is also `GET /login` (OIDC redirect). The client-side route renders the UI. When the user clicks "Sign In", `window.location.href = "/login"` does a full-page navigation to the server's GET /login which triggers the OIDC redirect to Logto. This works because in dev mode, Vite proxies unmatched paths to the Hono server, and in production, the SPA serves index.html for client routes but the server handles `/login` before the SPA fallback.
|
||||
|
||||
**IMPORTANT:** Check `src/server/index.ts` from Plan 02 -- the server-side `/login` route must be registered BEFORE the SPA static file fallback so it takes priority.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>! grep -q "useLogin\|useSetup\|useChangePassword" src/client/hooks/useAuth.ts && grep -q "authenticated" src/client/hooks/useAuth.ts && ! grep -q "setupRequired" src/client/hooks/useAuth.ts && ! grep -q 'id: number' src/client/hooks/useAuth.ts && grep -q "window.location.href" src/client/routes/login.tsx && ! grep -q "handleSubmit" src/client/routes/login.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- src/client/hooks/useAuth.ts does NOT export `useLogin`, `useSetup`, or `useChangePassword`
|
||||
- src/client/hooks/useAuth.ts AuthState has `user: { id: string; email?: string } | null`
|
||||
- src/client/hooks/useAuth.ts AuthState has `authenticated: boolean` (not `setupRequired`)
|
||||
- src/client/hooks/useAuth.ts useLogout uses `window.location.href = "/logout"` (not apiPost)
|
||||
- src/client/hooks/useAuth.ts DOES export `useAuth`, `useLogout`, `useApiKeys`, `useCreateApiKey`, `useDeleteApiKey`
|
||||
- src/client/routes/login.tsx does NOT contain a `<form>` element
|
||||
- src/client/routes/login.tsx does NOT contain username/password `<input>` elements
|
||||
- src/client/routes/login.tsx DOES contain `window.location.href = "/login"` in button onClick
|
||||
- src/client/routes/login.tsx DOES import `useAuth` from hooks
|
||||
</acceptance_criteria>
|
||||
<done>Login page redirects to Logto via server, auth hooks match new OIDC-based API responses, no credential forms remain</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update E2E seed script and auth-related tests</name>
|
||||
<files>e2e/seed.ts, tests/middleware/auth.test.ts, tests/services/auth.service.test.ts, tests/routes/auth.test.ts</files>
|
||||
<read_first>
|
||||
- e2e/seed.ts (current seed creates user with password hash in users table)
|
||||
- tests/middleware/auth.test.ts (current tests for requireAuth middleware)
|
||||
- tests/services/auth.service.test.ts (current tests for user/session/apiKey service functions)
|
||||
- tests/routes/auth.test.ts (current tests for auth routes)
|
||||
- tests/helpers/db.ts (test database setup helper)
|
||||
- src/server/middleware/auth.ts (new middleware from Plan 02 -- to understand what to test)
|
||||
- src/server/services/auth.service.ts (new service from Plan 02 -- only API key functions)
|
||||
- src/server/routes/auth.ts (new routes from Plan 02 -- /me and /keys)
|
||||
</read_first>
|
||||
<action>
|
||||
**Update `e2e/seed.ts`:**
|
||||
|
||||
Per AUTH-05 and Pitfall 4: E2E tests authenticate via API keys, no Logto dependency.
|
||||
|
||||
1. Remove the user creation block:
|
||||
```typescript
|
||||
// DELETE THIS:
|
||||
const passwordHash = await Bun.password.hash("password123");
|
||||
db.insert(schema.users).values({ username: "admin", passwordHash }).run();
|
||||
```
|
||||
|
||||
2. Add API key creation instead:
|
||||
```typescript
|
||||
// Create API key for E2E test authentication
|
||||
const rawKey = "e2e-test-api-key-for-gearbox-testing";
|
||||
const keyHash = await Bun.password.hash(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 8);
|
||||
db.insert(schema.apiKeys)
|
||||
.values({ name: "E2E Test Key", keyHash, keyPrefix })
|
||||
.run();
|
||||
```
|
||||
|
||||
3. Remove `import { users } from "../src/db/schema"` if it was used only for user creation. The seed script imports `* as schema`, so just remove the `schema.users` usage.
|
||||
|
||||
4. The seed script still uses `bun:sqlite` and Drizzle SQLite adapter for now (E2E tests run against SQLite). This is fine -- the `users` table won't exist in the generated schema migration. However, the seed script uses `migrate(db, { migrationsFolder: "./drizzle" })` which will apply the latest migration that drops the users table. So removing the users insert is necessary to prevent a "table not found" error.
|
||||
|
||||
**IMPORTANT:** The seed script will also need to handle that the `sessions` table is dropped. Verify there are no references to `schema.sessions` in the seed script (there shouldn't be based on current code).
|
||||
|
||||
**Update `tests/services/auth.service.test.ts`:**
|
||||
|
||||
Remove ALL tests for removed functions:
|
||||
- Tests for `createUser`, `verifyPassword`, `getUserCount`, `changePassword`
|
||||
- Tests for `createSession`, `getSession`, `deleteSession`, `refreshSession`
|
||||
|
||||
Keep ALL tests for API key functions:
|
||||
- Tests for `createApiKey`, `verifyApiKey`, `listApiKeys`, `deleteApiKey`
|
||||
|
||||
Update imports to only import the kept functions from `auth.service.ts`. Remove imports of `users`, `sessions` from schema if present.
|
||||
|
||||
The test db helper creates tables from migrations, so after Plan 01's migration drops users/sessions, the test DB won't have those tables either. API key tests should work unchanged.
|
||||
|
||||
**Update `tests/middleware/auth.test.ts`:**
|
||||
|
||||
The middleware now has three auth paths. Rewrite tests:
|
||||
|
||||
Remove:
|
||||
- Tests for `setup_required` response (getUserCount === 0 case -- removed)
|
||||
- Tests for cookie session auth path
|
||||
- Any mocking of `getSession`, `refreshSession`, `getUserCount`
|
||||
|
||||
Update/Add:
|
||||
- Test: API key in `X-API-Key` header -> valid -> 200 (keep existing)
|
||||
- Test: API key in `X-API-Key` header -> invalid -> 401 (keep existing)
|
||||
- Test: Bearer token in Authorization header -> valid -> 200 (new)
|
||||
- Test: Bearer token in Authorization header -> invalid -> 401 (new)
|
||||
- Test: No auth headers, no OIDC session -> 401 (update existing)
|
||||
- Test: OIDC session exists -> 200 (new -- mock `getAuth` from @hono/oidc-auth)
|
||||
|
||||
For mocking `getAuth` from `@hono/oidc-auth`, use `mock.module` (Bun's mock facility):
|
||||
```typescript
|
||||
import { mock } from "bun:test";
|
||||
|
||||
// Mock @hono/oidc-auth
|
||||
const mockGetAuth = mock(() => null);
|
||||
mock.module("@hono/oidc-auth", () => ({
|
||||
getAuth: mockGetAuth,
|
||||
oidcAuthMiddleware: () => async (c, next) => next(),
|
||||
processOAuthCallback: async (c) => c.json({ ok: true }),
|
||||
revokeSession: async () => {},
|
||||
}));
|
||||
```
|
||||
|
||||
Then in tests, set `mockGetAuth.mockReturnValue(...)` to simulate authenticated/unauthenticated OIDC sessions.
|
||||
|
||||
**Update `tests/routes/auth.test.ts`:**
|
||||
|
||||
Remove tests for:
|
||||
- POST /auth/login (removed)
|
||||
- POST /auth/setup (removed)
|
||||
- PUT /auth/password (removed)
|
||||
|
||||
Update tests for:
|
||||
- GET /auth/me -- now returns `{ user: { id: string, email: string }, authenticated: true }` or `{ user: null, authenticated: false }`
|
||||
- Mock `getAuth` to simulate OIDC session for /me tests
|
||||
|
||||
Keep tests for:
|
||||
- GET /auth/keys (requires auth -- use API key in test)
|
||||
- POST /auth/keys (requires auth)
|
||||
- DELETE /auth/keys/:id (requires auth)
|
||||
|
||||
Note: GET /login, GET /callback, GET /logout are registered in index.ts not authRoutes, so they are NOT tested in auth route tests. They would be E2E-level tests.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>! grep -q "schema.users" e2e/seed.ts && grep -q "apiKeys" e2e/seed.ts && ! grep -q "createUser\|verifyPassword\|getUserCount\|createSession\|getSession" tests/services/auth.service.test.ts && grep -q "verifyApiKey\|createApiKey" tests/services/auth.service.test.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- e2e/seed.ts does NOT insert into `schema.users`
|
||||
- e2e/seed.ts DOES insert an API key into `schema.apiKeys` with name "E2E Test Key"
|
||||
- e2e/seed.ts still seeds categories, items, threads, setups, settings
|
||||
- tests/services/auth.service.test.ts does NOT test `createUser`, `verifyPassword`, `getUserCount`, `changePassword`, `createSession`, `getSession`, `deleteSession`, `refreshSession`
|
||||
- tests/services/auth.service.test.ts DOES test `createApiKey`, `verifyApiKey`, `listApiKeys`, `deleteApiKey`
|
||||
- tests/middleware/auth.test.ts does NOT test `setup_required` response
|
||||
- tests/middleware/auth.test.ts DOES test API key auth path
|
||||
- tests/middleware/auth.test.ts DOES test Bearer token auth path
|
||||
- tests/middleware/auth.test.ts DOES mock and test OIDC session auth path via `getAuth`
|
||||
- tests/routes/auth.test.ts does NOT test POST /login, POST /setup, PUT /password
|
||||
- tests/routes/auth.test.ts DOES test GET /me with mocked OIDC session
|
||||
- tests/routes/auth.test.ts DOES test API key CRUD routes
|
||||
</acceptance_criteria>
|
||||
<done>E2E seed uses API keys, all auth tests updated for OIDC architecture, no references to removed user/session functions</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Verify OIDC login flow with running Logto</name>
|
||||
<what-built>Complete OIDC authentication integration: Logto in Docker Compose, server-side OIDC middleware, client-side login redirect, API key continuity, updated tests</what-built>
|
||||
<how-to-verify>
|
||||
1. Start infrastructure: `docker compose -f docker-compose.dev.yml up -d`
|
||||
2. Verify Logto is running: visit http://localhost:3002 (Logto Admin Console)
|
||||
3. In Logto Admin Console:
|
||||
a. Create a "Traditional Web" application
|
||||
b. Set redirect URI: `http://localhost:3000/callback`
|
||||
c. Set post-logout redirect URI: `http://localhost:3000/login`
|
||||
d. Copy App ID and App Secret
|
||||
4. Create a `.env` file with:
|
||||
```
|
||||
OIDC_ISSUER=http://localhost:3001/oidc
|
||||
OIDC_CLIENT_ID=<copied app id>
|
||||
OIDC_CLIENT_SECRET=<copied app secret>
|
||||
OIDC_AUTH_SECRET=a-random-string-at-least-32-characters-long
|
||||
```
|
||||
5. Start GearBox: `bun run dev`
|
||||
6. Visit http://localhost:5173/login -- should see "Sign in to GearBox" page
|
||||
7. Click "Sign In" -- should redirect to Logto login page
|
||||
8. Register a new account on Logto
|
||||
9. After registration, should redirect back to GearBox dashboard
|
||||
10. Visit http://localhost:5173 -- should show authenticated state
|
||||
11. Run unit tests: `bun test` -- all should pass
|
||||
12. Verify API key auth still works: create a key in Settings, test with curl:
|
||||
`curl -H "X-API-Key: <key>" http://localhost:3000/api/items`
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe issues found during verification</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `bun test` passes (all auth-related tests updated)
|
||||
- `bun run build` succeeds (no TypeScript errors)
|
||||
- E2E seed script runs without error: `bun run e2e/seed.ts`
|
||||
- No references to removed hooks in client code: `grep -rn "useLogin\|useSetup\|useChangePassword" src/client/`
|
||||
- No references to removed auth functions in test code: `grep -rn "createUser\|verifyPassword\|getUserCount" tests/`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Login page shows redirect button, not credential form
|
||||
- Auth hooks match new OIDC API response shape
|
||||
- E2E seed creates API key, not user
|
||||
- All unit/integration tests pass
|
||||
- Full OIDC login flow works end-to-end with Logto (verified by human checkpoint)
|
||||
- API keys still work for programmatic access
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/15-external-authentication/15-03-SUMMARY.md`
|
||||
</output>
|
||||
121
.planning/phases/15-external-authentication/15-CONTEXT.md
Normal file
121
.planning/phases/15-external-authentication/15-CONTEXT.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Phase 15: External Authentication - Context
|
||||
|
||||
**Gathered:** 2026-04-04
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Replace GearBox's built-in username/password authentication with Logto, a self-hosted open-source OIDC provider. Users register and log in through Logto. GearBox validates OIDC tokens instead of managing its own user credentials and sessions. API keys remain functional for programmatic access (MCP, scripts).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Auth Provider Choice
|
||||
- **D-01:** Use **Logto** as the external auth provider (not Authentik). Logto is purpose-built for auth, lighter-weight, no Redis required, first-class OIDC support, simpler deployment.
|
||||
|
||||
### Session Strategy
|
||||
- **D-02:** Replace GearBox's cookie-session system with OIDC-based authentication. Logto manages user sessions. GearBox validates tokens on each request.
|
||||
- **D-03:** Remove the `users` and `sessions` tables from GearBox schema — user identity comes from Logto. Keep `apiKeys` table for programmatic access.
|
||||
- **D-04:** The `requireAuth` middleware validates either an API key (X-API-Key header) OR an OIDC token/session from Logto. Both paths resolve to a user identity.
|
||||
|
||||
### Login Flow
|
||||
- **D-05:** Standard OIDC redirect flow. User clicks "Login" on GearBox → redirected to Logto login page → authenticated → redirected back with authorization code → GearBox exchanges code for tokens.
|
||||
- **D-06:** Registration happens on Logto's side — GearBox does not have its own registration form. Logto handles password reset, email verification, etc.
|
||||
- **D-07:** The existing `/login` route becomes a redirect trigger to Logto, not a credential form.
|
||||
|
||||
### Existing User Migration
|
||||
- **D-08:** Single user re-registers manually on Logto (one-time operation). A migration step links the Logto user ID to existing GearBox data.
|
||||
- **D-09:** No automated user import — only one existing user.
|
||||
|
||||
### API Key Continuity
|
||||
- **D-10:** API keys continue to work exactly as they do now. The `apiKeys` table remains in GearBox's schema.
|
||||
- **D-11:** API key management UI stays in Settings. Creating/deleting keys requires an authenticated OIDC session.
|
||||
|
||||
### MCP OAuth Coexistence
|
||||
- **D-12:** The existing MCP OAuth 2.1 + PKCE flow (for Claude mobile/web) coexists with Logto. MCP OAuth uses GearBox's own oauth tables; user-facing auth uses Logto. These are separate auth domains.
|
||||
|
||||
### Docker Compose
|
||||
- **D-13:** Logto runs as a service in docker-compose alongside Postgres. Logto uses the same Postgres instance (separate database) or its own.
|
||||
- **D-14:** Development docker-compose includes Logto for local auth testing.
|
||||
|
||||
### Claude's Discretion
|
||||
- Logto SDK choice (official `@logto/node` vs generic OIDC client library)
|
||||
- Token storage mechanism (httpOnly cookie with OIDC tokens, or server-side session backed by Logto)
|
||||
- Logto configuration details (sign-in experience, branding, connector setup)
|
||||
- Whether to use Logto's user ID directly as the foreign key in GearBox tables or maintain a mapping table
|
||||
- E2E test authentication strategy (likely API keys per AUTH-05, bypassing Logto)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Existing Auth Code (to be replaced)
|
||||
- `src/server/routes/auth.ts` — Current login/logout/setup/password/keys routes
|
||||
- `src/server/services/auth.service.ts` — Current user/session/API key management
|
||||
- `src/server/middleware/auth.ts` — Current requireAuth middleware (API key + cookie session)
|
||||
- `src/client/routes/login.tsx` — Current login page UI
|
||||
|
||||
### Existing MCP OAuth (to preserve)
|
||||
- `src/server/routes/oauth.ts` — MCP OAuth 2.1 routes (keep separate from Logto)
|
||||
- `src/server/services/oauth.service.ts` — MCP OAuth service
|
||||
- `docs/superpowers/specs/2026-04-04-mcp-oauth-design.md` — MCP OAuth design spec
|
||||
|
||||
### Database Schema
|
||||
- `src/db/schema.ts` — Current schema with users, sessions, apiKeys, oauthClients/Codes/Tokens tables
|
||||
|
||||
### Docker
|
||||
- `docker-compose.yml` — Production compose (add Logto service)
|
||||
- `docker-compose.dev.yml` — Dev compose (add Logto service)
|
||||
|
||||
### Requirements
|
||||
- `.planning/REQUIREMENTS.md` — AUTH-01 through AUTH-05 requirements
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `requireAuth` middleware pattern — will be refactored but same middleware slot in Hono
|
||||
- API key verification logic (`verifyApiKey`) — keeps working unchanged
|
||||
- `apiKeys` table and CRUD — no changes needed
|
||||
- MCP OAuth routes and service — preserved as-is, separate auth domain
|
||||
|
||||
### Established Patterns
|
||||
- **Middleware DI**: `requireAuth` gets `db` from Hono context — same pattern continues
|
||||
- **Service layer**: Auth service functions take `db` as first param — new OIDC validation functions follow same pattern
|
||||
- **Cookie handling**: `hono/cookie` helpers for set/get/delete — may shift to OIDC token cookies
|
||||
|
||||
### Integration Points
|
||||
- `src/server/middleware/auth.ts` — Primary integration point for OIDC token validation
|
||||
- `src/server/index.ts` — Route registration (remove old auth routes, add OIDC callback route)
|
||||
- `src/client/routes/login.tsx` — Replace credential form with Logto redirect
|
||||
- `src/client/hooks/` — Auth state hooks (useAuth, etc.) need OIDC awareness
|
||||
- `docker-compose.yml` / `docker-compose.dev.yml` — Add Logto service
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
No specific requirements — open to standard approaches for Logto OIDC integration with Hono.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 15-external-authentication*
|
||||
*Context gathered: 2026-04-04*
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 15: External Authentication - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Phase:** 15-external-authentication
|
||||
**Areas discussed:** Auth Provider Choice, Session Migration Strategy, Login Flow UX, Existing User Migration
|
||||
**Mode:** --auto --batch (all decisions auto-selected)
|
||||
|
||||
---
|
||||
|
||||
## Auth Provider Choice
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Logto | Lightweight, purpose-built auth, no Redis, first-class OIDC, simpler deployment | ✓ |
|
||||
| Authentik | Full IdP suite, more features but heavier, may need Redis, more complex setup | |
|
||||
|
||||
**User's choice:** Logto (auto-selected)
|
||||
**Notes:** Matches project's "no Redis" out-of-scope constraint. Logto is simpler to deploy and maintain for a single-app use case.
|
||||
|
||||
---
|
||||
|
||||
## Session Migration Strategy
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Replace with OIDC session management | Logto handles sessions, remove users/sessions tables from GearBox | ✓ |
|
||||
| Hybrid — keep GearBox sessions populated from OIDC | Validate OIDC on login, create local session for subsequent requests | |
|
||||
| Token-only — validate OIDC token on every request | No local sessions, every request validates against Logto | |
|
||||
|
||||
**User's choice:** Replace with OIDC session management (auto-selected)
|
||||
**Notes:** Simplifies the codebase by removing credential management from GearBox entirely. API keys remain as the programmatic access path.
|
||||
|
||||
---
|
||||
|
||||
## Login Flow UX
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Redirect to Logto login page | Standard OIDC redirect, Logto handles UI for login/register/reset | ✓ |
|
||||
| Embedded login form via Logto SDK | Use Logto's SDK to render login inline within GearBox | |
|
||||
|
||||
**User's choice:** Redirect to Logto login page (auto-selected)
|
||||
**Notes:** Standard OIDC pattern. More secure, less maintenance — Logto owns the login/registration UX.
|
||||
|
||||
---
|
||||
|
||||
## Existing User Migration
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Manual re-registration on Logto | User creates account on Logto, migration script links to GearBox data | ✓ |
|
||||
| Automated import from GearBox users table | Script creates Logto user from existing credentials | |
|
||||
|
||||
**User's choice:** Manual re-registration on Logto (auto-selected)
|
||||
**Notes:** Only one existing user — automation not worth the complexity.
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Logto SDK choice
|
||||
- Token storage mechanism
|
||||
- Logto configuration and branding
|
||||
- User ID mapping strategy
|
||||
- E2E test auth approach
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
478
.planning/phases/15-external-authentication/15-RESEARCH.md
Normal file
478
.planning/phases/15-external-authentication/15-RESEARCH.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Phase 15: External Authentication - Research
|
||||
|
||||
**Researched:** 2026-04-04
|
||||
**Domain:** OIDC authentication with Logto, Hono middleware, Docker Compose
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 15 replaces GearBox's built-in username/password auth with Logto, a self-hosted OIDC provider. The core integration pattern is: `@hono/oidc-auth` middleware handles the OIDC redirect flow (login/callback/logout) for browser sessions, while API key authentication remains unchanged for programmatic access (MCP tools, scripts). The existing MCP OAuth 2.1 flow (for Claude mobile/web) is a separate auth domain and must be preserved as-is.
|
||||
|
||||
The architecture cleanly separates three auth paths: (1) OIDC sessions for browser users via Logto, (2) API keys via X-API-Key header for programmatic access, (3) MCP OAuth Bearer tokens for Claude mobile/web. The `requireAuth` middleware becomes a three-way check. The `users` and `sessions` tables are removed from GearBox's schema -- user identity comes from Logto. The `apiKeys` table stays.
|
||||
|
||||
**Primary recommendation:** Use `@hono/oidc-auth` (v1.8.1) as the OIDC middleware for Hono. It provides storage-less sessions via JWT cookies, handles the authorization code flow with refresh tokens, and requires no session store. Configure it to point at the Logto instance. For API token validation in non-browser contexts, use `jose` for JWT verification against Logto's JWKS endpoint.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **D-01:** Use Logto as the external auth provider (not Authentik). Logto is purpose-built for auth, lighter-weight, no Redis required, first-class OIDC support, simpler deployment.
|
||||
- **D-02:** Replace GearBox's cookie-session system with OIDC-based authentication. Logto manages user sessions. GearBox validates tokens on each request.
|
||||
- **D-03:** Remove the `users` and `sessions` tables from GearBox schema -- user identity comes from Logto. Keep `apiKeys` table for programmatic access.
|
||||
- **D-04:** The `requireAuth` middleware validates either an API key (X-API-Key header) OR an OIDC token/session from Logto. Both paths resolve to a user identity.
|
||||
- **D-05:** Standard OIDC redirect flow. User clicks "Login" on GearBox -> redirected to Logto login page -> authenticated -> redirected back with authorization code -> GearBox exchanges code for tokens.
|
||||
- **D-06:** Registration happens on Logto's side -- GearBox does not have its own registration form. Logto handles password reset, email verification, etc.
|
||||
- **D-07:** The existing `/login` route becomes a redirect trigger to Logto, not a credential form.
|
||||
- **D-08:** Single user re-registers manually on Logto (one-time operation). A migration step links the Logto user ID to existing GearBox data.
|
||||
- **D-09:** No automated user import -- only one existing user.
|
||||
- **D-10:** API keys continue to work exactly as they do now. The `apiKeys` table remains in GearBox's schema.
|
||||
- **D-11:** API key management UI stays in Settings. Creating/deleting keys requires an authenticated OIDC session.
|
||||
- **D-12:** The existing MCP OAuth 2.1 + PKCE flow (for Claude mobile/web) coexists with Logto. MCP OAuth uses GearBox's own oauth tables; user-facing auth uses Logto. These are separate auth domains.
|
||||
- **D-13:** Logto runs as a service in docker-compose alongside Postgres. Logto uses the same Postgres instance (separate database) or its own.
|
||||
- **D-14:** Development docker-compose includes Logto for local auth testing.
|
||||
|
||||
### Claude's Discretion
|
||||
- Logto SDK choice (official `@logto/node` vs generic OIDC client library)
|
||||
- Token storage mechanism (httpOnly cookie with OIDC tokens, or server-side session backed by Logto)
|
||||
- Logto configuration details (sign-in experience, branding, connector setup)
|
||||
- Whether to use Logto's user ID directly as the foreign key in GearBox tables or maintain a mapping table
|
||||
- E2E test authentication strategy (likely API keys per AUTH-05, bypassing Logto)
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None -- discussion stayed within phase scope.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| AUTH-01 | User can register an account via external OIDC auth provider | Logto handles registration via its built-in sign-up experience. GearBox redirects to Logto, which handles the form. On first login, Logto's `sub` claim becomes the user identifier. |
|
||||
| AUTH-02 | User can log in via external auth provider and access their data | `@hono/oidc-auth` middleware handles the full OIDC redirect flow. Session stored as JWT cookie. User identity extracted from OIDC claims via `getAuth(c)`. |
|
||||
| AUTH-03 | API keys remain functional for programmatic access (MCP, scripts) | API key path in `requireAuth` middleware unchanged. `verifyApiKey` function and `apiKeys` table preserved as-is. |
|
||||
| AUTH-04 | Auth provider runs self-hosted alongside the application | Logto Docker image `svhd/logto:latest` added to `docker-compose.yml` and `docker-compose.dev.yml`. Shares Postgres instance with separate database. |
|
||||
| AUTH-05 | E2E tests authenticate via API keys without depending on the auth provider | E2E tests use `X-API-Key` header for all write operations. Seed script creates an API key. No Logto dependency in test infrastructure. |
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `@hono/oidc-auth` | 1.8.1 | OIDC middleware for Hono | Purpose-built for Hono, storage-less JWT sessions, handles full auth code flow, refresh token rotation, tested with multiple OIDC providers |
|
||||
| `jose` | 6.2.2 | JWT verification for API-level token validation | Standard library for JWKS-based JWT verification, used by `@hono/oidc-auth` internally (via oauth4webapi), also needed if validating Logto tokens directly |
|
||||
| Logto (Docker) | latest (`svhd/logto`) | Self-hosted OIDC identity provider | Lightweight, no Redis, first-class OIDC, Postgres-backed, admin console included |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `oauth4webapi` | 3.8.5 | Low-level OAuth/OIDC client | Transitive dependency of `@hono/oidc-auth`. Not used directly unless custom token introspection needed. |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `@hono/oidc-auth` | `@logto/express` + adapters | Logto SDK is Express-specific, requires `express-session` dependency -- poor fit for Hono. Generic OIDC middleware is cleaner. |
|
||||
| `@hono/oidc-auth` | Manual `oauth4webapi` integration | More control but significantly more code. `@hono/oidc-auth` wraps this cleanly. |
|
||||
| `@hono/oidc-auth` | `@logto/node` (base SDK) | Requires building session storage adapter manually. `@hono/oidc-auth` provides storage-less sessions out of the box. |
|
||||
|
||||
**Recommendation:** Use `@hono/oidc-auth`. It is the idiomatic Hono solution, avoids Express dependencies, and provides storage-less sessions via JWT cookies. Logto is a standard OIDC provider, so any OIDC-compliant middleware works. No Logto-specific SDK needed on the server side.
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
bun add @hono/oidc-auth jose
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### OIDC Integration Architecture
|
||||
|
||||
```
|
||||
Browser User Flow:
|
||||
Browser -> GET /login -> redirect to Logto -> authenticate -> callback -> JWT session cookie set
|
||||
Browser -> GET /api/* -> cookie sent automatically -> @hono/oidc-auth validates JWT -> request proceeds
|
||||
|
||||
API Key Flow (unchanged):
|
||||
Client -> POST /api/* with X-API-Key header -> verifyApiKey() -> request proceeds
|
||||
|
||||
MCP OAuth Flow (unchanged):
|
||||
Claude -> POST /mcp with Bearer token -> verifyAccessToken() -> request proceeds
|
||||
```
|
||||
|
||||
### Recommended Changes to Project Structure
|
||||
|
||||
```
|
||||
src/server/
|
||||
middleware/
|
||||
auth.ts # Refactored: OIDC session OR API key OR MCP Bearer
|
||||
routes/
|
||||
auth.ts # Simplified: /login redirect, /callback, /logout, /me, /keys CRUD
|
||||
oauth.ts # UNCHANGED: MCP OAuth 2.1 flow preserved
|
||||
services/
|
||||
auth.service.ts # Simplified: remove user/session CRUD, keep API key functions
|
||||
oauth.service.ts # UNCHANGED: MCP OAuth service preserved
|
||||
src/client/
|
||||
routes/
|
||||
login.tsx # Replace form with redirect-to-Logto button
|
||||
hooks/
|
||||
useAuth.ts # Refactor: remove useLogin/useSetup/useChangePassword, keep useAuth/useLogout/useApiKeys
|
||||
```
|
||||
|
||||
### Pattern 1: Auth Middleware (Three-Way Check)
|
||||
|
||||
**What:** The `requireAuth` middleware checks three auth methods in order: API key, MCP OAuth Bearer, OIDC session cookie.
|
||||
**When to use:** All POST/PUT/PATCH/DELETE requests on `/api/*` (except `/api/auth/*`).
|
||||
|
||||
```typescript
|
||||
// Conceptual pattern for the refactored requireAuth middleware
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
const db = c.get("db");
|
||||
|
||||
// 1. Check API key (programmatic access)
|
||||
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
|
||||
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)
|
||||
const auth = await getAuth(c);
|
||||
if (auth) return next();
|
||||
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: OIDC Middleware Selective Application
|
||||
|
||||
**What:** `@hono/oidc-auth` middleware should only apply to browser-facing routes, not API endpoints that use API keys or MCP OAuth.
|
||||
**When to use:** The OIDC middleware must NOT be applied globally to all routes. It should be scoped to browser auth routes only.
|
||||
|
||||
```typescript
|
||||
// OIDC middleware for browser auth routes only
|
||||
app.get("/callback", async (c) => processOAuthCallback(c));
|
||||
app.get("/logout", async (c) => { await revokeSession(c); return c.redirect("/"); });
|
||||
|
||||
// Login route triggers OIDC redirect
|
||||
app.get("/login", oidcAuthMiddleware()); // This redirects to Logto if no session
|
||||
|
||||
// For /api/* routes, use the custom requireAuth that checks all three methods
|
||||
app.use("/api/*", async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/auth")) return next();
|
||||
if (c.req.method === "GET") return next();
|
||||
return requireAuth(c, next);
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 3: User Identity from OIDC Claims
|
||||
|
||||
**What:** After OIDC authentication, the user's identity comes from the `sub` claim in the JWT session cookie. This replaces the old `users` table lookup.
|
||||
**When to use:** Anywhere user identity is needed.
|
||||
|
||||
```typescript
|
||||
import { getAuth } from "@hono/oidc-auth";
|
||||
|
||||
// In a route handler or middleware
|
||||
const auth = await getAuth(c);
|
||||
if (auth) {
|
||||
const logtoUserId = auth.sub; // Logto's unique user ID (string)
|
||||
// Use this as the user identifier for data ownership in Phase 16
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Logto Docker Compose Integration
|
||||
|
||||
**What:** Add Logto as a service that shares the Postgres instance but uses a separate database.
|
||||
**When to use:** Both production and development docker-compose files.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: gearbox
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: gearbox
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gearbox"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
logto:
|
||||
image: svhd/logto:latest
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
||||
ports:
|
||||
- "3001:3001" # Core service
|
||||
- "3002:3002" # Admin console
|
||||
environment:
|
||||
TRUST_PROXY_HEADER: "1"
|
||||
DB_URL: postgres://gearbox:${POSTGRES_PASSWORD}@postgres:5432/logto
|
||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
||||
|
||||
app:
|
||||
# ... existing app config ...
|
||||
environment:
|
||||
# ... existing env vars ...
|
||||
OIDC_ISSUER: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc
|
||||
OIDC_CLIENT_ID: ${LOGTO_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${LOGTO_CLIENT_SECRET}
|
||||
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET}
|
||||
depends_on:
|
||||
logto:
|
||||
condition: service_started
|
||||
```
|
||||
|
||||
```sql
|
||||
-- docker/init-logto-db.sql
|
||||
-- Creates a separate database for Logto on the shared Postgres instance
|
||||
CREATE DATABASE logto;
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Applying `oidcAuthMiddleware()` globally:** This would break API key and MCP OAuth flows. OIDC middleware should only handle browser auth routes.
|
||||
- **Storing OIDC tokens server-side in a custom sessions table:** `@hono/oidc-auth` handles this with storage-less JWT cookies. Don't recreate the sessions table.
|
||||
- **Using Logto's user ID as an integer:** Logto's `sub` claim is a string (UUID-like). All foreign keys referencing user identity must use `text`, not `integer`.
|
||||
- **Mixing MCP OAuth and Logto OIDC:** These are separate auth domains. MCP OAuth uses GearBox's own `oauthClients/Codes/Tokens` tables. Logto OIDC uses `@hono/oidc-auth` JWT cookies. They must not interfere.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| OIDC authorization code flow | Custom redirect/callback/token exchange | `@hono/oidc-auth` middleware | PKCE, nonce validation, token rotation, cookie security are all handled |
|
||||
| Session JWT creation/validation | Custom JWT sign/verify | `@hono/oidc-auth` internal JWT session | Handles refresh intervals, expiry, cookie security flags |
|
||||
| JWKS key fetching/caching | Custom HTTP fetch + cache | `jose` library (`createRemoteJWKSet`) | Handles key rotation, caching, concurrent requests |
|
||||
| Logto database initialization | Custom SQL scripts | Logto's built-in `npm run cli db seed -- --swe` | Schema is complex, versioned, and must match the Logto runtime |
|
||||
|
||||
**Key insight:** The entire OIDC flow (redirect, callback, token exchange, session management, token refresh) is handled by `@hono/oidc-auth`. The only custom code needed is the `requireAuth` middleware that orchestrates the three auth paths (API key, MCP OAuth, OIDC session).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: OIDC Issuer URL Mismatch Between Docker Internal and External
|
||||
**What goes wrong:** Logto's OIDC issuer URL must be accessible from both the browser (external: `http://localhost:3001`) and the app container (internal: `http://logto:3001`). If the issuer in the JWT doesn't match what the app expects, token validation fails.
|
||||
**Why it happens:** Docker networking uses internal hostnames, but the browser redirects use external URLs.
|
||||
**How to avoid:** Set `ENDPOINT` on Logto to the externally-accessible URL (`http://localhost:3001` for dev). Set `OIDC_ISSUER` on the app to the same external URL. Both the browser redirect and server-side validation must use the same issuer string.
|
||||
**Warning signs:** "issuer mismatch" errors during token validation; login redirects work but callback fails.
|
||||
|
||||
### Pitfall 2: Cookie Domain/Path Conflicts
|
||||
**What goes wrong:** `@hono/oidc-auth` sets its own session cookie (`oidc-auth` by default). If GearBox's old `gearbox_session` cookie is still being set or checked, auth state gets confused.
|
||||
**Why it happens:** Incomplete removal of old session code.
|
||||
**How to avoid:** Completely remove all `gearbox_session` cookie handling. Remove the `sessions` table. Clean up the old auth service functions. The only session cookie should be the one managed by `@hono/oidc-auth`.
|
||||
**Warning signs:** Users appear logged in but get 401s on writes, or vice versa.
|
||||
|
||||
### Pitfall 3: MCP OAuth POST /oauth/authorize Still Validates Against Removed Users Table
|
||||
**What goes wrong:** The MCP OAuth flow's `/oauth/authorize` POST handler calls `verifyPassword()`, which queries the now-removed `users` table.
|
||||
**Why it happens:** MCP OAuth was built to use GearBox's internal auth. When the users table is removed, this breaks.
|
||||
**How to avoid:** The MCP OAuth authorize form must be updated to validate against the OIDC session instead of username/password. If the user has a valid OIDC session, they can authorize MCP clients. If not, redirect to Logto first.
|
||||
**Warning signs:** MCP OAuth authorize endpoint returns 500 errors after users table is removed.
|
||||
|
||||
### Pitfall 4: E2E Tests Break Because Seed Script Creates Users in Removed Table
|
||||
**What goes wrong:** The E2E seed script (`e2e/seed.ts`) inserts into the `users` table, which no longer exists. All E2E tests fail.
|
||||
**Why it happens:** Seed script wasn't updated for the new auth model.
|
||||
**How to avoid:** Update seed script to create an API key directly (insert into `apiKeys` table only). E2E tests authenticate via `X-API-Key` header per AUTH-05. Remove user creation from seed.
|
||||
**Warning signs:** Seed script crashes on startup; all E2E tests fail before any assertions.
|
||||
|
||||
### Pitfall 5: `getUserCount` Check in Old Middleware
|
||||
**What goes wrong:** The old `requireAuth` middleware checks `getUserCount(db) === 0` and returns `setup_required`. With the users table removed, this call fails.
|
||||
**Why it happens:** The "first-run setup" flow assumed GearBox managed its own users.
|
||||
**How to avoid:** Remove the `getUserCount` check entirely. First-run setup now happens on Logto's admin console. GearBox doesn't need to know if users exist -- it just validates tokens.
|
||||
**Warning signs:** 500 errors on any protected endpoint after removing users table.
|
||||
|
||||
### Pitfall 6: OIDC_AUTH_SECRET Not Set
|
||||
**What goes wrong:** `@hono/oidc-auth` requires `OIDC_AUTH_SECRET` (min 32 chars) to sign session JWTs. If not set, the middleware crashes on startup.
|
||||
**Why it happens:** Missing from environment configuration.
|
||||
**How to avoid:** Generate a random 32+ character secret and set it in `.env` / docker-compose. Document this in setup instructions.
|
||||
**Warning signs:** Startup crash with "OIDC_AUTH_SECRET is required" or similar error.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### @hono/oidc-auth Configuration for Logto
|
||||
|
||||
```typescript
|
||||
// Environment variables required:
|
||||
// OIDC_ISSUER=http://localhost:3001/oidc (Logto's OIDC endpoint)
|
||||
// OIDC_CLIENT_ID=<from Logto admin console>
|
||||
// OIDC_CLIENT_SECRET=<from Logto admin console>
|
||||
// OIDC_AUTH_SECRET=<random 32+ char string for JWT signing>
|
||||
// OIDC_REDIRECT_URI=/callback (default)
|
||||
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
oidcAuthMiddleware,
|
||||
getAuth,
|
||||
revokeSession,
|
||||
processOAuthCallback,
|
||||
} from "@hono/oidc-auth";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Callback route - processes the OIDC redirect from Logto
|
||||
app.get("/callback", async (c) => {
|
||||
return processOAuthCallback(c);
|
||||
});
|
||||
|
||||
// Logout route
|
||||
app.get("/logout", async (c) => {
|
||||
await revokeSession(c);
|
||||
return c.redirect("/login");
|
||||
});
|
||||
|
||||
// Login route - redirects to Logto
|
||||
app.get("/login", oidcAuthMiddleware(), async (c) => {
|
||||
// If we reach here, user is authenticated (middleware redirects if not)
|
||||
return c.redirect("/");
|
||||
});
|
||||
```
|
||||
|
||||
### Checking Auth State in API Routes
|
||||
|
||||
```typescript
|
||||
import { getAuth } from "@hono/oidc-auth";
|
||||
|
||||
// In the /api/auth/me handler
|
||||
app.get("/api/auth/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 });
|
||||
});
|
||||
```
|
||||
|
||||
### Logto Application Setup (Admin Console)
|
||||
|
||||
```
|
||||
1. Access Logto Admin Console at http://localhost:3002
|
||||
2. Create a new "Traditional Web" application
|
||||
3. Set redirect URI: http://localhost:3000/callback
|
||||
4. Set post-logout redirect URI: http://localhost:3000/login
|
||||
5. Copy App ID -> OIDC_CLIENT_ID
|
||||
6. Copy App Secret -> OIDC_CLIENT_SECRET
|
||||
7. OIDC_ISSUER = http://localhost:3001/oidc
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Custom user/session tables | OIDC provider (Logto) | This phase | Remove users/sessions tables, auth service simplification |
|
||||
| Password hashing in app | Delegated to Logto | This phase | Remove Bun.password.hash usage for users (keep for API keys) |
|
||||
| Login form in GearBox | Redirect to Logto | This phase | Login page becomes a redirect trigger |
|
||||
| `@logto/express` SDK | `@hono/oidc-auth` | N/A | Hono-native, no Express dependencies |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Logto User ID Format**
|
||||
- What we know: Logto's `sub` claim is a string identifier
|
||||
- What's unclear: Exact format (UUID? custom ID?)
|
||||
- Recommendation: Use `text` type for user ID columns. Will be confirmed during Logto setup. This prepares for Phase 16 (Multi-User Data Model) which adds `userId` columns to all data tables.
|
||||
|
||||
2. **MCP OAuth Authorize Form After Users Table Removal**
|
||||
- What we know: The MCP OAuth `/oauth/authorize` POST handler currently calls `verifyPassword()` against the `users` table
|
||||
- What's unclear: Best UX for MCP OAuth authorization after migration
|
||||
- Recommendation: Check for an active OIDC session when the user hits the authorize page. If authenticated via OIDC, auto-approve or show a simplified consent screen. If not, redirect to Logto first, then back to the authorize page.
|
||||
|
||||
3. **Logto Shared Postgres vs Separate Instance**
|
||||
- What we know: D-13 says Logto can share the Postgres instance (separate database) or use its own
|
||||
- Recommendation: Share the Postgres instance with a separate `logto` database. Simpler infrastructure, one fewer container. Use a Postgres init script to create the `logto` database on first run.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Docker | Logto deployment | Needs verification at runtime | -- | Cannot proceed without Docker |
|
||||
| Docker Compose | Service orchestration | Needs verification at runtime | -- | Cannot proceed without Compose |
|
||||
| PostgreSQL | Logto + GearBox data | Available (via docker-compose) | 16-alpine | -- |
|
||||
| Bun | Runtime | Available | Project runtime | -- |
|
||||
|
||||
**Missing dependencies with no fallback:**
|
||||
- Docker and Docker Compose are required for Logto. These are assumed present since the project already has `docker-compose.yml` and `docker-compose.dev.yml`.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test runner + Playwright |
|
||||
| Config file | `bunfig.toml` (Bun), `playwright.config.ts` (E2E) |
|
||||
| Quick run command | `bun test tests/middleware/auth.test.ts` |
|
||||
| Full suite command | `bun test && bun run test:e2e` |
|
||||
|
||||
### Phase Requirements -> Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| AUTH-01 | User registers via Logto OIDC | manual | N/A (requires running Logto) | N/A -- manual verification |
|
||||
| AUTH-02 | User logs in via Logto OIDC | manual | N/A (requires running Logto) | N/A -- manual verification |
|
||||
| AUTH-03 | API keys work for programmatic access | unit | `bun test tests/middleware/auth.test.ts -x` | Exists (needs update) |
|
||||
| AUTH-04 | Logto runs in Docker Compose | integration | `docker compose -f docker-compose.dev.yml up -d && curl http://localhost:3001/oidc/.well-known/openid-configuration` | Wave 0 |
|
||||
| AUTH-05 | E2E tests use API keys, no Logto dependency | e2e | `bun run test:e2e` | Exists (needs update) |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun test tests/middleware/auth.test.ts`
|
||||
- **Per wave merge:** `bun test`
|
||||
- **Phase gate:** `bun test && bun run test:e2e`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] Update `tests/middleware/auth.test.ts` -- remove user/session tests, add OIDC session mock
|
||||
- [ ] Update `tests/services/auth.service.test.ts` -- remove user/session tests, keep API key tests
|
||||
- [ ] Update `tests/routes/auth.test.ts` -- update for new auth route structure
|
||||
- [ ] Update `e2e/seed.ts` -- remove users table insert, add API key seed
|
||||
- [ ] Update `e2e/auth.spec.ts` -- replace login form tests with redirect-based flow or API key auth
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- **Routing:** TanStack Router with file-based routes. `routeTree.gen.ts` auto-generated -- never edit manually.
|
||||
- **Data fetching:** TanStack React Query hooks. Auth state via `useAuth` hook.
|
||||
- **Testing:** Bun test runner for unit/integration, Playwright for E2E. Test helpers in `tests/helpers/db.ts`.
|
||||
- **Styling:** Tailwind CSS v4.
|
||||
- **Services pattern:** Pure business logic functions that take a db instance. No HTTP awareness.
|
||||
- **Path alias:** `@/*` maps to `./src/*`.
|
||||
- **Branching:** Create feature branch off Develop for this work.
|
||||
- **Lint:** Biome (tabs, double quotes, organized imports).
|
||||
- **Build:** `bun run build` outputs to `dist/client/`.
|
||||
- **Auth pattern:** Public-read, authenticated-write. POST/PUT/DELETE require auth on `/api/*` except `/api/auth/*`.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Logto OSS Deployment Docs](https://docs.logto.io/logto-oss/deployment-and-configuration) -- Docker setup, environment variables, Postgres requirements
|
||||
- [Logto Access Token Validation](https://docs.logto.io/authorization/validate-access-tokens) -- JWT validation with jose, JWKS URI, claims verification
|
||||
- [@hono/oidc-auth README](https://www.npmjs.com/package/@hono/oidc-auth) -- Configuration, exported functions, session handling, env vars
|
||||
- [Logto Official Docker Compose](https://github.com/logto-io/logto/blob/master/docker-compose.yml) -- Official compose file structure
|
||||
- Existing codebase analysis -- `src/server/middleware/auth.ts`, `src/server/routes/auth.ts`, `src/server/routes/oauth.ts`, `src/server/mcp/index.ts`
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Logto Express Tutorial](https://tutorials.logto.io/how-to/build-oidc-sign-in-with-express-and-logto/) -- Express SDK patterns (not directly used but informative for flow understanding)
|
||||
- [Logto OIDC Integration Guide](https://blog.logto.io/complete-guide-to-integrating-oidc-server) -- General OIDC integration patterns
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None -- all findings verified with official sources
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH -- `@hono/oidc-auth` is the official Hono OIDC middleware, well-documented, actively maintained
|
||||
- Architecture: HIGH -- OIDC redirect flow is standard, three-way auth middleware is straightforward
|
||||
- Pitfalls: HIGH -- Based on direct analysis of existing codebase and known OIDC integration issues
|
||||
- Docker/Logto setup: MEDIUM -- Official compose file verified, but Logto version pinning and Postgres sharing need runtime validation
|
||||
|
||||
**Research date:** 2026-04-04
|
||||
**Valid until:** 2026-05-04 (30 days -- stable domain, Logto has regular but non-breaking releases)
|
||||
79
.planning/phases/15-external-authentication/15-VALIDATION.md
Normal file
79
.planning/phases/15-external-authentication/15-VALIDATION.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
phase: 15
|
||||
slug: external-authentication
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 15 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Bun test runner + Playwright |
|
||||
| **Config file** | `bunfig.toml` (Bun), `playwright.config.ts` (E2E) |
|
||||
| **Quick run command** | `bun test tests/middleware/auth.test.ts` |
|
||||
| **Full suite command** | `bun test && bun run test:e2e` |
|
||||
| **Estimated runtime** | ~30 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test tests/middleware/auth.test.ts`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 30 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 15-01-01 | 01 | 1 | AUTH-04 | integration | `docker compose -f docker-compose.dev.yml up -d && curl http://localhost:3001/oidc/.well-known/openid-configuration` | ❌ W0 | ⬜ pending |
|
||||
| 15-02-01 | 02 | 1 | AUTH-03 | unit | `bun test tests/middleware/auth.test.ts` | ✅ (needs update) | ⬜ pending |
|
||||
| 15-02-02 | 02 | 1 | AUTH-01 | manual | N/A (requires running Logto) | N/A | ⬜ pending |
|
||||
| 15-02-03 | 02 | 1 | AUTH-02 | manual | N/A (requires running Logto) | N/A | ⬜ pending |
|
||||
| 15-03-01 | 03 | 2 | AUTH-05 | e2e | `bun run test:e2e` | ✅ (needs update) | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] Update `tests/middleware/auth.test.ts` — remove user/session tests, add OIDC session mock
|
||||
- [ ] Update `tests/services/auth.service.test.ts` — remove user/session tests, keep API key tests
|
||||
- [ ] Update `tests/routes/auth.test.ts` — update for new auth route structure
|
||||
- [ ] Update `e2e/seed.ts` — remove users table insert, add API key seed
|
||||
- [ ] Update `e2e/auth.spec.ts` — replace login form tests with redirect-based flow or API key auth
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| User registers via Logto | AUTH-01 | Requires running Logto instance with UI interaction | Start docker-compose.dev.yml, navigate to /login, complete Logto registration, verify dashboard loads |
|
||||
| User logs in via Logto | AUTH-02 | Requires running Logto instance with UI interaction | Start docker-compose.dev.yml, navigate to /login, complete Logto login, verify existing data visible |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 30s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
35
docker-compose.dev.yml
Normal file
35
docker-compose.dev.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: gearbox
|
||||
POSTGRES_PASSWORD: gearbox
|
||||
POSTGRES_DB: gearbox
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata-dev:/var/lib/postgresql/data
|
||||
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gearbox"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
logto:
|
||||
image: svhd/logto:latest
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "3002:3002"
|
||||
environment:
|
||||
TRUST_PROXY_HEADER: "1"
|
||||
DB_URL: postgres://gearbox:gearbox@postgres:5432/logto
|
||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
||||
|
||||
volumes:
|
||||
pgdata-dev:
|
||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: gearbox
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: gearbox
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gearbox"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
logto:
|
||||
image: svhd/logto:latest
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "3002:3002"
|
||||
environment:
|
||||
TRUST_PROXY_HEADER: "1"
|
||||
DB_URL: postgres://gearbox:${POSTGRES_PASSWORD}@postgres:5432/logto
|
||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
||||
|
||||
app:
|
||||
image: gearbox:latest
|
||||
environment:
|
||||
DATABASE_URL: postgresql://gearbox:${POSTGRES_PASSWORD}@postgres:5432/gearbox
|
||||
GEARBOX_URL: ${GEARBOX_URL}
|
||||
OIDC_ISSUER: ${LOGTO_ENDPOINT:-http://localhost:3001}/oidc
|
||||
OIDC_CLIENT_ID: ${LOGTO_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${LOGTO_CLIENT_SECRET}
|
||||
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logto:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
2
docker/init-logto-db.sql
Normal file
2
docker/init-logto-db.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Creates a separate database for Logto on the shared Postgres instance
|
||||
CREATE DATABASE logto;
|
||||
Reference in New Issue
Block a user