chore: merge executor worktree (worktree-agent-a2e0909f9686032ab)
This commit is contained in:
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.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) ===
|
# === VCS (large and unnecessary) ===
|
||||||
.git
|
.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,
|
"when": 1781545048917,
|
||||||
"tag": "0002_lethal_millenium_guard",
|
"tag": "0002_lethal_millenium_guard",
|
||||||
"breakpoints": true
|
"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(),
|
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.
|
* Items within a list.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
|||||||
import { startBrokerPoller } from './broker/poller.js';
|
import { startBrokerPoller } from './broker/poller.js';
|
||||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||||
import { startReminderScheduler } from './broker/reminderScheduler.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';
|
import webpush from 'web-push';
|
||||||
|
|
||||||
export const app = new Hono();
|
export const app = new Hono();
|
||||||
@@ -134,6 +134,8 @@ function isMainModule(): boolean {
|
|||||||
if (isMainModule()) {
|
if (isMainModule()) {
|
||||||
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
|
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
|
||||||
assertNotDevBypassInProduction();
|
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.
|
// Configure VAPID credentials for web-push before starting background workers.
|
||||||
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
|
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
|
||||||
|
|||||||
@@ -32,3 +32,35 @@ export function assertNotDevBypassInProduction(): void {
|
|||||||
process.exit(1);
|
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 { afterEach } from 'vitest';
|
||||||
import { db } from '../src/db/client.js';
|
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.
|
* 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(listShares);
|
||||||
await db.delete(pushSubscriptions);
|
await db.delete(pushSubscriptions);
|
||||||
await db.delete(lists);
|
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 {
|
} catch {
|
||||||
// DB may not be available in pure-unit test runs (no DB_HOST configured).
|
// DB may not be available in pure-unit test runs (no DB_HOST configured).
|
||||||
// Swallow the error — pure-logic tests do not need cleanup.
|
// 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 sessionSecret = randomBytes(32).toString('hex');
|
||||||
const encKey = 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)
|
// VAPID key generation (P-256 / prime256v1 — same curve as web-push)
|
||||||
const ecdhCurve = createECDH('prime256v1');
|
const ecdhCurve = createECDH('prime256v1');
|
||||||
@@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret}
|
|||||||
APP_PASSWORD_ENCRYPTION_KEY=${encKey}
|
APP_PASSWORD_ENCRYPTION_KEY=${encKey}
|
||||||
VAPID_PUBLIC_KEY=${vapid.publicKey}
|
VAPID_PUBLIC_KEY=${vapid.publicKey}
|
||||||
VAPID_PRIVATE_KEY=${vapid.privateKey}
|
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