Covers password hashing (node:crypto scrypt), JWT session cookies (hono/utils/jwt), middleware ordering, local_credentials schema, OIDC-link flow, dev-bypass rework (option C), and break-glass CLI. Resolves all five open questions from CONTEXT.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1162 lines
63 KiB
Markdown
1162 lines
63 KiB
Markdown
# Phase 19: Local Auth (No-OIDC Mode) - Research
|
||
|
||
**Researched:** 2026-06-17
|
||
**Domain:** Authentication — local username/password credentials, stateless JWT session cookies, Hono middleware ordering, Drizzle schema migration
|
||
**Confidence:** HIGH
|
||
|
||
---
|
||
|
||
<user_constraints>
|
||
## User Constraints (from CONTEXT.md)
|
||
|
||
### Locked Decisions
|
||
|
||
- **D-01:** Local auth is the **default and always available**. OIDC is **opt-in/additive**, never a replacement for the local path at the system level.
|
||
- **D-02:** OIDC is configured from the **admin UI** (extends the Phase-12 config that already lands in `app_config`: `oidc_issuer`, `oidc_client_id`, `app_external_url`). When OIDC is configured, **both methods are offered and the user chooses at login** (local username/password OR "Login with OIDC").
|
||
- **D-03:** This must **not break the existing live OIDC deployment**. The two current household members already authenticate via Authelia (`oidc_iss`/`oidc_sub` set, `claimed=true`); they continue as OIDC users. Local auth is layered on additively.
|
||
- **D-04:** A **new local login UI (username + password) must be built in the PWA** — none exists today. The PWA currently boots straight into the authed app (OIDC redirect) or via dev-bypass; there is no login form.
|
||
- **D-05:** Local logins are backed by a **stateless signed httpOnly JWT cookie** carrying `userId`, validated by a **new local-auth middleware that sets `c.get('user')`** the same way `auth/devBypass.ts` does — so every downstream route resolves the user unchanged. **No DB sessions table**. Tradeoff accepted: a password change cannot retroactively invalidate other live sessions; logout = clear cookie.
|
||
- **D-06 (BYO-Auth principle):** Local auth is first-class; OIDC is treated as a **generic RFC-compliant provider, not Authelia-hardcoded**. `@hono/oidc-auth` is already provider-agnostic — work is to de-Authelia-ize config keys and user-facing copy.
|
||
- **D-07 (BYO-Auth scope):** Ship **local + one generic OIDC** with a **clean internal seam** for future methods. **No plugin/registry framework** in this phase.
|
||
- **D-08:** Hash local passwords with **`node:crypto` scrypt** — zero new dependency, no native node-gyp build. Encode **algorithm + params + salt alongside the hash** so parameters can evolve.
|
||
- **D-09:** Store local credentials in a **new `local_credentials` table** — `user_id` (FK to `users`, UNIQUE), `username` (UNIQUE), `password_hash` (encoded), `createdAt`/`updatedAt`. Drizzle **generate+migrate, never push**.
|
||
- **D-10:** **Admin creates members** + sets an initial password; the member changes it later. **No open self-signup**.
|
||
- **D-11:** Password lifecycle = **self-change (current + new) + admin-reset** from the admin UI. **No email reset**.
|
||
- **D-12:** **OIDC link replaces local at the per-user level**: when a local user links an OIDC identity (explicit action while authenticated as that user — never an email match), **delete that user's `local_credentials` row** → they become OIDC-only.
|
||
- **D-13 (break-glass):** Lockout recovery is a **CLI/console command and/or env override** (e.g. create/reset a local admin, or disable/force-off OIDC), run on the host/container. **No new role/capability model**; reuse today's single `users.is_admin`.
|
||
- **D-14:** The new login UI requires touching existing API/unit tests and the **Phase 7/8 Playwright harness** (which today reaches the authed PWA purely via `DEV_AUTH_BYPASS`, skipping any login). Both the already-authed fast path and the **real login form** must remain testable.
|
||
- **D-15 (hard constraint):** Any seeded test login / reworked dev-bypass mechanism **stays dev-only and never ships in the Docker/prod image**. Bound by the existing Phase-16 image-hygiene gates: IMG-01 boot guard (`assertNotDevBypassInProduction`), `.dockerignore` (IMG-02), and the publish-time hygiene assertion (IMG-03).
|
||
|
||
### Claude's Discretion (decided in-discussion)
|
||
- Session backing mechanism (chose stateless signed JWT cookie — D-05).
|
||
- Credential storage location (chose separate `local_credentials` table — D-09).
|
||
|
||
### Deferred Ideas (OUT OF SCOPE)
|
||
- **Full pluggable auth-provider framework** (registry/plugin for LDAP, magic-link, multiple simultaneous OIDC providers) — auth-layer counterpart of backlog 999.1.
|
||
- **Member-vs-operator capability/role split** — explicitly rejected in favor of a CLI/env recovery mechanism.
|
||
- **Email-based password reset** — out of project scope.
|
||
</user_constraints>
|
||
|
||
---
|
||
|
||
<phase_requirements>
|
||
## Phase Requirements
|
||
|
||
The following REQ-IDs are newly defined by this phase. The planner should include them verbatim in PLAN.md task descriptions and VALIDATION.md.
|
||
|
||
| ID | Description | Research Support |
|
||
|----|-------------|------------------|
|
||
| AUTH-LOCAL-01 | `local_credentials` Drizzle schema + migration (0003): `user_id` FK UNIQUE, `username` UNIQUE, `password_hash` varchar | §Schema Change, §Standard Stack §Migration |
|
||
| AUTH-LOCAL-02 | `hashPassword(password)` and `verifyPassword(hash, candidate)` helpers using `node:crypto` scrypt in PHC-style encoded format | §Password Hashing |
|
||
| AUTH-LOCAL-03 | `POST /api/auth/local/login` route — timing-safe verify, issue `local-session` JWT cookie, return 401/429/423/200 | §Local Login Endpoint |
|
||
| AUTH-LOCAL-04 | `localAuthMiddleware` — reads `local-session` cookie, validates JWT, sets `c.get('user')` identical to devBypass; mounts in `index.ts` before OIDC guard | §Middleware Slot |
|
||
| AUTH-LOCAL-05 | `GET /api/auth/mode` pre-auth endpoint — returns `{ localEnabled: true, oidcEnabled: boolean }` based on `app_config` OIDC keys | §Auth Mode Endpoint |
|
||
| AUTH-LOCAL-06 | `POST /api/auth/local/logout` — clears `local-session` cookie; `GET /api/auth/local/logout` alias | §Logout |
|
||
| AUTH-LOCAL-07 | `POST /api/admin/members` — admin creates local member: insert `users` row + `local_credentials` row with hashed initial password | §Admin Account Management |
|
||
| AUTH-LOCAL-08 | `POST /api/admin/members/:id/password` — admin resets a local member's password (no current-password required); admin-gated | §Admin Account Management |
|
||
| AUTH-LOCAL-09 | `POST /api/me/password` — self-change password: verify current password, hash new, update `local_credentials` | §Self-Service Password |
|
||
| AUTH-LOCAL-10 | OIDC-link flow: `POST /api/me/link-oidc` (or reuse OIDC callback, see §OIDC-Link Flow) — bind `oidc_iss+oidc_sub` to the authenticated user, delete their `local_credentials` row | §OIDC-Link Flow |
|
||
| AUTH-LOCAL-11 | Break-glass CLI — Node.js script `scripts/reset-admin.ts` runnable as `tsx scripts/reset-admin.ts` inside the container, creates/resets a local admin by username without requiring an existing session | §Break-Glass |
|
||
| AUTH-LOCAL-12 | PWA `LoginPage` component (`/login` route) per UI-SPEC Surface 1–10: username+password form, `BrandSlot`, OIDC button when `oidcEnabled`, error state machine | §UI — LoginPage |
|
||
| AUTH-LOCAL-13 | PWA admin additions: "Add member" form (UI-SPEC Surface 11A) + "Reset password" modal (Surface 11B) in `AdminPage.tsx` | §UI — Admin |
|
||
| AUTH-LOCAL-14 | PWA self-service: "Change password" sheet (UI-SPEC Surface 12) in `SettingsSheet.tsx`; "Link OIDC identity" confirmation sheet (Surface 13) | §UI — Settings |
|
||
| AUTH-LOCAL-15 | `App.tsx` login gate: fetch `/api/auth/mode` pre-auth, add `/login` route (standalone), redirect unauthenticated users there when `localEnabled`, skip if valid local or OIDC session | §PWA Routing Gate |
|
||
| AUTH-LOCAL-16 | Playwright harness dev-bypass rework: "option C" (bypass issues a real local-session cookie), update `global-setup.ts` to seed `local_credentials` for dev user (id=1), update CI workflow | §Dev-Bypass Rework |
|
||
| AUTH-LOCAL-17 | `/api/me` response extended with `hasLocalCredential: boolean` so `SettingsSheet` and `AdminPage` know which users have local creds | §API Extensions |
|
||
| AUTH-LOCAL-18 | `app_config` OIDC key de-Authelia-ization: rename any Authelia-specific copy in config keys and comments to generic OIDC labels (no key name change — keys are already generic `oidc_issuer` etc.) | §BYO-Auth De-Authelia-ization |
|
||
| AUTH-LOCAL-19 | Rate-limiting on `POST /api/auth/local/login`: in-memory per-IP counter (Map), 5 failures → 60s cooldown → 429; account lockout at 10 failures → 423; cleared on success | §Rate Limiting |
|
||
| AUTH-LOCAL-20 | Vitest unit tests: password hashing round-trip, timing-safe compare, login success/failure/lockout, middleware session validation, OIDC-link 409 conflict | §Validation Architecture |
|
||
</phase_requirements>
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
Phase 19 builds a complete local username/password authentication system on top of the Phase 12 pre-OIDC user foundation. The core architectural move is to add a parallel authentication path alongside the existing `@hono/oidc-auth` middleware: a new `localAuthMiddleware` that reads a signed `local-session` cookie (JWT, HS256 via Hono's built-in `Jwt.sign`/`Jwt.verify`) and populates `c.get('user')` with the same shape that `devAuthBypass()` uses, so all downstream routes work unchanged.
|
||
|
||
No new npm dependencies are required. Password hashing uses `node:crypto` scrypt (Node.js stdlib), JWT signing uses Hono's built-in `Jwt` from `hono/utils/jwt`, and session cookies use the existing `hono/cookie` helpers (`getCookie`/`setCookie`). The `local_credentials` table mirrors the `member_credentials` shape already in the schema. The migration is a straightforward additive `drizzle-kit generate` + `migrate`.
|
||
|
||
The PWA adds a `/login` route (standalone, no AppNav/BottomTabBar — same pattern as `/setup`). The login gate is driven by a new pre-auth `GET /api/auth/mode` endpoint. Existing OIDC users are completely unaffected by all of this: their `oidcAuthMiddleware` path is unchanged; the local auth path only fires for requests that arrive with a `local-session` cookie.
|
||
|
||
**Primary recommendation:** Implement in three waves: (1) schema + credential helpers + API routes, (2) middleware wiring + mode endpoint + PWA login page, (3) admin UI extensions + OIDC-link + break-glass + harness update.
|
||
|
||
---
|
||
|
||
## Architectural Responsibility Map
|
||
|
||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||
|------------|-------------|----------------|-----------|
|
||
| Password hashing/verification | API / Backend | — | Secrets never leave server; `node:crypto` scrypt runs server-side only |
|
||
| Session JWT sign/verify | API / Backend | — | `LOCAL_SESSION_SECRET` is an env-floor secret; client only holds opaque cookie |
|
||
| Auth middleware selection | API / Backend | — | `index.ts` is the single mount-order source of truth |
|
||
| Auth mode signalling | API / Backend (pre-auth endpoint) | Frontend fetch | `/api/auth/mode` is the authoritative source; PWA reads it |
|
||
| Login form UI | Browser / Client | — | React component, form state, client-side validation |
|
||
| Admin member management | API / Backend | Admin UI (client) | Server enforces `requireAdmin`; client is UX-only |
|
||
| OIDC-link binding | API / Backend | Browser / Client (confirmation UI) | Actual `iss+sub` binding + `local_credentials` delete is a backend transaction |
|
||
| Break-glass recovery | API / Backend (CLI script) | — | Host/container-side only; no UI surface |
|
||
| Cookie issuance | API / Backend | — | `httpOnly + Secure + SameSite=Lax`; client cannot write it |
|
||
| Rate-limiting / lockout | API / Backend | Browser / Client (error display) | In-memory Map on the server; UI mirrors the 429/423 response |
|
||
|
||
---
|
||
|
||
## Standard Stack
|
||
|
||
### Core (no new packages — everything already installed)
|
||
|
||
| Library | Version | Purpose | Status |
|
||
|---------|---------|---------|--------|
|
||
| `node:crypto` | stdlib (Node 22) | `scrypt`, `scryptSync`, `randomBytes`, `timingSafeEqual` | [VERIFIED: codebase — confirmed available in Node 22.22.3 on this machine] |
|
||
| `hono` | 4.12.23 (installed) | `Jwt.sign`, `Jwt.verify` from `hono/utils/jwt`; `getCookie`, `setCookie` from `hono/cookie` | [VERIFIED: codebase — `Jwt.sign` and `Jwt.verify` confirmed callable at runtime from `hono/utils/jwt`; `getCookie`/`setCookie` confirmed from `hono/cookie`] |
|
||
| `drizzle-orm` | 0.45.2 (installed) | New `local_credentials` table; `drizzle-kit generate` + `migrate` | [VERIFIED: codebase — already in use; `mysqlTable`, `unique`, `index` patterns from `schema.ts`] |
|
||
| `zod` | 3.25.x (installed) | Validate login request body (`username`, `password`) | [VERIFIED: codebase — already used in every route] |
|
||
|
||
### Zero new npm dependencies
|
||
|
||
This phase installs **no new packages**. All required capabilities are in the existing stack:
|
||
|
||
- Password hashing: `node:crypto` scrypt (stdlib, confirmed available)
|
||
- JWT signing: `hono/utils/jwt` `Jwt.sign`/`Jwt.verify` (confirmed callable from `hono@4.12.23`)
|
||
- Cookie read/write: `hono/cookie` `getCookie`/`setCookie` (already used in `persistSessionCookie.ts`)
|
||
- Schema: `drizzle-orm` `mysqlTable` (same pattern as `member_credentials`)
|
||
- Input validation: `zod` + `@hono/zod-validator` (already used in every route)
|
||
|
||
**Installation:** None required.
|
||
|
||
---
|
||
|
||
## Package Legitimacy Audit
|
||
|
||
No new packages are introduced in this phase. All libraries listed above are already installed and legitimacy-verified from prior phases.
|
||
|
||
**Packages removed due to SLOP verdict:** None
|
||
**Packages flagged as suspicious:** None (hono is flagged SUS by the seam's "too-new" heuristic because its last publish date happens to be recent, but it is the same `hono@4.12.23` already installed and running in production — not a new install)
|
||
|
||
---
|
||
|
||
## Architecture Patterns
|
||
|
||
### System Architecture Diagram
|
||
|
||
```
|
||
PWA /login page
|
||
↓ GET /api/auth/mode (pre-auth, no middleware)
|
||
← { localEnabled: true, oidcEnabled: boolean }
|
||
|
||
↓ POST /api/auth/local/login { username, password }
|
||
API auth/localLogin.ts
|
||
→ local_credentials lookup by username
|
||
→ verifyPassword(stored_hash, candidate) [node:crypto timingSafeEqual]
|
||
→ Jwt.sign({ userId, iat, exp }, LOCAL_SESSION_SECRET)
|
||
← Set-Cookie: local-session=<JWT>; httpOnly; Secure; SameSite=Lax
|
||
← 200 { ok: true }
|
||
|
||
↓ Any subsequent /api/* request (carries local-session cookie)
|
||
index.ts middleware chain:
|
||
devAuthBypass() — no-op passthrough (bypass not set in prod)
|
||
localAuthMiddleware() — getCookie('local-session'), Jwt.verify(), c.set('user', {id, ...})
|
||
→ next() if valid cookie; else fall through
|
||
oidcConfigFallback — injects OIDC config from app_config if absent
|
||
oidcAuthMiddleware() — skipped if c.get('user') already set? [see §Middleware Slot]
|
||
persistSessionCookie()— OIDC sessions only
|
||
↓ downstream routes read c.get('user') — unchanged
|
||
|
||
PWA /admin → AdminPage
|
||
↓ GET /api/admin/members (returns hasLocalCredential per member)
|
||
↓ POST /api/admin/members { displayName, username, initialPassword }
|
||
→ users INSERT + local_credentials INSERT (hashed)
|
||
↓ POST /api/admin/members/:id/password { newPassword, confirmPassword }
|
||
→ local_credentials UPDATE (hashed)
|
||
|
||
PWA SettingsSheet
|
||
↓ POST /api/me/password { currentPassword, newPassword }
|
||
→ verifyPassword(stored, current) → UPDATE hash
|
||
↓ POST /api/me/link-oidc (authenticated as local user)
|
||
→ initiates OIDC authorization-code redirect
|
||
→ on /callback with valid OIDC session:
|
||
bind iss+sub to users row (must not conflict with existing user)
|
||
DELETE local_credentials WHERE user_id = current
|
||
→ user is now OIDC-only
|
||
|
||
Break-glass:
|
||
docker exec familysync-api node scripts/reset-admin.ts --username admin --password <new>
|
||
→ direct DB write: upsert local_credentials for username, ensure is_admin=true
|
||
```
|
||
|
||
### Recommended Project Structure
|
||
|
||
New files (additions only):
|
||
|
||
```
|
||
apps/api/src/
|
||
├── auth/
|
||
│ ├── localCredentials.ts # hashPassword(), verifyPassword() using node:crypto scrypt
|
||
│ ├── localSession.ts # issueLocalSessionCookie(), verifyLocalSessionCookie(), clearLocalSessionCookie()
|
||
│ └── localAuthMiddleware.ts # Hono middleware: getCookie → Jwt.verify → c.set('user')
|
||
├── routes/
|
||
│ ├── authMode.ts # GET /api/auth/mode (pre-auth)
|
||
│ └── localAuth.ts # POST /api/auth/local/login, /logout
|
||
└── db/
|
||
└── migrations/
|
||
└── 0003_local_credentials.sql # generated by drizzle-kit
|
||
|
||
apps/api/scripts/
|
||
└── reset-admin.ts # break-glass CLI (dev-only gate: .dockerignore excludes scripts/)
|
||
|
||
apps/pwa/src/
|
||
├── routes/
|
||
│ └── LoginPage.tsx # /login standalone page (UI-SPEC Surfaces 1–10)
|
||
└── components/
|
||
└── BrandSlot.tsx # phase-17 seam component (UI-SPEC §Brand Slot)
|
||
```
|
||
|
||
Modified files:
|
||
|
||
```
|
||
apps/api/src/
|
||
├── db/schema.ts # + local_credentials table definition
|
||
├── routes/admin.ts # + POST /members, POST /members/:id/password
|
||
├── routes/me.ts # + POST /password, + hasLocalCredential in GET response
|
||
├── routes/setup.ts # + link-oidc callback handler (or reuse /callback)
|
||
└── index.ts # + mount localAuthMiddleware, authModeRouter before OIDC guard
|
||
|
||
apps/pwa/src/
|
||
├── App.tsx # + /login route, auth-mode fetch gate
|
||
├── api/client.ts # + fetchAuthMode(), fetchLocalLogin(), fetchLocalLogout(), etc.
|
||
├── routes/AdminPage.tsx # + LOCAL ACCOUNTS section (Surfaces 11A, 11B)
|
||
└── components/SettingsSheet.tsx # + Change password row (Surface 12), Link OIDC row (Surface 13)
|
||
```
|
||
|
||
---
|
||
|
||
## Password Hashing Pattern
|
||
|
||
### PHC-Style Encoding with `node:crypto` scrypt
|
||
|
||
D-08 mandates `node:crypto` scrypt with the algorithm + params + salt encoded alongside the hash so parameters can evolve. The established pattern for self-describing encoded hashes is a `$`-delimited PHC-style string. [VERIFIED: codebase — scryptSync, randomBytes, timingSafeEqual all available in Node 22.22.3; runtime confirmed]
|
||
|
||
```typescript
|
||
// Source: node:crypto docs + runtime-verified on Node 22.22.3
|
||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||
|
||
// Parameters (OWASP-compatible for scrypt at this resource level)
|
||
const SCRYPT_N = 16384; // CPU/memory cost — 2^14; increase to 2^15 if hardware permits
|
||
const SCRYPT_R = 8;
|
||
const SCRYPT_P = 1;
|
||
const KEY_LEN = 32; // 256-bit output
|
||
|
||
/**
|
||
* Hash a password. Returns a self-describing encoded string:
|
||
* scrypt$N$r$p$<salt_base64url>$<hash_base64url>
|
||
*
|
||
* The $-delimited format is inspired by PHC and allows future parameter upgrades
|
||
* without a separate migration — verifyPassword parses all fields from the string.
|
||
*/
|
||
export function hashPassword(password: string): string {
|
||
const salt = randomBytes(16);
|
||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||
return [
|
||
'scrypt',
|
||
SCRYPT_N,
|
||
SCRYPT_R,
|
||
SCRYPT_P,
|
||
salt.toString('base64url'),
|
||
hash.toString('base64url'),
|
||
].join('$');
|
||
}
|
||
|
||
/**
|
||
* Verify a password against a stored encoded hash.
|
||
* Uses timingSafeEqual to prevent timing-oracle attacks.
|
||
* Returns false (never throws) on any parse/format mismatch.
|
||
*/
|
||
export function verifyPassword(storedEncoded: string, candidate: string): boolean {
|
||
try {
|
||
const [, n, r, p, saltB64, hashB64] = storedEncoded.split('$');
|
||
const salt = Buffer.from(saltB64, 'base64url');
|
||
const storedHash = Buffer.from(hashB64, 'base64url');
|
||
const candidateHash = scryptSync(candidate, salt, storedHash.length, {
|
||
N: Number(n), r: Number(r), p: Number(p),
|
||
});
|
||
return timingSafeEqual(storedHash, candidateHash);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Key points:**
|
||
- `scryptSync` blocks the event loop. For login (infrequent in a 2-person household) this is acceptable. If async is preferred, use `promisify(scrypt)` from `node:util`.
|
||
- `timingSafeEqual` requires equal-length buffers — `storedHash.length` as keylen ensures this.
|
||
- The encoded string is ~83 characters at N=16384 — fits comfortably in `varchar(256)`.
|
||
|
||
**Note on pepper:** D-08 specifies no pepper (env-only secret kernel). The scrypt salt + encoding is sufficient for this use case. Adding a pepper would require another env var and would not materially improve security for this threat model (household scale, Pangolin-exposed but not public).
|
||
|
||
---
|
||
|
||
## Drizzle Schema Change
|
||
|
||
### `local_credentials` Table
|
||
|
||
Mirrors the `member_credentials` shape but stores username + password hash. [VERIFIED: codebase — `member_credentials` pattern in `schema.ts` lines 74–94; `mysqlTable`, `int`, `varchar`, `timestamp`, `unique`, `index` all imported]
|
||
|
||
```typescript
|
||
// In apps/api/src/db/schema.ts — additive only
|
||
export const localCredentials = mysqlTable(
|
||
'local_credentials',
|
||
{
|
||
id: int().primaryKey().autoincrement(),
|
||
userId: int('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
username: varchar('username', { length: 128 }).notNull(),
|
||
// PHC-encoded: scrypt$N$r$p$<salt_b64url>$<hash_b64url> — self-describing
|
||
passwordHash: varchar('password_hash', { length: 256 }).notNull(),
|
||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||
},
|
||
(t) => [
|
||
// One local credential per user (UNIQUE on user_id)
|
||
unique('uniq_local_cred_user').on(t.userId),
|
||
// Username is globally unique (login identifier)
|
||
unique('uniq_local_cred_username').on(t.username),
|
||
index('idx_local_credentials_user_id').on(t.userId),
|
||
],
|
||
);
|
||
```
|
||
|
||
### Migration Workflow
|
||
|
||
Drizzle generate+migrate only — never push (established rule; `push` emits false destructive diffs on MariaDB). [VERIFIED: codebase — 0002 migration and `scripts` in `package.json`]
|
||
|
||
```bash
|
||
# 1. Add localCredentials to schema.ts
|
||
# 2. Generate migration
|
||
pnpm --filter @familysync/api db:generate
|
||
# → apps/api/src/db/migrations/0003_local_credentials.sql
|
||
|
||
# 3. Review generated SQL (must be purely additive — CREATE TABLE only)
|
||
# 4. Apply
|
||
pnpm --filter @familysync/api db:migrate
|
||
```
|
||
|
||
The generated SQL will be something like:
|
||
```sql
|
||
CREATE TABLE `local_credentials` (
|
||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||
`user_id` int NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||
`username` varchar(128) NOT NULL,
|
||
`password_hash` varchar(256) NOT NULL,
|
||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
|
||
CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`),
|
||
INDEX `idx_local_credentials_user_id`(`user_id`)
|
||
);
|
||
```
|
||
|
||
**Remember:** Export `localCredentials` from `schema.ts` so `test/setup.ts` can truncate it in `afterEach`.
|
||
|
||
---
|
||
|
||
## Middleware Slot and Ordering
|
||
|
||
### Current `index.ts` middleware chain
|
||
|
||
[VERIFIED: codebase — read `apps/api/src/index.ts`]
|
||
|
||
```
|
||
app.route('/api/setup', setupRouter) // pre-auth
|
||
app.use('/api/*', devAuthBypass()) // DEV only — no-op in prod
|
||
if (!devBypassActive) {
|
||
app.use('/api/*', oidcConfigFallbackMiddleware)
|
||
app.use('/api/*', oidcAuthMiddleware())
|
||
app.use('/api/*', persistSessionCookie())
|
||
}
|
||
```
|
||
|
||
### New middleware slot for Phase 19
|
||
|
||
The `localAuthMiddleware` must sit **between `devAuthBypass` and `oidcAuthMiddleware`**. It reads the `local-session` cookie. If the cookie is present and valid, it sets `c.get('user')` and calls `next()`. If no cookie, it falls through to `oidcAuthMiddleware`.
|
||
|
||
The key architectural requirement: `oidcAuthMiddleware` must **not** redirect to Authelia when the request already has a valid local session. The solution is to check whether `c.get('user')` is set before mounting `oidcAuthMiddleware`, or to make `localAuthMiddleware` short-circuit the OIDC path.
|
||
|
||
**Recommended approach:** Wrap `oidcAuthMiddleware` in a guard that skips it when `c.get('user')` is already populated:
|
||
|
||
```typescript
|
||
// index.ts updated middleware chain
|
||
app.route('/api/setup', setupRouter) // pre-auth (no change)
|
||
app.route('/api/auth', authModeRouter) // GET /api/auth/mode — pre-auth, no middleware
|
||
app.route('/api/auth', localAuthRouter) // POST /api/auth/local/login + /logout — pre-auth
|
||
|
||
app.use('/api/*', devAuthBypass()) // DEV only (existing)
|
||
|
||
// NEW: local session check — populates c.get('user') if local-session cookie valid
|
||
app.use('/api/*', localAuthMiddleware())
|
||
|
||
if (!devBypassActive) {
|
||
app.use('/api/*', oidcConfigFallbackMiddleware)
|
||
// OIDC guard: skip if user already set by localAuthMiddleware or devAuthBypass
|
||
app.use('/api/*', async (c, next) => {
|
||
if (c.get('user')) { await next(); return; }
|
||
await oidcAuthMiddleware()(c, next);
|
||
});
|
||
app.use('/api/*', persistSessionCookie())
|
||
}
|
||
```
|
||
|
||
**Why pre-auth for login/logout routes:** `POST /api/auth/local/login` must be reachable without a session (it's how a session is created). Mount it before the OIDC guard, just like `/api/setup/*`. The `GET /api/auth/mode` endpoint similarly needs no auth.
|
||
|
||
**Security note:** `localAuthMiddleware` must be a no-op when no `local-session` cookie is present — it should not attempt to verify a missing cookie and must not set `c.get('user')` to `undefined`. The downstream `oidcAuthMiddleware` redirects only when `c.get('user')` is falsy.
|
||
|
||
---
|
||
|
||
## JWT Session Cookie Pattern
|
||
|
||
### `issueLocalSessionCookie` / `verifyLocalSessionCookie`
|
||
|
||
Uses Hono's built-in `Jwt` from `hono/utils/jwt`. The algorithm is HS256 (symmetric, fast, appropriate for a single-server household app). [VERIFIED: codebase — `Jwt.sign` and `Jwt.verify` confirmed callable at runtime]
|
||
|
||
```typescript
|
||
// Source: hono/utils/jwt (runtime-verified in this project)
|
||
import { Jwt } from 'hono/utils/jwt';
|
||
import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
|
||
import type { Context } from 'hono';
|
||
|
||
const COOKIE_NAME = 'local-session';
|
||
const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); // 1 day default
|
||
|
||
export async function issueLocalSessionCookie(c: Context, userId: number): Promise<void> {
|
||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||
if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set');
|
||
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const payload = { userId, iat: now, exp: now + SESSION_MAX_AGE_SECONDS };
|
||
|
||
const token = await Jwt.sign(payload, secret, 'HS256');
|
||
|
||
setCookie(c, COOKIE_NAME, token, {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'Lax',
|
||
path: '/',
|
||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||
});
|
||
}
|
||
|
||
export async function verifyLocalSessionCookie(c: Context): Promise<number | null> {
|
||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||
if (!secret) return null;
|
||
|
||
const token = getCookie(c, COOKIE_NAME);
|
||
if (!token) return null;
|
||
|
||
try {
|
||
const payload = await Jwt.verify(token, secret, 'HS256');
|
||
return typeof payload.userId === 'number' ? payload.userId : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function clearLocalSessionCookie(c: Context): void {
|
||
deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, secure: true, sameSite: 'Lax' });
|
||
}
|
||
```
|
||
|
||
**Cookie name:** `local-session` (distinct from the OIDC cookie `oidc-auth` — avoids collision).
|
||
|
||
**`LOCAL_SESSION_SECRET` env var:** This is a new env-floor secret that must be added to the D-01 minimal env kernel documentation (it encrypts the local session JWT). It never goes to `app_config`. Operators generate it with `openssl rand -base64 32`. The `generate-secrets` script from Phase 12 should be extended to emit it.
|
||
|
||
---
|
||
|
||
## Local Login Endpoint
|
||
|
||
### `POST /api/auth/local/login`
|
||
|
||
[ASSUMED — specific implementation, but directly derived from the patterns in `setup.ts` and `admin.ts`]
|
||
|
||
```typescript
|
||
// apps/api/src/routes/localAuth.ts
|
||
const loginSchema = z.object({
|
||
username: z.string().min(1).max(128).trim(),
|
||
password: z.string().min(1).max(1000),
|
||
});
|
||
|
||
// Rate-limiting: simple in-memory Map (household scale; no Redis needed)
|
||
// { ip → { failCount, lockedUntil } }
|
||
const loginAttempts = new Map<string, { count: number; lockedUntil: number; lockedOut: boolean }>();
|
||
const RATE_WINDOW_FAILURES = 5; // 5 failures → 60s cooldown
|
||
const RATE_WINDOW_SECS = 60;
|
||
const LOCKOUT_FAILURES = 10; // 10 failures → account locked (admin must reset)
|
||
|
||
localAuthRouter.post('/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
|
||
const ip = c.req.header('x-forwarded-for') ?? c.req.raw.headers.get('host') ?? 'unknown';
|
||
|
||
// Check rate limit / lockout
|
||
const attempt = loginAttempts.get(ip);
|
||
if (attempt?.lockedOut) {
|
||
return c.json({ error: 'Account locked' }, 423);
|
||
}
|
||
if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) {
|
||
return c.json({ error: 'Too many attempts' }, 429);
|
||
}
|
||
|
||
const { username, password } = c.req.valid('json');
|
||
|
||
// Lookup by username — constant-time operation for timing safety
|
||
const [cred] = await db
|
||
.select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash })
|
||
.from(localCredentials)
|
||
.where(eq(localCredentials.username, username))
|
||
.limit(1);
|
||
|
||
// Always run verifyPassword even on unknown username (dummy hash) to prevent timing oracle
|
||
const dummy = hashPassword('dummy-constant-time-filler');
|
||
const valid = cred ? verifyPassword(cred.passwordHash, password) : verifyPassword(dummy, password);
|
||
|
||
if (!valid || !cred) {
|
||
// Increment failure counter
|
||
const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false };
|
||
cur.count += 1;
|
||
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
||
loginAttempts.set(ip, cur);
|
||
return c.json({ error: 'Invalid credentials' }, 401);
|
||
}
|
||
|
||
// Success: clear failure counter, issue session cookie
|
||
loginAttempts.delete(ip);
|
||
await issueLocalSessionCookie(c, cred.userId);
|
||
return c.json({ ok: true }, 200);
|
||
});
|
||
```
|
||
|
||
**Security notes:**
|
||
- The `noEchoHook` must be used on `zValidator` for the login route — same as credential routes — to prevent Zod errors from echoing the submitted password.
|
||
- "Username not found" and "wrong password" return the same 401 + same copy — no field discrimination.
|
||
- The dummy hash prevents timing oracle on username enumeration.
|
||
- Rate-limiting is per-IP (from `X-Forwarded-For` header, which Pangolin sets). For a 2-person household this is more than sufficient.
|
||
- Lockout (423) is resolved only by admin password reset — mirrors the UI copy "Contact your admin to reset access".
|
||
|
||
---
|
||
|
||
## Auth Mode Endpoint
|
||
|
||
### `GET /api/auth/mode` (pre-auth)
|
||
|
||
Must be mounted **before** all auth middleware in `index.ts` (same pre-auth pattern as `/api/setup/*` and `/health`). [VERIFIED: codebase — `app.route('/api/setup', setupRouter)` mounts pre-auth; same pattern applies]
|
||
|
||
```typescript
|
||
// apps/api/src/routes/authMode.ts
|
||
authModeRouter.get('/', async (c) => {
|
||
// localEnabled: always true — local auth is the default and always available (D-01)
|
||
// oidcEnabled: true when oidc_issuer is configured in app_config OR process.env
|
||
const issuerFromEnv = process.env.OIDC_ISSUER;
|
||
let oidcEnabled = Boolean(issuerFromEnv);
|
||
|
||
if (!oidcEnabled) {
|
||
const [row] = await db
|
||
.select({ value: appConfig.value })
|
||
.from(appConfig)
|
||
.where(eq(appConfig.key, 'oidc_issuer'))
|
||
.limit(1);
|
||
oidcEnabled = Boolean(row?.value);
|
||
}
|
||
|
||
return c.json({ localEnabled: true, oidcEnabled });
|
||
});
|
||
```
|
||
|
||
This endpoint is not protected by OIDC middleware. The PWA fetches it on app load before knowing if the user is authenticated.
|
||
|
||
---
|
||
|
||
## OIDC-Link Flow
|
||
|
||
D-12: when a local user explicitly links an OIDC identity, their `local_credentials` row is deleted and `users.oidc_iss`/`oidc_sub` are populated. The returned `iss+sub` must not already belong to another user.
|
||
|
||
### Recommended Implementation
|
||
|
||
The simplest approach reuses the existing `/callback` handler: add a `link_mode` query param that signals the OIDC callback to run in "link" mode rather than "new session" mode.
|
||
|
||
**Flow:**
|
||
1. User (authenticated as local user with valid `local-session` cookie) clicks "Continue with OIDC"
|
||
2. PWA calls `POST /api/me/link-oidc` → server initiates OIDC authorization-code redirect with `state` parameter encoding `{ linkUserId: currentUserId, nonce }`
|
||
3. OIDC callback fires → `processOAuthCallback` handles the code exchange, gets `iss+sub`
|
||
4. Backend detects `linkUserId` in state → look up if `iss+sub` already belongs to a different user → if yes, 409 error page; if no, UPDATE `users SET oidc_iss, oidc_sub WHERE id = linkUserId`, DELETE from `local_credentials WHERE user_id = linkUserId`
|
||
5. Issue OIDC session (the user is now OIDC-only) → redirect to `/calendar`
|
||
|
||
**Alternative (simpler, recommended):** A dedicated `POST /api/me/link-oidc` endpoint that initiates the OIDC redirect. The current user's `userId` is encoded in the OIDC `state` parameter (signed to prevent CSRF). On callback, the backend reads `state.userId`, verifies the OIDC identity is unique, binds it.
|
||
|
||
**D-10 constraint:** Identity binding must use `iss+sub` from the OIDC token, never email. The `upsertUser` function already enforces this — the link flow must replicate this strictness.
|
||
|
||
**409 conflict:** If the `iss+sub` returned by OIDC already exists in `users`, return a redirect to an error page. The PWA displays: "This OIDC identity is already linked to another account. Please contact your admin."
|
||
|
||
---
|
||
|
||
## Admin Account Management API
|
||
|
||
### New Routes on `adminRouter`
|
||
|
||
Extends `apps/api/src/routes/admin.ts`. The `requireAdmin` guard at the router level already covers these. [VERIFIED: codebase — `adminRouter.use('*', requireAdmin)` is the first statement]
|
||
|
||
**Create member:**
|
||
```typescript
|
||
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
|
||
// 1. Insert into users (displayName, color from palette)
|
||
// 2. Hash initialPassword via hashPassword()
|
||
// 3. Insert into local_credentials (userId, username, passwordHash)
|
||
// If username conflict: 409
|
||
});
|
||
```
|
||
|
||
**Reset password:**
|
||
```typescript
|
||
adminRouter.post('/members/:id/password', zValidator('json', resetPasswordSchema, noEchoHook), async (c) => {
|
||
// Admin does not need to know the current password (D-11)
|
||
// 1. Verify target user exists + has local_credentials row
|
||
// 2. Hash newPassword
|
||
// 3. UPDATE local_credentials SET password_hash WHERE user_id
|
||
// Also clear any in-memory lockout entry for this user
|
||
});
|
||
```
|
||
|
||
**Extend `GET /api/admin/members`** to include `hasLocalCredential: boolean` (LEFT JOIN on `local_credentials`).
|
||
|
||
---
|
||
|
||
## Self-Service Password Change
|
||
|
||
### `POST /api/me/password`
|
||
|
||
Added to `apps/api/src/routes/me.ts`. [ASSUMED — derived from existing `POST /api/me/credential` pattern]
|
||
|
||
```typescript
|
||
meRouter.post('/password', zValidator('json', changePasswordSchema, meNoEchoHook), async (c) => {
|
||
const currentUserId = await resolveUserId(c);
|
||
if (!currentUserId) return c.json({ error: 'Unauthorized' }, 401);
|
||
|
||
const [cred] = await db.select().from(localCredentials).where(eq(localCredentials.userId, currentUserId)).limit(1);
|
||
if (!cred) return c.json({ error: 'No local credential' }, 404);
|
||
|
||
const { currentPassword, newPassword } = c.req.valid('json');
|
||
if (!verifyPassword(cred.passwordHash, currentPassword)) {
|
||
return c.json({ error: 'Current password incorrect' }, 401);
|
||
}
|
||
|
||
const newHash = hashPassword(newPassword);
|
||
await db.update(localCredentials).set({ passwordHash: newHash }).where(eq(localCredentials.userId, currentUserId));
|
||
|
||
return c.json({ ok: true }, 200);
|
||
});
|
||
```
|
||
|
||
**Note:** After a password change, existing sessions remain valid (D-05 tradeoff — stateless JWT, no revocation). This is documented and accepted.
|
||
|
||
---
|
||
|
||
## `/api/me` Extensions
|
||
|
||
`GET /api/me` must return `hasLocalCredential: boolean` so the PWA knows whether to show "Change password" and "Link OIDC identity" in `SettingsSheet`. [ASSUMED — straightforward LEFT JOIN on `local_credentials`]
|
||
|
||
Add `hasLocalCredential` alongside `isAdmin` and `needsProviderSetup` in the `resolveAdminAndSetupStatus` function.
|
||
|
||
---
|
||
|
||
## Break-Glass CLI
|
||
|
||
D-13: break-glass is a CLI/console command or env override. Recommended form: a standalone Node.js/tsx script `apps/api/scripts/reset-admin.ts` that:
|
||
1. Accepts `--username` and `--password` CLI args
|
||
2. Connects to the DB using the same env vars as the app (`DB_HOST`, `DB_USER`, etc.)
|
||
3. Upserts a `users` row with `is_admin=true, claimed=true` for the given username (or finds existing by username)
|
||
4. Upserts `local_credentials` for that user with the hashed password
|
||
5. Prints the resulting user ID
|
||
|
||
This script is:
|
||
- Not imported by any production code
|
||
- Listed in `.dockerignore` `scripts/` exclusion (verify `.dockerignore` covers this; if `scripts/` is not yet excluded, add it)
|
||
- Gated with a `NODE_ENV !== 'production'` guard as defense-in-depth
|
||
|
||
Usage: `docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts --username admin --password 'newpass'`
|
||
|
||
Alternatively: an `APP_RECOVERY_USER` + `APP_RECOVERY_PASSWORD` env pair that, if set at boot, creates/updates that local user before the server starts (similar to some Docker apps' `INITIAL_ADMIN_PASSWORD`). This is simpler to deploy but exposes the password in env. The CLI script is cleaner.
|
||
|
||
---
|
||
|
||
## Dev-Bypass Rework
|
||
|
||
### Recommendation: Option C — bypass issues a real local-session cookie
|
||
|
||
The three options from the CONTEXT.md open questions:
|
||
|
||
| Option | Description | Assessment |
|
||
|--------|-------------|------------|
|
||
| A | Keep bypass + seed a real test login for login-specific specs | Most complex — two auth paths in harness |
|
||
| B | Replace bypass with seeded auto-login through the real local flow | Requires changing ALL 40+ harness startup assertions; riskiest |
|
||
| C | Bypass auto-issues a real local-session cookie | Minimal change to existing harness; satisfies D-15 |
|
||
|
||
**Recommendation: Option C.** When `DEV_AUTH_BYPASS=true`:
|
||
- `devAuthBypass()` still sets `c.get('user')` (existing behavior — unchanged)
|
||
- A new companion `devSessionCookieMiddleware()` mounted just after `devAuthBypass()` issues a signed `local-session` cookie for `DEV_USER.id` (using `LOCAL_SESSION_SECRET`) on every request that doesn't already have one
|
||
- The PWA login page sees `local-session` cookie already set → skips to `/calendar`
|
||
- Login-specific Playwright specs can explicitly clear the cookie and test the real login form
|
||
|
||
This means:
|
||
1. The harness `global-setup.ts` seeds `local_credentials` for `DEV_USER` (id=1) with a known dev-only username/password
|
||
2. The `devAuthBypass()` function already handles the API-side auth
|
||
3. The PWA routing gate (which checks for a valid session cookie) works because a `local-session` cookie is present
|
||
|
||
**D-15 compliance:** `devSessionCookieMiddleware()` is inside `auth/devBypass.ts` (the same file the IMG-01 boot guard protects) and is only mounted when `DEV_AUTH_BYPASS=true`. The `assertNotDevBypassInProduction()` guard already blocks this in production.
|
||
|
||
**Minimal `global-setup.ts` change:**
|
||
```typescript
|
||
// Seed local_credentials for dev user (id=1) — Option C
|
||
await conn.execute(
|
||
`INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?)
|
||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`,
|
||
[hashPassword('devpass')]
|
||
);
|
||
// (hashPassword is called inline or imported from a compiled path)
|
||
```
|
||
|
||
**CI `ci.yml` change:** Add `LOCAL_SESSION_SECRET=dev-secret-change-me` to the harness job env (used by `devSessionCookieMiddleware` and `global-setup.ts` hash). Add `local_credentials` table seed to the CI seeding step.
|
||
|
||
---
|
||
|
||
## PWA Routing Gate
|
||
|
||
### App.tsx changes
|
||
|
||
The current `App.tsx` setup gate checks `setupQuery.data?.setupComplete`. Phase 19 adds a parallel auth-mode gate. [VERIFIED: codebase — `App.tsx` lines 140–230 read]
|
||
|
||
**New fetch on app load:**
|
||
```typescript
|
||
const authModeQuery = useQuery({
|
||
queryKey: ['authMode'],
|
||
queryFn: () => fetch('/api/auth/mode').then(r => r.json()),
|
||
staleTime: 60_000, // auth mode changes rarely; 1 min stale is fine
|
||
});
|
||
```
|
||
|
||
**Gate logic (simplified):**
|
||
1. If setup not complete → `/setup`
|
||
2. If `meQuery` succeeds (user authenticated) → normal app
|
||
3. If `meQuery` fails 401 and `authMode.localEnabled` → `/login`
|
||
4. If `meQuery` fails 401 and `!authMode.localEnabled && authMode.oidcEnabled` → trigger OIDC redirect (top-level nav to `/api/login`)
|
||
|
||
The `/login` route renders `<LoginPage />` standalone (no AppNav, no BottomTabBar) — same pattern as `/setup`.
|
||
|
||
---
|
||
|
||
## BYO-Auth De-Authelia-ization
|
||
|
||
D-06 requires removing Authelia-specific copy from user-facing strings and config. [VERIFIED: codebase — checked `middleware.ts`, `index.ts`, `setup.ts` for "Authelia" references]
|
||
|
||
**What to change:**
|
||
- `apps/api/src/auth/middleware.ts` header comment: "Authelia as the identity provider" → "generic OIDC identity provider"
|
||
- Remove references to `OIDC_ISSUER` being "Authelia base URL" in inline comments → "OIDC issuer URL"
|
||
- The `app_config` keys are already generic (`oidc_issuer`, `oidc_client_id`) — no key changes needed
|
||
- The setup wizard Step 3 label (if any) mentioning Authelia → generic "OIDC provider"
|
||
- User-facing copy: already handled by UI-SPEC (never say "Authelia")
|
||
|
||
**What NOT to change:** The actual `@hono/oidc-auth` library, PKCE flow, or any runtime behavior — these are already provider-agnostic.
|
||
|
||
---
|
||
|
||
## Don't Hand-Roll
|
||
|
||
| Problem | Don't Build | Use Instead | Why |
|
||
|---------|-------------|-------------|-----|
|
||
| Password hashing | Custom hash function | `node:crypto` scrypt | Side-channel timing, parameter management, salt uniqueness |
|
||
| JWT signing | HMAC-SHA256 manually | `Jwt.sign`/`Jwt.verify` from `hono/utils/jwt` | Already installed; handles base64url encoding, exp checking |
|
||
| Cookie serialization | Manual `Set-Cookie` string | `hono/cookie` `setCookie`/`getCookie` | Already used in `persistSessionCookie.ts`; handles attributes correctly |
|
||
| Constant-time comparison | `===` on hash strings | `timingSafeEqual` from `node:crypto` | Prevents timing oracle attacks on credential comparison |
|
||
| OIDC code exchange | Custom OAuth flow | `@hono/oidc-auth` `processOAuthCallback` + `oidcAuthMiddleware` | Already installed and working |
|
||
| Rate-limiting storage | Redis or DB sessions | In-memory Map | Household scale; single process; Redis is overkill |
|
||
|
||
**Key insight:** This phase's auth primitives are entirely in stdlib (`node:crypto`) and packages already installed (`hono/utils/jwt`, `hono/cookie`). The zero-new-dependency constraint is achievable without compromise.
|
||
|
||
---
|
||
|
||
## Common Pitfalls
|
||
|
||
### Pitfall 1: OIDC guard 302-redirecting local-session requests
|
||
|
||
**What goes wrong:** `oidcAuthMiddleware()` intercepts requests that already have a valid `local-session` cookie and redirects to Authelia.
|
||
|
||
**Why it happens:** `oidcAuthMiddleware` redirects any request where `getAuth(c)` returns null, regardless of whether another auth mechanism already authenticated the user.
|
||
|
||
**How to avoid:** Wrap `oidcAuthMiddleware` in a guard that skips it when `c.get('user')` is already set (by `localAuthMiddleware` or `devAuthBypass`). See §Middleware Slot.
|
||
|
||
**Warning signs:** Local login succeeds (200 + cookie set) but next `/api/me` request returns 302.
|
||
|
||
---
|
||
|
||
### Pitfall 2: Timing oracle on username enumeration
|
||
|
||
**What goes wrong:** Login returns faster for non-existent usernames (no hash computation) than for wrong passwords (hash computed).
|
||
|
||
**Why it happens:** `if (!cred) return 401` skips `verifyPassword`.
|
||
|
||
**How to avoid:** Always call `verifyPassword` — use a pre-computed dummy hash when the username is not found (see §Local Login Endpoint).
|
||
|
||
**Warning signs:** Measurable latency difference in login responses for known vs unknown usernames.
|
||
|
||
---
|
||
|
||
### Pitfall 3: Zod error echoing the password
|
||
|
||
**What goes wrong:** `zValidator` default error handler returns `result.error` which includes `issues[].received` — the submitted password.
|
||
|
||
**Why it happens:** No `noEchoHook` provided.
|
||
|
||
**How to avoid:** All `zValidator` calls on the login, password-change, and create-member routes MUST use `noEchoHook`. [VERIFIED: codebase — pattern established in `setup.ts`, `admin.ts`, `me.ts`]
|
||
|
||
---
|
||
|
||
### Pitfall 4: `local-session` cookie colliding with `oidc-auth` cookie
|
||
|
||
**What goes wrong:** If the cookie name `local-session` matches the OIDC cookie name, `persistSessionCookie.ts` reads the wrong cookie.
|
||
|
||
**Why it happens:** `persistSessionCookie.ts` reads `process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'` — these are different names by default.
|
||
|
||
**How to avoid:** Keep the local-session cookie name `local-session` (distinct from `oidc-auth`). Never set `OIDC_COOKIE_NAME=local-session` in env.
|
||
|
||
---
|
||
|
||
### Pitfall 5: `local_credentials` FK constraint before `users` row
|
||
|
||
**What goes wrong:** Inserting `local_credentials` before the `users` row exists fails with FK error.
|
||
|
||
**Why it happens:** `local_credentials.user_id` references `users.id`.
|
||
|
||
**How to avoid:** Always insert `users` row first, then `local_credentials`. Wrap in a transaction for admin create-member. [VERIFIED: codebase — same pattern documented in `setup.ts` at `POST /api/setup/credential`]
|
||
|
||
---
|
||
|
||
### Pitfall 6: OIDC-link without checking `iss+sub` uniqueness
|
||
|
||
**What goes wrong:** Two local users attempt to link the same OIDC account → one succeeds, one silently overwrites.
|
||
|
||
**Why it happens:** No uniqueness check before binding `iss+sub`.
|
||
|
||
**How to avoid:** Before UPDATE-ing `users.oidc_iss`/`oidc_sub`, SELECT to verify no existing row has that `iss+sub` pair. Return 409 if conflict. The `uniq_oidc_identity` index on `users` is also a safety net (will throw a DB unique violation). [VERIFIED: codebase — `schema.ts` line 63, `unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub)`]
|
||
|
||
---
|
||
|
||
### Pitfall 7: Dev bypass seeds in prod image
|
||
|
||
**What goes wrong:** `local_credentials` seed for dev user (id=1) ships in the production Docker image, granting access with a known password.
|
||
|
||
**Why it happens:** `scripts/` or seed data not excluded from Docker image.
|
||
|
||
**How to avoid:** Verify `.dockerignore` excludes `scripts/`. The `local_credentials` dev seed goes in `global-setup.ts` (Playwright) and the CI step — NOT in any migration or startup code. The `assertNotDevBypassInProduction()` guard blocks the dev-session-cookie middleware in production. [VERIFIED: codebase — IMG-01/02/03 gates from Phase 16]
|
||
|
||
---
|
||
|
||
### Pitfall 8: `Jwt.verify` import path
|
||
|
||
**What goes wrong:** `import { sign, verify } from 'hono/utils/jwt'` fails — `hono/utils/jwt` exports only `{ Jwt }` (default object), not named exports.
|
||
|
||
**Why it happens:** The Hono `utils/jwt/index.js` wraps the functions in a `Jwt` namespace object.
|
||
|
||
**How to avoid:** Use `import { Jwt } from 'hono/utils/jwt'` then call `Jwt.sign()` / `Jwt.verify()`. [VERIFIED: codebase — confirmed at runtime: `Jwt.sign type: function`]
|
||
|
||
---
|
||
|
||
### Pitfall 9: `Jwt.verify` throws on expired token (must catch)
|
||
|
||
**What goes wrong:** If the `local-session` JWT is expired, `Jwt.verify` throws `JwtTokenExpired` rather than returning null.
|
||
|
||
**Why it happens:** This is expected Hono behavior — errors are thrown, not returned.
|
||
|
||
**How to avoid:** Wrap `Jwt.verify` in try/catch in `verifyLocalSessionCookie`. Return `null` on any error (including expiry). [ASSUMED — derived from Hono JWT error types visible in `jwt.js` source]
|
||
|
||
---
|
||
|
||
### Pitfall 10: `LOCAL_SESSION_SECRET` missing at boot
|
||
|
||
**What goes wrong:** `issueLocalSessionCookie` throws because `LOCAL_SESSION_SECRET` is not set.
|
||
|
||
**Why it happens:** New env var; operator didn't add it to Docker compose.
|
||
|
||
**How to avoid:** Add a boot-time assertion alongside `assertNotDevBypassInProduction()`: check `LOCAL_SESSION_SECRET` is set and >= 32 chars when not in dev-bypass mode. Log a clear error and refuse to start.
|
||
|
||
---
|
||
|
||
### Pitfall 11: Harness `global-setup.ts` seeding `local_credentials` hash without importing app code
|
||
|
||
**What goes wrong:** `global-setup.ts` is plain Node.js (no tsx/TypeScript — per its own comment "Plain Node.js only"). If it tries to import `hashPassword` from the API source, it needs to compile first.
|
||
|
||
**Why it happens:** `global-setup.ts` uses `mysql2/promise` directly, no app imports.
|
||
|
||
**How to avoid:** Inline the `hashPassword` implementation in `global-setup.ts` (copy the 5-line scrypt hash function), or pre-hash the dev password at a known constant and hard-code the encoded string in the seed. Since `global-setup.ts` already knows the dev-bypass semantics, a hard-coded dev hash (never used in production) is acceptable.
|
||
|
||
---
|
||
|
||
## Code Examples
|
||
|
||
### scrypt hash + verify (production pattern)
|
||
|
||
```typescript
|
||
// Source: runtime-verified on Node 22.22.3 in this project
|
||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||
|
||
export function hashPassword(password: string): string {
|
||
const salt = randomBytes(16);
|
||
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||
return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
|
||
}
|
||
|
||
export function verifyPassword(stored: string, candidate: string): boolean {
|
||
try {
|
||
const [, n, r, p, saltB64, hashB64] = stored.split('$');
|
||
const salt = Buffer.from(saltB64, 'base64url');
|
||
const storedHash = Buffer.from(hashB64, 'base64url');
|
||
const check = scryptSync(candidate, salt, storedHash.length, {
|
||
N: Number(n), r: Number(r), p: Number(p),
|
||
});
|
||
return timingSafeEqual(storedHash, check);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
### Hono JWT session cookie issuance
|
||
|
||
```typescript
|
||
// Source: runtime-verified — Jwt.sign/verify confirmed from hono/utils/jwt
|
||
import { Jwt } from 'hono/utils/jwt';
|
||
import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
|
||
|
||
export async function issueLocalSessionCookie(c: Context, userId: number): Promise<void> {
|
||
const secret = process.env.LOCAL_SESSION_SECRET!;
|
||
const maxAge = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const token = await Jwt.sign({ userId, iat: now, exp: now + maxAge }, secret, 'HS256');
|
||
setCookie(c, 'local-session', token, {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'Lax',
|
||
path: '/',
|
||
maxAge,
|
||
});
|
||
}
|
||
```
|
||
|
||
### localAuthMiddleware pattern (mirrors devAuthBypass)
|
||
|
||
```typescript
|
||
// Source: apps/api/src/auth/devBypass.ts (verified — the pattern to mirror)
|
||
import type { MiddlewareHandler } from 'hono';
|
||
import { verifyLocalSessionCookie } from './localSession.js';
|
||
import { db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import { eq } from 'drizzle-orm';
|
||
|
||
export function localAuthMiddleware(): MiddlewareHandler {
|
||
return async (c, next) => {
|
||
// Skip if already authenticated (devAuthBypass ran first)
|
||
if (c.get('user')) { await next(); return; }
|
||
|
||
const userId = await verifyLocalSessionCookie(c);
|
||
if (!userId) { await next(); return; }
|
||
|
||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||
if (user) {
|
||
c.set('user', {
|
||
id: user.id,
|
||
oidcIss: user.oidcIss ?? 'local',
|
||
oidcSub: user.oidcSub ?? String(user.id),
|
||
displayName: user.displayName ?? null,
|
||
color: user.color,
|
||
});
|
||
}
|
||
await next();
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Environment Availability
|
||
|
||
| Dependency | Required By | Available | Version | Fallback |
|
||
|------------|------------|-----------|---------|----------|
|
||
| Node.js 22 | `node:crypto` scrypt | ✓ | 22.22.3 | — (required) |
|
||
| MariaDB | `local_credentials` table | ✓ (Docker) | 11.x | — |
|
||
| `hono/utils/jwt` | JWT session signing | ✓ | hono@4.12.23 | — (already installed) |
|
||
| `hono/cookie` | Cookie read/write | ✓ | hono@4.12.23 | — (already installed) |
|
||
| `drizzle-kit` | Schema migration | ✓ | 0.31.10 | — |
|
||
| `LOCAL_SESSION_SECRET` env | JWT signing | ✗ (not yet set) | — | Add to docker-compose env + generate-secrets script |
|
||
|
||
**Missing dependencies with no fallback:**
|
||
- `LOCAL_SESSION_SECRET` env var — must be added to the operator's Docker Compose file and to the `generate-secrets` script. Absence must be caught at boot.
|
||
|
||
---
|
||
|
||
## Validation Architecture
|
||
|
||
`workflow.nyquist_validation` is enabled (absent = enabled per config). Security-critical auth flows — all test seams enumerated.
|
||
|
||
### Test Framework
|
||
|
||
| Property | Value |
|
||
|----------|-------|
|
||
| Framework | Vitest 4.1.8 |
|
||
| Config file | `apps/api/vitest.config.ts` |
|
||
| Quick run command | `pnpm --filter @familysync/api test` |
|
||
| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test:e2e` |
|
||
|
||
### Phase Requirements → Test Map
|
||
|
||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||
|--------|----------|-----------|-------------------|-------------|
|
||
| AUTH-LOCAL-01 | `local_credentials` table schema | integration | `pnpm --filter @familysync/api test tests/db/schema.test.ts` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-02 | `hashPassword` round-trip + `verifyPassword` timing-safe | unit | `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-03 | `POST /api/auth/local/login` — 200 success, 401 wrong, 429 rate, 423 lockout | unit+integration | `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-04 | `localAuthMiddleware` — sets c.get('user') with valid cookie; no-op without cookie | unit | `pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-05 | `GET /api/auth/mode` — returns `{localEnabled:true, oidcEnabled}` | unit | `pnpm --filter @familysync/api test tests/routes/authMode.test.ts` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-06 | `POST /api/auth/local/logout` — clears cookie | unit | included in `localAuth.test.ts` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-07 | Admin create member — 201 on success, 409 on duplicate username | unit | `pnpm --filter @familysync/api test tests/routes/admin.test.ts` | ✅ (extend) |
|
||
| AUTH-LOCAL-08 | Admin reset password — updates hash | unit | included in `admin.test.ts` | ✅ (extend) |
|
||
| AUTH-LOCAL-09 | Self-change password — verify current, update hash | unit | `pnpm --filter @familysync/api test tests/routes/me.test.ts` | ✅ (extend) |
|
||
| AUTH-LOCAL-10 | OIDC-link — binds iss+sub, deletes local_credentials, 409 on conflict | unit | included in `me.test.ts` | ✅ (extend) |
|
||
| AUTH-LOCAL-11 | Break-glass CLI — creates admin user | manual/smoke | `tsx scripts/reset-admin.ts --dry-run` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-12 | LoginPage renders brand slot + form; submits and receives cookie | e2e (Playwright) | `pnpm --filter @familysync/pwa test:e2e --grep "login"` | ❌ Wave 0 |
|
||
| AUTH-LOCAL-15 | App.tsx redirects unauthed user to /login | e2e | included in login spec | ❌ Wave 0 |
|
||
| AUTH-LOCAL-16 | Harness continues to work with Option C dev-bypass | e2e | existing harness | ✅ (verify after change) |
|
||
| AUTH-LOCAL-19 | Rate-limit 429 after 5 failures; lockout 423 after 10 | unit | included in `localAuth.test.ts` | ❌ Wave 0 |
|
||
|
||
### Sampling Rate
|
||
|
||
- **Per task commit:** `pnpm --filter @familysync/api test` (unit suite, ~10s)
|
||
- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` (unit + PWA unit)
|
||
- **Phase gate:** Full suite including Playwright harness before `/gsd-verify-work`
|
||
|
||
### Wave 0 Gaps
|
||
|
||
- [ ] `tests/auth/localCredentials.test.ts` — covers AUTH-LOCAL-02 (hash round-trip, timing-safe)
|
||
- [ ] `tests/auth/localAuthMiddleware.test.ts` — covers AUTH-LOCAL-04
|
||
- [ ] `tests/routes/authMode.test.ts` — covers AUTH-LOCAL-05
|
||
- [ ] `tests/routes/localAuth.test.ts` — covers AUTH-LOCAL-03/06/19
|
||
- [ ] `apps/pwa/e2e/login.spec.ts` — covers AUTH-LOCAL-12/15
|
||
|
||
---
|
||
|
||
## Security Domain
|
||
|
||
`security_enforcement: true` (enabled in config.json).
|
||
|
||
### Applicable ASVS Categories (Level 1)
|
||
|
||
| ASVS Category | Applies | Standard Control |
|
||
|---------------|---------|-----------------|
|
||
| V2 Authentication | YES | `node:crypto` scrypt + PHC encoding; `timingSafeEqual`; no username-enumeration timing oracle |
|
||
| V3 Session Management | YES | Stateless signed JWT; `httpOnly + Secure + SameSite=Lax`; maxAge 1 day; logout = cookie clear |
|
||
| V4 Access Control | YES | `requireAdmin` on all admin routes; `resolveUserId` always from session, never body; self-service can only modify own credential |
|
||
| V5 Input Validation | YES | `zod` + `@hono/zod-validator` on all auth routes; `noEchoHook` prevents Zod errors from echoing passwords |
|
||
| V6 Cryptography | YES (partially) | `node:crypto` scrypt (strong KDF); HS256 JWT (symmetric — acceptable for single-server; if multi-server ever applies, upgrade to RS256) |
|
||
|
||
### Known Threat Patterns
|
||
|
||
| Pattern | STRIDE | Standard Mitigation |
|
||
|---------|--------|---------------------|
|
||
| Username enumeration via timing | Information Disclosure | Dummy hash verify when username not found; `timingSafeEqual` |
|
||
| Credential brute force | Elevation of Privilege | Per-IP rate-limit (5 failures → 60s cooldown → 429); account lockout at 10 (423) |
|
||
| Session fixation | Elevation of Privilege | Issue new JWT on every login; old JWTs expire via `exp` claim |
|
||
| Password echoed in error response | Information Disclosure | `noEchoHook` on all `zValidator` calls on auth routes |
|
||
| Local-session cookie in prod image | Elevation of Privilege | D-15: `assertNotDevBypassInProduction()`; `.dockerignore` for scripts; seed only in `global-setup.ts` |
|
||
| OIDC-link CSRF | Tampering | `state` parameter in OIDC redirect must be signed/nonce'd; `iss+sub` uniqueness checked server-side |
|
||
| OIDC-link identity collision | Elevation of Privilege | `uniq_oidc_identity` DB constraint + pre-flight SELECT → 409 on conflict |
|
||
| Missing `LOCAL_SESSION_SECRET` | Elevation of Privilege | Boot-time assertion; loud error + non-zero exit |
|
||
| `local-session` cookie in XSS | Information Disclosure | `httpOnly: true` prevents JS access; OIDC cookie has same protection |
|
||
|
||
---
|
||
|
||
## State of the Art
|
||
|
||
| Old Approach | Current Approach | Notes |
|
||
|--------------|------------------|-------|
|
||
| argon2id (native addon) | `node:crypto` scrypt | D-08 mandates no native deps; scrypt is OWASP-approved for this use |
|
||
| DB session table | Stateless JWT cookie | D-05; consistent with `@hono/oidc-auth` pattern |
|
||
| Hardcoded Authelia references | Generic OIDC copy | D-06 BYO-Auth principle |
|
||
|
||
---
|
||
|
||
## Open Questions
|
||
|
||
### Resolved by Research
|
||
|
||
1. **Dev-bypass rework** → Recommend Option C (bypass issues real `local-session` cookie). Minimal harness change; satisfies D-14 and D-15.
|
||
2. **Break-glass form** → Recommend CLI script `scripts/reset-admin.ts` (tsx, runnable via `docker exec`). No new role model needed.
|
||
3. **OIDC-link mechanism** → Reuse OIDC callback (`/callback`) with a signed `state` parameter encoding `{ linkUserId }`. Simpler than a new endpoint because `processOAuthCallback` already handles the code exchange.
|
||
4. **`Jwt.sign`/`Jwt.verify` import** → `import { Jwt } from 'hono/utils/jwt'` (namespace import) — not named exports.
|
||
|
||
### Still Open (require planner decision)
|
||
|
||
1. **OIDC-only user provisioning:** How does an OIDC-only user get their `users` row now that the setup wizard's single-unclaimed-row claim (Phase 12 D-08) only works for one pre-created user? The current `upsertUser` in `auth/user.ts` already handles this: when `setup_complete === true` and no unclaimed row exists, it inserts a new fully-claimed OIDC user row (step 3/5 in `upsertUser`). The existing behavior already handles OIDC-only users without Phase 19 changes — no open issue here in practice. **Confirm:** planner should verify this path still works after Phase 19 DB changes.
|
||
|
||
2. **Admin UI for managing OIDC-only users:** Can an admin remove a user's OIDC binding (reverting them to local-only)? D-12 says OIDC-link is one-directional (removes local cred) and "can't be undone from the app." This is correct per the UI-SPEC. No admin UI for OIDC-unlinking is in scope for Phase 19.
|
||
|
||
3. **`LOCAL_SESSION_SECRET` and `generate-secrets` script update:** The Phase 12 `generate-secrets` script generates `SESSION_SECRET`, `APP_PASSWORD_ENCRYPTION_KEY`, and VAPID keys. It should be extended to also generate `LOCAL_SESSION_SECRET`. Planner should include a task to update `scripts/generate-secrets.ts`.
|
||
|
||
4. **`scripts/` in `.dockerignore`:** Verify `.dockerignore` already excludes `scripts/`. If not, a task to add `scripts/` to `.dockerignore` is required for D-15 compliance.
|
||
|
||
---
|
||
|
||
## Assumptions Log
|
||
|
||
| # | Claim | Section | Risk if Wrong |
|
||
|---|-------|---------|---------------|
|
||
| A1 | `Jwt.verify` throws on expired token (caught in try/catch in `verifyLocalSessionCookie`) | §Common Pitfalls 9, §JWT Session Cookie | Session cookie not cleared on expiry; user remains "logged in" until cookie maxAge expires naturally |
|
||
| A2 | `deleteCookie` from `hono/cookie` is available in hono@4.12.23 | §JWT Session Cookie | Logout implementation needs alternative cookie-clear method (can use `setCookie` with `maxAge: 0`) |
|
||
| A3 | OIDC-link via signed `state` param in `/callback` is the cleanest mechanism | §OIDC-Link Flow | May need a dedicated endpoint; planner may choose differently |
|
||
| A4 | The `scripts/reset-admin.ts` approach satisfies break-glass requirement | §Break-Glass | Operator may prefer env-var recovery; both are viable |
|
||
| A5 | `hono/utils/jwt` `Jwt.sign` uses Web Crypto API internally (async, returns Promise) | §JWT Session Cookie | If sync behavior is needed, alternative needed — but async is the Hono convention |
|
||
|
||
---
|
||
|
||
## Sources
|
||
|
||
### Primary (VERIFIED from codebase)
|
||
- `apps/api/src/auth/devBypass.ts` — `c.set('user')` pattern, `DEV_USER` shape, IMG-01 guard
|
||
- `apps/api/src/auth/middleware.ts` — OIDC middleware wiring, `oidcConfigFallbackMiddleware`
|
||
- `apps/api/src/auth/persistSessionCookie.ts` — `setCookie` pattern, maxAge, httpOnly/Secure
|
||
- `apps/api/src/auth/user.ts` — `upsertUser`, first-login-claims, D-10 identity model
|
||
- `apps/api/src/db/schema.ts` — `users`, `memberCredentials`, `appConfig` definitions
|
||
- `apps/api/src/index.ts` — full middleware ordering, pre-auth routes, `devBypassActive` pattern
|
||
- `apps/api/src/routes/admin.ts` — `requireAdmin`, `noEchoHook`, admin route patterns
|
||
- `apps/api/src/routes/me.ts` — `resolveUserId`, `resolveAdminAndSetupStatus`, `POST /credential`
|
||
- `apps/api/src/routes/setup.ts` — pre-auth route pattern, `isSetupLocked`, `noEchoHook`
|
||
- `apps/api/src/lib/bootGuards.ts` — `assertNotDevBypassInProduction`
|
||
- `apps/api/tests/routes/login.test.ts` — vitest mock patterns for auth tests
|
||
- `apps/pwa/e2e/global-setup.ts` — harness seed pattern, DEV_AUTH_BYPASS guard
|
||
- `apps/pwa/src/App.tsx` — PWA routing gate, setup gate, `meQuery`, route structure
|
||
- `apps/api/src/db/migrations/0002_lethal_millenium_guard.sql` — additive migration example
|
||
- `.gitea/workflows/ci.yml` — CI harness step, DEV_AUTH_BYPASS env, seed step pattern
|
||
- Node.js 22.22.3 runtime — confirmed: `scryptSync`, `randomBytes`, `timingSafeEqual` available
|
||
- `hono@4.12.23` runtime — confirmed: `Jwt.sign`, `Jwt.verify` from `hono/utils/jwt`; `getCookie`, `setCookie` from `hono/cookie`
|
||
- `hono/dist/utils/jwt/jwt.js` — source-read to understand error types and import shape
|
||
|
||
### Secondary (ASSUMED from training + project patterns)
|
||
- OWASP scrypt parameters (N=16384, r=8, p=1 as minimum; N=65536 preferred if hardware allows)
|
||
- PHC-style `$`-delimited encoded hash format for self-describing password hashes
|
||
- Rate-limit implementation as in-memory Map (appropriate for single-process household scale)
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Confidence breakdown:**
|
||
- Standard stack: HIGH — everything is the existing installed stack; no new packages; runtime-verified
|
||
- Architecture: HIGH — derived directly from reading the actual middleware chain and existing patterns
|
||
- Pitfalls: HIGH — derived from actual code reading; most pitfalls are known from existing code comments
|
||
- Password hashing: HIGH — runtime-verified on Node 22.22.3
|
||
- JWT signing: HIGH — runtime-verified `Jwt.sign`/`Jwt.verify` from `hono/utils/jwt`
|
||
- OIDC-link mechanism: MEDIUM — derived from CONTEXT.md D-12 + existing `upsertUser` patterns; specific implementation is ASSUMED
|
||
|
||
**Research date:** 2026-06-17
|
||
**Valid until:** 2026-07-17 (stable stack; scrypt parameters are stable)
|