---
phase: 15-external-authentication
verified: 2026-04-04T19:30:00Z
status: passed
score: 12/12 must-haves verified
re_verification: false
---

# Phase 15: External Authentication Verification Report

**Phase Goal:** Users can register and log in via a self-hosted OIDC auth provider, replacing the built-in single-user auth system
**Verified:** 2026-04-04T19:30:00Z
**Status:** PASSED
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

All truths are drawn from must_haves across the three plan files.

#### Plan 01 Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Logto container starts alongside Postgres in docker-compose | VERIFIED | `docker-compose.yml` lines 17-31 and `docker-compose.dev.yml` lines 19-33 both define `svhd/logto:latest` service with `depends_on: postgres: condition: service_healthy` |
| 2 | Logto admin console is accessible at port 3002 | VERIFIED | Both compose files expose ports `"3001:3001"` and `"3002:3002"` |
| 3 | A separate logto database is created automatically on Postgres first boot | VERIFIED | `docker/init-logto-db.sql` contains `CREATE DATABASE logto;`, mounted to `docker-entrypoint-initdb.d` in both compose files |
| 4 | GearBox schema no longer contains users or sessions tables | VERIFIED | `src/db/schema.ts` has no `export const users` or `export const sessions`; migration `drizzle/0010_foamy_marvel_zombies.sql` drops both tables |
| 5 | All OIDC env vars documented | VERIFIED | `.env.example` contains `LOGTO_ENDPOINT`, `LOGTO_CLIENT_ID`, `LOGTO_CLIENT_SECRET`, `OIDC_AUTH_SECRET` |

#### Plan 02 Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 6 | requireAuth middleware validates API keys, MCP Bearer tokens, and OIDC session cookies | VERIFIED | `src/server/middleware/auth.ts` checks `X-API-Key` → `Authorization: Bearer` → `getAuth(c)` in that order; no `getUserCount` bypass |
| 7 | GET /login redirects unauthenticated users to Logto | VERIFIED | `src/server/index.ts` line 44: `app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/"))` |
| 8 | GET /callback processes the OIDC authorization code and sets a session cookie | VERIFIED | `src/server/index.ts` line 45: `app.get("/callback", async (c) => processOAuthCallback(c))` |
| 9 | GET /api/auth/me returns user identity from OIDC claims or null | VERIFIED | `src/server/routes/auth.ts` uses `getAuth(c)` and returns `{ user: { id: auth.sub, email: auth.email }, authenticated: true }` or `{ user: null, authenticated: false }` |
| 10 | API keys continue to authenticate programmatic requests | VERIFIED | `verifyApiKey` first path in `requireAuth`; `auth.service.ts` retains all four API key functions |
| 11 | MCP OAuth /oauth/authorize validates via OIDC session instead of username/password | VERIFIED | `src/server/routes/oauth.ts` GET and POST `/authorize` both call `getAuth(c)`, redirect to `/login` if null; no `verifyPassword` reference anywhere |

#### Plan 03 Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 12 | Login page redirects users to Logto instead of showing a credential form | VERIFIED | `src/client/routes/login.tsx` has no `<form>`, no `<input>`, shows a "Sign In" button with `onClick={() => { window.location.href = "/login"; }}` |
| 13 | useAuth hook returns OIDC-based user identity (sub string, not integer id) | VERIFIED | `src/client/hooks/useAuth.ts` defines `AuthState` with `user: { id: string; email?: string } | null` |
| 14 | E2E seed script creates API keys directly without inserting into users table | VERIFIED | `e2e/seed.ts` has no `schema.users` reference; creates API key with `db.insert(schema.apiKeys)` with hardcoded key string |
| 15 | Unit tests for auth middleware and service pass without users/sessions tables | VERIFIED | All three test files pass: `auth.service.test.ts` (5/5), `auth.test.ts` middleware (8/8), `auth.test.ts` routes (8/8) |

**Score:** 12/12 truths verified (note: truths 1-5 count as one per plan, mapping to 12 discrete checks above)

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `docker-compose.yml` | Production Logto service definition | VERIFIED | Contains `svhd/logto:latest`, `depends_on: postgres: condition: service_healthy`, ports 3001/3002, OIDC env vars on app service |
| `docker-compose.dev.yml` | Dev Logto service definition | VERIFIED | Matching logto service with hardcoded dev password |
| `docker/init-logto-db.sql` | Postgres init script creating logto database | VERIFIED | Contains `CREATE DATABASE logto;` |
| `src/db/schema.ts` | Schema without users/sessions tables | VERIFIED | No `users` or `sessions` exports; retains `apiKeys`, `oauthClients`, `oauthCodes`, `oauthTokens` |
| `.env.example` | Documentation of required OIDC env vars | VERIFIED | Contains `LOGTO_ENDPOINT`, `LOGTO_CLIENT_ID`, `LOGTO_CLIENT_SECRET`, `OIDC_AUTH_SECRET` |
| `src/server/middleware/auth.ts` | Three-way auth middleware | VERIFIED | Exports `requireAuth`; imports `getAuth`, `verifyApiKey`, `verifyAccessToken`; no `getUserCount` |
| `src/server/services/auth.service.ts` | API key CRUD only | VERIFIED | Exports exactly `createApiKey`, `verifyApiKey`, `listApiKeys`, `deleteApiKey`; no user/session functions |
| `src/server/routes/auth.ts` | OIDC /me + API key CRUD routes | VERIFIED | Exports `authRoutes`; GET /me uses `getAuth(c)`; GET/POST/DELETE /keys present |
| `src/server/routes/oauth.ts` | MCP OAuth with OIDC session validation | VERIFIED | Imports `getAuth`; both GET and POST /authorize redirect to /login if no OIDC session; no `verifyPassword` |
| `src/server/index.ts` | Root-level OIDC routes + updated registration | VERIFIED | GET /login, /callback, /logout at top-level before /api/* middleware |
| `src/client/routes/login.tsx` | Login page that redirects to /login (OIDC) | VERIFIED | No form, no inputs; button with `window.location.href = "/login"` |
| `src/client/hooks/useAuth.ts` | Auth hooks without useLogin, useSetup, useChangePassword | VERIFIED | Exports: `useAuth`, `useLogout`, `useApiKeys`, `useCreateApiKey`, `useDeleteApiKey` only |
| `e2e/seed.ts` | E2E seed without users table insert | VERIFIED | No `schema.users` reference; inserts API key into `schema.apiKeys` |
| `tests/middleware/auth.test.ts` | Middleware tests for three-way auth | VERIFIED | 8 tests covering API key, Bearer token, OIDC session paths with mocked `getAuth` |
| `tests/services/auth.service.test.ts` | Service tests for API key functions only | VERIFIED | 5 tests for `createApiKey`, `verifyApiKey`, `listApiKeys`, `deleteApiKey` only |
| `tests/routes/auth.test.ts` | Route tests for /me and /keys endpoints | VERIFIED | 8 tests covering GET /me with mocked OIDC, GET/POST/DELETE /keys with API key auth |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `src/server/middleware/auth.ts` | `@hono/oidc-auth` | `getAuth()` for OIDC session check | WIRED | Line 2: `import { getAuth } from "@hono/oidc-auth"`, used at line 26 |
| `src/server/middleware/auth.ts` | `src/server/services/auth.service.ts` | `verifyApiKey` for API key path | WIRED | Line 3 import, used at line 12 |
| `src/server/routes/auth.ts` | `@hono/oidc-auth` | `getAuth` for /me response | WIRED | Line 2 import, used at lines 22-29 |
| `src/server/index.ts` | `@hono/oidc-auth` | `oidcAuthMiddleware`, `processOAuthCallback`, `revokeSession` for OIDC routes | WIRED | Lines 5-8 import; used at lines 44-49 |
| `src/server/routes/oauth.ts` | `@hono/oidc-auth` | `getAuth()` replaces `verifyPassword` in authorize GET/POST | WIRED | Line 1 import; used at lines 136 and 182 |
| `src/server/mcp/index.ts` | `src/server/services/auth.service.ts` | `verifyApiKey` | WIRED | Line 6 import; used in auth middleware block |
| `docker-compose.yml` | `docker/init-logto-db.sql` | postgres volume mount to `docker-entrypoint-initdb.d` | WIRED | Line 10: `./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql` |
| `src/client/hooks/useAuth.ts` | `/api/auth/me` | `apiGet` fetch | WIRED | Line 12: `apiGet<AuthState>("/api/auth/me")` |
| `src/client/routes/login.tsx` | `/login` (OIDC server route) | `window.location.href` redirect | WIRED | Line 40: `window.location.href = "/login"` |
| `e2e/seed.ts` | `apiKeys` table | direct insert | WIRED | Lines 209-211: `db.insert(schema.apiKeys).values(...)` |

### Data-Flow Trace (Level 4)

Level 4 data-flow tracing is not applicable for this phase. The primary artifacts are authentication middleware, service functions, and configuration — not components that render dynamic data fetched from a database. The auth middleware routes requests, it doesn't render data. The login page renders a static redirect button.

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| auth.service exports only API key functions | Module export check | No `createUser`, `verifyPassword`, `getUserCount`, `createSession` anywhere in src/server/ | PASS |
| Three-way auth middleware has all paths | Code inspection | `getAuth`, `verifyApiKey`, `verifyAccessToken` all present, no `getUserCount` | PASS |
| OIDC routes registered before /api/* in index.ts | Code inspection | Lines 44-49 before lines 65+ | PASS |
| Login page has no form or inputs | Code inspection | No `<form>`, no `<input>` in login.tsx | PASS |
| Build compiles cleanly | `bun run build` | Built in 474ms, no TypeScript errors | PASS |
| Auth service tests | `bun test tests/services/auth.service.test.ts` | 5 pass, 0 fail | PASS |
| Auth middleware tests | `bun test tests/middleware/auth.test.ts` | 8 pass, 0 fail | PASS |
| Auth route tests | `bun test tests/routes/auth.test.ts` | 8 pass, 0 fail | PASS |

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| AUTH-01 | 15-02, 15-03 | User can register an account via external OIDC auth provider | SATISFIED | Logto handles registration; /login redirects to Logto via `oidcAuthMiddleware()`; login.tsx renders redirect button |
| AUTH-02 | 15-02, 15-03 | User can log in via external auth provider and access their data | SATISFIED | OIDC login/callback/logout flow implemented; `getAuth(c)` validates OIDC session in middleware and /me route |
| AUTH-03 | 15-02 | API keys remain functional for programmatic access (MCP, scripts) | SATISFIED | `verifyApiKey` is first check in `requireAuth`; MCP middleware checks API key; all four API key service functions preserved; 8/8 middleware tests pass including API key paths. Note: REQUIREMENTS.md still shows `[ ]` (unchecked) — documentation inconsistency, implementation is complete |
| AUTH-04 | 15-01 | Auth provider runs self-hosted alongside the application | SATISFIED | `svhd/logto:latest` in both `docker-compose.yml` and `docker-compose.dev.yml` with Postgres dependency and init script. Note: REQUIREMENTS.md still shows `[ ]` (unchecked) — documentation inconsistency, implementation is complete |
| AUTH-05 | 15-03 | E2E tests authenticate via API keys without depending on the auth provider | SATISFIED | `e2e/seed.ts` creates `apiKeys` record with static key; no `schema.users` reference; no Logto dependency in E2E auth path |

**Note on REQUIREMENTS.md checkbox discrepancy:** AUTH-03 and AUTH-04 are marked `[ ]` (pending) in REQUIREMENTS.md but their implementation is fully present. The plan summaries claim them complete. This is a documentation synchronization gap — the code satisfies the requirements.

### Anti-Patterns Found

No anti-patterns found in phase 15 artifacts.

- No TODO/FIXME/placeholder comments in modified files
- No empty handlers or stub implementations
- No hardcoded empty data passed to renderers
- No references to removed functions (`getUserCount`, `createUser`, `verifyPassword`, `createSession`, `getSession`, `deleteSession`, `refreshSession`) anywhere in `src/server/`
- No references to removed hooks (`useLogin`, `useSetup` (auth), `useChangePassword`) in `src/client/`
- The deferred item noted in 15-03-SUMMARY ("tests/routes/oauth.test.ts still references createUser") was resolved — no such reference exists in current code

### Human Verification Required

The following items require a running Logto instance and cannot be verified programmatically:

**1. End-to-End OIDC Login Flow**

Test: Start `docker compose -f docker-compose.dev.yml up -d`, configure a Logto application (Traditional Web), set `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_AUTH_SECRET` in environment, start `bun run dev`, visit `/login`, click "Sign In".

Expected: Redirected to Logto login page; after completing Logto login/registration, redirected back to GearBox dashboard as authenticated user; `GET /api/auth/me` returns `{ authenticated: true, user: { id: "<logto-sub>", email: "..." } }`.

Why human: Requires running Logto container, browser interaction, real OIDC token exchange; cannot be tested with grep or static analysis.

**2. MCP OAuth Flow via OIDC Session**

Test: With a valid OIDC session active in the browser, navigate to `/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256`. Expected: See consent form with "Authorize" button (no username/password fields).

Expected: Consent form shown when OIDC session is present; redirect to login if no session.

Why human: Requires running Logto, active OIDC session cookie, and OAuth client registration.

**3. Logto Admin Console Accessibility**

Test: `docker compose -f docker-compose.dev.yml up -d && curl -s http://localhost:3002` (or open in browser).

Expected: Logto admin console UI loads at port 3002.

Why human: Requires Docker environment and Logto container to be running; cannot verify port accessibility from static analysis.

### Gaps Summary

No gaps. All automated verification checks pass. Phase goal is achieved from the perspective of code correctness, architecture, and test coverage. The only items requiring human attention are live integration tests with a running Logto instance, which were gated as a human checkpoint in Plan 03 Task 3.

One minor documentation note: REQUIREMENTS.md checkboxes for AUTH-03 and AUTH-04 remain unchecked despite their implementation being complete. This does not affect the verification status — the code satisfies the requirements.

---

_Verified: 2026-04-04T19:30:00Z_
_Verifier: Claude (gsd-verifier)_
