Phase 19: Local Auth (No-OIDC Mode) #23
+3
-1
@@ -2,7 +2,9 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
apps/api/scripts/seed-credential.mjs
|
||||
# Phase 19 (D-15 / IMG-02): exclude the entire break-glass scripts directory so
|
||||
# reset-admin.ts and any future dev-only scripts never ship in the production image.
|
||||
apps/api/scripts/
|
||||
|
||||
# === VCS (large and unnecessary) ===
|
||||
.git
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
plan: "01"
|
||||
subsystem: auth
|
||||
tags: [local-auth, scrypt, jwt, session-cookie, migration, boot-guard, docker-hygiene]
|
||||
status: checkpoint
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- hashPassword/verifyPassword (node:crypto scrypt, PHC-encoded)
|
||||
- issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie (Hono Jwt HS256)
|
||||
- assertLocalSessionSecretSet (boot guard)
|
||||
- local_credentials Drizzle table + 0003 migration
|
||||
- LOCAL_SESSION_SECRET in generate-secrets.mjs
|
||||
- apps/api/scripts/ .dockerignore exclusion (D-15)
|
||||
affects:
|
||||
- apps/api/src/index.ts (boot guard wired)
|
||||
- apps/api/test/setup.ts (afterEach cleanup)
|
||||
- .dockerignore (D-15 image hygiene)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- PHC-style encoded scrypt hash (scrypt$N$r$p$salt_b64url$hash_b64url)
|
||||
- Stateless JWT session cookie via hono/utils/jwt Jwt.sign/Jwt.verify
|
||||
- Boot guard pattern (mirrors assertNotDevBypassInProduction)
|
||||
- TDD RED/GREEN: failing test committed before implementation
|
||||
key_files:
|
||||
created:
|
||||
- apps/api/src/auth/localCredentials.ts
|
||||
- apps/api/src/auth/localSession.ts
|
||||
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
|
||||
- apps/api/tests/auth/localCredentials.test.ts
|
||||
- apps/api/tests/auth/localSession.test.ts
|
||||
modified:
|
||||
- apps/api/src/lib/bootGuards.ts
|
||||
- apps/api/src/index.ts
|
||||
- apps/api/src/db/schema.ts
|
||||
- apps/api/test/setup.ts
|
||||
- scripts/generate-secrets.mjs
|
||||
- .dockerignore
|
||||
decisions:
|
||||
- "Used node:crypto scryptSync (not async) — blocking but acceptable for 2-person household infrequent logins (D-08)"
|
||||
- "PHC-style encoding embeds N/r/p/salt in stored string — future parameter upgrades without DB migration"
|
||||
- "Jwt namespace import from hono/utils/jwt (Pitfall 8 — named sign/verify don't exist)"
|
||||
- "Cookie name: local-session (distinct from oidc-auth, Pitfall 4)"
|
||||
- "assertLocalSessionSecretSet exempts DEV_AUTH_BYPASS=true — bypass never issues local JWTs"
|
||||
- "Migration generated by drizzle-kit generate (never push) — purely additive CREATE TABLE"
|
||||
- ".dockerignore: excluded entire apps/api/scripts/ dir (supersedes per-file exclusion, D-15)"
|
||||
metrics:
|
||||
duration: "~6 minutes"
|
||||
completed: "2026-06-17"
|
||||
tasks_completed: 3
|
||||
tasks_total: 4
|
||||
files_created: 5
|
||||
files_modified: 6
|
||||
---
|
||||
|
||||
# Phase 19 Plan 01: Local Auth Foundation Summary
|
||||
|
||||
**One-liner:** Scrypt password primitives, stateless local-session JWT cookie helpers, `local_credentials` MariaDB table + additive migration, `LOCAL_SESSION_SECRET` boot guard wired in `index.ts`, and `.dockerignore` break-glass script exclusion.
|
||||
|
||||
## Status: CHECKPOINT REACHED
|
||||
|
||||
Task 4 is a `type="checkpoint:human-verify"` (gate="blocking"). Tasks 1-3 are complete and committed. The plan pauses for human review of the generated migration SQL before proceeding.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Key Files |
|
||||
|------|------|--------|-----------|
|
||||
| 1 (RED) | hashPassword/verifyPassword tests | 7ece966 | apps/api/tests/auth/localCredentials.test.ts |
|
||||
| 1 (GREEN) | hashPassword/verifyPassword implementation | 85b01b5 | apps/api/src/auth/localCredentials.ts |
|
||||
| 2 (RED) | localSession + bootGuards tests | 0d8f3fa | apps/api/tests/auth/localSession.test.ts |
|
||||
| 2 (GREEN) | localSession + bootGuards + index.ts | 7d61148 | apps/api/src/auth/localSession.ts, bootGuards.ts, index.ts |
|
||||
| 3 | schema + migration + secrets + dockerignore | 96f0991 | schema.ts, 0003_warm_deathstrike.sql, generate-secrets.mjs, .dockerignore |
|
||||
|
||||
## Task 4: Checkpoint (Pending Human Review)
|
||||
|
||||
**Checkpoint type:** `human-verify` (blocking)
|
||||
|
||||
The migration `0003_warm_deathstrike.sql` was generated by `drizzle-kit generate` and applied to the dev DB with `pnpm --filter @familysync/api db:migrate` (exit 0). It contains:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `local_credentials` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`username` varchar(128) NOT NULL,
|
||||
`password_hash` varchar(256) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
|
||||
CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`);
|
||||
```
|
||||
|
||||
The SQL is purely additive. No `ALTER/DROP/TRUNCATE/RENAME` touches any existing table.
|
||||
|
||||
**What the human needs to verify:**
|
||||
1. Review `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — confirm only `CREATE TABLE local_credentials` (no statements touching users, member_credentials, calendars, calendar_events, app_config, or any other existing table).
|
||||
2. Confirm `LOCAL_SESSION_SECRET` is set in your local `.env` (>=32 chars) — without it the API will refuse to boot in non-bypass mode. Add it via `node scripts/generate-secrets.mjs` if not present.
|
||||
|
||||
**Resume signal:** Type "approved" if migration is purely additive and LOCAL_SESSION_SECRET is set.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: hashPassword/verifyPassword (TDD)
|
||||
|
||||
`apps/api/src/auth/localCredentials.ts` exports:
|
||||
- `hashPassword(password: string): string` — scrypt + 16-byte random salt, returns PHC-encoded string
|
||||
- `verifyPassword(storedEncoded: string, candidate: string): boolean` — timingSafeEqual, never throws
|
||||
|
||||
Zero new npm dependencies. All 5 unit tests pass (round-trip, wrong-password, unique-salt, malformed-hash, PHC-shape).
|
||||
|
||||
### Task 2: localSession.ts + boot guard (TDD)
|
||||
|
||||
`apps/api/src/auth/localSession.ts` exports:
|
||||
- `issueLocalSessionCookie(c, userId)` — signs JWT (HS256) with LOCAL_SESSION_SECRET, sets httpOnly cookie
|
||||
- `verifyLocalSessionCookie(c)` — returns userId or null (never throws, catches Jwt.verify expiry throws)
|
||||
- `clearLocalSessionCookie(c)` — deletes the cookie with matching attributes
|
||||
|
||||
`apps/api/src/lib/bootGuards.ts` adds:
|
||||
- `assertLocalSessionSecretSet()` — exits with FATAL if secret missing/<32 chars when not in bypass mode
|
||||
|
||||
`apps/api/src/index.ts` — `assertLocalSessionSecretSet()` called immediately after `assertNotDevBypassInProduction()`.
|
||||
|
||||
All 5 unit tests pass; `pnpm --filter @familysync/api typecheck` exits 0.
|
||||
|
||||
### Task 3: Schema + Migration + Secrets + .dockerignore
|
||||
|
||||
- `apps/api/src/db/schema.ts` — `localCredentials` table exported (UNIQUE user_id, UNIQUE username, FK->users cascade)
|
||||
- `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — purely additive CREATE TABLE; applied to dev DB
|
||||
- `apps/api/test/setup.ts` — `localCredentials` added to afterEach cleanup (FK-safe ordering)
|
||||
- `scripts/generate-secrets.mjs` — emits `LOCAL_SESSION_SECRET` (base64 32-byte, 44 chars)
|
||||
- `.dockerignore` — added `apps/api/scripts/` directory exclusion (D-15/IMG-02)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
None. Plan executed as written.
|
||||
|
||||
### Notes
|
||||
|
||||
- The migration file generated by drizzle-kit is named `0003_warm_deathstrike.sql` (drizzle-kit generates random animal names for migrations). The plan referenced `0003_local_credentials.sql` as an expected name — this is not a semantic deviation, only a filename difference from drizzle-kit's naming convention. The content and purpose match exactly.
|
||||
- `pnpm --filter @familysync/api db:migrate` was run against the dev stack DB (credentials from `.env`). The worktree shares the main repo's dev DB connection, which is expected and safe for an additive migration.
|
||||
- Tests requiring MariaDB were run with `CI=true` to bypass the global-setup root-DB-provisioning step (which requires a root MySQL connection that isn't available from the worktree's network context). Pure unit tests (no DB access) work correctly in this mode.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
No new network endpoints introduced in this plan. All new surface is internal stdlib / crypto utilities and a DB table migration. No changes to trust boundaries that aren't already covered by the plan's threat model (T-19-01 through T-19-04 and T-19-SC).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. This plan provides foundational utilities without UI or stub placeholders.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files confirmed present on disk:
|
||||
- FOUND: apps/api/src/auth/localCredentials.ts
|
||||
- FOUND: apps/api/src/auth/localSession.ts
|
||||
- FOUND: apps/api/src/db/migrations/0003_warm_deathstrike.sql
|
||||
- FOUND: apps/api/tests/auth/localCredentials.test.ts
|
||||
- FOUND: apps/api/tests/auth/localSession.test.ts
|
||||
|
||||
All commits confirmed in git log:
|
||||
- 7ece966: test(19-01): add failing tests for hashPassword/verifyPassword
|
||||
- 85b01b5: feat(19-01): implement hashPassword/verifyPassword
|
||||
- 0d8f3fa: test(19-01): add failing tests for localSession
|
||||
- 7d61148: feat(19-01): implement localSession JWT cookie helpers
|
||||
- 96f0991: feat(19-01): schema + migration + secrets + dockerignore
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* localCredentials.ts — password hashing and verification using node:crypto scrypt.
|
||||
*
|
||||
* D-08: Uses node:crypto scrypt — zero new npm dependencies; no native node-gyp build
|
||||
* in the Docker image. PHC-style encoded format allows parameter evolution without
|
||||
* a separate DB migration.
|
||||
*
|
||||
* Security properties:
|
||||
* - 16-byte per-hash random salt — unique salt per password prevents rainbow table attacks
|
||||
* - scrypt parameters: N=16384 (2^14), r=8, p=1 — OWASP-compatible
|
||||
* - 32-byte (256-bit) output key
|
||||
* - timingSafeEqual for constant-time comparison — prevents timing oracle attacks
|
||||
* - verifyPassword never throws — returns false on any parse/format/crypto error
|
||||
* - Passwords are never logged
|
||||
*
|
||||
* Encoded format: scrypt$N$r$p$<salt_base64url>$<hash_base64url>
|
||||
* Example: scrypt$16384$8$1$<22-char-b64url>$<43-char-b64url>
|
||||
* Max length: ~83 chars — fits in varchar(256) password_hash column
|
||||
*/
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// OWASP-compatible scrypt parameters for password hashing
|
||||
const SCRYPT_N = 16384; // CPU/memory cost factor (2^14)
|
||||
const SCRYPT_R = 8; // block size
|
||||
const SCRYPT_P = 1; // parallelization factor
|
||||
const KEY_LEN = 32; // 256-bit derived key output
|
||||
|
||||
/**
|
||||
* Hash a password using scrypt with a random 16-byte salt.
|
||||
*
|
||||
* Returns a self-describing PHC-style encoded string:
|
||||
* scrypt$N$r$p$<salt_base64url>$<hash_base64url>
|
||||
*
|
||||
* The encoded format embeds all parameters so verifyPassword can re-derive
|
||||
* the hash without relying on hardcoded constants — supports future parameter
|
||||
* migration without a DB schema change.
|
||||
*
|
||||
* NOTE: scryptSync blocks the event loop. For a 2-person household with
|
||||
* infrequent logins this is acceptable. Use promisify(scrypt) if async is needed.
|
||||
*/
|
||||
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 PHC-encoded hash.
|
||||
*
|
||||
* Parses the algorithm parameters from the stored string, re-derives the
|
||||
* candidate hash using scryptSync, and compares with timingSafeEqual to
|
||||
* prevent timing-oracle attacks.
|
||||
*
|
||||
* Security:
|
||||
* - timingSafeEqual: requires equal-length buffers; storedHash.length as keylen
|
||||
* ensures this regardless of the stored KEY_LEN at hash time.
|
||||
* - Returns false (never throws) on any parse error, invalid base64url, or
|
||||
* scrypt parameter error — safe to call with untrusted input.
|
||||
* - Never logs the candidate password.
|
||||
*
|
||||
* @returns true if candidate matches the stored hash; false otherwise (incl. errors)
|
||||
*/
|
||||
export function verifyPassword(storedEncoded: string, candidate: string): boolean {
|
||||
try {
|
||||
const parts = storedEncoded.split('$');
|
||||
if (parts.length !== 6) return false;
|
||||
const [, n, r, p, saltB64, hashB64] = parts;
|
||||
const salt = Buffer.from(saltB64, 'base64url');
|
||||
const storedHash = Buffer.from(hashB64, 'base64url');
|
||||
if (salt.length === 0 || storedHash.length === 0) return false;
|
||||
const candidateHash = scryptSync(candidate, salt, storedHash.length, {
|
||||
N: Number(n),
|
||||
r: Number(r),
|
||||
p: Number(p),
|
||||
});
|
||||
return timingSafeEqual(storedHash, candidateHash);
|
||||
} catch {
|
||||
// Catch any scrypt parameter errors, buffer errors, or other crypto exceptions.
|
||||
// Never propagate — return false for all error cases.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* localSession.ts — stateless JWT session-cookie helpers for local auth (D-05).
|
||||
*
|
||||
* Issues and verifies a signed httpOnly JWT cookie named 'local-session',
|
||||
* distinct from the OIDC cookie 'oidc-auth' (Pitfall 4).
|
||||
*
|
||||
* Uses Hono's built-in Jwt from 'hono/utils/jwt' via the { Jwt } namespace import
|
||||
* (Pitfall 8 — named sign/verify do not exist; Jwt.sign/Jwt.verify is correct).
|
||||
*
|
||||
* Security properties (T-19-02):
|
||||
* - HS256 signed with LOCAL_SESSION_SECRET from env (never stored in DB — SC-3)
|
||||
* - httpOnly: true — not accessible from client JavaScript
|
||||
* - secure: true in production, false in non-production (allows local dev over HTTP)
|
||||
* - sameSite: 'Lax' — CSRF mitigation for browser navigation
|
||||
* - Jwt.verify throws on expiry (Pitfall 9) — verifyLocalSessionCookie wraps in try/catch
|
||||
* - verifyLocalSessionCookie returns null (never throws) on any error
|
||||
*
|
||||
* Boot guard:
|
||||
* assertLocalSessionSecretSet() in lib/bootGuards.ts refuses to start if secret
|
||||
* is missing or < 32 chars when not in dev-bypass mode (Pitfall 10 / T-19-03).
|
||||
*/
|
||||
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
// Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4)
|
||||
const COOKIE_NAME = 'local-session';
|
||||
|
||||
// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env
|
||||
const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
|
||||
|
||||
/**
|
||||
* Issue a signed local-session JWT cookie for the given userId.
|
||||
*
|
||||
* The JWT payload carries: { userId, iat, exp } (HS256, signed with LOCAL_SESSION_SECRET).
|
||||
* Throws if LOCAL_SESSION_SECRET is not set — the boot guard should have caught this.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
|
||||
// Pitfall 8: use Jwt.sign (namespace import), NOT named sign from hono/utils/jwt
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the local-session JWT cookie and return the userId, or null.
|
||||
*
|
||||
* Returns null (never throws) when:
|
||||
* - LOCAL_SESSION_SECRET is not set
|
||||
* - No 'local-session' cookie is present
|
||||
* - The JWT is expired (Jwt.verify throws JwtTokenExpired — caught here, Pitfall 9)
|
||||
* - The JWT has been tampered with
|
||||
* - The payload.userId is not a number
|
||||
* - Any other crypto/parse error
|
||||
*/
|
||||
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 {
|
||||
// Pitfall 8: Jwt.sign/Jwt.verify (namespace import — NOT named exports)
|
||||
// Pitfall 9: Jwt.verify throws on expiry — must catch all errors and return null
|
||||
const payload = await Jwt.verify(token, secret, 'HS256');
|
||||
return typeof payload.userId === 'number' ? payload.userId : null;
|
||||
} catch {
|
||||
// Includes JwtTokenExpired, tampered signature, malformed token, etc.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the local-session cookie.
|
||||
*
|
||||
* Cookie attributes must match the ones set on issue so the browser correctly
|
||||
* expires the cookie (path, httpOnly, sameSite all must match).
|
||||
*/
|
||||
export function clearLocalSessionCookie(c: Context): void {
|
||||
deleteCookie(c, COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
// Use secure:true for delete (browsers only accept the attribute in matching context)
|
||||
// In practice this is safe because logout should happen over HTTPS in production.
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `local_credentials` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`username` varchar(128) NOT NULL,
|
||||
`password_hash` varchar(256) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
|
||||
CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1781545048917,
|
||||
"tag": "0002_lethal_millenium_guard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1781727317172,
|
||||
"tag": "0003_warm_deathstrike",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -309,6 +309,47 @@ export const appConfig = mysqlTable('app_config', {
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Local authentication credentials per member (Phase 19 — D-09).
|
||||
*
|
||||
* Stores username + PHC-encoded scrypt password hash for members who authenticate
|
||||
* via local username/password rather than (or before) OIDC.
|
||||
*
|
||||
* Design decisions:
|
||||
* - Separate table from `users` to keep the users row identity-method-agnostic (D-09).
|
||||
* - A user has a local login iff a `local_credentials` row exists (UNIQUE on user_id).
|
||||
* - OIDC-link flow (D-12): when a local user links OIDC, their `local_credentials`
|
||||
* row is deleted — they become OIDC-only.
|
||||
* - CASCADE DELETE on users.id keeps credentials clean when a member is removed.
|
||||
* - username is globally unique (login identifier, separate from displayName).
|
||||
* - password_hash is PHC-encoded: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (varchar 256).
|
||||
*
|
||||
* PROHIBITION: LOCAL_SESSION_SECRET (the signing key for this table's sessions) is an
|
||||
* env-only secret and must NEVER be stored in this table or app_config (SC-3).
|
||||
*/
|
||||
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_base64url>$<hash_base64url> — max ~83 chars
|
||||
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 — user_id is unique (D-09: auth method is per-user property)
|
||||
unique('uniq_local_cred_user').on(t.userId),
|
||||
// Username is globally unique (login identifier; case-sensitive per MariaDB default)
|
||||
unique('uniq_local_cred_username').on(t.username),
|
||||
// Index for fast lookup by user_id (e.g., on middleware / self-change-password)
|
||||
index('idx_local_credentials_user_id').on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Items within a list.
|
||||
*
|
||||
|
||||
@@ -21,7 +21,7 @@ import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||
import { startBrokerPoller } from './broker/poller.js';
|
||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
||||
import { assertNotDevBypassInProduction } from './lib/bootGuards.js';
|
||||
import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js';
|
||||
import webpush from 'web-push';
|
||||
|
||||
export const app = new Hono();
|
||||
@@ -134,6 +134,8 @@ function isMainModule(): boolean {
|
||||
if (isMainModule()) {
|
||||
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
|
||||
assertNotDevBypassInProduction();
|
||||
// D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode.
|
||||
assertLocalSessionSecretSet();
|
||||
|
||||
// Configure VAPID credentials for web-push before starting background workers.
|
||||
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
|
||||
|
||||
@@ -32,3 +32,35 @@ export function assertNotDevBypassInProduction(): void {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to start the process when LOCAL_SESSION_SECRET is absent or shorter
|
||||
* than 32 characters, UNLESS dev-bypass mode is active.
|
||||
*
|
||||
* Rationale (D-05 / T-19-03 / Pitfall 10):
|
||||
* LOCAL_SESSION_SECRET signs the local-session JWT cookie. A missing or weak
|
||||
* secret means any issued session cookie can be trivially forged. This boot
|
||||
* guard converts a silent misconfiguration into an immediate loud failure
|
||||
* instead of letting the API start and issue insecure JWTs.
|
||||
*
|
||||
* DEV_AUTH_BYPASS=true is exempt: bypass mode never issues local-session cookies
|
||||
* (the OIDC/dev-bypass path handles auth), so the secret is not required there.
|
||||
* This mirrors assertNotDevBypassInProduction's exempt logic.
|
||||
*
|
||||
* Call immediately after assertNotDevBypassInProduction() in the isMainModule()
|
||||
* boot block in index.ts.
|
||||
*/
|
||||
export function assertLocalSessionSecretSet(): void {
|
||||
// Exempt when dev-bypass is active — bypass mode doesn't issue local-session JWTs
|
||||
if (process.env.DEV_AUTH_BYPASS === 'true') return;
|
||||
|
||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
console.error(
|
||||
'[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. ' +
|
||||
'This secret signs local-session JWT cookies. Refusing to start. ' +
|
||||
'Run: node scripts/generate-secrets.mjs to generate a value.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
import { afterEach } from 'vitest';
|
||||
import { db } from '../src/db/client.js';
|
||||
import { lists, listItems, listShares, pushSubscriptions } from '../src/db/schema.js';
|
||||
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js';
|
||||
|
||||
/**
|
||||
* Truncate list and push tables in FK-safe order after each test.
|
||||
@@ -39,6 +39,9 @@ afterEach(async () => {
|
||||
await db.delete(listShares);
|
||||
await db.delete(pushSubscriptions);
|
||||
await db.delete(lists);
|
||||
// Phase 19: local_credentials has FK to users (cascade delete via users); truncate here
|
||||
// so each test starts with a clean credential slate. users intentionally left intact.
|
||||
await db.delete(localCredentials);
|
||||
} catch {
|
||||
// DB may not be available in pure-unit test runs (no DB_HOST configured).
|
||||
// Swallow the error — pure-logic tests do not need cleanup.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* localCredentials.ts — unit tests for hashPassword / verifyPassword.
|
||||
*
|
||||
* Uses node:crypto scrypt under the hood; no external dependencies.
|
||||
* All tests run without MariaDB or any external service.
|
||||
*
|
||||
* Test suite (TDD RED → GREEN — Plan 19-01 Task 1):
|
||||
* Test 1: correct password verifies true
|
||||
* Test 2: wrong password verifies false
|
||||
* Test 3: two hashes of the same input produce different encoded strings (unique salt)
|
||||
* Test 4: verifyPassword never throws on a malformed hash (returns false)
|
||||
* Test 5: encoded string has the PHC shape: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (6 segments)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js';
|
||||
|
||||
describe('hashPassword / verifyPassword', () => {
|
||||
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', () => {
|
||||
const encoded = hashPassword('hunter2');
|
||||
const result = verifyPassword(encoded, 'hunter2');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', () => {
|
||||
const encoded = hashPassword('hunter2');
|
||||
const result = verifyPassword(encoded, 'wrong-password');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', () => {
|
||||
const encoded1 = hashPassword('x');
|
||||
const encoded2 = hashPassword('x');
|
||||
expect(encoded1).not.toBe(encoded2);
|
||||
});
|
||||
|
||||
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', () => {
|
||||
expect(() => verifyPassword('not-a-valid-hash', 'x')).not.toThrow();
|
||||
expect(verifyPassword('not-a-valid-hash', 'x')).toBe(false);
|
||||
expect(verifyPassword('', 'x')).toBe(false);
|
||||
expect(verifyPassword('scrypt$bad$data', 'x')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', () => {
|
||||
const encoded = hashPassword('testpassword');
|
||||
const segments = encoded.split('$');
|
||||
expect(segments).toHaveLength(6);
|
||||
expect(segments[0]).toBe('scrypt');
|
||||
// N, r, p are numeric
|
||||
expect(Number(segments[1])).toBeGreaterThan(0); // N
|
||||
expect(Number(segments[2])).toBeGreaterThan(0); // r
|
||||
expect(Number(segments[3])).toBeGreaterThan(0); // p
|
||||
// salt and hash are non-empty base64url strings
|
||||
expect(segments[4].length).toBeGreaterThan(0);
|
||||
expect(segments[5].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* localSession.ts + bootGuards.ts — unit tests for JWT session cookie helpers
|
||||
* and assertLocalSessionSecretSet boot guard.
|
||||
*
|
||||
* Uses Hono test app for cookie round-trips. All tests run without MariaDB.
|
||||
*
|
||||
* Test suite (TDD RED → GREEN — Plan 19-01 Task 2):
|
||||
* Test 1: issue then verify round-trips userId
|
||||
* Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)
|
||||
* Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)
|
||||
* Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// ── Test constants ─────────────────────────────────────────────────────────────
|
||||
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
|
||||
const TEST_USER_ID = 42;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a minimal Hono test app with an issue route and a verify route. */
|
||||
function makeTestApp(secret: string | undefined) {
|
||||
return {
|
||||
setup: async () => {
|
||||
// Import inside function to pick up modified env
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
||||
'../../src/auth/localSession.js'
|
||||
);
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/issue', async (c) => {
|
||||
await issueLocalSessionCookie(c, TEST_USER_ID);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/verify', async (c) => {
|
||||
const userId = await verifyLocalSessionCookie(c);
|
||||
return c.json({ userId });
|
||||
});
|
||||
|
||||
return app;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env.LOCAL_SESSION_SECRET = TEST_SECRET;
|
||||
vi.resetModules(); // ensure fresh imports pick up env changes
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('Test 1: issue then verify round-trips userId', async () => {
|
||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
||||
'../../src/auth/localSession.js'
|
||||
);
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/issue', async (c) => {
|
||||
await issueLocalSessionCookie(c, TEST_USER_ID);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/verify', async (c) => {
|
||||
const userId = await verifyLocalSessionCookie(c);
|
||||
return c.json({ userId });
|
||||
});
|
||||
|
||||
// Issue cookie
|
||||
const issueRes = await app.request('/issue', { method: 'POST' });
|
||||
expect(issueRes.status).toBe(200);
|
||||
|
||||
const setCookieHeader = issueRes.headers.get('set-cookie');
|
||||
expect(setCookieHeader).not.toBeNull();
|
||||
expect(setCookieHeader).toContain('local-session=');
|
||||
|
||||
// Extract cookie value and forward it for verify
|
||||
const cookieHeader = setCookieHeader?.split(';')[0]; // just name=value
|
||||
const verifyRes = await app.request('/verify', {
|
||||
headers: { cookie: cookieHeader ?? '' },
|
||||
});
|
||||
expect(verifyRes.status).toBe(200);
|
||||
const body = (await verifyRes.json()) as { userId: number | null };
|
||||
expect(body.userId).toBe(TEST_USER_ID);
|
||||
});
|
||||
|
||||
it('Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)', async () => {
|
||||
const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js');
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/verify', async (c) => {
|
||||
const userId = await verifyLocalSessionCookie(c);
|
||||
return c.json({ userId });
|
||||
});
|
||||
|
||||
const res = await app.request('/verify');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { userId: number | null };
|
||||
expect(body.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)', async () => {
|
||||
const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js');
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/verify', async (c) => {
|
||||
const userId = await verifyLocalSessionCookie(c);
|
||||
return c.json({ userId });
|
||||
});
|
||||
|
||||
// Send a garbage token — should return null without throwing
|
||||
const res = await app.request('/verify', {
|
||||
headers: { cookie: 'local-session=garbage.token.value' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { userId: number | null };
|
||||
expect(body.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertLocalSessionSecretSet (bootGuards)', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as never);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
exitSpy.mockRestore();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS=true even if secret unset', async () => {
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
delete process.env.LOCAL_SESSION_SECRET;
|
||||
|
||||
const { assertLocalSessionSecretSet } = await import('../../src/lib/bootGuards.js');
|
||||
|
||||
// Must not throw / must not call process.exit
|
||||
expect(() => assertLocalSessionSecretSet()).not.toThrow();
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assertLocalSessionSecretSet is exported from bootGuards', async () => {
|
||||
process.env.LOCAL_SESSION_SECRET = TEST_SECRET;
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
const bootGuards = await import('../../src/lib/bootGuards.js');
|
||||
|
||||
// Must export the function
|
||||
expect(typeof bootGuards.assertLocalSessionSecretSet).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,9 @@ import { randomBytes, createECDH } from 'node:crypto';
|
||||
|
||||
const sessionSecret = randomBytes(32).toString('hex');
|
||||
const encKey = randomBytes(32).toString('hex');
|
||||
// Phase 19 (D-05): LOCAL_SESSION_SECRET signs the local-auth JWT session cookie.
|
||||
// Must be >= 32 chars. 32 random bytes encoded as base64 = 44 chars (safe, distinct from hex keys).
|
||||
const localSessionSecret = randomBytes(32).toString('base64');
|
||||
|
||||
// VAPID key generation (P-256 / prime256v1 — same curve as web-push)
|
||||
const ecdhCurve = createECDH('prime256v1');
|
||||
@@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret}
|
||||
APP_PASSWORD_ENCRYPTION_KEY=${encKey}
|
||||
VAPID_PUBLIC_KEY=${vapid.publicKey}
|
||||
VAPID_PRIVATE_KEY=${vapid.privateKey}
|
||||
# Phase 19 (D-05): Signs local-auth JWT session cookies. Required when not using DEV_AUTH_BYPASS.
|
||||
LOCAL_SESSION_SECRET=${localSessionSecret}
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user