---
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>
