266 lines
20 KiB
Markdown
266 lines
20 KiB
Markdown
---
|
|
phase: 19-local-auth-no-oidc-mode
|
|
plan: 01
|
|
type: tdd
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- apps/api/src/db/schema.ts
|
|
- apps/api/src/db/migrations/0003_local_credentials.sql
|
|
- apps/api/src/auth/localCredentials.ts
|
|
- apps/api/src/auth/localSession.ts
|
|
- apps/api/src/lib/bootGuards.ts
|
|
- apps/api/src/index.ts
|
|
- scripts/generate-secrets.mjs
|
|
- .dockerignore
|
|
- apps/api/tests/auth/localCredentials.test.ts
|
|
- apps/api/tests/auth/localSession.test.ts
|
|
- apps/api/test/setup.ts
|
|
autonomous: false
|
|
requirements: [AUTH-LOCAL-01, AUTH-LOCAL-02]
|
|
user_setup:
|
|
- service: env
|
|
why: "New env-floor secret for signing local-session JWTs (D-05). Operator must add LOCAL_SESSION_SECRET to docker-compose env (>=32 chars). generate-secrets.mjs emits a value to copy."
|
|
env_vars:
|
|
- name: LOCAL_SESSION_SECRET
|
|
source: "Generate with `node scripts/generate-secrets.mjs` (this plan extends it to emit LOCAL_SESSION_SECRET) or `openssl rand -base64 32`"
|
|
|
|
must_haves:
|
|
truths:
|
|
- "A password can be hashed and the same password verifies true; a wrong password verifies false"
|
|
- "verifyPassword returns false (never throws) on a malformed stored hash"
|
|
- "A signed local-session JWT round-trips: issue then verify returns the same userId"
|
|
- "An expired or tampered local-session token verifies to null, never throws"
|
|
- "The API process refuses to boot (exit 1) when LOCAL_SESSION_SECRET is missing/short and dev-bypass is off"
|
|
- "The local_credentials table exists after migration with unique user_id and unique username"
|
|
artifacts:
|
|
- path: "apps/api/src/auth/localCredentials.ts"
|
|
provides: "hashPassword + verifyPassword (node:crypto scrypt, PHC-encoded)"
|
|
exports: ["hashPassword", "verifyPassword"]
|
|
min_lines: 25
|
|
- path: "apps/api/src/auth/localSession.ts"
|
|
provides: "issueLocalSessionCookie + verifyLocalSessionCookie + clearLocalSessionCookie"
|
|
exports: ["issueLocalSessionCookie", "verifyLocalSessionCookie", "clearLocalSessionCookie"]
|
|
min_lines: 30
|
|
- path: "apps/api/src/db/migrations/0003_local_credentials.sql"
|
|
provides: "additive CREATE TABLE local_credentials"
|
|
contains: "CREATE TABLE"
|
|
- path: "apps/api/src/db/schema.ts"
|
|
provides: "localCredentials Drizzle table export"
|
|
contains: "localCredentials"
|
|
- path: "apps/api/src/lib/bootGuards.ts"
|
|
provides: "assertLocalSessionSecretSet boot guard"
|
|
contains: "assertLocalSessionSecretSet"
|
|
key_links:
|
|
- from: "apps/api/src/auth/localSession.ts"
|
|
to: "process.env.LOCAL_SESSION_SECRET"
|
|
via: "Jwt.sign / Jwt.verify HS256 using the env secret"
|
|
pattern: "LOCAL_SESSION_SECRET"
|
|
- from: "apps/api/src/index.ts"
|
|
to: "apps/api/src/lib/bootGuards.ts"
|
|
via: "assertLocalSessionSecretSet() called in isMainModule() boot block"
|
|
pattern: "assertLocalSessionSecretSet"
|
|
- from: "apps/api/src/db/schema.ts"
|
|
to: "apps/api/src/db/migrations/0003_local_credentials.sql"
|
|
via: "drizzle-kit generate emits SQL from the localCredentials table"
|
|
pattern: "local_credentials"
|
|
---
|
|
|
|
<objective>
|
|
Build the Phase 19 local-auth foundation: the `local_credentials` table + migration, the password hashing primitives, the stateless JWT session-cookie helpers, the new `LOCAL_SESSION_SECRET` env var + boot-time assertion, and the D-15 image-hygiene fix for the break-glass script directory.
|
|
|
|
Purpose: Every other Phase 19 plan depends on these primitives. Hashing and session signing are security-critical with defined I/O — prime TDD candidates (RED before GREEN). This plan also closes the two D-15 gaps the researcher flagged (`scripts/` not in `.dockerignore`; `LOCAL_SESSION_SECRET` not in generate-secrets) so no later plan ships a dev artifact.
|
|
|
|
Output: `localCredentials.ts`, `localSession.ts`, the schema table + `0003` migration, the `assertLocalSessionSecretSet` boot guard wired in `index.ts`, generate-secrets emitting `LOCAL_SESSION_SECRET`, and `.dockerignore` excluding the break-glass scripts.
|
|
|
|
Derived REQ-IDs covered: AUTH-LOCAL-01 (local_credentials schema + migration, per D-09), AUTH-LOCAL-02 (scrypt hash/verify, per D-08). Also lands the `LOCAL_SESSION_SECRET` env + boot assertion (D-05) and D-15 hygiene preconditions.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
|
|
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
|
|
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="tdd" tdd="true">
|
|
<name>Task 1: hashPassword / verifyPassword (node:crypto scrypt, PHC-encoded)</name>
|
|
<read_first>
|
|
- apps/api/src/auth/user.ts (analog imports + module style; localCredentials.ts substitutes node:crypto for the drizzle imports)
|
|
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Password Hashing Pattern (the verified scrypt + PHC implementation)
|
|
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/auth/localCredentials.ts (PHC params N=16384 r=8 p=1 KEY_LEN=32)
|
|
- apps/api/tests/auth/user.test.ts (vitest unit test style for auth helpers)
|
|
</read_first>
|
|
<files>apps/api/src/auth/localCredentials.ts, apps/api/tests/auth/localCredentials.test.ts</files>
|
|
<behavior>
|
|
- RED first: write apps/api/tests/auth/localCredentials.test.ts asserting:
|
|
- Test 1: verifyPassword(hashPassword('hunter2'), 'hunter2') === true
|
|
- Test 2: verifyPassword(hashPassword('hunter2'), 'wrong') === false
|
|
- Test 3: two hashPassword('x') calls produce different encoded strings (unique salt)
|
|
- Test 4: verifyPassword('not-a-valid-hash', 'x') === false (no throw)
|
|
- Test 5: a hash encodes 'scrypt' + N + r + p + salt + hash joined by '$' (6 segments)
|
|
- Run the suite; confirm it FAILS (module not yet implemented).
|
|
</behavior>
|
|
<action>
|
|
Create apps/api/src/auth/localCredentials.ts exporting `hashPassword(password: string): string` and `verifyPassword(storedEncoded: string, candidate: string): boolean`. Import `scryptSync`, `randomBytes`, `timingSafeEqual` from `node:crypto` — no npm deps (D-08). Constants: SCRYPT_N=16384, SCRYPT_R=8, SCRYPT_P=1, KEY_LEN=32. hashPassword: 16-byte random salt, scryptSync to KEY_LEN, return `['scrypt', N, r, p, salt.toString('base64url'), hash.toString('base64url')].join('$')`. verifyPassword: split on '$', parse params, re-derive with scryptSync using `storedHash.length` as keylen (so buffers are equal length for timingSafeEqual), return `timingSafeEqual(storedHash, candidateHash)` inside try/catch that returns false on any error. Do not log the password. After implementing, run the suite — it must pass (GREEN).
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` exits 0 with all 5 tests green
|
|
- Source assertion: `grep -c "node:crypto" apps/api/src/auth/localCredentials.ts` >= 1 and the file contains no `import` from any npm auth/hash package
|
|
- Source assertion: `grep -c "timingSafeEqual" apps/api/src/auth/localCredentials.ts` == 1
|
|
</acceptance_criteria>
|
|
<done>hashPassword/verifyPassword implemented with scrypt + timingSafeEqual; all unit tests pass; zero new dependencies.</done>
|
|
</task>
|
|
|
|
<task type="tdd" tdd="true">
|
|
<name>Task 2: localSession.ts JWT cookie helpers + LOCAL_SESSION_SECRET boot guard</name>
|
|
<read_first>
|
|
- apps/api/src/auth/persistSessionCookie.ts (exact analog: setCookie attributes httpOnly/secure/sameSite/maxAge; cookie-name resolution)
|
|
- apps/api/tests/auth/persistSessionCookie.test.ts (test harness for cookie middleware/helpers)
|
|
- .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §JWT Session Cookie Pattern + §Common Pitfalls 8/9/10 (Jwt namespace import; verify throws on expiry; missing-secret boot guard)
|
|
- apps/api/src/lib/bootGuards.ts (assertNotDevBypassInProduction structure to mirror)
|
|
- apps/api/src/index.ts lines 121-140 (isMainModule + assertNotDevBypassInProduction call site)
|
|
</read_first>
|
|
<files>apps/api/src/auth/localSession.ts, apps/api/src/lib/bootGuards.ts, apps/api/src/index.ts, apps/api/tests/auth/localSession.test.ts</files>
|
|
<behavior>
|
|
- RED first: write apps/api/tests/auth/localSession.test.ts asserting (set process.env.LOCAL_SESSION_SECRET to a >=32-char test value in the test):
|
|
- Test 1: issue then verify round-trips userId (use a minimal Hono Context mock or a real Hono app route that sets then reads the cookie)
|
|
- Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)
|
|
- Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw — covers Pitfall 9 expiry/throw path)
|
|
- Test 4 (bootGuards): assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset; and the function is exported
|
|
- Run the suite; confirm FAIL.
|
|
</behavior>
|
|
<action>
|
|
Create apps/api/src/auth/localSession.ts. Import `{ Jwt }` from `hono/utils/jwt` (namespace import — NOT named sign/verify, per Pitfall 8). Import `setCookie, getCookie, deleteCookie` from `hono/cookie`, `Context` type from `hono`. Cookie name constant `local-session` (distinct from `oidc-auth` — Pitfall 4). `SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)`. Export async `issueLocalSessionCookie(c, userId)`: read `process.env.LOCAL_SESSION_SECRET`, throw if unset; sign `{ userId, iat, exp }` HS256; setCookie with httpOnly:true, secure:(NODE_ENV==='production'), sameSite:'Lax', path:'/', maxAge. Export async `verifyLocalSessionCookie(c): Promise<number|null>`: return null if no secret or no cookie; try Jwt.verify and return `payload.userId` when numeric, catch → return null. Export `clearLocalSessionCookie(c)`: deleteCookie with matching path/httpOnly/secure/sameSite attributes.
|
|
|
|
In apps/api/src/lib/bootGuards.ts add `export function assertLocalSessionSecretSet(): void`: return early when `process.env.DEV_AUTH_BYPASS === 'true'` (bypass issues no real secret-signed cookie in dev); otherwise if `LOCAL_SESSION_SECRET` is unset or shorter than 32 chars, console.error a FATAL message and `process.exit(1)`. Mirror the assertNotDevBypassInProduction structure exactly.
|
|
|
|
In apps/api/src/index.ts, import `assertLocalSessionSecretSet` and call it inside the existing `isMainModule()` boot block immediately AFTER the existing `assertNotDevBypassInProduction()` call (around line 136). Do not change any other boot behavior. Run the suite — it must pass (GREEN).
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api test tests/auth/localSession.test.ts && pnpm --filter @familysync/api typecheck</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `pnpm --filter @familysync/api test tests/auth/localSession.test.ts` exits 0, all 4 tests green
|
|
- Source assertion: `grep -c "import { Jwt }" apps/api/src/auth/localSession.ts` == 1 (namespace import, Pitfall 8)
|
|
- Source assertion: localSession.ts cookie name is `local-session` (grep `'local-session'`) and is NOT `oidc-auth`
|
|
- Source assertion: `grep -c "assertLocalSessionSecretSet" apps/api/src/index.ts` >= 1 (wired at boot)
|
|
- `pnpm --filter @familysync/api typecheck` exits 0
|
|
</acceptance_criteria>
|
|
<done>localSession helpers issue/verify/clear the local-session JWT cookie; verify never throws; LOCAL_SESSION_SECRET boot guard added and wired in index.ts.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: local_credentials schema + 0003 migration + generate-secrets + .dockerignore (D-15)</name>
|
|
<read_first>
|
|
- apps/api/src/db/schema.ts (memberCredentials block — the exact template; confirm int/varchar/timestamp/unique/index already imported)
|
|
- apps/api/src/db/migrations/0002_lethal_millenium_guard.sql (additive-migration example shape)
|
|
- apps/api/test/setup.ts (afterEach TRUNCATE list — localCredentials must be added so tests reset it)
|
|
- scripts/generate-secrets.mjs (existing secret-emitter to extend with LOCAL_SESSION_SECRET)
|
|
- .dockerignore (currently excludes only apps/api/scripts/seed-credential.mjs — the break-glass dir is NOT covered; D-15 / RESEARCH open question 4)
|
|
- .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/db/schema.ts (localCredentials table definition)
|
|
</read_first>
|
|
<files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0003_local_credentials.sql, apps/api/test/setup.ts, scripts/generate-secrets.mjs, .dockerignore</files>
|
|
<action>
|
|
In apps/api/src/db/schema.ts add and export `localCredentials = mysqlTable('local_credentials', {...})` mirroring memberCredentials: `id` autoincrement PK; `userId` int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }); `username` varchar('username', { length: 128 }).notNull(); `passwordHash` varchar('password_hash', { length: 256 }).notNull(); `createdAt` timestamp defaultNow().notNull(); `updatedAt` timestamp defaultNow().onUpdateNow(). Indexes/constraints: `unique('uniq_local_cred_user').on(t.userId)`, `unique('uniq_local_cred_username').on(t.username)`, `index('idx_local_credentials_user_id').on(t.userId)`. No new imports needed.
|
|
|
|
Generate the migration: run `pnpm --filter @familysync/api db:generate` to emit apps/api/src/db/migrations/0003_local_credentials.sql. Review it — it MUST be purely additive (CREATE TABLE local_credentials only; no ALTER/DROP/TRUNCATE on existing tables). Drizzle generate+migrate, never push (established rule). Commit the generated SQL as an artifact.
|
|
|
|
In apps/api/test/setup.ts add `local_credentials` to the afterEach TRUNCATE set so unit/integration tests reset it between runs.
|
|
|
|
In scripts/generate-secrets.mjs add a `LOCAL_SESSION_SECRET` line emitting a base64 32-byte value (same generation approach as the existing SESSION_SECRET / encryption key it already emits), so an operator copies it into env (D-05 / Pitfall 10).
|
|
|
|
In .dockerignore add a line `apps/api/scripts/` (exclude the entire break-glass scripts dir) so the future reset-admin.ts can never ship in the prod image (D-15, IMG-02). Keep the existing `apps/api/scripts/seed-credential.mjs` line or let the dir exclusion supersede it.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/api db:migrate && pnpm --filter @familysync/api test tests/db 2>/dev/null || pnpm --filter @familysync/api test</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- File exists: `apps/api/src/db/migrations/0003_local_credentials.sql` and `grep -c "CREATE TABLE" apps/api/src/db/migrations/0003_local_credentials.sql` >= 1
|
|
- Negative assertion: the 0003 SQL contains no `DROP TABLE` and no `TRUNCATE` (grep -c each == 0)
|
|
- `pnpm --filter @familysync/api db:migrate` exits 0 (table applied to dev DB)
|
|
- Source assertion: `grep -c "localCredentials" apps/api/src/db/schema.ts` >= 1 and the export is present
|
|
- Source assertion: `grep -c "apps/api/scripts/$" .dockerignore` >= 1 OR `.dockerignore` contains a line `apps/api/scripts/` excluding the dir
|
|
- Source assertion: `grep -c "LOCAL_SESSION_SECRET" scripts/generate-secrets.mjs` >= 1
|
|
- Source assertion: `grep -c "local_credentials" apps/api/test/setup.ts` >= 1
|
|
</acceptance_criteria>
|
|
<done>local_credentials table defined + migrated; generate-secrets emits LOCAL_SESSION_SECRET; .dockerignore excludes the break-glass scripts dir; test teardown truncates the new table.</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<name>Task 4: Verify the 0003 migration is purely additive + LOCAL_SESSION_SECRET set</name>
|
|
<action>Pause for human review of the generated migration SQL and the local env before proceeding. This is a blocking checkpoint — the executor performs no code change here; it presents the migration and waits for approval.</action>
|
|
<what-built>The 0003 migration was generated by drizzle-kit and applied to the dev DB. Because Drizzle's generate step can occasionally emit unexpected ALTER/DROP statements against populated MariaDB (the exact reason this repo forbids `push`), the generated SQL needs a human eyeball before it is trusted as a committed artifact.</what-built>
|
|
<how-to-verify>
|
|
1. Open apps/api/src/db/migrations/0003_local_credentials.sql.
|
|
2. Confirm it contains ONLY a `CREATE TABLE local_credentials (...)` statement with the two UNIQUE constraints (uniq_local_cred_user, uniq_local_cred_username) and the user_id index.
|
|
3. Confirm there is NO statement touching users, member_credentials, calendars, calendar_events, app_config, or any existing table (no ALTER, DROP, RENAME, TRUNCATE).
|
|
4. Confirm LOCAL_SESSION_SECRET is set in your local .env (>=32 chars) — without it the API will refuse to boot in non-bypass mode.
|
|
</how-to-verify>
|
|
<resume-signal>Type "approved" if the migration is purely additive and LOCAL_SESSION_SECRET is set, or describe what the migration unexpectedly touches.</resume-signal>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| operator env → API process | LOCAL_SESSION_SECRET and scrypt run server-side only; never returned to a client |
|
|
| build context → published image | `.dockerignore` is the boundary that keeps dev/break-glass artifacts out of the prod image |
|
|
|
|
## STRIDE Threat Register (ASVS L1, block on high)
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-19-01 | Information Disclosure | password hashing | mitigate | scrypt + 16-byte per-hash random salt; timingSafeEqual; no password in logs (V2/V6 ASVS L1) |
|
|
| T-19-02 | Spoofing | local-session JWT | mitigate | HS256 signed with LOCAL_SESSION_SECRET; verify rejects tampered tokens (returns null) (V3) |
|
|
| T-19-03 | Elevation of Privilege | missing LOCAL_SESSION_SECRET | mitigate | assertLocalSessionSecretSet boot guard: refuse to start (exit 1) when unset/<32 chars in non-bypass mode (Pitfall 10) |
|
|
| T-19-04 | Tampering | dev/break-glass artifact in prod image | mitigate | `.dockerignore` excludes apps/api/scripts/ (IMG-02); tests/ already excluded; defense-in-depth for D-15 |
|
|
| T-19-SC | Tampering | npm installs | mitigate | Zero new packages this phase (RESEARCH §Standard Stack); nothing to vet |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `pnpm --filter @familysync/api test` green (includes the two new unit suites)
|
|
- `pnpm --filter @familysync/api typecheck` exits 0
|
|
- `pnpm --filter @familysync/api db:migrate` applies 0003 cleanly
|
|
- Human checkpoint confirms the migration is purely additive
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- AUTH-LOCAL-01: local_credentials table exists with unique user_id + unique username (migration applied)
|
|
- AUTH-LOCAL-02: hashPassword/verifyPassword pass round-trip, wrong-password, malformed-hash, and unique-salt tests
|
|
- LOCAL_SESSION_SECRET present in generate-secrets.mjs; boot guard wired in index.ts
|
|
- .dockerignore excludes apps/api/scripts/ (D-15)
|
|
</success_criteria>
|
|
|
|
<artifacts_produced>
|
|
## Artifacts this phase produces (Plan 01)
|
|
- Table: `local_credentials` (columns: id, user_id [UNIQUE, FK→users.id cascade], username [UNIQUE], password_hash, created_at, updated_at)
|
|
- Migration: `apps/api/src/db/migrations/0003_local_credentials.sql`
|
|
- Functions: `hashPassword`, `verifyPassword` (apps/api/src/auth/localCredentials.ts)
|
|
- Functions: `issueLocalSessionCookie`, `verifyLocalSessionCookie`, `clearLocalSessionCookie` (apps/api/src/auth/localSession.ts)
|
|
- Function: `assertLocalSessionSecretSet` (apps/api/src/lib/bootGuards.ts)
|
|
- Env var: `LOCAL_SESSION_SECRET` (env-only; never in app_config/DB)
|
|
- Cookie: `local-session` (httpOnly, Secure in prod, SameSite=Lax)
|
|
- Schema export: `localCredentials`
|
|
- .dockerignore: `apps/api/scripts/` exclusion (D-15)
|
|
</artifacts_produced>
|
|
|
|
<output>
|
|
Create `.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md` when done
|
|
</output>
|