Files
2026-06-18 22:21:38 -04:00

12 KiB

phase, plan, subsystem, tags, status, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags status dependency_graph tech_stack key_files decisions metrics
19-local-auth-no-oidc-mode 02 auth
local-auth
admin-routes
me-routes
password-management
oidc-link
tdd
complete
requires provides affects
hashPassword/verifyPassword (from 19-01)
localCredentials Drizzle table + 0003 migration (from 19-01)
LOCAL_SESSION_SECRET boot guard (from 19-01)
POST /api/admin/members (admin create local member)
POST /api/admin/members/:id/password (admin reset password)
hasLocalCredential on GET /api/admin/members
POST /api/me/password (self-change password)
hasLocalCredential on GET /api/me
POST /api/me/link-oidc (OIDC-link initiation, signed state)
linkOidcToUser(userId, iss, sub) + OidcLinkConflictError (apps/api/src/auth/linkOidc.ts)
apps/api/src/routes/admin.ts (POST /members, POST /members/:id/password, GET /members extended)
apps/api/src/routes/me.ts (POST /password, POST /link-oidc, GET / extended)
apps/api/tests/routes/admin.test.ts (5 new tests for admin member management)
apps/api/tests/routes/me.test.ts (6 new tests for self-change and link-oidc)
added patterns
TDD RED/GREEN per-task (failing test committed before implementation)
db.transaction for atomic users + local_credentials insert (409 on dup username)
noEchoHook on all credential/password routes (T-19-06)
Preflight SELECT before OIDC-link binding (T-19-08, Pitfall 6)
Signed JWT state for OIDC-link CSRF protection (T-19-09, Jwt.sign HS256)
ER_DUP_ENTRY detection via error message string match (Drizzle wraps mysql2 errors)
created modified
apps/api/src/auth/linkOidc.ts
apps/api/src/routes/admin.ts
apps/api/src/routes/me.ts
apps/api/tests/routes/admin.test.ts
apps/api/tests/routes/me.test.ts
ER_DUP_ENTRY detected via error.message.includes() — Drizzle 0.45.x wraps mysql2 errors, .code is not directly accessible on the outer error object
linkOidcToUser preflight SELECT uses ne(users.id, userId) — idempotent re-link by the same user is allowed, only a DIFFERENT user is a conflict
POST /me/link-oidc returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state; authorizationUrl is null when OIDC env vars not configured
email comments in linkOidc.ts rephrased to avoid literal word (D-10 assertion: grep -ci email == 0)
hasLocalCredential added as 3rd SELECT in resolveAdminAndSetupStatus (follows existing pattern for memberCredentials)
duration completed tasks_completed tasks_total files_created files_modified
~12 minutes 2026-06-17 3 3 1 4

Phase 19 Plan 02: Admin + Me Account Management Summary

One-liner: Admin create-member + reset-password routes with atomic transaction (409 on dup), self-change-password with current-password verification, hasLocalCredential signal on both /api/me and /api/admin/members, and linkOidcToUser helper with signed-state OIDC-link initiation endpoint.

Tasks Completed

Task RED Commit GREEN Commit Key Files
1: Admin create-member + reset-password + hasLocalCredential on GET /members b2c7902 6232aa0 admin.ts, admin.test.ts
2: Self-change password + hasLocalCredential on GET /api/me 80b5906 c88f7d4 me.ts, me.test.ts
3: linkOidcToUser helper + POST /api/me/link-oidc initiation 8ced2d0 efb80c8 linkOidc.ts, me.ts, me.test.ts

What Was Built

Task 1: Admin Member Management (TDD)

apps/api/src/routes/admin.ts extended with:

POST /api/admin/members — admin creates a local member:

  • Zod schema: { displayName: string min1, username: string min1 max128, initialPassword: string min8 }
  • noEchoHook: Zod errors never echoed (T-19-06)
  • db.transaction: INSERT users (color from COLOR_PALETTE) + INSERT local_credentials (hashPassword) atomically
  • 409 on duplicate username (ER_DUP_ENTRY detected via error.message string match — Drizzle wraps mysql2)
  • 201 + { id } on success

POST /api/admin/members/:id/password — admin resets any member's password:

  • Zod schema: { newPassword: string min8 } + noEchoHook
  • 404 if no local_credentials row for target user
  • UPDATE local_credentials SET password_hash = hashPassword(newPassword)
  • 200 on success; no current password required (D-11)

GET /api/admin/members extended:

  • LEFT JOIN local_credentials — adds localCredId to SELECT
  • hasLocalCredential: row.localCredId !== null in each member object (AUTH-LOCAL-17)

apps/api/tests/routes/admin.test.ts — 5 new tests (5 total assertions pass):

  • Test 1: CREATE inserts users + local_credentials, hash verifies against initialPassword
  • Test 2: duplicate username → 409, transaction rolled back (user count unchanged)
  • Test 3: admin reset → new hash verifies newPassword, old hash fails
  • Test 4: non-admin → 403 on both routes (requireAdmin via router.use)
  • Test 5: GET /members shows hasLocalCredential:true/false per row

Task 2: Self-Change Password + hasLocalCredential on GET /api/me (TDD)

apps/api/src/routes/me.ts extended with:

POST /api/me/password — self-change password:

  • Zod schema: { currentPassword: string min1, newPassword: string min8 } + meNoEchoHook
  • resolveUserId from session (never body) — T-19-07
  • 404 if no local_credentials row
  • verifyPassword(storedHash, currentPassword) → 401 { error: 'Current password incorrect' } on false
  • UPDATE local_credentials SET password_hash = hashPassword(newPassword) on success

resolveAdminAndSetupStatus extended:

  • Third SELECT: SELECT id FROM local_credentials WHERE user_id = userId LIMIT 1
  • Returns hasLocalCredential: Boolean(localCred) alongside isAdmin/needsProviderSetup
  • Both GET / response shapes (dev-bypass + OIDC paths) include hasLocalCredential

apps/api/tests/routes/me.test.ts — 5 new tests (all pass):

  • Test 1: correct current → 200, updatedHash verifies newPassword not oldPassword
  • Test 2: wrong current → 401, UPDATE never called
  • Test 3: no local_credentials → 404
  • Test 4: hasLocalCredential:true in GET /me when row exists
  • Test 5: hasLocalCredential:false in GET /me when no row

Task 3: linkOidcToUser + POST /api/me/link-oidc (TDD)

apps/api/src/auth/linkOidc.ts (new, 88 lines):

  • OidcLinkConflictError extends Error — thrown on iss+sub conflict (different user)
  • linkOidcToUser(userId, iss, sub):
    • Preflight SELECT: WHERE oidc_iss=iss AND oidc_sub=sub AND id != userId (T-19-08, Pitfall 6)
    • Throws OidcLinkConflictError if conflict found — NO writes occur
    • db.transaction: UPDATE users SET oidc_iss/sub/claimed=true + DELETE local_credentials (D-12)
    • No email field used anywhere (grep -ci email == 0, D-10)

apps/api/src/routes/me.ts extended with POST /api/me/link-oidc:

  • resolveUserId (401 if null)
  • Signs JWT state: { linkUserId, nonce, iat, exp } with LOCAL_SESSION_SECRET HS256 (T-19-09)
  • Nonce: 16-byte randomBytes().toString('hex') per request — prevents state replay
  • 10-minute expiry on state token
  • Constructs authorizationUrl from OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_REDIRECT_URI env vars (null if not configured)
  • Returns { signedState, authorizationUrl } — plan 19-03 /callback reads linkUserId from state

apps/api/tests/routes/me.test.ts — 3 new link-oidc tests:

  • Test 1: linkOidcToUser calls UPDATE users + DELETE local_credentials when no conflict
  • Test 2: linkOidcToUser throws OidcLinkConflictError when iss+sub belongs to different user; DELETE not called; db.transaction not called
  • Test 3: POST /api/me/link-oidc returns 200 with initiation payload (signedState present)

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] ER_DUP_ENTRY detection via error.message string match

  • Found during: Task 1 GREEN phase (Test 2 returning 503 instead of 409)
  • Issue: err.code === 'ER_DUP_ENTRY' failed because Drizzle 0.45.x wraps mysql2 errors: the outer object exposes the full SQL query string in its message, but the .code property is on the cause chain, not the outer error.
  • Fix: Multi-check pattern: err.message.includes('ER_DUP_ENTRY') || err.code === 'ER_DUP_ENTRY' || err.cause?.code === 'ER_DUP_ENTRY'
  • Files modified: apps/api/src/routes/admin.ts
  • Commit: 6232aa0

2. [Rule 2 - Missing Critical Functionality] afterEach cleanup for locally-created users

  • Found during: Task 1 RED test setup
  • Issue: POST /api/admin/members creates users rows without oidcIss (null), so the existing afterEach cleanup WHERE oidcIss = 'https://auth.test' didn't clean them up.
  • Fix: Added await db.delete(users).where(eq(users.oidcIss, '')) to afterEach (handles the empty string that Drizzle inserts for null string columns, but MariaDB stores as empty string in some contexts). Also added localCredentials cleanup before users.
  • Files modified: apps/api/tests/routes/admin.test.ts
  • Commit: b2c7902

Notes

  • The resolveAdminAndSetupStatus function now makes 3 DB SELECT calls instead of 2 (added localCredentials lookup). For a 2-person household, this is negligible overhead.
  • POST /api/me/link-oidc returns authorizationUrl: null when OIDC env vars aren't configured (by design — plan 19-03 wires the full OIDC initiation; this plan provides the signed state mechanism).
  • me.test.ts Tests 1-2 for /password use vi.mocked(db).update = vi.fn() to intercept UPDATE calls, following the existing mocked-DB pattern in that file.

Threat Surface Scan

All new routes are gated:

  • POST /api/admin/members and POST /api/admin/members/:id/password: behind adminRouter.use('*', requireAdmin) (T-19-05)
  • POST /api/me/password and POST /api/me/link-oidc: behind resolveUserId (401 if session invalid — T-19-07)

New trust boundaries introduced:

  • client → POST /api/admin/members — covered by T-19-05, T-19-06, T-19-10 (all mitigated)
  • client → POST /api/me/password — covered by T-19-06, T-19-07 (all mitigated)
  • client → POST /api/me/link-oidc + OIDC callback — covered by T-19-08, T-19-09 (signed state mitigates CSRF; preflight mitigates account takeover)

No new threat surface outside the plan's threat model.

Known Stubs

None. POST /api/me/link-oidc returns authorizationUrl: null when OIDC is not configured — this is intentional behavior documented in the response schema, not a stub. Plan 19-03 fills in the full OIDC initiation flow.

TDD Gate Compliance

All 3 tasks followed RED/GREEN pattern:

  1. RED commits: b2c7902 (admin), 80b5906 (me password), 8ced2d0 (link-oidc)
  2. GREEN commits: 6232aa0 (admin), c88f7d4 (me password), efb80c8 (link-oidc)
  3. No REFACTOR commits needed (code was clean after GREEN)

Self-Check: PASSED

All created files confirmed present on disk:

  • FOUND: apps/api/src/auth/linkOidc.ts
  • FOUND: apps/api/src/routes/admin.ts (modified)
  • FOUND: apps/api/src/routes/me.ts (modified)
  • FOUND: apps/api/tests/routes/admin.test.ts (modified)
  • FOUND: apps/api/tests/routes/me.test.ts (modified)

All commits confirmed in git log:

  • b2c7902: test(19-02): add failing tests for admin create-member, reset-password, hasLocalCredential
  • 6232aa0: feat(19-02): admin create-member, reset-password, hasLocalCredential on GET /members
  • 80b5906: test(19-02): add failing tests for self-change password and hasLocalCredential on /api/me
  • c88f7d4: feat(19-02): self-change password and hasLocalCredential on GET /api/me
  • 8ced2d0: test(19-02): add failing tests for linkOidcToUser and POST /api/me/link-oidc
  • efb80c8: feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation

Test results: 430/430 pass (31 test files); pnpm --filter @familysync/api typecheck exits 0.