26 KiB
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
usersandsessionstables from GearBox schema -- user identity comes from Logto. KeepapiKeystable for programmatic access. - D-04: The
requireAuthmiddleware 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
/loginroute 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
apiKeystable 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/nodevs 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:
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/*).
// 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.
// 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.
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.
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
-- 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-authhandles this with storage-less JWT cookies. Don't recreate the sessions table. - Using Logto's user ID as an integer: Logto's
subclaim is a string (UUID-like). All foreign keys referencing user identity must usetext, notinteger. - Mixing MCP OAuth and Logto OIDC: These are separate auth domains. MCP OAuth uses GearBox's own
oauthClients/Codes/Tokenstables. Logto OIDC uses@hono/oidc-authJWT 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
// 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
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
-
Logto User ID Format
- What we know: Logto's
subclaim is a string identifier - What's unclear: Exact format (UUID? custom ID?)
- Recommendation: Use
texttype for user ID columns. Will be confirmed during Logto setup. This prepares for Phase 16 (Multi-User Data Model) which addsuserIdcolumns to all data tables.
- What we know: Logto's
-
MCP OAuth Authorize Form After Users Table Removal
- What we know: The MCP OAuth
/oauth/authorizePOST handler currently callsverifyPassword()against theuserstable - 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.
- What we know: The MCP OAuth
-
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
logtodatabase. Simpler infrastructure, one fewer container. Use a Postgres init script to create thelogtodatabase 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.ymlanddocker-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.tsauto-generated -- never edit manually. - Data fetching: TanStack React Query hooks. Auth state via
useAuthhook. - 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 buildoutputs todist/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 -- Docker setup, environment variables, Postgres requirements
- Logto Access Token Validation -- JWT validation with jose, JWKS URI, claims verification
- @hono/oidc-auth README -- Configuration, exported functions, session handling, env vars
- Logto Official Docker Compose -- 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 -- Express SDK patterns (not directly used but informative for flow understanding)
- Logto OIDC Integration Guide -- General OIDC integration patterns
Tertiary (LOW confidence)
- None -- all findings verified with official sources
Metadata
Confidence breakdown:
- Standard stack: HIGH --
@hono/oidc-authis 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)