Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-02-PLAN.md
T

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
19-local-auth-no-oidc-mode 02 tdd 2
19-01
apps/api/src/routes/admin.ts
apps/api/src/routes/me.ts
apps/api/src/auth/linkOidc.ts
apps/api/tests/routes/admin.test.ts
apps/api/tests/routes/me.test.ts
true
AUTH-LOCAL-07
AUTH-LOCAL-08
AUTH-LOCAL-09
AUTH-LOCAL-10
AUTH-LOCAL-17
truths artifacts key_links
An admin can create a local member (users row + local_credentials row with a hashed initial password) in one transaction
Creating a member with an already-used username returns 409, not a 500 or a partial insert
An admin can reset any local member's password without knowing the current one
A user can change their own password only after verifying their current password
GET /api/me returns hasLocalCredential so the PWA knows whether to show Change-password / Link-OIDC
Linking an OIDC identity binds iss+sub to the current user and deletes their local_credentials row; a conflicting iss+sub is rejected (409) and no local row is deleted
No password or Zod received-value is ever echoed in any response or log on these routes
path provides exports min_lines
apps/api/src/auth/linkOidc.ts linkOidcToUser(userId, iss, sub) — binds identity + deletes local cred, 409 on conflict
linkOidcToUser
OidcLinkConflictError
25
path provides contains
apps/api/src/routes/admin.ts POST /members + POST /members/:id/password + hasLocalCredential in GET /members members
path provides contains
apps/api/src/routes/me.ts POST /password + POST /link-oidc + hasLocalCredential in GET / hasLocalCredential
from to via pattern
apps/api/src/routes/admin.ts apps/api/src/auth/localCredentials.ts hashPassword on create-member and reset-password hashPassword
from to via pattern
apps/api/src/routes/me.ts apps/api/src/auth/localCredentials.ts verifyPassword(current) then hashPassword(new) on self-change verifyPassword
from to via pattern
apps/api/src/auth/linkOidc.ts apps/api/src/db/schema.ts uniq_oidc_identity preflight SELECT + UPDATE users + DELETE local_credentials localCredentials
Build the backend account-management surface for local auth: admin-creates-member, admin-reset-password, self-change-password, the `hasLocalCredential` signal on `/api/me`, and the OIDC-link binding helper (`linkOidcToUser`) that the middleware plan's `/callback` will invoke.

Purpose: These are API endpoints with defined request/response contracts and high security stakes (credential creation, password reset, identity binding) — TDD candidates. The OIDC-link binding is extracted into a standalone linkOidc.ts helper so the middleware plan (19-03) can call it from /callback without this plan and that plan touching the same file.

Output: extended admin.ts + me.ts, new linkOidc.ts helper, extended admin.test.ts + me.test.ts.

Derived REQ-IDs covered: AUTH-LOCAL-07 (admin create member, D-10), AUTH-LOCAL-08 (admin reset, D-11), AUTH-LOCAL-09 (self-change, D-11), AUTH-LOCAL-10 (OIDC-link, D-12), AUTH-LOCAL-17 (hasLocalCredential).

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 @.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md Task 1: Admin create-member + reset-password + hasLocalCredential on GET /members - apps/api/src/routes/admin.ts (requireAdmin guard at line ~42; noEchoHook lines ~70-74; POST /credentials lines ~112-132; GET /members lines ~83-102; db.transaction in PUT /calendars/:id/shared lines ~170-183) - apps/api/tests/routes/admin.test.ts (existing admin route test patterns + mock setup) - apps/api/src/auth/localCredentials.ts (hashPassword — from 19-01) - apps/api/src/db/schema.ts (users, localCredentials, COLOR_PALETTE for member color) - .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/routes/admin.ts (LEFT JOIN extension + 409 pattern + transaction) - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 11A/11B (field names, copy, validation: passwords-match, min-8-char) apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts - RED first: extend apps/api/tests/routes/admin.test.ts asserting: - Test 1: POST /api/admin/members { displayName, username, initialPassword } → 201, inserts a users row + a local_credentials row whose hash verifies against the initial password - Test 2: POST /api/admin/members with a username already in local_credentials → 409, no new users row created (transaction rolled back) - Test 3: POST /api/admin/members/:id/password { newPassword } → 200, the stored hash now verifies the new password; current password NOT required - Test 4: a non-admin caller gets 403 on both routes (requireAdmin already covers it — assert it) - Test 5: GET /api/admin/members returns hasLocalCredential:true for a member with a local_credentials row, false otherwise - Run; confirm FAIL. Extend apps/api/src/routes/admin.ts (do NOT move the `adminRouter.use('*', requireAdmin)` first statement). Reuse the existing `noEchoHook`. Add `POST /members` with a zValidator json schema `{ displayName: string min1, username: string min1 max128, initialPassword: string min8 }` + noEchoHook: in a `db.transaction`, INSERT users (displayName, color from COLOR_PALETTE round-robin or existing color-assignment helper), then INSERT local_credentials (user_id, username, passwordHash via hashPassword(initialPassword)); on a username uniqueness violation return `c.json({ error: 'Username already in use' }, 409)`. Add `POST /members/:id/password` with schema `{ newPassword: string min8 }` + noEchoHook: verify the target user exists and has a local_credentials row (404 if not), UPDATE local_credentials SET password_hash = hashPassword(newPassword) WHERE user_id = :id. Extend the existing `GET /members` query with a `.leftJoin(localCredentials, eq(localCredentials.userId, users.id))` and map `hasLocalCredential: row.localCredId !== null` into each member object alongside the existing `hasCredential`. Use the established error-response pattern (known error → 4xx; unexpected → console.error without body + 503). Never log request bodies. Run the suite — GREEN. pnpm --filter @familysync/api test tests/routes/admin.test.ts - `pnpm --filter @familysync/api test tests/routes/admin.test.ts` exits 0, all new tests green - Source assertion: `grep -c "requireAdmin" apps/api/src/routes/admin.ts` >= 1 and the `.use('*', requireAdmin)` line remains the first router statement - Source assertion: `grep -c "hashPassword" apps/api/src/routes/admin.ts` >= 1 - Source assertion: `grep -c "noEchoHook" apps/api/src/routes/admin.ts` >= 1 used on both new POST routes - Behavior: duplicate-username create returns 409 and leaves the users table unchanged (transaction rollback verified in Test 2) Admin can create local members (atomic users+local_credentials), reset member passwords, and GET /members reports hasLocalCredential; non-admin is 403; no password echo. Task 2: Self-change password + hasLocalCredential on GET /api/me - apps/api/src/routes/me.ts (resolveUserId lines ~74-86; meNoEchoHook lines ~164-168; resolveAdminAndSetupStatus lines ~50-66; POST /credential lines ~154-202; response shape lines ~93-139) - apps/api/tests/routes/me.test.ts (existing me-route test patterns) - apps/api/src/auth/localCredentials.ts (verifyPassword + hashPassword) - .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/src/routes/me.ts (resolveAdminAndSetupStatus extension + change-password route) - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 12 (current/new/confirm fields, error copy) apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts - RED first: extend apps/api/tests/routes/me.test.ts asserting: - Test 1: POST /api/me/password { currentPassword, newPassword } with correct current → 200, stored hash now verifies newPassword - Test 2: wrong currentPassword → 401, hash unchanged - Test 3: user with no local_credentials row → 404 - Test 4: GET /api/me includes hasLocalCredential (true when a row exists, false otherwise) alongside isAdmin/needsProviderSetup - Run; confirm FAIL. Extend apps/api/src/routes/me.ts. Reuse `resolveUserId` and the existing `meNoEchoHook`. Add `POST /password` with zValidator json `{ currentPassword: string min1, newPassword: string min8 }` + meNoEchoHook: resolveUserId (401 if null); SELECT the user's local_credentials row (404 if none); `verifyPassword(cred.passwordHash, currentPassword)` → 401 `{ error: 'Current password incorrect' }` on false; else UPDATE local_credentials SET password_hash = hashPassword(newPassword) WHERE user_id. Extend `resolveAdminAndSetupStatus` (or the GET / handler) to also SELECT whether a local_credentials row exists for the user and include `hasLocalCredential: boolean` in the GET /api/me response object next to isAdmin and needsProviderSetup. Use the established error pattern; never log the body. Run the suite — GREEN. pnpm --filter @familysync/api test tests/routes/me.test.ts - `pnpm --filter @familysync/api test tests/routes/me.test.ts` exits 0, all new tests green - Source assertion: `grep -c "verifyPassword" apps/api/src/routes/me.ts` >= 1 - Source assertion: `grep -c "hasLocalCredential" apps/api/src/routes/me.ts` >= 1 - Behavior: wrong current password returns 401 and leaves the hash unchanged (Test 2) Self password-change verifies current then updates; GET /api/me exposes hasLocalCredential; no echo. Task 3: linkOidcToUser helper + POST /api/me/link-oidc initiation - apps/api/src/auth/user.ts (upsertUser — identity = oidc_iss+oidc_sub never email, D-10; the strictness the link helper must replicate) - apps/api/src/db/schema.ts (users.oidcIss/oidcSub, uniq_oidc_identity index line ~63; localCredentials) - apps/api/src/routes/me.ts (resolveUserId; route registration style) - .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §OIDC-Link Flow + §Common Pitfalls 6 (iss+sub uniqueness; 409; state param) - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surface 13 (link copy + 409 post-redirect error) apps/api/src/auth/linkOidc.ts, apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts - RED first: extend apps/api/tests/routes/me.test.ts asserting: - Test 1: linkOidcToUser(userId, iss, sub) where no other user holds iss+sub → UPDATEs users.oidc_iss/oidc_sub for userId AND DELETEs that user's local_credentials row - Test 2: linkOidcToUser when iss+sub already belongs to a DIFFERENT user → throws OidcLinkConflictError AND the target user's local_credentials row is NOT deleted (binding aborted before any write) - Test 3: POST /api/me/link-oidc (authenticated) → returns a redirect target / authorization-code initiation payload that encodes the current userId in signed state (assert the response shape only; the actual OIDC redirect is exercised by 19-03's /callback) - Run; confirm FAIL. Create apps/api/src/auth/linkOidc.ts exporting `class OidcLinkConflictError extends Error` and async `linkOidcToUser(userId: number, iss: string, sub: string): Promise`: preflight SELECT users WHERE oidc_iss=iss AND oidc_sub=sub LIMIT 1 — if a row exists with id !== userId, throw OidcLinkConflictError (do NOT write anything). Otherwise run a db.transaction: UPDATE users SET oidc_iss=iss, oidc_sub=sub, claimed=true WHERE id=userId; DELETE FROM local_credentials WHERE user_id=userId. Bind by iss+sub only — never email (D-10/D-12). The uniq_oidc_identity DB constraint is the safety net behind the preflight (Pitfall 6).
In apps/api/src/routes/me.ts add `POST /link-oidc`: resolveUserId (401 if null); produce the OIDC authorization-code initiation with a signed `state` encoding `{ linkUserId: userId, nonce }` so the 19-03 /callback can read it. Return the initiation payload/redirect target the PWA needs (Surface 13 "Continue with OIDC"). Do not perform the binding here — the binding happens in /callback (19-03) via linkOidcToUser. Run the suite — GREEN.
pnpm --filter @familysync/api test tests/routes/me.test.ts - `pnpm --filter @familysync/api test tests/routes/me.test.ts` exits 0, link tests green - Source assertion: linkOidc.ts performs a preflight SELECT before the UPDATE (grep for the conflict check) and throws OidcLinkConflictError on a foreign iss+sub - Behavior: Test 2 confirms NO local_credentials deletion occurs on conflict - Source assertion: `grep -ci "email" apps/api/src/auth/linkOidc.ts` == 0 (binding never uses email — D-10) linkOidcToUser binds iss+sub and drops the local credential atomically, 409-equivalent on conflict with no partial write; /api/me/link-oidc initiates the signed-state OIDC redirect.

<threat_model>

Trust Boundaries

Boundary Description
client → /api/admin/* untrusted member-management input crosses here; gated by requireAdmin
client → /api/me/* self-service password/link input; gated by session (resolveUserId)
OIDC token → users row iss+sub binding crosses an external-identity boundary

STRIDE Threat Register (ASVS L1, block on high)

Threat ID Category Component Disposition Mitigation Plan
T-19-05 Elevation of Privilege POST /api/admin/members mitigate requireAdmin router guard; integration test asserts 403 for non-admin (V4)
T-19-06 Information Disclosure password in Zod error mitigate noEchoHook on every credential route; no console.log of bodies (V5; RESEARCH Pitfall 3)
T-19-07 Elevation of Privilege self-change password mitigate verifyPassword(current) required before update; resolveUserId from session not body (V4)
T-19-08 Elevation of Privilege OIDC-link account takeover mitigate preflight iss+sub uniqueness → conflict aborts before any write; uniq_oidc_identity DB constraint backstop (RESEARCH Pitfall 6)
T-19-09 Tampering OIDC-link CSRF mitigate userId carried in signed OIDC state (nonce); binding only for the state-encoded user
T-19-10 Tampering partial insert on create-member failure mitigate db.transaction wraps users + local_credentials; 409 rolls back (RESEARCH Pitfall 5)
</threat_model>
- `pnpm --filter @familysync/api test` green (admin + me suites) - `pnpm --filter @familysync/api typecheck` exits 0 - No password or Zod received-value appears in any response body or log (noEchoHook everywhere)

<success_criteria>

  • AUTH-LOCAL-07/08: admin create + reset member passwords (atomic, 409 on dup, 403 for non-admin)
  • AUTH-LOCAL-09: self-change requires correct current password
  • AUTH-LOCAL-10: OIDC-link binds iss+sub + drops local cred, conflict aborts cleanly
  • AUTH-LOCAL-17: GET /api/me exposes hasLocalCredential </success_criteria>

<artifacts_produced>

Artifacts this phase produces (Plan 02)

  • Route: POST /api/admin/members (admin create local member)
  • Route: POST /api/admin/members/:id/password (admin reset)
  • Route: POST /api/me/password (self-change)
  • Route: POST /api/me/link-oidc (OIDC-link initiation, signed state)
  • Function: linkOidcToUser(userId, iss, sub) + OidcLinkConflictError (apps/api/src/auth/linkOidc.ts)
  • Response field: hasLocalCredential on GET /api/me and GET /api/admin/members </artifacts_produced>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-02-SUMMARY.md` when done