---
phase: 01-foundation-broker-spike
plan: 02
type: execute
wave: 2
depends_on: ['01-01']
files_modified:
  - apps/api/src/auth/middleware.ts
  - apps/api/src/auth/user.ts
  - apps/api/src/routes/me.ts
  - apps/api/src/index.ts
  - apps/api/tests/auth/user.test.ts
  - apps/pwa/src/App.tsx
  - apps/pwa/src/api/client.ts
  - .env.example
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-03]

must_haves:
  truths:
    - "An unauthenticated request to /api/* is redirected to Authelia's authorize endpoint (302)"
    - 'After login, the OIDC callback upserts a users row keyed by oidc_iss + oidc_sub (never email)'
    - 'Each member is auto-assigned a stable, distinct color from a curated palette on first login; re-login returns the same color'
    - 'Session persists via @hono/oidc-auth refresh-token rotation — no iframe, refresh held backend-side'
    - "GET /api/me returns the authenticated user's identity + color"
    - "The PWA shell renders the logged-in member's name and color swatch"
  artifacts:
    - path: 'apps/api/src/auth/user.ts'
      provides: 'upsertUser(oidcIss, oidcSub, displayName) with round-robin color assignment'
      exports: ['upsertUser', 'COLOR_PALETTE']
    - path: 'apps/api/src/auth/middleware.ts'
      provides: 'oidcAuthMiddleware wiring + getAuth → upsertUser bridge'
    - path: 'apps/api/src/routes/me.ts'
      provides: 'GET /api/me → { user: { id, displayName, color } }'
      exports: ['meRouter']
  key_links:
    - from: 'apps/api/src/routes/me.ts'
      to: 'apps/api/src/auth/user.ts'
      via: 'upsertUser call'
      pattern: "upsertUser\\("
    - from: 'apps/api/src/index.ts'
      to: '@hono/oidc-auth'
      via: 'oidcAuthMiddleware on /api/*'
      pattern: 'oidcAuthMiddleware'
    - from: 'apps/pwa/src/App.tsx'
      to: '/api/me'
      via: 'React Query fetch'
      pattern: 'api/me'
---

<objective>
Deliver the OIDC authentication vertical slice: wire `@hono/oidc-auth` against the already-deployed Authelia, upsert a stable user identity keyed by `oidc_iss + oidc_sub` on first authenticated request, auto-assign a stable per-member color from a curated palette, expose `GET /api/me`, and render the logged-in member (name + color) in the PWA shell.

After this plan a real user can: hit the app, get redirected to Authelia, log in, and land on a shell that shows their name and their assigned color — with the session persisting across the access-token refresh window. This is AUTH-01/02/03 end to end.

Purpose: Authentication is the gate for every other feature; the identity + color row is consumed by the calendar broker (Plan 03) and all later phases.
Output: Working Authelia OIDC login, stable identity + color, /api/me, authenticated PWA shell.
</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
@./CLAUDE.md
@.planning/phases/01-foundation-broker-spike/01-CONTEXT.md
@.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
@.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md
</context>

<artifacts_produced>

## Artifacts this phase produces (Plan 02)

New files: `apps/api/src/auth/middleware.ts`, `apps/api/src/auth/user.ts`, `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`.

New exported symbols: `upsertUser` (auth/user.ts), `COLOR_PALETTE` (auth/user.ts), `meRouter` (routes/me.ts), `setupAuth`/`oidc middleware mount` (auth/middleware.ts).

New route paths: `GET /api/me`, `GET /callback` (OIDC callback handled by processOAuthCallback).

New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`, `OIDC_AUTH_EXTERNAL_URL`.
</artifacts_produced>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: User upsert + stable color assignment (AUTH-03)</name>
  <files>apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts</files>
  <read_first>
    - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "User upsert with color assignment" code example, § "Pattern 2" users schema)
    - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-06 color auto-assign round-robin by join order; D-10 identity = oidc_iss+oidc_sub never email)
    - apps/api/src/db/schema.ts (users table from Plan 01)
    - apps/api/tests/auth/user.test.ts (RED stub from Plan 01 — fill GREEN here)
  </read_first>
  <behavior>
    - upsertUser called with a new (oidcIss, oidcSub) inserts a row and assigns COLOR_PALETTE[userCount % palette.length]
    - upsertUser called twice with the same (oidcIss, oidcSub) returns the SAME row and SAME color (idempotent, no duplicate insert)
    - Two distinct oidcSub values receive DISTINCT colors (until palette wraps)
    - Identity lookup uses oidc_iss AND oidc_sub — never email/displayName
  </behavior>
  <action>
    Create `src/auth/user.ts` exporting `COLOR_PALETTE` (a curated array of >=4 visually-distinct, accessible hex hues per D-06 / Claude's Discretion — e.g. calm blue, warm coral, forest green, soft purple; exact values Claude's choice) and `upsertUser(oidcIss, oidcSub, displayName?)`. Logic per RESEARCH example: SELECT existing by `and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))`; if present return it; else COUNT existing users, assign `COLOR_PALETTE[count % length]`, INSERT, return the new row. Use `$returningId()` then re-select (mysql2 has no RETURNING). Never key on email.

    Fill `tests/auth/user.test.ts` GREEN using the test-DB fixture (tests/helpers/db.ts): assert (a) first insert assigns palette[0]; (b) second user assigns palette[1]; (c) re-upsert of user 1 returns the identical row + color and does not create a duplicate; (d) lookup is by iss+sub.

  </action>
  <verify>
    <automated>cd apps/api && pnpm vitest run tests/auth/user.test.ts --reporter=verbose</automated>
  </verify>
  <acceptance_criteria>
    - `src/auth/user.ts` exports `upsertUser` and `COLOR_PALETTE` (length >= 4)
    - tests/auth/user.test.ts passes all four cases (insert color, second distinct color, idempotent re-upsert, identity by iss+sub)
    - grep confirms `users.oidcIss` and `users.oidcSub` used in the WHERE; no `users.email` lookup exists
  </acceptance_criteria>
  <done>user.test.ts green; color assignment deterministic and stable; identity keyed on iss+sub.</done>
</task>

<task type="auto">
  <name>Task 2: Authelia OIDC middleware + /api/me + authenticated PWA shell (AUTH-01/02)</name>
  <files>apps/api/src/auth/middleware.ts, apps/api/src/routes/me.ts, apps/api/src/index.ts, .env.example, apps/pwa/src/api/client.ts, apps/pwa/src/App.tsx</files>
  <read_first>
    - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 1: @hono/oidc-auth Middleware Wiring" incl. env vars + Authelia client YAML, § "Hono app bootstrap", § "Pitfall 1: OIDC_AUTH_EXTERNAL_URL", § "Pitfall 7: client secret plain vs hashed")
    - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-11 skip groups claim; D-12 backend holds refresh token, no iframe)
    - apps/api/src/auth/user.ts (upsertUser from Task 1)
    - apps/api/src/index.ts (Hono bootstrap from Plan 01)
  </read_first>
  <action>
    Create `src/auth/middleware.ts`: configure `oidcAuthMiddleware()` from @hono/oidc-auth. In `src/index.ts`: register `app.get('/callback', (c) => processOAuthCallback(c))` BEFORE the auth middleware, then `app.use('/api/*', oidcAuthMiddleware())`. Keep `/health` (Plan 01) unauthenticated — mount it before the /api guard. Required env vars (add real placeholders to .env.example): OIDC_AUTH_SECRET (32+ char), OIDC_ISSUER (Authelia base URL — middleware fetches /.well-known/openid-configuration), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (PLAIN secret per Pitfall 7, NOT the pbkdf2 hash), OIDC_REDIRECT_URI (https://familysync.<domain>/callback), OIDC_AUTH_EXTERNAL_URL (https://familysync.<domain> — MANDATORY behind Pangolin per Pitfall 1, else redirect_uri mismatch). Do NOT request the `groups` scope (D-11). Scopes: openid, profile, email only.

    Create `src/routes/me.ts` exporting `meRouter` (Hono): GET / calls `getAuth(c)` to read `iss`, `sub`, `email`, then `upsertUser(auth.iss, auth.sub, auth.email)` and returns `{ user: { id, displayName, color } }`. Mount `app.route('/api/me', meRouter)`.

    Session persistence (AUTH-02) is handled by @hono/oidc-auth refresh-token rotation (backend-held refresh token, no iframe — D-12). Note in a code comment that OIDC_AUTH_REFRESH_INTERVAL / OIDC_AUTH_EXPIRES govern this; defaults are acceptable for v1.

    PWA: create `apps/pwa/src/api/client.ts` with a typed `fetchMe()` (GET /api/me, credentials: 'include'). Update `App.tsx`: React Query `useQuery(['me'], fetchMe)`; on 401/redirect the browser follows Authelia (full-page). Render the member's displayName and a color swatch using `user.color`. Keep the /health indicator from Plan 01.

    Also record the Authelia client registration YAML (from RESEARCH Pattern 1) in the SUMMARY so the operator can paste it into Authelia's configuration.yml — this is the only human-side config (no code change in this repo).

  </action>
  <verify>
    <automated>cd apps/api && pnpm exec tsc --noEmit && grep -q "oidcAuthMiddleware" src/index.ts && grep -q "OIDC_AUTH_EXTERNAL_URL" ../../.env.example && grep -q "upsertUser" src/routes/me.ts</automated>
  </verify>
  <acceptance_criteria>
    - `src/index.ts` registers `/callback` via processOAuthCallback BEFORE `oidcAuthMiddleware` and guards `/api/*`
    - `/health` remains reachable without authentication (mounted before the /api guard)
    - `.env.example` lists OIDC_AUTH_SECRET, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_AUTH_EXTERNAL_URL
    - `src/routes/me.ts` calls `getAuth` then `upsertUser` and returns user id/displayName/color
    - No `groups` scope requested anywhere (grep -v '^#' src | grep -c "groups" == 0 in auth code)
    - `apps/pwa/src/App.tsx` fetches `/api/me` and renders `user.color`
    - `pnpm exec tsc --noEmit` exits 0
  </acceptance_criteria>
  <done>tsc clean; /api/* guarded by OIDC, /callback wired, /health still public; /api/me returns identity+color; PWA renders the member; Authelia client YAML captured in SUMMARY.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                          | Description                                                                        |
| --------------------------------- | ---------------------------------------------------------------------------------- |
| Browser → Pangolin → Hono /api/\* | Untrusted client; only authenticated requests cross (OIDC session cookie)          |
| Authelia → /callback              | OIDC authorization-code exchange; PKCE + state validate the callback               |
| Hono → Authelia token endpoint    | Backend confidential client; client_secret + refresh token never reach the browser |

## STRIDE Threat Register

| Threat ID | Category               | Component                            | Disposition | Mitigation Plan                                                                                                                                                               |
| --------- | ---------------------- | ------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-02-01   | Spoofing               | OIDC redirect_uri                    | mitigate    | Authelia validates exact match; OIDC_REDIRECT_URI env must equal the registered URI; OIDC_AUTH_EXTERNAL_URL set so Pangolin Host header cannot forge the redirect (Pitfall 1) |
| T-02-02   | Spoofing               | CSRF on /callback                    | mitigate    | @hono/oidc-auth uses PKCE (state + code_verifier); require_pkce true, S256 in Authelia client                                                                                 |
| T-02-03   | Tampering              | OIDC session JWT cookie              | mitigate    | Cookie signed with OIDC_AUTH_SECRET (32+ char), httpOnly + Secure + SameSite; verified every request                                                                          |
| T-02-04   | Information Disclosure | Refresh token / client_secret        | mitigate    | Backend-only (D-12); never serialized to frontend; not logged; OIDC_CLIENT_SECRET is the plain secret in env, never committed                                                 |
| T-02-05   | Elevation of Privilege | /api/\* without auth                 | mitigate    | oidcAuthMiddleware mounted on /api/\*; no guest access (ASVS V4)                                                                                                              |
| T-02-06   | Spoofing               | Identity confusion via mutable email | mitigate    | Identity keyed on oidc_iss + oidc_sub, never email (D-10)                                                                                                                     |

</threat_model>

<verification>
- `pnpm exec tsc --noEmit` clean
- user.test.ts green (from Task 1)
- index.ts mounts oidcAuthMiddleware on /api/*, /callback before it, /health public
- /api/me returns identity + color
- .env.example complete (OIDC_* + OIDC_AUTH_EXTERNAL_URL)
- Manual (Plan 04 deploy): unauthenticated /api/me → 302 to Authelia; after login lands on shell with name + color
</verification>

<success_criteria>

- AUTH-01: unauthenticated /api/\* redirects to Authelia; login lands authenticated (verified live in Plan 04)
- AUTH-02: session persists via backend refresh-token rotation (no iframe)
- AUTH-03: stable identity (iss+sub) + stable distinct per-member color, asserted by unit tests
- /api/me returns the member; PWA shell shows name + color
  </success_criteria>

<output>
Create `.planning/phases/01-foundation-broker-spike/01-02-SUMMARY.md` when done. Include the Authelia client registration YAML for the operator.
</output>
