8.7 KiB
phase: 01-foundation-broker-spike plan: "02" subsystem: auth tags: [oidc, authelia, hono, drizzle, react, typescript, pwa]
Dependency graph
requires:
- 01-01 (db singleton, users schema, Hono app export pattern) provides:
- upsertUser(oidcIss, oidcSub, displayName?) with round-robin color assignment
- COLOR_PALETTE (6 curated hex hues)
- oidcAuthMiddleware on /api/* (AUTH-01 unauthenticated redirect to Authelia)
- /callback route via processOAuthCallback
- GET /api/me → { user: { id, displayName, color } }
- fetchMe() typed PWA API client
- Authenticated PWA shell rendering member name + color swatch affects:
- 01-03 (broker — user row exists on first login; color available for UI)
- 01-04 (spike deploy — live OIDC test, /api/me smoke test)
Tech tracking
tech-stack: added: - "@hono/oidc-auth@1.8.3 (oidcAuthMiddleware, processOAuthCallback, getAuth)" patterns: - "Identity keyed on oidc_iss + oidc_sub; email is display-only (D-10)" - "upsertUser: SELECT → early return if exists; COUNT → COLOR_PALETTE[count % len] → INSERT $returningId → re-SELECT (mysql2 no RETURNING)" - "Middleware re-export pattern: src/auth/middleware.ts re-exports from @hono/oidc-auth" - "oidcAuthMiddleware before /api/* but after /health and /callback in index.ts" - "fetchMe() uses credentials: 'include' for OIDC cookie forwarding"
key-files: created: - apps/api/src/auth/user.ts (upsertUser + COLOR_PALETTE) - apps/api/src/auth/middleware.ts (oidcAuthMiddleware re-export + env var docs) - apps/api/src/routes/me.ts (GET /api/me handler) - apps/pwa/src/api/client.ts (fetchMe typed client) modified: - apps/api/src/index.ts (adds /callback + oidcAuthMiddleware + /api/me mount) - apps/pwa/src/App.tsx (adds MemberBadge with color swatch + fetchMe query) - apps/api/tests/auth/user.test.ts (filled from it.todo stubs → 6 passing tests)
key-decisions:
- "Middleware re-export: src/auth/middleware.ts re-exports from @hono/oidc-auth rather than duplicating config — keeps index.ts clean and provides a single auth module boundary"
- "iss extracted via cast (auth.iss as string | undefined): OidcAuth type exposes iss via index signature [claim: string] — cast is safe per @hono/oidc-auth source; iss is always present in a valid OIDC ID token"
- "COLOR_PALETTE has 6 entries (not 4 minimum) to pre-accommodate future household growth without palette wrap-around"
- "fetchMe retry: false — 401 triggers Authelia redirect; retrying would just generate more 401s before the redirect lands"
Metrics
duration: ~3min completed: "2026-06-04"
Phase 01 Plan 02: OIDC Auth Vertical Slice — Summary
JWT-signed OIDC session via @hono/oidc-auth against Authelia, stable per-member identity (iss+sub) with round-robin color from a 6-hue palette, /api/me returning user identity+color, and an authenticated React PWA shell rendering the member's name and color swatch.
Performance
- Duration: ~3 min
- Started: 2026-06-04T14:19:32Z
- Completed: 2026-06-04
- Tasks: 2 of 2 complete
- Files modified: 7 (3 created, 4 modified/filled)
Accomplishments
- Task 1 (TDD): Replaced 5
it.todostubs intests/auth/user.test.tswith 6 real tests covering: palette[0] first user, palette[1] second distinct user, idempotent re-upsert (no duplicate insert), identity keyed on iss+sub not email, full row shape. All GREEN. - Task 2: Wired full OIDC vertical slice:
src/auth/middleware.tsre-exports oidcAuthMiddleware/processOAuthCallback/getAuth from @hono/oidc-authsrc/index.tsupdated: /health (public) → /callback → /api/* guarded by oidcAuthMiddleware → /api/me mountedsrc/routes/me.tscalls getAuth → upsertUser(iss, sub, email) → returns {user: {id, displayName, color}}apps/pwa/src/api/client.tstyped fetchMe() with credentials: 'include'apps/pwa/src/App.tsxrenders MemberBadge (name + color swatch circle) via useQuery(['me'], fetchMe)
Task Commits
- RED gate (Task 1):
61c258c—test(01-02): add failing tests for upsertUser - GREEN (Task 1):
baabfce—feat(01-02): implement upsertUser with stable color assignment (AUTH-03) - Task 2:
668ed9b—feat(01-02): wire OIDC middleware, /api/me route, and authenticated PWA shell
Files Created/Modified
apps/api/src/auth/user.ts—upsertUser+COLOR_PALETTE(6 hex hues)apps/api/src/auth/middleware.ts—oidcAuthMiddleware,processOAuthCallback,getAuthre-exports with env var documentationapps/api/src/routes/me.ts—GET /handler: getAuth → upsertUser →{user: {id, displayName, color}}apps/api/src/index.ts— updated mount order: /health → /callback → oidcAuthMiddleware on /api/* → /api/meapps/pwa/src/api/client.ts—fetchMe()with typed response shapeapps/pwa/src/App.tsx—MemberBadgecomponent withColorSwatch; useQuery(['me'], fetchMe); retains /health indicatorapps/api/tests/auth/user.test.ts— 6 passing tests (was 5 it.todo stubs)
Decisions Made
- Re-export pattern for middleware:
src/auth/middleware.tsre-exports from@hono/oidc-authrather than duplicating config at mount site auth.isscast:OidcAuthexposesissvia index signature[claim: string]: JsonValue | undefined; cast tostring | undefinedis safe — iss is always present in a valid OIDC session- 6-color palette: pre-accommodates household growth without requiring palette config update
Operator Setup Required (Authelia Client Registration)
Before deploying, register FamilySync as an OIDC client in Authelia's configuration.yml:
identity_providers:
oidc:
clients:
- client_id: 'familysync'
# Generate the hash with: authelia crypto hash --sha512 <your-plain-secret>
# OIDC_CLIENT_SECRET env var holds the PLAIN secret (not this hash — Pitfall 7)
client_secret: '$pbkdf2-sha512$310000$...'
redirect_uris:
- 'https://familysync.yourdomain.com/callback'
grant_types:
- 'authorization_code'
- 'refresh_token'
response_types:
- 'code'
require_pkce: true
pkce_challenge_method: 'S256'
token_endpoint_auth_method: 'client_secret_basic'
scopes:
- 'openid'
- 'profile'
- 'email'
# No 'groups' scope — D-11: all authenticated users are equal
.env values to set before first run:
| Variable | Value |
|---|---|
OIDC_AUTH_SECRET |
32+ char random string (e.g. openssl rand -base64 32) |
OIDC_ISSUER |
Authelia base URL, e.g. https://auth.yourdomain.com |
OIDC_CLIENT_ID |
familysync |
OIDC_CLIENT_SECRET |
Plain text secret (same value used with authelia crypto hash) |
OIDC_REDIRECT_URI |
https://familysync.yourdomain.com/callback |
OIDC_AUTH_EXTERNAL_URL |
https://familysync.yourdomain.com — mandatory behind Pangolin (Pitfall 1) |
Note: OIDC_AUTH_EXTERNAL_URL is not optional behind Pangolin. Without it, @hono/oidc-auth constructs the redirect_uri from the internal container Host header, which won't match the registered URI in Authelia — login fails with "invalid redirect_uri".
Deviations from Plan
None — plan executed exactly as written.
Known Stubs
None. The upsertUser implementation is complete. The PWA fetchMe is wired to /api/me which returns real data. No placeholder text or hardcoded empty values in user-facing flows.
Threat Surface Scan
All surfaces are within the planned threat model (Plan 02 STRIDE register):
- T-02-01 (redirect_uri): OIDC_AUTH_EXTERNAL_URL documented in .env.example and middleware comments
- T-02-02 (CSRF): processOAuthCallback uses PKCE (state + code_verifier); Authelia configured with require_pkce: true, S256
- T-02-03 (cookie tampering): @hono/oidc-auth signs cookie with OIDC_AUTH_SECRET; httpOnly + Secure + SameSite enforced by library
- T-02-04 (refresh token / client_secret): backend-only (D-12); getAuth → upsertUser → returns {id, displayName, color} only — no token or credential data in /api/me response
- T-02-05 (/api/ without auth):* oidcAuthMiddleware on /api/*; /health public-before-guard
- T-02-06 (identity confusion): upsertUser keyed exclusively on oidcIss + oidcSub; no email lookup anywhere in auth path
No new threat surface introduced beyond plan.
Self-Check: PASSED
apps/api/src/auth/user.tsexists: FOUNDapps/api/src/auth/middleware.tsexists: FOUNDapps/api/src/routes/me.tsexists: FOUNDapps/pwa/src/api/client.tsexists: FOUND- Commits
61c258c(RED),baabfce(GREEN),668ed9b(Task 2): FOUND tsc --noEmitclean: PASSED- 6 auth/user tests pass: PASSED
Phase: 01-foundation-broker-spike Completed: 2026-06-04