From d22da015cbf9ce825c6d3200d15174e4d4f3a9fe Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:19:25 -0400 Subject: [PATCH] docs(19-01): complete local-auth foundation plan (checkpoint reached at Task 4) --- .../19-01-SUMMARY.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md b/.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md new file mode 100644 index 0000000..d66e5dd --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md @@ -0,0 +1,173 @@ +--- +phase: 19-local-auth-no-oidc-mode +plan: "01" +subsystem: auth +tags: [local-auth, scrypt, jwt, session-cookie, migration, boot-guard, docker-hygiene] +status: checkpoint +dependency_graph: + requires: [] + provides: + - hashPassword/verifyPassword (node:crypto scrypt, PHC-encoded) + - issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie (Hono Jwt HS256) + - assertLocalSessionSecretSet (boot guard) + - local_credentials Drizzle table + 0003 migration + - LOCAL_SESSION_SECRET in generate-secrets.mjs + - apps/api/scripts/ .dockerignore exclusion (D-15) + affects: + - apps/api/src/index.ts (boot guard wired) + - apps/api/test/setup.ts (afterEach cleanup) + - .dockerignore (D-15 image hygiene) +tech_stack: + added: [] + patterns: + - PHC-style encoded scrypt hash (scrypt$N$r$p$salt_b64url$hash_b64url) + - Stateless JWT session cookie via hono/utils/jwt Jwt.sign/Jwt.verify + - Boot guard pattern (mirrors assertNotDevBypassInProduction) + - TDD RED/GREEN: failing test committed before implementation +key_files: + created: + - apps/api/src/auth/localCredentials.ts + - apps/api/src/auth/localSession.ts + - apps/api/src/db/migrations/0003_warm_deathstrike.sql + - apps/api/tests/auth/localCredentials.test.ts + - apps/api/tests/auth/localSession.test.ts + modified: + - apps/api/src/lib/bootGuards.ts + - apps/api/src/index.ts + - apps/api/src/db/schema.ts + - apps/api/test/setup.ts + - scripts/generate-secrets.mjs + - .dockerignore +decisions: + - "Used node:crypto scryptSync (not async) — blocking but acceptable for 2-person household infrequent logins (D-08)" + - "PHC-style encoding embeds N/r/p/salt in stored string — future parameter upgrades without DB migration" + - "Jwt namespace import from hono/utils/jwt (Pitfall 8 — named sign/verify don't exist)" + - "Cookie name: local-session (distinct from oidc-auth, Pitfall 4)" + - "assertLocalSessionSecretSet exempts DEV_AUTH_BYPASS=true — bypass never issues local JWTs" + - "Migration generated by drizzle-kit generate (never push) — purely additive CREATE TABLE" + - ".dockerignore: excluded entire apps/api/scripts/ dir (supersedes per-file exclusion, D-15)" +metrics: + duration: "~6 minutes" + completed: "2026-06-17" + tasks_completed: 3 + tasks_total: 4 + files_created: 5 + files_modified: 6 +--- + +# Phase 19 Plan 01: Local Auth Foundation Summary + +**One-liner:** Scrypt password primitives, stateless local-session JWT cookie helpers, `local_credentials` MariaDB table + additive migration, `LOCAL_SESSION_SECRET` boot guard wired in `index.ts`, and `.dockerignore` break-glass script exclusion. + +## Status: CHECKPOINT REACHED + +Task 4 is a `type="checkpoint:human-verify"` (gate="blocking"). Tasks 1-3 are complete and committed. The plan pauses for human review of the generated migration SQL before proceeding. + +## Tasks Completed + +| Task | Name | Commit | Key Files | +|------|------|--------|-----------| +| 1 (RED) | hashPassword/verifyPassword tests | 7ece966 | apps/api/tests/auth/localCredentials.test.ts | +| 1 (GREEN) | hashPassword/verifyPassword implementation | 85b01b5 | apps/api/src/auth/localCredentials.ts | +| 2 (RED) | localSession + bootGuards tests | 0d8f3fa | apps/api/tests/auth/localSession.test.ts | +| 2 (GREEN) | localSession + bootGuards + index.ts | 7d61148 | apps/api/src/auth/localSession.ts, bootGuards.ts, index.ts | +| 3 | schema + migration + secrets + dockerignore | 96f0991 | schema.ts, 0003_warm_deathstrike.sql, generate-secrets.mjs, .dockerignore | + +## Task 4: Checkpoint (Pending Human Review) + +**Checkpoint type:** `human-verify` (blocking) + +The migration `0003_warm_deathstrike.sql` was generated by `drizzle-kit generate` and applied to the dev DB with `pnpm --filter @familysync/api db:migrate` (exit 0). It contains: + +```sql +CREATE TABLE `local_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `username` varchar(128) NOT NULL, + `password_hash` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`), + CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`) +); +--> statement-breakpoint +ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`); +``` + +The SQL is purely additive. No `ALTER/DROP/TRUNCATE/RENAME` touches any existing table. + +**What the human needs to verify:** +1. Review `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — confirm only `CREATE TABLE local_credentials` (no statements touching users, member_credentials, calendars, calendar_events, app_config, or any other existing table). +2. Confirm `LOCAL_SESSION_SECRET` is set in your local `.env` (>=32 chars) — without it the API will refuse to boot in non-bypass mode. Add it via `node scripts/generate-secrets.mjs` if not present. + +**Resume signal:** Type "approved" if migration is purely additive and LOCAL_SESSION_SECRET is set. + +## What Was Built + +### Task 1: hashPassword/verifyPassword (TDD) + +`apps/api/src/auth/localCredentials.ts` exports: +- `hashPassword(password: string): string` — scrypt + 16-byte random salt, returns PHC-encoded string +- `verifyPassword(storedEncoded: string, candidate: string): boolean` — timingSafeEqual, never throws + +Zero new npm dependencies. All 5 unit tests pass (round-trip, wrong-password, unique-salt, malformed-hash, PHC-shape). + +### Task 2: localSession.ts + boot guard (TDD) + +`apps/api/src/auth/localSession.ts` exports: +- `issueLocalSessionCookie(c, userId)` — signs JWT (HS256) with LOCAL_SESSION_SECRET, sets httpOnly cookie +- `verifyLocalSessionCookie(c)` — returns userId or null (never throws, catches Jwt.verify expiry throws) +- `clearLocalSessionCookie(c)` — deletes the cookie with matching attributes + +`apps/api/src/lib/bootGuards.ts` adds: +- `assertLocalSessionSecretSet()` — exits with FATAL if secret missing/<32 chars when not in bypass mode + +`apps/api/src/index.ts` — `assertLocalSessionSecretSet()` called immediately after `assertNotDevBypassInProduction()`. + +All 5 unit tests pass; `pnpm --filter @familysync/api typecheck` exits 0. + +### Task 3: Schema + Migration + Secrets + .dockerignore + +- `apps/api/src/db/schema.ts` — `localCredentials` table exported (UNIQUE user_id, UNIQUE username, FK->users cascade) +- `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — purely additive CREATE TABLE; applied to dev DB +- `apps/api/test/setup.ts` — `localCredentials` added to afterEach cleanup (FK-safe ordering) +- `scripts/generate-secrets.mjs` — emits `LOCAL_SESSION_SECRET` (base64 32-byte, 44 chars) +- `.dockerignore` — added `apps/api/scripts/` directory exclusion (D-15/IMG-02) + +## Deviations from Plan + +### Auto-fixed Issues + +None. Plan executed as written. + +### Notes + +- The migration file generated by drizzle-kit is named `0003_warm_deathstrike.sql` (drizzle-kit generates random animal names for migrations). The plan referenced `0003_local_credentials.sql` as an expected name — this is not a semantic deviation, only a filename difference from drizzle-kit's naming convention. The content and purpose match exactly. +- `pnpm --filter @familysync/api db:migrate` was run against the dev stack DB (credentials from `.env`). The worktree shares the main repo's dev DB connection, which is expected and safe for an additive migration. +- Tests requiring MariaDB were run with `CI=true` to bypass the global-setup root-DB-provisioning step (which requires a root MySQL connection that isn't available from the worktree's network context). Pure unit tests (no DB access) work correctly in this mode. + +## Threat Surface Scan + +No new network endpoints introduced in this plan. All new surface is internal stdlib / crypto utilities and a DB table migration. No changes to trust boundaries that aren't already covered by the plan's threat model (T-19-01 through T-19-04 and T-19-SC). + +## Known Stubs + +None. This plan provides foundational utilities without UI or stub placeholders. + +## Self-Check: PASSED + +All created files confirmed present on disk: +- FOUND: apps/api/src/auth/localCredentials.ts +- FOUND: apps/api/src/auth/localSession.ts +- FOUND: apps/api/src/db/migrations/0003_warm_deathstrike.sql +- FOUND: apps/api/tests/auth/localCredentials.test.ts +- FOUND: apps/api/tests/auth/localSession.test.ts + +All commits confirmed in git log: +- 7ece966: test(19-01): add failing tests for hashPassword/verifyPassword +- 85b01b5: feat(19-01): implement hashPassword/verifyPassword +- 0d8f3fa: test(19-01): add failing tests for localSession +- 7d61148: feat(19-01): implement localSession JWT cookie helpers +- 96f0991: feat(19-01): schema + migration + secrets + dockerignore