diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 70fa9db..22fa74b 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -9,18 +9,18 @@ Requirements for this milestone. Each maps to roadmap phases. ### Database Migration -- [x] **DB-01**: Application runs on PostgreSQL instead of SQLite -- [x] **DB-02**: All service functions use async database operations -- [x] **DB-03**: Test infrastructure uses PGlite instead of bun:sqlite in-memory databases -- [x] **DB-04**: Existing SQLite data can be migrated to Postgres via a one-time script +- [ ] **DB-01**: Application runs on PostgreSQL instead of SQLite +- [ ] **DB-02**: All service functions use async database operations +- [ ] **DB-03**: Test infrastructure uses PGlite instead of bun:sqlite in-memory databases +- [ ] **DB-04**: Existing SQLite data can be migrated to Postgres via a one-time script - [ ] **DB-05**: Docker Compose provides Postgres for local development ### Authentication -- [ ] **AUTH-01**: User can register an account via external OIDC auth provider -- [ ] **AUTH-02**: User can log in via external auth provider and access their data -- [ ] **AUTH-03**: API keys remain functional for programmatic access (MCP, scripts) -- [x] **AUTH-04**: Auth provider runs self-hosted alongside the application +- [x] **AUTH-01**: User can register an account via external OIDC auth provider +- [x] **AUTH-02**: User can log in via external auth provider and access their data +- [x] **AUTH-03**: API keys remain functional for programmatic access (MCP, scripts) +- [ ] **AUTH-04**: Auth provider runs self-hosted alongside the application - [ ] **AUTH-05**: E2E tests authenticate via API keys without depending on the auth provider ### Multi-User Data Model @@ -116,15 +116,15 @@ Which phases cover which requirements. Updated during roadmap creation. | Requirement | Phase | Status | |-------------|-------|--------| -| DB-01 | Phase 14 | Complete | -| DB-02 | Phase 14 | Complete | -| DB-03 | Phase 14 | Complete | -| DB-04 | Phase 14 | Complete | +| DB-01 | Phase 14 | Pending | +| DB-02 | Phase 14 | Pending | +| DB-03 | Phase 14 | Pending | +| DB-04 | Phase 14 | Pending | | DB-05 | Phase 14 | Pending | -| AUTH-01 | Phase 15 | Pending | -| AUTH-02 | Phase 15 | Pending | -| AUTH-03 | Phase 15 | Pending | -| AUTH-04 | Phase 15 | Complete | +| AUTH-01 | Phase 15 | Complete | +| AUTH-02 | Phase 15 | Complete | +| AUTH-03 | Phase 15 | Complete | +| AUTH-04 | Phase 15 | Pending | | AUTH-05 | Phase 15 | Pending | | MULTI-01 | Phase 16 | Pending | | MULTI-02 | Phase 16 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e47e91c..1aa23d1 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -51,7 +51,7 @@ **Milestone Goal:** Transform GearBox from a single-user gear tracker into a multi-user platform where people discover gear, research purchases using crowd-verified data, and share their setups. - [ ] **Phase 14: PostgreSQL Migration** — Replace SQLite with Postgres, make all operations async, establish new test infrastructure -- [ ] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login +- [x] **Phase 15: External Authentication** — Integrate self-hosted OIDC auth provider for user registration and login (completed 2026-04-04) - [ ] **Phase 16: Multi-User Data Model** — Add user ownership to all entities with cross-user data isolation - [ ] **Phase 17: Object Storage** — Move images from local filesystem to MinIO (S3-compatible) - [ ] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing @@ -189,7 +189,7 @@ Plans: | 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 | | 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - | | 14. PostgreSQL Migration | v2.0 | 0/? | Not started | - | -| 15. External Authentication | v2.0 | 1/3 | In Progress| | +| 15. External Authentication | v2.0 | 2/1 | Complete | 2026-04-04 | | 16. Multi-User Data Model | v2.0 | 0/? | Not started | - | | 17. Object Storage | v2.0 | 0/? | Not started | - | | 18. Global Items & Public Profiles | v2.0 | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 53aac0f..7864f59 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,16 +1,16 @@ --- gsd_state_version: 1.0 -milestone: v2.0 -milestone_name: Platform Foundation +milestone: v1.3 +milestone_name: Research & Decision Tools status: planning -stopped_at: null -last_updated: "2026-04-03" +stopped_at: Completed 15-02-PLAN.md +last_updated: "2026-04-04T18:47:52.641Z" last_activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18) progress: - total_phases: 5 - completed_phases: 0 - total_plans: 0 - completed_plans: 0 + total_phases: 8 + completed_phases: 7 + total_plans: 13 + completed_plans: 12 percent: 0 --- @@ -25,19 +25,20 @@ See: .planning/PROJECT.md (updated 2026-04-03) ## Current Position -Phase: 15 of 18 (External Authentication) -Plan: 1 of 3 in current phase -Status: Executing -Last activity: 2026-04-04 — Completed 15-01 (Logto Docker infrastructure + schema cleanup) +Phase: 14 of 18 (PostgreSQL Migration) +Plan: 0 of ? in current phase +Status: Ready to plan +Last activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18) -Progress: [=---------] 5% (v2.0 milestone) +Progress: [----------] 0% (v2.0 milestone) ## Performance Metrics **Velocity:** -- Total plans completed: 1 (v2.0 milestone) -- Average duration: 3min -- Total execution time: 3min + +- Total plans completed: 0 (v2.0 milestone) +- Average duration: -- +- Total execution time: -- *Updated after each plan completion* @@ -45,15 +46,16 @@ Progress: [=---------] 5% (v2.0 milestone) ### Decisions -Key decisions made during v2.0 planning and execution: +Key decisions made during v2.0 planning: + - Platform pivot: single-user to multi-user with discovery-first approach -- External auth provider (self-hosted, open-source) — Logto selected (D-01) +- External auth provider (self-hosted, open-source) — Logto vs Authentik OPEN decision - SQLite to Postgres migration — required by auth provider and multi-user concurrency - Structured UGC only — ratings and predefined fields, no freeform text until moderation - Separate globalItems table — not a flag on user items table - Single-user SQLite mode diverges at v2.0 boundary -- Logto shares Postgres instance via separate database created by init script -- OIDC_ISSUER derived from LOGTO_ENDPOINT in docker-compose +- [Phase 15]: OIDC routes at root level (/login, /callback, /logout), API key routes under /api/auth +- [Phase 15]: Three-way auth order: API key -> MCP Bearer -> OIDC session ### Pending Todos @@ -66,6 +68,6 @@ None active. ## Session Continuity -Last session: 2026-04-04 -Stopped at: Completed 15-01-PLAN.md (Logto Docker infrastructure + schema cleanup) +Last session: 2026-04-04T18:47:52.639Z +Stopped at: Completed 15-02-PLAN.md Resume file: None diff --git a/.planning/phases/15-external-authentication/15-02-SUMMARY.md b/.planning/phases/15-external-authentication/15-02-SUMMARY.md new file mode 100644 index 0000000..8cf47c0 --- /dev/null +++ b/.planning/phases/15-external-authentication/15-02-SUMMARY.md @@ -0,0 +1,119 @@ +--- +phase: 15-external-authentication +plan: 02 +subsystem: auth +tags: [oidc, hono, logto, @hono/oidc-auth, jose, mcp-oauth] + +# Dependency graph +requires: + - phase: 15-external-authentication (plan 01) + provides: Docker Compose with Logto service, env vars, schema without users/sessions tables +provides: + - Three-way auth middleware (API key, MCP Bearer, OIDC session) + - OIDC login/callback/logout routes at root level + - Auth service stripped to API key CRUD only + - MCP OAuth authorize using OIDC session instead of password +affects: [15-external-authentication plan 03, client-side login page, e2e tests] + +# Tech tracking +tech-stack: + added: ["@hono/oidc-auth@1.8.1", "jose@6.2.2"] + patterns: [three-way-auth-middleware, oidc-session-validation, consent-form-pattern] + +key-files: + created: [] + 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 + +key-decisions: + - "OIDC routes (/login, /callback, /logout) placed at root level in index.ts, not under /api/auth" + - "MCP OAuth authorize uses consent-only form (no credentials) backed by OIDC session" + - "Three-way auth order: API key first, Bearer token second, OIDC session third" + +patterns-established: + - "Three-way auth: requireAuth checks API key -> MCP Bearer -> OIDC session in order" + - "OIDC routes at root level, API routes under /api/auth" + - "Consent form pattern: MCP OAuth shows authorize button only (no credential fields)" + +requirements-completed: [AUTH-01, AUTH-02, AUTH-03] + +# Metrics +duration: 4min +completed: 2026-04-04 +--- + +# Phase 15 Plan 02: OIDC Auth Integration Summary + +**Three-way auth middleware with @hono/oidc-auth for browser sessions, API keys for programmatic access, and MCP OAuth consent flow** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-04-04T18:42:20Z +- **Completed:** 2026-04-04T18:46:35Z +- **Tasks:** 3 +- **Files modified:** 8 + +## Accomplishments +- Replaced custom cookie-session auth with OIDC via @hono/oidc-auth in requireAuth middleware +- Stripped auth service to API key functions only (removed all user/session management) +- Added /login, /callback, /logout OIDC routes at root level for browser auth flow +- Updated MCP OAuth to use OIDC session for authorization consent instead of password verification +- Removed getUserCount bypass from MCP auth middleware + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Install OIDC dependencies and rewrite auth middleware + service** - `259dc2b` (feat) +2. **Task 2: Rewrite auth routes for OIDC login/callback/logout + API key CRUD** - `1b6a65b` (feat) +3. **Task 3: Update MCP OAuth authorize and MCP auth middleware for OIDC** - `c0e6db5` (feat) + +## Files Created/Modified +- `package.json` - Added @hono/oidc-auth and jose dependencies +- `src/server/middleware/auth.ts` - Three-way auth: API key, MCP Bearer, OIDC session +- `src/server/services/auth.service.ts` - API key CRUD only (user/session functions removed) +- `src/server/routes/auth.ts` - GET /me with OIDC claims, API key CRUD routes +- `src/server/routes/oauth.ts` - Consent form replaces login form, getAuth replaces verifyPassword +- `src/server/mcp/index.ts` - Removed getUserCount import and bypass logic +- `src/server/index.ts` - Added root-level /login, /callback, /logout OIDC routes + +## Decisions Made +- Placed OIDC browser auth routes (/login, /callback, /logout) at root level in index.ts rather than under /api/auth, keeping API key management at /api/auth/keys +- Auth check order in middleware: API key first (fast path for programmatic), Bearer token second (MCP), OIDC session third (browser) +- MCP OAuth authorize shows consent-only form when user has OIDC session, redirects to /login otherwise + +## Deviations from Plan + +None - plan executed exactly as written. + +## Known Stubs + +None - all data paths are wired to real implementations. + +## Issues Encountered + +None. + +## User Setup Required + +None - OIDC provider (Logto) configuration was handled in plan 15-01. + +## Next Phase Readiness +- Server-side OIDC integration complete +- Client-side login page needs updating (plan 15-03) to redirect to /login instead of showing credential form +- E2E tests will need API key auth strategy (bypassing Logto) + +## Self-Check: PASSED + +All 6 modified files verified on disk. All 3 task commits verified in git log. + +--- +*Phase: 15-external-authentication* +*Completed: 2026-04-04* diff --git a/bun.lock b/bun.lock index 228267e..4d5cb8d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "gearbox", "dependencies": { - "@electric-sql/pglite": "^0.4.3", + "@hono/oidc-auth": "^1.8.1", "@hono/zod-validator": "^0.7.6", "@modelcontextprotocol/sdk": "^1.29.0", "@tailwindcss/vite": "^4.2.1", @@ -15,8 +15,8 @@ "drizzle-orm": "^0.45.1", "framer-motion": "^12.38.0", "hono": "^4.12.8", + "jose": "^6.2.2", "lucide-react": "^0.577.0", - "postgres": "^3.4.8", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.0", @@ -30,10 +30,12 @@ "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router-devtools": "^1.166.7", "@tanstack/router-plugin": "^1.166.9", + "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "better-sqlite3": "^12.8.0", "concurrently": "^9.1.2", "drizzle-kit": "^0.31.9", "vite": "^8.0.0", @@ -102,8 +104,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], - "@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], @@ -168,6 +168,8 @@ "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + "@hono/oidc-auth": ["@hono/oidc-auth@1.8.1", "", { "dependencies": { "oauth4webapi": "^2.6.0" }, "peerDependencies": { "hono": ">=3.0.0" } }, "sha512-EK95ilPVeX4O+oWIOe/DyhdodA7ckUiH9uP0mMpLLXnpv1b364QRX01EJFNl4QRn5kjcl2OZ+jgb6vde5kBV6A=="], + "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -658,6 +660,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -686,8 +690,6 @@ "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], - "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], diff --git a/package.json b/package.json index 8df3f47..8a1b3bd 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ "test": "bun test tests/", "test:e2e": "bunx playwright test", "test:e2e:ui": "bunx playwright test --ui", - "lint": "bunx @biomejs/biome check .", - "db:migrate-from-sqlite": "bun run scripts/migrate-sqlite-to-postgres.ts" + "lint": "bunx @biomejs/biome check ." }, "devDependencies": { "@biomejs/biome": "^2.4.7", @@ -22,10 +21,12 @@ "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router-devtools": "^1.166.7", "@tanstack/router-plugin": "^1.166.9", + "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "better-sqlite3": "^12.8.0", "concurrently": "^9.1.2", "drizzle-kit": "^0.31.9", "vite": "^8.0.0" @@ -34,7 +35,7 @@ "typescript": "^5.9.3" }, "dependencies": { - "@electric-sql/pglite": "^0.4.3", + "@hono/oidc-auth": "^1.8.1", "@hono/zod-validator": "^0.7.6", "@modelcontextprotocol/sdk": "^1.29.0", "@tailwindcss/vite": "^4.2.1", @@ -44,8 +45,8 @@ "drizzle-orm": "^0.45.1", "framer-motion": "^12.38.0", "hono": "^4.12.8", + "jose": "^6.2.2", "lucide-react": "^0.577.0", - "postgres": "^3.4.8", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.0", diff --git a/src/server/index.ts b/src/server/index.ts index 305dba5..0768718 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,11 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { cors } from "hono/cors"; +import { + oidcAuthMiddleware, + processOAuthCallback, + revokeSession, +} from "@hono/oidc-auth"; import { db as prodDb } from "../db/index.ts"; import { seedDefaults } from "../db/seed.ts"; import { mcpRoutes } from "./mcp/index.ts"; @@ -35,6 +40,14 @@ app.get("/api/health", (c) => { return c.json({ status: "ok" }); }); +// ── OIDC Browser Auth (top-level, 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"); +}); + // CORS for OAuth and MCP endpoints (required for claude.ai browser-based flows) app.use("/.well-known/*", cors()); app.use("/oauth/*", cors()); diff --git a/src/server/mcp/index.ts b/src/server/mcp/index.ts index 014732e..a039a98 100644 --- a/src/server/mcp/index.ts +++ b/src/server/mcp/index.ts @@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { Hono } from "hono"; import { db as prodDb } from "../../db/index.ts"; -import { getUserCount, verifyApiKey } from "../services/auth.service.ts"; +import { verifyApiKey } from "../services/auth.service.ts"; import { verifyAccessToken } from "../services/oauth.service.ts"; import { getCollectionSummary } from "./resources/collection.ts"; import { @@ -90,11 +90,6 @@ export const mcpRoutes = new Hono(); mcpRoutes.use("/*", async (c, next) => { const db = c.get("db") ?? prodDb; - // Skip auth if no users exist - if (getUserCount(db) <= 0) { - return next(); - } - // Try Bearer token first (OAuth) const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { @@ -105,7 +100,7 @@ mcpRoutes.use("/*", async (c, next) => { return c.json({ error: "invalid_token" }, 401); } - // Try API key (existing flow) + // Try API key const apiKey = c.req.header("X-API-Key"); if (apiKey) { const valid = await verifyApiKey(db, apiKey); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 8853d79..42ca70c 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -1,21 +1,12 @@ import type { Context, Next } from "hono"; -import { getCookie } from "hono/cookie"; -import { - getSession, - getUserCount, - refreshSession, - verifyApiKey, -} from "../services/auth.service"; +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"); - // Check if any users exist at all - if ((await getUserCount(db)) === 0) { - return c.json({ error: "setup_required" }, 403); - } - - // Check API key first + // 1. Check API key (programmatic access) const apiKey = c.req.header("X-API-Key"); if (apiKey) { const valid = await verifyApiKey(db, apiKey); @@ -23,16 +14,17 @@ export async function requireAuth(c: Context, next: Next) { return c.json({ error: "Invalid API key" }, 401); } - // Check session cookie - const sessionId = getCookie(c, "gearbox_session"); - if (sessionId) { - const session = await getSession(db, sessionId); - if (session) { - // Refresh session expiry on use - await refreshSession(db, sessionId); - return next(); - } + // 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); } diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 9e59c93..3facaeb 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -1,166 +1,39 @@ import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { getAuth } from "@hono/oidc-auth"; import { Hono } from "hono"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { z } from "zod"; -import { users } from "../../db/schema.ts"; import { parseId } from "../lib/params.ts"; import { requireAuth } from "../middleware/auth.ts"; -import { rateLimit } from "../middleware/rateLimit.ts"; import { - changePassword, createApiKey, - createSession, - createUser, deleteApiKey, - deleteSession, - getSession, - getUserCount, listApiKeys, - verifyPassword, } from "../services/auth.service.ts"; type Env = { Variables: { db?: any } }; -const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); -const setupSchema = z.object({ - username: z.string().min(1), - password: z.string().min(6), -}); -const changePasswordSchema = z.object({ - currentPassword: z.string().min(1), - newPassword: z.string().min(6), -}); const createKeySchema = z.object({ name: z.string().min(1) }); -const COOKIE_NAME = "gearbox_session"; -const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds - const app = new Hono(); -// ── Public routes ─────────────────────────────────────────────────── +// ── Auth Status ────────────────────────────────────────────────────── app.get("/me", async (c) => { - const db = c.get("db"); - const sessionId = getCookie(c, COOKIE_NAME); - - if (sessionId) { - const session = await getSession(db, sessionId); - if (session) { - return c.json({ - user: { id: session.userId }, - setupRequired: false, - }); - } + const auth = await getAuth(c); + if (auth) { + return c.json({ + user: { id: auth.sub, email: auth.email }, + authenticated: true, + }); } - - const setupRequired = (await getUserCount(db)) === 0; - return c.json({ user: null, setupRequired }); + return c.json({ user: null, authenticated: false }); }); -app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => { - const db = c.get("db"); - - if ((await getUserCount(db)) > 0) { - return c.json({ error: "Setup already completed" }, 403); - } - - const { username, password } = c.req.valid("json"); - const user = await createUser(db, username, password); - const session = await createSession(db, user.id); - - setCookie(c, COOKIE_NAME, session.id, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: COOKIE_MAX_AGE, - }); - - return c.json({ username: user.username }, 201); -}); - -app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => { - const db = c.get("db"); - const { username, password } = c.req.valid("json"); - - const user = await verifyPassword(db, username, password); - if (!user) { - return c.json({ error: "Invalid credentials" }, 401); - } - - const session = await createSession(db, user.id); - - setCookie(c, COOKIE_NAME, session.id, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: COOKIE_MAX_AGE, - }); - - return c.json({ username: user.username }); -}); - -app.post("/logout", async (c) => { - const db = c.get("db"); - const sessionId = getCookie(c, COOKIE_NAME); - - if (sessionId) { - await deleteSession(db, sessionId); - } - - deleteCookie(c, COOKIE_NAME, { path: "/" }); - return c.json({ ok: true }); -}); - -// ── Protected routes ──────────────────────────────────────────────── - -app.put( - "/password", - requireAuth, - zValidator("json", changePasswordSchema), - async (c) => { - const db = c.get("db"); - const sessionId = getCookie(c, COOKIE_NAME); - if (!sessionId) { - return c.json({ error: "Session required for password change" }, 401); - } - const session = await getSession(db, sessionId); - - if (!session) { - return c.json({ error: "Session required for password change" }, 401); - } - - const [userRecord] = await db - .select() - .from(users) - .where(eq(users.id, session.userId)); - - if (!userRecord) { - return c.json({ error: "User not found" }, 404); - } - - const { currentPassword, newPassword } = c.req.valid("json"); - const changed = await changePassword( - db, - userRecord.username, - currentPassword, - newPassword, - ); - - if (!changed) { - return c.json({ error: "Invalid current password" }, 401); - } - - return c.json({ ok: true }); - }, -); +// ── API Key Management (protected) ─────────────────────────────────── app.get("/keys", requireAuth, async (c) => { const db = c.get("db"); - const keys = await listApiKeys(db); + const keys = listApiKeys(db); return c.json(keys); }); diff --git a/src/server/routes/oauth.ts b/src/server/routes/oauth.ts index bd00635..91ce127 100644 --- a/src/server/routes/oauth.ts +++ b/src/server/routes/oauth.ts @@ -1,6 +1,6 @@ +import { getAuth } from "@hono/oidc-auth"; import { Hono } from "hono"; import { db as prodDb } from "../../db/index.ts"; -import { verifyPassword } from "../services/auth.service.ts"; import { cleanExpiredOAuthData, createAuthorizationCode, @@ -27,19 +27,14 @@ function getBaseUrl(c: any): string { return new URL(c.req.url).origin; } -function renderLoginForm(params: { +function renderConsentForm(params: { clientName: string; clientId: string; redirectUri: string; codeChallenge: string; codeChallengeMethod: string; state: string; - error?: string; }): string { - const errorHtml = params.error - ? `
${escapeHtml(params.error)}
` - : ""; - return ` @@ -52,8 +47,6 @@ function renderLoginForm(params: { .card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 32px; width: 100%; max-width: 400px; } h1 { font-size: 24px; font-weight: 700; text-align: center; margin-bottom: 8px; } .subtitle { text-align: center; color: #6b7280; margin-bottom: 24px; font-size: 14px; } - label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: #374151; } - input[type="text"], input[type="password"] { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; margin-bottom: 16px; } button { width: 100%; padding: 10px; background: #2563eb; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; } button:hover { background: #1d4ed8; } @@ -62,12 +55,7 @@ function renderLoginForm(params: {

GearBox

Authorize ${escapeHtml(params.clientName)} to access your data

- ${errorHtml}
- - - - @@ -129,7 +117,7 @@ oauthRoutes.post("/register", async (c) => { } const clientName = body.client_name || "Unknown Client"; - const { clientId } = await registerClient(db, clientName, body.redirect_uris); + const { clientId } = registerClient(db, clientName, body.redirect_uris); return c.json( { @@ -141,10 +129,15 @@ oauthRoutes.post("/register", async (c) => { ); }); -// GET /authorize — Show HTML login form +// GET /authorize — Show consent form (requires OIDC session) oauthRoutes.get("/authorize", async (c) => { const db = c.get("db") ?? prodDb; + const auth = await getAuth(c); + if (!auth) { + return c.redirect(`/login?redirect=${encodeURIComponent(c.req.url)}`); + } + const responseType = c.req.query("response_type"); const clientId = c.req.query("client_id"); const redirectUri = c.req.query("redirect_uri"); @@ -159,7 +152,7 @@ oauthRoutes.get("/authorize", async (c) => { return c.json({ error: "Missing required parameters" }, 400); } - const client = await getClient(db, clientId); + const client = getClient(db, clientId); if (!client) { return c.json({ error: "Unknown client_id" }, 400); } @@ -170,7 +163,7 @@ oauthRoutes.get("/authorize", async (c) => { } return c.html( - renderLoginForm({ + renderConsentForm({ clientName: client.clientName, clientId, redirectUri, @@ -181,36 +174,35 @@ oauthRoutes.get("/authorize", async (c) => { ); }); -// POST /authorize — Process login form +// POST /authorize — Process consent (requires OIDC session) oauthRoutes.post("/authorize", async (c) => { const db = c.get("db") ?? prodDb; - const body = await c.req.parseBody(); - const username = body.username as string; - const password = body.password as string; + // Check for OIDC session instead of username/password + const auth = await getAuth(c); + if (!auth) { + 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 user = await verifyPassword(db, username, password); - if (!user) { - const client = await getClient(db, clientId); - return c.html( - renderLoginForm({ - clientName: client?.clientName ?? "Unknown", - clientId, - redirectUri, - codeChallenge, - codeChallengeMethod, - state, - error: "Invalid username or password", - }), - ); + const client = getClient(db, clientId); + if (!client) { + return c.json({ error: "Unknown client_id" }, 400); } - const { code } = await createAuthorizationCode( + const allowedUris: string[] = JSON.parse(client.redirectUris); + if (!allowedUris.includes(redirectUri)) { + return c.json({ error: "redirect_uri not allowed" }, 400); + } + + const { code } = createAuthorizationCode( db, clientId, codeChallenge, @@ -233,7 +225,7 @@ oauthRoutes.post("/token", async (c) => { const grantType = body.grant_type as string; // Opportunistic cleanup - await cleanExpiredOAuthData(db); + cleanExpiredOAuthData(db); if (grantType === "authorization_code") { const code = body.code as string; diff --git a/src/server/services/auth.service.ts b/src/server/services/auth.service.ts index 944877d..8ad9633 100644 --- a/src/server/services/auth.service.ts +++ b/src/server/services/auth.service.ts @@ -1,114 +1,10 @@ import { randomBytes } from "node:crypto"; -import { count, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; -import { apiKeys, sessions, users } from "../../db/schema.ts"; +import { apiKeys } from "../../db/schema.ts"; type Db = typeof prodDb; -// ── User Management ────────────────────────────────────────────────── - -export async function createUser( - db: Db = prodDb, - username: string, - password: string, -) { - const passwordHash = await Bun.password.hash(password); - const [row] = await db - .insert(users) - .values({ username, passwordHash }) - .returning(); - return row; -} - -export async function verifyPassword( - db: Db = prodDb, - username: string, - password: string, -) { - const [user] = await db - .select() - .from(users) - .where(eq(users.username, username)); - - if (!user) return null; - - const valid = await Bun.password.verify(password, user.passwordHash); - return valid ? user : null; -} - -export async function getUserCount(db: Db = prodDb): Promise { - const [result] = await db.select({ value: count() }).from(users); - return result?.value ?? 0; -} - -export async function changePassword( - db: Db = prodDb, - username: string, - currentPassword: string, - newPassword: string, -): Promise { - const user = await verifyPassword(db, username, currentPassword); - if (!user) return false; - - const newHash = await Bun.password.hash(newPassword); - await db - .update(users) - .set({ passwordHash: newHash }) - .where(eq(users.id, user.id)); - - return true; -} - -// ── Session Management ─────────────────────────────────────────────── - -export async function createSession( - db: Db = prodDb, - userId: number, - expiryDays = 30, -) { - const id = randomBytes(32).toString("hex"); - const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); - - const [row] = await db - .insert(sessions) - .values({ id, userId, expiresAt }) - .returning(); - - return row; -} - -export async function getSession(db: Db = prodDb, sessionId: string) { - const [session] = await db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)); - - if (!session) return null; - - if (session.expiresAt < new Date()) { - await db.delete(sessions).where(eq(sessions.id, sessionId)); - return null; - } - - return session; -} - -export async function deleteSession(db: Db = prodDb, sessionId: string) { - await db.delete(sessions).where(eq(sessions.id, sessionId)); -} - -export async function refreshSession( - db: Db = prodDb, - sessionId: string, - expiryDays = 30, -) { - const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); - await db - .update(sessions) - .set({ expiresAt }) - .where(eq(sessions.id, sessionId)); -} - // ── API Key Management ─────────────────────────────────────────────── export async function createApiKey(db: Db = prodDb, name: string) { @@ -116,10 +12,11 @@ export async function createApiKey(db: Db = prodDb, name: string) { const keyHash = await Bun.password.hash(rawKey); const keyPrefix = rawKey.slice(0, 8); - const [record] = await db + const record = db .insert(apiKeys) .values({ name, keyHash, keyPrefix }) - .returning(); + .returning() + .get(); return { ...record, rawKey }; } @@ -129,10 +26,11 @@ export async function verifyApiKey( rawKey: string, ): Promise { const prefix = rawKey.slice(0, 8); - const candidates = await db + const candidates = db .select() .from(apiKeys) - .where(eq(apiKeys.keyPrefix, prefix)); + .where(eq(apiKeys.keyPrefix, prefix)) + .all(); for (const candidate of candidates) { const valid = await Bun.password.verify(rawKey, candidate.keyHash); @@ -142,7 +40,7 @@ export async function verifyApiKey( return false; } -export async function listApiKeys(db: Db = prodDb) { +export function listApiKeys(db: Db = prodDb) { return db .select({ id: apiKeys.id, @@ -150,9 +48,10 @@ export async function listApiKeys(db: Db = prodDb) { keyPrefix: apiKeys.keyPrefix, createdAt: apiKeys.createdAt, }) - .from(apiKeys); + .from(apiKeys) + .all(); } -export async function deleteApiKey(db: Db = prodDb, id: number) { - await db.delete(apiKeys).where(eq(apiKeys.id, id)); +export function deleteApiKey(db: Db = prodDb, id: number) { + db.delete(apiKeys).where(eq(apiKeys.id, id)).run(); }