chore: merge executor worktree (worktree-agent-a2e0909f9686032ab)

This commit is contained in:
Lucas Berger
2026-06-17 16:24:55 -04:00
14 changed files with 1847 additions and 3 deletions
+3 -1
View File
@@ -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
+90
View File
@@ -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;
}
}
+106
View File
@@ -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
}
]
}
+41
View File
@@ -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.
*
+3 -1
View File
@@ -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
View File
@@ -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);
}
}
+4 -1
View File
@@ -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);
});
});
+168
View File
@@ -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');
});
});
+5
View File
@@ -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}
`);