docs(01-02): complete OIDC auth slice plan summary
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
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.todo` stubs in `tests/auth/user.test.ts` with 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.ts` re-exports oidcAuthMiddleware/processOAuthCallback/getAuth from @hono/oidc-auth
|
||||||
|
- `src/index.ts` updated: /health (public) → /callback → /api/* guarded by oidcAuthMiddleware → /api/me mounted
|
||||||
|
- `src/routes/me.ts` calls getAuth → upsertUser(iss, sub, email) → returns {user: {id, displayName, color}}
|
||||||
|
- `apps/pwa/src/api/client.ts` typed fetchMe() with credentials: 'include'
|
||||||
|
- `apps/pwa/src/App.tsx` renders MemberBadge (name + color swatch circle) via useQuery(['me'], fetchMe)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **RED gate (Task 1):** `61c258c` — `test(01-02): add failing tests for upsertUser`
|
||||||
|
2. **GREEN (Task 1):** `baabfce` — `feat(01-02): implement upsertUser with stable color assignment (AUTH-03)`
|
||||||
|
3. **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`, `getAuth` re-exports with env var documentation
|
||||||
|
- `apps/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/me
|
||||||
|
- `apps/pwa/src/api/client.ts` — `fetchMe()` with typed response shape
|
||||||
|
- `apps/pwa/src/App.tsx` — `MemberBadge` component with `ColorSwatch`; useQuery(['me'], fetchMe); retains /health indicator
|
||||||
|
- `apps/api/tests/auth/user.test.ts` — 6 passing tests (was 5 it.todo stubs)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- Re-export pattern for middleware: `src/auth/middleware.ts` re-exports from `@hono/oidc-auth` rather than duplicating config at mount site
|
||||||
|
- `auth.iss` cast: `OidcAuth` exposes `iss` via index signature `[claim: string]: JsonValue | undefined`; cast to `string | undefined` is 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`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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.ts` exists: FOUND
|
||||||
|
- `apps/api/src/auth/middleware.ts` exists: FOUND
|
||||||
|
- `apps/api/src/routes/me.ts` exists: FOUND
|
||||||
|
- `apps/pwa/src/api/client.ts` exists: FOUND
|
||||||
|
- Commits 61c258c (RED), baabfce (GREEN), 668ed9b (Task 2): FOUND
|
||||||
|
- `tsc --noEmit` clean: PASSED
|
||||||
|
- 6 auth/user tests pass: PASSED
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 01-foundation-broker-spike*
|
||||||
|
*Completed: 2026-06-04*
|
||||||
Reference in New Issue
Block a user