From 31e0602067b964aea856edd67a9f8be9fda57477 Mon Sep 17 00:00:00 2001 From: ansidian Date: Fri, 17 Jul 2026 14:45:01 -0700 Subject: [PATCH 01/44] feat: add database-backed owner bootstrap --- .env.example | 7 +- ARCHITECTURE.md | 36 ++++- FLOWS.md | 15 ++ README.md | 16 +- server/CLAUDE.md | 5 + server/auth/owner-bootstrap.test.ts | 71 +++++++++ server/auth/owner-bootstrap.ts | 76 ++++++++++ server/auth/owner-claim-service.ts | 55 +++++++ server/auth/owner-context.ts | 39 +++++ server/auth/owner-runtime.test.ts | 28 ++++ server/auth/owner-runtime.ts | 14 ++ server/auth/owner-store.test.ts | 54 +++++++ server/auth/owner-store.ts | 62 ++++++++ server/db/migrations/030_owner_bootstrap.sql | 7 + server/env.test.ts | 9 +- server/env.ts | 2 +- server/index.ts | 51 +++++-- server/middleware/CLAUDE.md | 1 + server/middleware/owner-gate.test.ts | 44 ++++++ server/middleware/owner-gate.ts | 12 ++ server/routes/auth.test.ts | 59 +++++++- server/routes/auth.ts | 65 ++++++-- server/routes/briefing/bills.ts | 28 ++-- server/routes/briefing/dev.ts | 4 +- server/routes/briefing/email-index.ts | 6 +- server/routes/briefing/email.ts | 26 ++-- server/routes/briefing/snapshot.ts | 20 +-- server/routes/briefing/tasks.ts | 6 +- server/test-utils/auth-db.ts | 22 ++- shared/types/setup.ts | 12 ++ src/App.test.tsx | 35 +++++ src/App.tsx | 54 ++++--- src/pages/OwnerSetup.test.tsx | 54 +++++++ src/pages/OwnerSetup.tsx | 151 +++++++++++++++++++ src/setupApi.test.ts | 49 ++++++ src/setupApi.ts | 37 +++++ 36 files changed, 1118 insertions(+), 114 deletions(-) create mode 100644 server/auth/owner-bootstrap.test.ts create mode 100644 server/auth/owner-bootstrap.ts create mode 100644 server/auth/owner-claim-service.ts create mode 100644 server/auth/owner-context.ts create mode 100644 server/auth/owner-runtime.test.ts create mode 100644 server/auth/owner-runtime.ts create mode 100644 server/auth/owner-store.test.ts create mode 100644 server/auth/owner-store.ts create mode 100644 server/db/migrations/030_owner_bootstrap.sql create mode 100644 server/middleware/owner-gate.test.ts create mode 100644 server/middleware/owner-gate.ts create mode 100644 shared/types/setup.ts create mode 100644 src/pages/OwnerSetup.test.tsx create mode 100644 src/pages/OwnerSetup.tsx create mode 100644 src/setupApi.test.ts create mode 100644 src/setupApi.ts diff --git a/.env.example b/.env.example index c96669bf..11ac4901 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ -# Auth (run `node server/hash-password.js ` to generate) -EA_PASSWORD_HASH=$2b$12$... -EA_USER_ID=your-user-id +# Optional legacy auth import for existing installations. New instances create +# the owner password and stable user id in the browser after first startup. +# EA_PASSWORD_HASH=$2b$12$... +# EA_USER_ID=your-user-id # WebAuthn passkeys. Production requires all three and must use your HTTPS app origin. # Local dev defaults to Setpoint / localhost / http://localhost:5173 when unset. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3fa5ad98..c755ba1a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -336,7 +336,7 @@ graph LR | Group | Mount | Endpoints | Key Responsibilities | |-------|-------|-----------|---------------------| -| Auth | `/api/auth` | 13 | Password/passkey login, passkey management, session check/logout, scoped API tokens | +| Auth | `/api/auth` | 15 | First-run owner claim, password/passkey login, passkey management, session check/logout, scoped API tokens | | Briefing | `/api/briefing` | domain routers | Email ops (read/trash/snooze/dismiss), snapshots, FTS email search, task ops, Actual Budget | | Dashboard | `/api/dashboard` | 5 | Current dashboard envelope, current refresh/sync, health, SSE change events | | Accounts | `/api/ea` | 15 | Account CRUD, Gmail OAuth, settings, schedules, geocode, important senders | @@ -350,8 +350,18 @@ sequenceDiagram participant S as Server participant DB as Turso + B->>S: GET /api/auth/setup/status + alt Instance is unclaimed + B->>S: POST /api/auth/setup/claim {password} + S->>S: Generate stable owner UUID and bcrypt hash + S->>DB: INSERT OR IGNORE ea_owner singleton + S->>DB: INSERT ea_sessions (hashed token, expires_at) + S->>B: Set-Cookie: ea_session; close public setup + end + B->>S: POST /api/auth/login {password} - S->>S: bcrypt.compare(password, EA_PASSWORD_HASH) + S->>DB: SELECT password_hash FROM ea_owner singleton + S->>S: bcrypt.compare(password, stored password_hash) alt No registered passkeys S->>DB: INSERT ea_sessions (token, expires_at) S->>B: Set-Cookie: ea_session (httpOnly, secure, sameSite=strict) @@ -373,12 +383,19 @@ sequenceDiagram S->>B: 200 current dashboard envelope (or 401 if expired) ``` -The browser auth model has four distinct states: +The browser auth model has five distinct states: + +1. **Unclaimed Instance** - no `ea_owner` singleton row. Only static/setup auth routes and `GET /healthz` are available; provider APIs and all background workers are gated. +2. **Authenticated Session** - `ea_session` cookie. The browser receives a raw 32-byte hex session token, but `ea_sessions` stores only `sha256:`. Used by the SPA and required by normal dashboard routes. Once issued, it is trusted until expiry or logout; the app does not prompt for passkey on every request. +3. **Pending Password Authentication** - `ea_pending_auth` cookie plus a row in `ea_pending_auth`. Created only after a correct password when at least one passkey is registered. It can request and verify WebAuthn authentication options, but it cannot access dashboard routes or passkey registration endpoints. +4. **Registered Passkey** - row in `ea_passkey_credentials` containing credential ID, public key, sign count, label, transports, backup state, and device type. Public key material never leaves the server in management responses. +5. **Passkey Reset** - local operator recovery via `npm run auth:reset-passkeys -- --confirm`. It clears registered passkeys, pending auth, WebAuthn challenges, and browser sessions so the next password login returns to setup mode. -1. **Authenticated Session** - `ea_session` cookie. The browser receives a raw 32-byte hex session token, but `ea_sessions` stores only `sha256:`. Used by the SPA and required by normal dashboard routes. Once issued, it is trusted until expiry or logout; the app does not prompt for passkey on every request. -2. **Pending Password Authentication** - `ea_pending_auth` cookie plus a row in `ea_pending_auth`. Created only after a correct password when at least one passkey is registered. It can request and verify WebAuthn authentication options, but it cannot access dashboard routes or passkey registration endpoints. -3. **Registered Passkey** - row in `ea_passkey_credentials` containing credential ID, public key, sign count, label, transports, backup state, and device type. Public key material never leaves the server in management responses. -4. **Passkey Reset** - local operator recovery via `npm run auth:reset-passkeys -- --confirm`. It clears registered passkeys, pending auth, WebAuthn challenges, and browser sessions so the next password login returns to setup mode. +Ownership is database-backed in the singleton `ea_owner` row. Fresh claims rely on +the singleton primary-key invariant so exactly one concurrent insert succeeds. +Existing `EA_USER_ID` plus `EA_PASSWORD_HASH` values are an optional startup +compatibility source: startup imports the exact pair when no owner exists and +fails closed for partial or conflicting state. Two credential paths exist, but they no longer feed a single shared "any auth works" guard: @@ -635,6 +652,7 @@ erDiagram | `ea_news_sources` | `026_news.sql`, `029_news_retry_after.sql` | | `ea_news_topics` | `026_news.sql`, `027_news_mute_terms.sql` | | `ea_notes` | `001_ea_tables.sql`, `021_notes_archive.sql` | +| `ea_owner` | `030_owner_bootstrap.sql` | | `ea_passkey_credentials` | `012_passkey_auth.sql` | | `ea_pending_auth` | `012_passkey_auth.sql` | | `ea_pinned_emails` | `022_pinned_emails.sql`, `023_pinned_emails_rebuild.sql` | @@ -722,6 +740,8 @@ The structural route table below is regenerated from `server/index.ts` and `serv | DELETE | `/api/auth/passkeys/:credentialId` | `server/routes/auth.ts` | | POST | `/api/auth/passkeys/registration/options` | `server/routes/auth.ts` | | POST | `/api/auth/passkeys/registration/verify` | `server/routes/auth.ts` | +| POST | `/api/auth/setup/claim` | `server/routes/auth.ts` | +| GET | `/api/auth/setup/status` | `server/routes/auth.ts` | | GET | `/api/briefing/actual/accounts` | `server/routes/briefing/bills.ts` | | POST | `/api/briefing/actual/bills/:id/mark-paid` | `server/routes/briefing/bills.ts` | | POST | `/api/briefing/actual/cache/hydrate` | `server/routes/briefing/bills.ts` | @@ -955,6 +975,6 @@ Passkeys and API tokens are separate auth surfaces. A registered passkey can unl 1. `npm run dev` → concurrently runs Vite (HMR) + Express (--watch) 2. Vite proxies `/api/*` to Express on port 3001 -**Environment variables:** See `.env.example` for full reference. Key secrets: `EA_PASSWORD_HASH` (bcrypt), `EA_ENCRYPTION_KEY` (AES-256), `ANTHROPIC_API_KEY`, `GOOGLE_CLIENT_ID`/`SECRET`, database tokens. +**Environment variables:** See `.env.example` for full reference. Key secrets: `EA_ENCRYPTION_KEY` (AES-256), `ANTHROPIC_API_KEY`, `GOOGLE_CLIENT_ID`/`SECRET`, and database tokens. `EA_USER_ID` plus `EA_PASSWORD_HASH` remain an optional legacy owner-import pair. **Security defaults:** production enables HSTS + CSP + frame/referrer/permissions headers. `trust proxy` defaults to `1` only in production and can be overridden via `TRUST_PROXY`. diff --git a/FLOWS.md b/FLOWS.md index fa700e3b..6b9231dc 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -151,3 +151,18 @@ Selection path: **SSE:** none — purely client-side state. **UI:** selected chips get the selection accent border/wash on every surface; first modifier-click closes any open detail/editor; bare cmd/ctrl promotes-or-dismisses; plain click anywhere clears the set. + +## 7. First-run owner claim → authenticated runtime + +**Trigger:** the SPA reads `GET /api/auth/setup/status` before normal session auth. A missing `ea_owner` singleton routes the browser to `/setup`. + +1. `src/pages/OwnerSetup.tsx` — confirms the password locally and sends only the write-only password to `POST /api/auth/setup/claim`. +2. `server/auth/owner-claim-service.ts:claimInitialOwner` — rate-limited route work generates a stable UUID and bcrypt hash. +3. `server/auth/owner-store.ts:claimOwner` — `INSERT OR IGNORE` against singleton key `1`; the uniqueness invariant admits one concurrent claimant and all others receive the fixed conflict. +4. `server/middleware/auth.ts:createSession` — persists only the hashed session token; the successful browser receives the raw token in an HttpOnly cookie. +5. `server/auth/owner-context.ts:activateOwner` — exposes the claimed ID to remaining single-owner runtime modules and notifies startup gating. +6. `server/auth/owner-runtime.ts:createOwnerRuntimeGate` — starts schedulers and provider workers once, only after a stored or newly claimed owner exists. + +**Compatibility:** `server/auth/owner-bootstrap.ts:resolveOwnerBootstrap` runs after migrations and before listen. It imports an exact legacy `EA_USER_ID`/`EA_PASSWORD_HASH` pair into `ea_owner`, preserves the bcrypt hash and ID, and fails closed for partial or conflicting state. + +**Pre-claim boundary:** `server/middleware/owner-gate.ts` returns a fixed setup-required response for non-setup APIs. `GET /healthz` remains successful and reports only readiness plus the non-secret claimed boolean. Demo mode resolves setup as already claimed and rejects claim mutations locally without a network call. diff --git a/README.md b/README.md index 5ef3d2a6..c6d37948 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ This project requires your own API keys and credentials. ### Environment variables ```bash -# Auth (run `node server/hash-password.ts ` to generate) -EA_PASSWORD_HASH=$2b$12$... -EA_USER_ID=your-user-id +# Optional legacy auth import for existing installations +# EA_PASSWORD_HASH=$2b$12$... +# EA_USER_ID=your-user-id # WebAuthn passkeys. Production requires all three and must use your HTTPS app origin. # Local dev defaults to Setpoint / localhost / http://localhost:5173 when unset. @@ -117,6 +117,16 @@ new broad backfill automatically. ### Dashboard auth and passkey recovery +On a fresh database, open Setpoint after startup and create the owner password +in the browser. The first successful claim atomically creates the stable owner +ID, stores only the bcrypt password hash, signs that browser in, and permanently +closes public setup. Provider APIs and background workers remain disabled until +the claim succeeds. `GET /healthz` remains available for deployment readiness. + +Existing installations may keep `EA_USER_ID` and `EA_PASSWORD_HASH`; startup +imports that exact legacy identity once. Partial or conflicting legacy auth +configuration fails closed instead of reopening public setup. + The private app uses a dashboard password plus WebAuthn passkeys. If no registered passkey exists, a valid password creates an authenticated browser session and Settings -> System shows setup mode. After the first passkey is diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 4a8a9a2f..93f1b700 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -21,6 +21,11 @@ Composition root and cross-cutting server concerns that don't belong to a single - `auth/passkey-store.ts` — CRUD for stored passkey credentials - `auth/pending-auth-store.ts` — short-lived pending-auth token issuance/lookup (WebAuthn ceremony handoff) - `auth/session-rotation.ts` — bulk session revocation (e.g. on passkey changes), clears the auth validation cache +- `auth/owner-store.ts` — singleton owner persistence and atomic claim invariant +- `auth/owner-bootstrap.ts` — startup resolution and fail-closed legacy env import +- `auth/owner-claim-service.ts` — first-visitor password hashing and owner claim orchestration +- `auth/owner-context.ts` — process-local claimed-owner context and runtime activation notifications +- `auth/owner-runtime.ts` — one-shot gate that admits background work only after owner claim - `auth/webauthn-challenge-store.ts` — short-lived WebAuthn challenge issuance/lookup - `auth/webauthn-config.ts` — relying-party (RP) id/name/origin resolution for dev vs. production - `auth/webauthn-service.ts` — registration/authentication option + verification flows (via `@simplewebauthn/server`) diff --git a/server/auth/owner-bootstrap.test.ts b/server/auth/owner-bootstrap.test.ts new file mode 100644 index 00000000..9353c9fa --- /dev/null +++ b/server/auth/owner-bootstrap.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import bcrypt from "bcrypt"; +import { createClient, type Client } from "@libsql/client"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { createOwnerStore } from "./owner-store.ts"; +import { resolveOwnerBootstrap } from "./owner-bootstrap.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("owner bootstrap", () => { + let db: Client; + + beforeEach(async () => { + db = createClient({ url: "file::memory:" }); + const sql = readFileSync(join(__dirname, "../db/migrations/030_owner_bootstrap.sql"), "utf8"); + await db.executeMultiple(sql); + }); + + afterEach(() => db.close()); + + it("leaves an instance unclaimed when no legacy identity exists", async () => { + const result = await resolveOwnerBootstrap({ + store: createOwnerStore(db), + env: {}, + }); + + expect(result).toEqual({ claimed: false, owner: null, source: "unclaimed" }); + }); + + it("imports the exact legacy user id and bcrypt hash", async () => { + const passwordHash = bcrypt.hashSync("existing password", 4); + const result = await resolveOwnerBootstrap({ + store: createOwnerStore(db), + env: { EA_USER_ID: "legacy-owner", EA_PASSWORD_HASH: passwordHash }, + now: () => 123, + }); + + expect(result).toMatchObject({ + claimed: true, + source: "legacy_import", + owner: { userId: "legacy-owner", passwordHash, claimedAt: 123 }, + }); + }); + + it.each([ + { EA_USER_ID: "legacy-owner" }, + { EA_PASSWORD_HASH: bcrypt.hashSync("existing password", 4) }, + ])("fails closed for partial legacy state", async (env) => { + await expect(resolveOwnerBootstrap({ store: createOwnerStore(db), env })) + .rejects.toThrow("Legacy owner configuration is incomplete"); + }); + + it("fails closed when legacy state conflicts with the stored owner", async () => { + const store = createOwnerStore(db); + await store.claimOwner({ + userId: "stored-owner", + passwordHash: bcrypt.hashSync("stored password", 4), + claimedAt: 100, + }); + + await expect(resolveOwnerBootstrap({ + store, + env: { + EA_USER_ID: "different-owner", + EA_PASSWORD_HASH: bcrypt.hashSync("different password", 4), + }, + })).rejects.toThrow("Legacy owner configuration conflicts with the stored owner"); + }); +}); diff --git a/server/auth/owner-bootstrap.ts b/server/auth/owner-bootstrap.ts new file mode 100644 index 00000000..3a86c603 --- /dev/null +++ b/server/auth/owner-bootstrap.ts @@ -0,0 +1,76 @@ +import type { OwnerRecord } from "./owner-store.ts"; + +interface OwnerBootstrapStore { + getOwner(): Promise; + claimOwner(input: { + userId: string; + passwordHash: string; + claimedAt: number; + }): Promise<{ claimed: boolean }>; +} + +interface OwnerBootstrapOptions { + store: OwnerBootstrapStore; + env: NodeJS.ProcessEnv | Record; + now?: () => number; +} + +export type OwnerBootstrapResult = + | { claimed: false; owner: null; source: "unclaimed" } + | { claimed: true; owner: OwnerRecord; source: "stored" | "legacy_import" }; + +function isBcryptHash(value: string): boolean { + return /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value); +} + +function readLegacyIdentity(env: OwnerBootstrapOptions["env"]): { + userId: string; + passwordHash: string; +} | null { + const userId = env.EA_USER_ID; + const passwordHash = env.EA_PASSWORD_HASH; + const hasUserId = typeof userId === "string" && userId.length > 0; + const hasPasswordHash = typeof passwordHash === "string" && passwordHash.length > 0; + + if (hasUserId !== hasPasswordHash) { + throw new Error("Legacy owner configuration is incomplete"); + } + if (!hasUserId || !hasPasswordHash) return null; + if (!isBcryptHash(passwordHash!)) { + throw new Error("Legacy owner password hash is invalid"); + } + return { userId: userId!, passwordHash: passwordHash! }; +} + +export async function resolveOwnerBootstrap({ + store, + env, + now = Date.now, +}: OwnerBootstrapOptions): Promise { + const legacy = readLegacyIdentity(env); + const stored = await store.getOwner(); + + if (stored) { + if (legacy && ( + legacy.userId !== stored.userId + || legacy.passwordHash !== stored.passwordHash + )) { + throw new Error("Legacy owner configuration conflicts with the stored owner"); + } + return { claimed: true, owner: stored, source: "stored" }; + } + + if (!legacy) return { claimed: false, owner: null, source: "unclaimed" }; + + const result = await store.claimOwner({ + userId: legacy.userId, + passwordHash: legacy.passwordHash, + claimedAt: now(), + }); + if (!result.claimed) { + throw new Error("Owner bootstrap changed concurrently; restart required"); + } + const owner = await store.getOwner(); + if (!owner) throw new Error("Legacy owner import did not persist"); + return { claimed: true, owner, source: "legacy_import" }; +} diff --git a/server/auth/owner-claim-service.ts b/server/auth/owner-claim-service.ts new file mode 100644 index 00000000..0a04af7d --- /dev/null +++ b/server/auth/owner-claim-service.ts @@ -0,0 +1,55 @@ +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import { activateOwner } from "./owner-context.ts"; +import { ownerStore, type OwnerRecord } from "./owner-store.ts"; + +interface OwnerClaimStore { + getOwner(): Promise; + claimOwner(input: { + userId: string; + passwordHash: string; + claimedAt: number; + }): Promise<{ claimed: boolean }>; +} + +interface ClaimOwnerOptions { + store?: OwnerClaimStore; + now?: () => number; + createUserId?: () => string; + hashPassword?: (password: string) => Promise; + onClaimed?: (owner: OwnerRecord) => void; +} + +export type InitialOwnerClaimResult = + | { status: "claimed"; owner: OwnerRecord } + | { status: "conflict" } + | { status: "invalid" }; + +export async function claimInitialOwner( + password: unknown, + { + store = ownerStore, + now = Date.now, + createUserId = crypto.randomUUID, + hashPassword = (value) => bcrypt.hash(value, 12), + onClaimed = activateOwner, + }: ClaimOwnerOptions = {}, +): Promise { + if (typeof password !== "string" || password.length === 0 || password.length > 1024) { + return { status: "invalid" }; + } + if (await store.getOwner()) return { status: "conflict" }; + + const input = { + userId: createUserId(), + passwordHash: await hashPassword(password), + claimedAt: now(), + }; + const result = await store.claimOwner(input); + if (!result.claimed) return { status: "conflict" }; + + const owner = await store.getOwner(); + if (!owner) throw new Error("Owner claim did not persist"); + onClaimed(owner); + return { status: "claimed", owner }; +} diff --git a/server/auth/owner-context.ts b/server/auth/owner-context.ts new file mode 100644 index 00000000..3fa07b5a --- /dev/null +++ b/server/auth/owner-context.ts @@ -0,0 +1,39 @@ +import type { OwnerRecord } from "./owner-store.ts"; + +export type OwnerIdentity = Pick; +type OwnerActivationListener = (owner: OwnerIdentity) => void | Promise; + +let activeOwner: OwnerIdentity | null = null; +const activationListeners = new Set(); + +export function getActiveOwner(): OwnerIdentity | null { + return activeOwner; +} + +export function activateOwner(owner: OwnerRecord): void { + if (activeOwner?.userId === owner.userId) return; + activeOwner = { + singletonId: owner.singletonId, + userId: owner.userId, + claimedAt: owner.claimedAt, + }; + // Compatibility bridge for provider modules that still resolve the historical + // single-owner id from process.env at operation time. Setpoint, not the host, + // owns this value for newly claimed instances. + process.env.EA_USER_ID = owner.userId; + for (const listener of activationListeners) { + Promise.resolve(listener(activeOwner)).catch((error: unknown) => { + console.error("[EA] Owner runtime activation failed:", error instanceof Error ? error.message : error); + }); + } +} + +export function onOwnerActivated(listener: OwnerActivationListener): () => void { + activationListeners.add(listener); + return () => activationListeners.delete(listener); +} + +export function __resetOwnerContextForTests(): void { + activeOwner = null; + activationListeners.clear(); +} diff --git a/server/auth/owner-runtime.test.ts b/server/auth/owner-runtime.test.ts new file mode 100644 index 00000000..048196a8 --- /dev/null +++ b/server/auth/owner-runtime.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { createOwnerRuntimeGate } from "./owner-runtime.ts"; + +const owner = { + singletonId: 1 as const, + userId: "owner-1", + claimedAt: 1, +}; + +describe("owner runtime gate", () => { + it("does not start background work for an unclaimed instance", () => { + const start = vi.fn(); + const gate = createOwnerRuntimeGate(start); + + expect(gate.startForOwner(null)).toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + + it("starts background work once when the owner becomes available", () => { + const start = vi.fn(); + const gate = createOwnerRuntimeGate(start); + + expect(gate.startForOwner(owner)).toBe(true); + expect(gate.startForOwner(owner)).toBe(false); + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith(owner); + }); +}); diff --git a/server/auth/owner-runtime.ts b/server/auth/owner-runtime.ts new file mode 100644 index 00000000..d109b83a --- /dev/null +++ b/server/auth/owner-runtime.ts @@ -0,0 +1,14 @@ +import type { OwnerIdentity } from "./owner-context.ts"; + +export function createOwnerRuntimeGate(start: (owner: OwnerIdentity) => void) { + let started = false; + + return { + startForOwner(owner: OwnerIdentity | null): boolean { + if (!owner || started) return false; + started = true; + start(owner); + return true; + }, + }; +} diff --git a/server/auth/owner-store.test.ts b/server/auth/owner-store.test.ts new file mode 100644 index 00000000..495d0f66 --- /dev/null +++ b/server/auth/owner-store.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createClient, type Client } from "@libsql/client"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { createOwnerStore } from "./owner-store.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("owner store", () => { + let db: Client; + + beforeEach(async () => { + db = createClient({ url: "file::memory:" }); + const sql = readFileSync(join(__dirname, "../db/migrations/030_owner_bootstrap.sql"), "utf8"); + await db.executeMultiple(sql); + }); + + afterEach(() => db.close()); + + it("reports a fresh instance as unclaimed", async () => { + const store = createOwnerStore(db); + + await expect(store.getOwner()).resolves.toBeNull(); + }); + + it("allows exactly one concurrent singleton claim", async () => { + const store = createOwnerStore(db); + + const results = await Promise.all([ + store.claimOwner({ userId: "owner-a", passwordHash: "hash-a", claimedAt: 100 }), + store.claimOwner({ userId: "owner-b", passwordHash: "hash-b", claimedAt: 101 }), + ]); + + expect(results.filter((result) => result.claimed)).toHaveLength(1); + expect(results.filter((result) => !result.claimed)).toHaveLength(1); + const owner = await store.getOwner(); + expect(owner).toMatchObject({ singletonId: 1, claimedAt: expect.any(Number) }); + expect(["owner-a", "owner-b"]).toContain(owner?.userId); + }); + + it("never mutates the owner after the singleton is claimed", async () => { + const store = createOwnerStore(db); + await store.claimOwner({ userId: "owner-a", passwordHash: "hash-a", claimedAt: 100 }); + + await expect(store.claimOwner({ userId: "owner-b", passwordHash: "hash-b", claimedAt: 101 })) + .resolves.toEqual({ claimed: false }); + await expect(store.getOwner()).resolves.toMatchObject({ + userId: "owner-a", + passwordHash: "hash-a", + claimedAt: 100, + }); + }); +}); diff --git a/server/auth/owner-store.ts b/server/auth/owner-store.ts new file mode 100644 index 00000000..24871051 --- /dev/null +++ b/server/auth/owner-store.ts @@ -0,0 +1,62 @@ +import db from "../db/connection.ts"; +import type { Client } from "@libsql/client"; + +const OWNER_SINGLETON_ID = 1; + +export interface OwnerRecord { + singletonId: 1; + userId: string; + passwordHash: string; + claimedAt: number; +} + +export interface OwnerClaimInput { + userId: string; + passwordHash: string; + claimedAt: number; +} + +type OwnerStoreDb = Pick; + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : String(value ?? ""); +} + +function numberValue(value: unknown): number { + return typeof value === "number" ? value : Number(value); +} + +export function createOwnerStore(dbClient: OwnerStoreDb = db) { + async function getOwner(): Promise { + const result = await dbClient.execute({ + sql: `SELECT singleton_id, user_id, password_hash, claimed_at + FROM ea_owner + WHERE singleton_id = ?`, + args: [OWNER_SINGLETON_ID], + }); + const row = result.rows[0]; + if (!row) return null; + return { + singletonId: 1, + userId: stringValue(row.user_id), + passwordHash: stringValue(row.password_hash), + claimedAt: numberValue(row.claimed_at), + }; + } + + async function claimOwner(input: OwnerClaimInput): Promise<{ claimed: boolean }> { + const result = await dbClient.execute({ + sql: `INSERT OR IGNORE INTO ea_owner + (singleton_id, user_id, password_hash, claimed_at) + VALUES (?, ?, ?, ?)`, + args: [OWNER_SINGLETON_ID, input.userId, input.passwordHash, input.claimedAt], + }); + return { claimed: result.rowsAffected === 1 }; + } + + return { getOwner, claimOwner }; +} + +export const ownerStore = createOwnerStore(); +export const getOwner = ownerStore.getOwner; +export const claimOwner = ownerStore.claimOwner; diff --git a/server/db/migrations/030_owner_bootstrap.sql b/server/db/migrations/030_owner_bootstrap.sql new file mode 100644 index 00000000..0f53fc83 --- /dev/null +++ b/server/db/migrations/030_owner_bootstrap.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS ea_owner ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + user_id TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/server/env.test.ts b/server/env.test.ts index 168c0594..45be1004 100644 --- a/server/env.test.ts +++ b/server/env.test.ts @@ -4,8 +4,6 @@ import { getMissingRequiredEnv } from "./env.ts"; describe("required env validation", () => { it("requires Turso credentials only in production", () => { const baseEnv = { - EA_USER_ID: "user-1", - EA_PASSWORD_HASH: "hash", EA_ENCRYPTION_KEY: "a".repeat(64), }; @@ -27,4 +25,11 @@ describe("required env validation", () => { EA_WEBAUTHN_ORIGIN: "https://dashboard.example.com", })).toEqual([]); }); + + it("keeps legacy owner variables optional in every environment", () => { + expect(getMissingRequiredEnv({ + NODE_ENV: "development", + EA_ENCRYPTION_KEY: "a".repeat(64), + })).toEqual([]); + }); }); diff --git a/server/env.ts b/server/env.ts index c0f9c969..3d2c590c 100644 --- a/server/env.ts +++ b/server/env.ts @@ -1,4 +1,4 @@ -const BASE_REQUIRED_ENV = ["EA_USER_ID", "EA_PASSWORD_HASH", "EA_ENCRYPTION_KEY"]; +const BASE_REQUIRED_ENV = ["EA_ENCRYPTION_KEY"]; const PRODUCTION_REQUIRED_ENV = [ "TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN", diff --git a/server/index.ts b/server/index.ts index 01f938b8..61b7b201 100644 --- a/server/index.ts +++ b/server/index.ts @@ -37,6 +37,11 @@ import { logTiming, timeAsync } from "./timing.ts"; import { installProductionFrontend } from "./static-assets.ts"; import { responseCompression } from "./middleware/compression.ts"; import { errorHandler } from "./middleware/async-handler.ts"; +import { requireClaimedInstance } from "./middleware/owner-gate.ts"; +import { resolveOwnerBootstrap } from "./auth/owner-bootstrap.ts"; +import { ownerStore } from "./auth/owner-store.ts"; +import { activateOwner, getActiveOwner, onOwnerActivated } from "./auth/owner-context.ts"; +import { createOwnerRuntimeGate } from "./auth/owner-runtime.ts"; // fail fast if critical env vars are missing @@ -70,6 +75,10 @@ applySecurityMiddleware(app); // so the Alfred + dashboard event streams are never buffered. Sits ahead of the // routes and installProductionFrontend so both API and asset payloads shrink. app.use(responseCompression()); +app.get("/healthz", (_req, res) => { + res.json({ status: "ok", claimed: Boolean(getActiveOwner()) }); +}); +app.use("/api", requireClaimedInstance); app.use("/api/todoist/webhook", express.raw({ type: "*/*" }), todoistWebhookRoutes); app.use(express.json()); app.use(cookieParser()); @@ -83,6 +92,7 @@ app.use("/api", (req, res, next) => { } if (req.path === "/gmail/push") return next(); if (req.path === "/auth/login") return next(); + if (req.path === "/auth/setup/claim") return next(); if (req.headers.authorization?.startsWith("Bearer ")) return next(); if (req.headers["x-requested-with"] !== "Setpoint") { return res.status(403).json({ message: "Forbidden" }); @@ -136,9 +146,30 @@ function scheduleStartupWorker( timer.unref?.(); } +function startOwnerRuntime(): void { + const startupDelays = buildStartupWorkerDelays(); + scheduleStartupWorker("scheduler", startupDelays.scheduler, () => initScheduler()); + scheduleStartupWorker("indexer", startupDelays.indexer, () => startBackgroundIndexer()); + scheduleStartupWorker("backfill", startupDelays.backfill, () => startEmailBackfillWorker()); + scheduleStartupWorker("snooze", startupDelays.snooze, () => startSnoozeWaker()); + scheduleStartupWorker("todoist-sync", startupDelays.todoistSync, () => startTodoistMirrorSyncWorker()); + scheduleStartupWorker("bills-mirror", startupDelays.billsMirror, () => startBillsMirrorRefreshWorker()); + scheduleStartupWorker("calendar-search-mirror", startupDelays.calendarSearchMirror, () => startCalendarSearchMirrorSyncWorker()); + scheduleStartupWorker("reminders", startupDelays.reminders, () => startReminderSchedulerWorker()); + scheduleStartupWorker("news-poll", startupDelays.news, () => startNewsPollWorker()); + startAlfredConversationSweeper(); +} + +const ownerRuntimeGate = createOwnerRuntimeGate(() => startOwnerRuntime()); + timeAsync("migrations", () => migrate()) .then(() => timeAsync("encryption-rewrite", () => migrateCbcEncryption())) - .then(() => { + .then(() => timeAsync("owner-bootstrap", () => resolveOwnerBootstrap({ + store: ownerStore, + env: process.env, + }))) + .then((bootstrap) => { + if (bootstrap.claimed) activateOwner(bootstrap.owner); const server = app.listen(PORT, () => { console.log(`Setpoint running on http://localhost:${PORT}`); logTiming({ @@ -148,17 +179,11 @@ timeAsync("migrations", () => migrate()) status: "ok", port: PORT, }); - const startupDelays = buildStartupWorkerDelays(); - scheduleStartupWorker("scheduler", startupDelays.scheduler, () => initScheduler()); - scheduleStartupWorker("indexer", startupDelays.indexer, () => startBackgroundIndexer()); - scheduleStartupWorker("backfill", startupDelays.backfill, () => startEmailBackfillWorker()); - scheduleStartupWorker("snooze", startupDelays.snooze, () => startSnoozeWaker()); - scheduleStartupWorker("todoist-sync", startupDelays.todoistSync, () => startTodoistMirrorSyncWorker()); - scheduleStartupWorker("bills-mirror", startupDelays.billsMirror, () => startBillsMirrorRefreshWorker()); - scheduleStartupWorker("calendar-search-mirror", startupDelays.calendarSearchMirror, () => startCalendarSearchMirrorSyncWorker()); - scheduleStartupWorker("reminders", startupDelays.reminders, () => startReminderSchedulerWorker()); - scheduleStartupWorker("news-poll", startupDelays.news, () => startNewsPollWorker()); - startAlfredConversationSweeper(); + ownerRuntimeGate.startForOwner(getActiveOwner()); + }); + + onOwnerActivated((owner) => { + ownerRuntimeGate.startForOwner(owner); }); const { shutdown } = createGracefulShutdown({ @@ -176,6 +201,6 @@ timeAsync("migrations", () => migrate()) }); for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => shutdown(signal)); }).catch((err) => { - console.error("Migration failed:", err); + console.error("Startup failed:", err); process.exit(1); }); diff --git a/server/middleware/CLAUDE.md b/server/middleware/CLAUDE.md index e16de4ee..4a84fd99 100644 --- a/server/middleware/CLAUDE.md +++ b/server/middleware/CLAUDE.md @@ -8,6 +8,7 @@ Cross-cutting Express request-pipeline middleware composed in `server/index.ts`: - `auth.ts` — session + API-token authentication: `validateSession` / `createSession` / `deleteSession` (hashed cookie tokens, 30-day TTL, 30s positive-validation cache), `validateBearer` (scoped `ea_api_tokens`), and the route guards `requireCookieSession`, `requireApiTokenScope`, `requireCookieSessionOrApiTokenScope`. - `compression.ts` — `responseCompression`, a streaming-safe gzip built on Node `zlib` (no dependency). Decides buffer-vs-passthrough on the first write/end by Content-Type, and deliberately never buffers `text/event-stream` (Alfred + dashboard SSE). - `rate-limits.ts` — per-route spend guards for LLM/paid-API routes (bills/extract, alfred run, email-search, places); each limiter is exported as both a `makeXLimiter()` factory (fresh, test-isolated instance) and a singleton built from it (used by real route wiring), since `express-rate-limit` tracks counts per-instance. +- `owner-gate.ts` — blocks all non-setup APIs until the singleton owner has been claimed; returns a fixed setup-required response. (Tests are not listed: `X.test.ts(x)` covers `X` by convention.) diff --git a/server/middleware/owner-gate.test.ts b/server/middleware/owner-gate.test.ts new file mode 100644 index 00000000..12400db7 --- /dev/null +++ b/server/middleware/owner-gate.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import express from "express"; +import request from "supertest"; +import { activateOwner, __resetOwnerContextForTests } from "../auth/owner-context.ts"; +import { requireClaimedInstance } from "./owner-gate.ts"; + +function makeApp() { + const app = express(); + app.use("/api", requireClaimedInstance); + app.get("/api/auth/setup/status", (_req, res) => res.json({ claimed: false })); + app.get("/api/provider", (_req, res) => res.json({ ok: true })); + return app; +} + +describe("claimed-instance API gate", () => { + afterEach(() => __resetOwnerContextForTests()); + + it("keeps public setup status reachable before claim", async () => { + const res = await request(makeApp()).get("/api/auth/setup/status"); + + expect(res.status).toBe(200); + }); + + it("blocks provider APIs before claim with a fixed response", async () => { + const res = await request(makeApp()).get("/api/provider"); + + expect(res.status).toBe(503); + expect(res.body).toEqual({ message: "Instance setup required" }); + }); + + it("allows provider APIs after claim", async () => { + activateOwner({ + singletonId: 1, + userId: "owner-1", + passwordHash: "not-exposed", + claimedAt: 1, + }); + + const res = await request(makeApp()).get("/api/provider"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/server/middleware/owner-gate.ts b/server/middleware/owner-gate.ts new file mode 100644 index 00000000..e5e59cff --- /dev/null +++ b/server/middleware/owner-gate.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from "express"; +import { getActiveOwner } from "../auth/owner-context.ts"; + +export const requireClaimedInstance: RequestHandler = (req, res, next) => { + if (req.path === "/auth/setup/status" || req.path === "/auth/setup/claim") { + return next(); + } + if (!getActiveOwner()) { + return res.status(503).json({ message: "Instance setup required" }); + } + return next(); +}; diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 7a9e7dcd..b07d646a 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -9,7 +9,7 @@ import type { GenerateAuthenticationOptionsOpts, GenerateRegistrationOptionsOpts, } from "@simplewebauthn/server"; -import { createAuthTestDb, hashApiToken, hashSessionToken, seedSession } from "../test-utils/auth-db.ts"; +import { createAuthTestDb, hashApiToken, hashSessionToken, seedOwner, seedSession } from "../test-utils/auth-db.ts"; import { createPasskeyStore } from "../auth/passkey-store.ts"; import { createPendingAuthStore, hashPendingAuthToken } from "../auth/pending-auth-store.ts"; import { createWebAuthnChallengeStore } from "../auth/webauthn-challenge-store.ts"; @@ -85,6 +85,7 @@ function setCookieHeader(response: SuperTestResponse): string { describe("auth routes", () => { beforeEach(async () => { testState.db.current = await createAuthTestDb(); + await seedOwner(currentDb(), { passwordHash: authPasswordHash }); // P2-27: validateSession now memoizes positive results in a module-level cache; // clear it between tests so each starts from a clean DB-backed state (otherwise // a prior test's cached "cookie-session" masks this test's DB-error path). @@ -109,6 +110,62 @@ describe("auth routes", () => { process.env.EA_PASSWORD_HASH = authPasswordHash; }); + it("exposes only whether public setup is still available", async () => { + await currentDb().execute("DELETE FROM ea_owner"); + + const res = await request(makeApp()).get("/api/auth/setup/status"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ claimed: false }); + }); + + it("atomically claims a fresh instance and authenticates that browser", async () => { + await currentDb().execute("DELETE FROM ea_owner"); + + const res = await request(makeApp()) + .post("/api/auth/setup/claim") + .send({ password: "new-owner-password" }); + const ownerResult = await currentDb().execute( + "SELECT user_id, password_hash, claimed_at FROM ea_owner", + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: true, claimed: true }); + expect(setCookieHeader(res)).toContain("ea_session="); + expect(ownerResult.rows).toHaveLength(1); + expect(ownerResult.rows[0]!.user_id).toMatch(/^[0-9a-f-]{36}$/); + expect(await bcrypt.compare("new-owner-password", String(ownerResult.rows[0]!.password_hash))).toBe(true); + expect(res.text).not.toContain(String(ownerResult.rows[0]!.user_id)); + expect(res.text).not.toContain(String(ownerResult.rows[0]!.password_hash)); + }); + + it("returns a fixed conflict without replacing an existing owner", async () => { + const before = await currentDb().execute("SELECT * FROM ea_owner"); + + const res = await request(makeApp()) + .post("/api/auth/setup/claim") + .send({ password: "replacement-password" }); + const after = await currentDb().execute("SELECT * FROM ea_owner"); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ message: "Instance is already claimed" }); + expect(after.rows).toEqual(before.rows); + }); + + it("lets exactly one of two concurrent claim requests succeed", async () => { + await currentDb().execute("DELETE FROM ea_owner"); + const app = makeApp(); + + const responses = await Promise.all([ + request(app).post("/api/auth/setup/claim").send({ password: "first-owner-password" }), + request(app).post("/api/auth/setup/claim").send({ password: "second-owner-password" }), + ]); + + expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); + const owners = await currentDb().execute("SELECT user_id FROM ea_owner"); + expect(owners.rows).toHaveLength(1); + }); + afterEach(async () => { testState.db.current?.close(); testState.db.current = null; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index c7911780..9fcd804c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -43,14 +43,14 @@ import { } from "../auth/webauthn-service.ts"; import { resolveWebAuthnConfig } from "../auth/webauthn-config.ts"; import { rotateSessionsForCurrentBrowser } from "../auth/session-rotation.ts"; +import { getOwner } from "../auth/owner-store.ts"; +import { claimInitialOwner } from "../auth/owner-claim-service.ts"; const router = Router(); // P1-12: forward async-handler rejections to the terminal errorHandler so a // transient DB/crypto failure returns a 500 instead of hanging the request // (notably the CSRF-exempt /login). Must run before any route is registered. wrapRouterAsync(router); -const EA_PASSWORD_HASH = process.env.EA_PASSWORD_HASH; -const EA_USER_ID = process.env.EA_USER_ID!; const API_TOKEN_TTL_DAYS = Number.parseInt(process.env.EA_API_TOKEN_TTL_DAYS || "90", 10) || 90; const API_TOKEN_TTL_MS = API_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000; @@ -82,6 +82,14 @@ const passkeyAuthLimiter = rateLimit({ legacyHeaders: false, }); +const ownerClaimLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + message: { message: "Too many setup attempts, try again later" }, + standardHeaders: true, + legacyHeaders: false, +}); + function setSessionCookie(res: Response, token: string) { res.cookie("ea_session", token, { httpOnly: true, @@ -127,21 +135,41 @@ async function clearPendingAuthState(req: Request, res: Response) { clearPendingAuthCookie(res); } +router.get("/setup/status", async (_req, res) => { + res.json({ claimed: Boolean(await getOwner()) }); +}); + +router.post("/setup/claim", ownerClaimLimiter, async (req, res) => { + const result = await claimInitialOwner(req.body?.password); + if (result.status === "invalid") { + return res.status(400).json({ message: "Password is required" }); + } + if (result.status === "conflict") { + return res.status(409).json({ message: "Instance is already claimed" }); + } + + const token = await createSession(); + setSessionCookie(res, token); + clearPendingAuthCookie(res); + return res.json({ authenticated: true, claimed: true }); +}); + router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, res) => { const { password } = req.body; + const owner = await getOwner(); - if (!EA_USER_ID || !EA_PASSWORD_HASH || !password) { + if (!owner || !password) { return res.status(401).json({ message: "Invalid password" }); } - const match = await bcrypt.compare(password, EA_PASSWORD_HASH); + const match = await bcrypt.compare(password, owner.passwordHash); if (!match) { return res.status(401).json({ message: "Invalid password" }); } - const registeredPasskeyCount = await countPasskeys(EA_USER_ID); + const registeredPasskeyCount = await countPasskeys(owner.userId); if (registeredPasskeyCount > 0) { - const pending = await createPendingAuth({ userId: EA_USER_ID }); + const pending = await createPendingAuth({ userId: owner.userId }); setPendingAuthCookie(res, pending.token); clearSessionCookie(res); return res.json({ @@ -241,7 +269,8 @@ router.post("/passkey/authentication/cancel", passkeyAuthLimiter, async (req, re }); router.get("/passkeys", requireCookieSession, async (_req, res) => { - const passkeys = await listPasskeyMetadata(EA_USER_ID); + const owner = await getOwner(); + const passkeys = owner ? await listPasskeyMetadata(owner.userId) : []; res.json({ enforcementActive: passkeys.length > 0, passkeys, @@ -254,13 +283,15 @@ router.post("/passkeys/registration/options", requireCookieSession, async (req, return res.status(400).json({ message: "label is required" }); } - const existingPasskeys = await listPasskeys(EA_USER_ID); + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const existingPasskeys = await listPasskeys(owner.userId); const challenge = await createChallenge({ - userId: EA_USER_ID, + userId: owner.userId, challengeType: "registration", }); const options = await buildRegistrationOptions({ - userId: EA_USER_ID, + userId: owner.userId, existingPasskeys, challenge: challenge.challenge, config: webAuthnConfigForRequest(req), @@ -276,13 +307,15 @@ router.post("/passkeys/registration/verify", requireCookieSession, async (req, r let consumedChallenge = null; try { - const existingCount = await countPasskeys(EA_USER_ID); + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const existingCount = await countPasskeys(owner.userId); const verification = await verifyRegistrationCredential({ response: req.body, config: webAuthnConfigForRequest(req), expectedChallenge: async (challenge) => { consumedChallenge = await consumeChallenge(challenge, { - userId: EA_USER_ID, + userId: owner.userId, challengeType: "registration", }); return Boolean(consumedChallenge); @@ -296,7 +329,7 @@ router.post("/passkeys/registration/verify", requireCookieSession, async (req, r const registrationInfo = verification.registrationInfo; const credential = registrationInfo.credential; const passkey = await createPasskey({ - userId: EA_USER_ID, + userId: owner.userId, credentialId: credential.id, label, publicKey: Buffer.from(credential.publicKey).toString("base64url"), @@ -323,14 +356,16 @@ router.post("/passkeys/registration/verify", requireCookieSession, async (req, r router.delete("/passkeys/:credentialId", requireCookieSession, async (req, res) => { const credentialId = req.params.credentialId!; - const deleted = await deletePasskey(credentialId, EA_USER_ID); + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const deleted = await deletePasskey(credentialId, owner.userId); if (!deleted) { return res.status(404).json({ message: "Passkey not found" }); } const token = await rotateSessionsForCurrentBrowser(); setSessionCookie(res, token); - const passkeys = await listPasskeyMetadata(EA_USER_ID); + const passkeys = await listPasskeyMetadata(owner.userId); res.json({ success: true, enforcementActive: passkeys.length > 0, diff --git a/server/routes/briefing/bills.ts b/server/routes/briefing/bills.ts index 52f8c565..0dfed0a7 100644 --- a/server/routes/briefing/bills.ts +++ b/server/routes/briefing/bills.ts @@ -9,7 +9,7 @@ type HttpError = Error & { status?: number }; const router = Router(); const quickTxnRouter = Router(); -const EA_USER_ID = process.env.EA_USER_ID as string; +const ownerUserId = (): string => process.env.EA_USER_ID!; function isBlank(value: unknown): boolean { return value == null || String(value).trim() === ""; @@ -57,7 +57,7 @@ router.post("/actual/send", async (req, res) => { return res.status(400).json({ message: validationError }); } try { - res.json(await billsService.sendBill(EA_USER_ID, billData as ActualBillWriteInput)); + res.json(await billsService.sendBill(ownerUserId(), billData as ActualBillWriteInput)); } catch (error: unknown) { const err = error as HttpError; console.error("Error sending to Actual Budget:", err); @@ -80,7 +80,7 @@ quickTxnRouter.post("/actual/quick-txn", requireCookieSessionOrApiTokenScope("ac return res.status(400).json({ message: "amount must be greater than 0" }); } try { - const result = await billsService.createQuickTxn(EA_USER_ID, { + const result = await billsService.createQuickTxn(ownerUserId(), { accountName: account, amount: numericAmount, payee: String(payee), @@ -104,7 +104,7 @@ router.post("/bills/extract", billExtractLimiter, async (req, res) => { return res.status(400).json({ message: "body is required" }); } try { - res.json(await billsService.extractBill(EA_USER_ID, { subject, from, body })); + res.json(await billsService.extractBill(ownerUserId(), { subject, from, body })); } catch (error: unknown) { const err = error as HttpError; const status = err.status || 500; @@ -125,7 +125,7 @@ router.post("/bills/resolve", async (req, res) => { source = "triage", } = req.body || {}; try { - res.json(await billsService.resolveBillPaySeed(EA_USER_ID, { + res.json(await billsService.resolveBillPaySeed(ownerUserId(), { emailId, accountId, subject, @@ -146,7 +146,7 @@ router.post("/bills/resolve", async (req, res) => { router.post("/bills/resolve-sample", async (req, res) => { const { mappings, email, candidate } = req.body || {}; try { - res.json(await billsService.resolveBillPaySample(EA_USER_ID, { + res.json(await billsService.resolveBillPaySample(ownerUserId(), { mappings, email, candidate, @@ -161,7 +161,7 @@ router.post("/bills/resolve-sample", async (req, res) => { router.post("/actual/bills/:id/mark-paid", async (req, res) => { try { - res.json(await billsService.markBillPaid(EA_USER_ID, req.params.id)); + res.json(await billsService.markBillPaid(ownerUserId(), req.params.id)); } catch (error: unknown) { const err = error as HttpError; console.error("Error marking bill paid:", err); @@ -171,7 +171,7 @@ router.post("/actual/bills/:id/mark-paid", async (req, res) => { router.get("/actual/metadata", async (_req, res) => { try { - res.json(await billsService.getMetadata(EA_USER_ID)); + res.json(await billsService.getMetadata(ownerUserId())); } catch (error: unknown) { const err = error as HttpError; console.error("Error fetching Actual Budget metadata:", err.message); @@ -181,7 +181,7 @@ router.get("/actual/metadata", async (_req, res) => { router.get("/actual/accounts", async (_req, res) => { try { - res.json(await billsService.listAccounts(EA_USER_ID)); + res.json(await billsService.listAccounts(ownerUserId())); } catch (error: unknown) { const err = error as HttpError; console.error("Error fetching Actual Budget accounts:", err.message); @@ -191,7 +191,7 @@ router.get("/actual/accounts", async (_req, res) => { router.get("/actual/payees", async (_req, res) => { try { - res.json(await billsService.listPayees(EA_USER_ID)); + res.json(await billsService.listPayees(ownerUserId())); } catch (error: unknown) { const err = error as HttpError; console.error("Error fetching Actual Budget payees:", err.message); @@ -201,7 +201,7 @@ router.get("/actual/payees", async (_req, res) => { router.get("/actual/categories", async (_req, res) => { try { - res.json(await billsService.listCategories(EA_USER_ID)); + res.json(await billsService.listCategories(ownerUserId())); } catch (error: unknown) { const err = error as HttpError; console.error("Error fetching Actual Budget categories:", err.message); @@ -219,7 +219,7 @@ router.post("/actual/test", async (req, res) => { } const overrides = serverURL && syncId ? { serverURL, password, syncId } : null; try { - res.json(await billsService.testConnection(EA_USER_ID, overrides)); + res.json(await billsService.testConnection(ownerUserId(), overrides)); } catch (error: unknown) { const err = error as HttpError; console.error("Actual Budget test failed:", err.message); @@ -229,7 +229,7 @@ router.post("/actual/test", async (req, res) => { router.post("/actual/cache/hydrate", async (_req, res) => { try { - res.json(await billsService.hydrateActualCache(EA_USER_ID)); + res.json(await billsService.hydrateActualCache(ownerUserId())); } catch (error: unknown) { const err = error as HttpError; console.error("Actual Budget cache hydration failed:", err.message); @@ -239,7 +239,7 @@ router.post("/actual/cache/hydrate", async (_req, res) => { router.get("/actual/cache/status", async (_req, res) => { try { - res.json(await billsService.getActualCacheStatus(EA_USER_ID)); + res.json(await billsService.getActualCacheStatus(ownerUserId())); } catch (error: unknown) { const err = error as HttpError; console.error("Actual Budget cache status check failed:", err.message); diff --git a/server/routes/briefing/dev.ts b/server/routes/briefing/dev.ts index 2437c80e..d6bab64a 100644 --- a/server/routes/briefing/dev.ts +++ b/server/routes/briefing/dev.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import * as devService from "../../email/dev-service.ts"; const router = Router(); -const EA_USER_ID = process.env.EA_USER_ID!; +const ownerUserId = (): string => process.env.EA_USER_ID!; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -14,7 +14,7 @@ router.post("/dev-reindex-emails", async (req, res) => { } const hoursBack = Math.min(parseInt(req.query.hours as string) || 720, 2160); try { - const result = await devService.reindexEmails(EA_USER_ID, hoursBack); + const result = await devService.reindexEmails(ownerUserId(), hoursBack); res.json(result); } catch (err) { console.error("[EA] Dev reindex failed:", err); diff --git a/server/routes/briefing/email-index.ts b/server/routes/briefing/email-index.ts index cf002783..b2f1b99a 100644 --- a/server/routes/briefing/email-index.ts +++ b/server/routes/briefing/email-index.ts @@ -6,7 +6,7 @@ import { import { wakeEmailBackfillWorker } from "../../email/email-backfill-worker.ts"; const router = Router(); -const EA_USER_ID = process.env.EA_USER_ID!; +const ownerUserId = (): string => process.env.EA_USER_ID!; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error || ""); @@ -14,7 +14,7 @@ function errorMessage(error: unknown): string { router.get("/email-index/health", async (_req, res) => { try { - res.json(await getEmailIndexHealth(EA_USER_ID)); + res.json(await getEmailIndexHealth(ownerUserId())); } catch (err) { console.error("[EA] Email index health failed:", errorMessage(err)); res.status(500).json({ message: "Email index health failed" }); @@ -23,7 +23,7 @@ router.get("/email-index/health", async (_req, res) => { router.post("/email-index/backfill", async (req, res) => { try { - const result = await queueEmailIndexBackfill(EA_USER_ID, { + const result = await queueEmailIndexBackfill(ownerUserId(), { targetDays: req.body?.targetDays, }); wakeEmailBackfillWorker(); diff --git a/server/routes/briefing/email.ts b/server/routes/briefing/email.ts index dd5e6f99..710c0d6f 100644 --- a/server/routes/briefing/email.ts +++ b/server/routes/briefing/email.ts @@ -4,7 +4,7 @@ import { emailSearchLimiter } from "../../middleware/rate-limits.ts"; import type { PinnedEmailSnapshot } from "../../../shared/types/email.ts"; const router = Router(); -const EA_USER_ID = process.env.EA_USER_ID!; +const ownerUserId = (): string => process.env.EA_USER_ID!; function errorStatus(error: unknown, fallback = 500): number { return error && typeof error === "object" && "status" in error && typeof error.status === "number" @@ -18,7 +18,7 @@ function errorMessage(error: unknown): string { router.get("/email/:uid", async (req, res) => { try { - res.json(await emailService.getEmailBody(EA_USER_ID, req.params.uid!)); + res.json(await emailService.getEmailBody(ownerUserId(), req.params.uid!)); } catch (err) { const status = errorStatus(err); if (status >= 500) console.error("Error fetching email body:", err); @@ -28,7 +28,7 @@ router.get("/email/:uid", async (req, res) => { router.post("/dismiss/:emailId", async (req, res) => { try { - await emailService.dismiss(EA_USER_ID, req.params.emailId!); + await emailService.dismiss(ownerUserId(), req.params.emailId!); res.json({ ok: true }); } catch (err) { console.error("Error dismissing email:", err); @@ -42,7 +42,7 @@ router.post("/email/:uid/snooze", async (req, res) => { return res.status(400).json({ message: "until_ts must be a future epoch millisecond value" }); } try { - await emailService.snooze(EA_USER_ID, req.params.uid!, untilTs, (req.body?.snapshot ?? null) as PinnedEmailSnapshot | null); + await emailService.snooze(ownerUserId(), req.params.uid!, untilTs, (req.body?.snapshot ?? null) as PinnedEmailSnapshot | null); res.json({ ok: true }); } catch (err) { const status = errorStatus(err); @@ -53,7 +53,7 @@ router.post("/email/:uid/snooze", async (req, res) => { router.delete("/email/:uid/snooze", async (req, res) => { try { - await emailService.wake(EA_USER_ID, req.params.uid!); + await emailService.wake(ownerUserId(), req.params.uid!); res.json({ ok: true }); } catch (err) { console.error("Error unsnoozing email:", err); @@ -63,7 +63,7 @@ router.delete("/email/:uid/snooze", async (req, res) => { router.post("/email/:uid/pin", async (req, res) => { try { - await emailService.pin(EA_USER_ID, req.params.uid!, (req.body?.snapshot ?? null) as PinnedEmailSnapshot | null); + await emailService.pin(ownerUserId(), req.params.uid!, (req.body?.snapshot ?? null) as PinnedEmailSnapshot | null); res.json({ ok: true }); } catch (err) { const status = errorStatus(err); @@ -74,7 +74,7 @@ router.post("/email/:uid/pin", async (req, res) => { router.delete("/email/:uid/pin", async (req, res) => { try { - await emailService.unpin(EA_USER_ID, req.params.uid!); + await emailService.unpin(ownerUserId(), req.params.uid!); res.json({ ok: true }); } catch (err) { const status = errorStatus(err); @@ -85,7 +85,7 @@ router.delete("/email/:uid/pin", async (req, res) => { router.post("/email/:uid/mark-read", async (req, res) => { try { - await emailService.markRead(EA_USER_ID, req.params.uid!); + await emailService.markRead(ownerUserId(), req.params.uid!); res.json({ ok: true }); } catch (err) { const status = errorStatus(err); @@ -96,7 +96,7 @@ router.post("/email/:uid/mark-read", async (req, res) => { router.post("/email/:uid/mark-unread", async (req, res) => { try { - await emailService.markUnread(EA_USER_ID, req.params.uid!); + await emailService.markUnread(ownerUserId(), req.params.uid!); res.json({ ok: true }); } catch (err) { const status = errorStatus(err); @@ -107,7 +107,7 @@ router.post("/email/:uid/mark-unread", async (req, res) => { router.post("/email/:uid/trash", async (req, res) => { try { - await emailService.trash(EA_USER_ID, req.params.uid!); + await emailService.trash(ownerUserId(), req.params.uid!); res.json({ ok: true }); } catch (err) { const status = errorStatus(err); @@ -122,7 +122,7 @@ router.post("/email/mark-all-read", async (req, res) => { return res.status(400).json({ message: "uids array required" }); } try { - const result = await emailService.markAllRead(EA_USER_ID, uids); + const result = await emailService.markAllRead(ownerUserId(), uids); res.json({ ok: !result.failed?.length, updatedUids: result.updatedUids || [], @@ -136,7 +136,7 @@ router.post("/email/mark-all-read", async (req, res) => { router.post("/email/arrival-grace/settle", async (_req, res) => { try { - res.json({ ok: true, ...(await emailService.settleArrivalGrace(EA_USER_ID)) }); + res.json({ ok: true, ...(await emailService.settleArrivalGrace(ownerUserId())) }); } catch (err) { console.error("Error settling arrival-grace email:", err); res.status(errorStatus(err)).json({ message: errorMessage(err) }); @@ -149,7 +149,7 @@ router.get("/email-search", emailSearchLimiter, async (req, res) => { return res.status(400).json({ message: "Query parameter 'q' is required" }); } try { - res.json(await emailService.searchEmails(EA_USER_ID, { q, limit, offset, debug: debug === "1" })); + res.json(await emailService.searchEmails(ownerUserId(), { q, limit, offset, debug: debug === "1" })); } catch (err) { console.error("[EA] Email search error:", errorMessage(err)); const status = errorStatus(err); diff --git a/server/routes/briefing/snapshot.ts b/server/routes/briefing/snapshot.ts index 01257519..c15bbd55 100644 --- a/server/routes/briefing/snapshot.ts +++ b/server/routes/briefing/snapshot.ts @@ -4,11 +4,11 @@ import { errorMessage, errorStatus } from "../../snapshots/snapshot-types.ts"; import { timeRoute } from "../../timing.ts"; const router = Router(); -const EA_USER_ID = process.env.EA_USER_ID as string; +const ownerUserId = (): string => process.env.EA_USER_ID!; router.get("/snapshot/history", timeRoute("/api/briefing/snapshot/history"), async (_req, res) => { try { - res.json(await snapshotService.getSnapshotHistory(EA_USER_ID)); + res.json(await snapshotService.getSnapshotHistory(ownerUserId())); } catch (err) { console.error("Error fetching snapshot history:", err); const status = errorStatus(err); @@ -18,7 +18,7 @@ router.get("/snapshot/history", timeRoute("/api/briefing/snapshot/history"), asy router.get("/snapshot/active", timeRoute("/api/briefing/snapshot/active"), async (_req, res) => { try { - res.json(await snapshotService.getActiveSnapshotView(EA_USER_ID)); + res.json(await snapshotService.getActiveSnapshotView(ownerUserId())); } catch (err) { console.error("Error fetching active snapshot:", err); res.status(errorStatus(err) || 500).json({ message: "Failed to fetch active snapshot" }); @@ -27,7 +27,7 @@ router.get("/snapshot/active", timeRoute("/api/briefing/snapshot/active"), async router.post("/snapshot/sync", timeRoute("/api/briefing/snapshot/sync"), async (_req, res) => { try { - res.json(await snapshotService.syncActiveSnapshot(EA_USER_ID)); + res.json(await snapshotService.syncActiveSnapshot(ownerUserId())); } catch (err) { console.error("Error syncing active snapshot:", err); res.status(errorStatus(err) || 500).json({ message: "Failed to sync active snapshot" }); @@ -36,7 +36,7 @@ router.post("/snapshot/sync", timeRoute("/api/briefing/snapshot/sync"), async (_ router.get("/snapshot/:id", timeRoute("/api/briefing/snapshot/:id"), async (req, res) => { try { - res.json(await snapshotService.getSnapshotViewById(EA_USER_ID, Number(req.params.id))); + res.json(await snapshotService.getSnapshotViewById(ownerUserId(), Number(req.params.id))); } catch (err) { console.error("Error fetching snapshot detail:", err); const status = errorStatus(err); @@ -47,7 +47,7 @@ router.get("/snapshot/:id", timeRoute("/api/briefing/snapshot/:id"), async (req, router.patch("/snapshot/items/:itemId/lane", async (req, res) => { try { const itemId = Number(req.params.itemId); - res.json(await snapshotService.moveSnapshotItemLane(EA_USER_ID, itemId, req.body?.lane)); + res.json(await snapshotService.moveSnapshotItemLane(ownerUserId(), itemId, req.body?.lane)); } catch (err) { console.error("Error moving snapshot item lane:", err); const status = errorStatus(err); @@ -58,7 +58,7 @@ router.patch("/snapshot/items/:itemId/lane", async (req, res) => { router.post("/snapshot/items/:itemId/dismiss", async (req, res) => { try { const itemId = Number(req.params.itemId); - res.json(await snapshotService.dismissSnapshotItemForToday(EA_USER_ID, itemId)); + res.json(await snapshotService.dismissSnapshotItemForToday(ownerUserId(), itemId)); } catch (err) { console.error("Error dismissing snapshot item:", err); const status = errorStatus(err); @@ -69,7 +69,7 @@ router.post("/snapshot/items/:itemId/dismiss", async (req, res) => { router.post("/snapshot/items/:itemId/restore", async (req, res) => { try { const itemId = Number(req.params.itemId); - res.json(await snapshotService.restoreSnapshotItemForToday(EA_USER_ID, itemId)); + res.json(await snapshotService.restoreSnapshotItemForToday(ownerUserId(), itemId)); } catch (err) { console.error("Error restoring snapshot item:", err); const status = errorStatus(err); @@ -80,7 +80,7 @@ router.post("/snapshot/items/:itemId/restore", async (req, res) => { router.post("/snapshot/items/:itemId/handled", async (req, res) => { try { const itemId = Number(req.params.itemId); - res.json(await snapshotService.markSnapshotItemHandled(EA_USER_ID, itemId)); + res.json(await snapshotService.markSnapshotItemHandled(ownerUserId(), itemId)); } catch (err) { console.error("Error marking snapshot item handled:", err); const status = errorStatus(err); @@ -91,7 +91,7 @@ router.post("/snapshot/items/:itemId/handled", async (req, res) => { router.post("/snapshot/items/:itemId/reopen", async (req, res) => { try { const itemId = Number(req.params.itemId); - res.json(await snapshotService.reopenSnapshotItem(EA_USER_ID, itemId)); + res.json(await snapshotService.reopenSnapshotItem(ownerUserId(), itemId)); } catch (err) { console.error("Error reopening snapshot item:", err); const status = errorStatus(err); diff --git a/server/routes/briefing/tasks.ts b/server/routes/briefing/tasks.ts index a5f42b56..4c5d8f88 100644 --- a/server/routes/briefing/tasks.ts +++ b/server/routes/briefing/tasks.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import * as tasksService from "../../tasks/tasks-service.ts"; const router = Router(); -const EA_USER_ID = process.env.EA_USER_ID; +const ownerUserId = (): string => process.env.EA_USER_ID!; function errorDetails(error: unknown): { message: string; status: number } { if (error instanceof Error) { @@ -14,7 +14,7 @@ function errorDetails(error: unknown): { message: string; status: number } { router.get("/todoist/projects", async (_req, res) => { try { - res.json(await tasksService.listProjects(EA_USER_ID!)); + res.json(await tasksService.listProjects(ownerUserId())); } catch (err) { const { message, status } = errorDetails(err); console.error("Error fetching Todoist projects:", message); @@ -24,7 +24,7 @@ router.get("/todoist/projects", async (_req, res) => { router.get("/todoist/labels", async (_req, res) => { try { - res.json(await tasksService.listLabels(EA_USER_ID!)); + res.json(await tasksService.listLabels(ownerUserId())); } catch (err) { const { message, status } = errorDetails(err); console.error("Error fetching Todoist labels:", message); diff --git a/server/test-utils/auth-db.ts b/server/test-utils/auth-db.ts index db2fc466..38eb1f64 100644 --- a/server/test-utils/auth-db.ts +++ b/server/test-utils/auth-db.ts @@ -7,7 +7,12 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const migrationsDir = join(__dirname, "../db/migrations"); -const migrationFiles = ["001_ea_tables.sql", "012_passkey_auth.sql", "028_provider_needs_reauth.sql"]; +const migrationFiles = [ + "001_ea_tables.sql", + "012_passkey_auth.sql", + "028_provider_needs_reauth.sql", + "030_owner_bootstrap.sql", +]; const migrationSql = migrationFiles.map((file) => readFileSync(join(migrationsDir, file), "utf8"), @@ -51,6 +56,21 @@ export async function seedSession( }); } +export async function seedOwner( + db: Client, + { + userId = "user-1", + passwordHash, + claimedAt = Date.now(), + }: { userId?: string; passwordHash: string; claimedAt?: number }, +) { + await db.execute({ + sql: `INSERT INTO ea_owner (singleton_id, user_id, password_hash, claimed_at) + VALUES (1, ?, ?, ?)`, + args: [userId, passwordHash, claimedAt], + }); +} + export async function seedGmailAccount( db: Client, account: Partial = {}, diff --git a/shared/types/setup.ts b/shared/types/setup.ts new file mode 100644 index 00000000..3ebd9054 --- /dev/null +++ b/shared/types/setup.ts @@ -0,0 +1,12 @@ +export interface SetupStatusResponse { + claimed: boolean; +} + +export interface OwnerClaimRequest { + password: string; +} + +export interface OwnerClaimResponse { + claimed: true; + authenticated: true; +} diff --git a/src/App.test.tsx b/src/App.test.tsx index e692739a..599ebb05 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockApi = vi.hoisted(() => ({ checkAuth: vi.fn(), + getSetupStatus: vi.fn(), prefetchCurrentDashboard: vi.fn(), })); const routeFailures = vi.hoisted(() => ({ @@ -15,6 +16,16 @@ vi.mock("./api", () => ({ prefetchCurrentDashboard: mockApi.prefetchCurrentDashboard, })); +vi.mock("./setupApi", () => ({ + getSetupStatus: mockApi.getSetupStatus, +})); + +vi.mock("./pages/OwnerSetup", () => ({ + default: function OwnerSetupMock({ onClaimed }: { onClaimed: () => void }) { + return ; + }, +})); + vi.mock("./pages/Login", () => ({ default: function LoginMock() { if (routeFailures.login) throw new Error("Login render failed"); @@ -43,6 +54,7 @@ describe("App auth redirects", () => { routeFailures.login = false; routeFailures.settings = false; mockApi.checkAuth.mockResolvedValue({ authenticated: true }); + mockApi.getSetupStatus.mockResolvedValue({ claimed: true }); window.history.replaceState({}, "", "/"); window.matchMedia = vi.fn().mockReturnValue({ matches: false, @@ -108,6 +120,28 @@ describe("App auth redirects", () => { expect(mockApi.prefetchCurrentDashboard).not.toHaveBeenCalled(); }); + it("routes an unclaimed instance to owner setup without checking auth", async () => { + mockApi.getSetupStatus.mockResolvedValue({ claimed: false }); + window.history.replaceState({}, "", "/"); + + render(); + + expect(await screen.findByTestId("owner-setup-page")).toBeTruthy(); + expect(window.location.pathname).toBe("/setup"); + expect(mockApi.checkAuth).not.toHaveBeenCalled(); + expect(mockApi.prefetchCurrentDashboard).not.toHaveBeenCalled(); + }); + + it("enters the authenticated app immediately after owner claim", async () => { + mockApi.getSetupStatus.mockResolvedValue({ claimed: false }); + + render(); + fireEvent.click(await screen.findByTestId("owner-setup-page")); + + expect(await screen.findByTestId("dashboard-page")).toBeTruthy(); + expect(window.location.pathname).toBe("/"); + }); + it("shows a recoverable fallback when Login throws during render", async () => { routeFailures.login = true; mockApi.checkAuth.mockResolvedValue({ authenticated: false }); @@ -137,6 +171,7 @@ describe("App auth redirects", () => { expect(await screen.findByTestId("dashboard-page")).toBeTruthy(); expect(mockApi.checkAuth).not.toHaveBeenCalled(); + expect(mockApi.getSetupStatus).not.toHaveBeenCalled(); expect(mockApi.prefetchCurrentDashboard).not.toHaveBeenCalled(); }); diff --git a/src/App.tsx b/src/App.tsx index 35df0fd8..6c91a0d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, lazy, Suspense } from "react"; import type { ReactElement } from "react"; import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom"; import { checkAuth, prefetchCurrentDashboard } from "./api"; +import { getSetupStatus } from "./setupApi"; import { isDemoMode } from "./demo/config.ts"; import { resolveRouterBasename } from "./routerBase"; import MouseSpotlightCanvas from "./components/layout/MouseSpotlightCanvas"; @@ -12,6 +13,7 @@ import RecoverableErrorBoundary from "./components/layout/RecoverableErrorBounda const importDashboard = () => import("./pages/Dashboard"); const Dashboard = lazy(importDashboard); const Login = lazy(() => import("./pages/Login")); +const OwnerSetup = lazy(() => import("./pages/OwnerSetup")); const SettingsRoute = lazy(() => import("./pages/SettingsRoute")); function AuthSpinner(): ReactElement { @@ -47,51 +49,59 @@ function SettingsShortcut({ enabled }: SettingsShortcutProps): null { export default function App(): ReactElement { const demoMode = isDemoMode(); - const [authenticated, setAuthenticated] = useState(demoMode ? true : null); // null = loading + const [bootstrap, setBootstrap] = useState<{ claimed: boolean; authenticated: boolean } | null>( + demoMode ? { claimed: true, authenticated: true } : null, + ); useEffect(() => { if (demoMode) return undefined; - // Warm the Dashboard chunk in parallel with the auth round trip so its fetch - // is no longer serialized behind checkAuth → Suspense mount. Correctness is - // unchanged: the gate below still renders Dashboard only when authenticated; - // this only overlaps the (otherwise wasted) waterfall. Swallow rejections so - // a prefetch failure never surfaces — the real lazy() mount handles errors. - importDashboard().catch(() => {}); - - checkAuth() - .then((res) => { - setAuthenticated(res.authenticated); - // Auth-gated data prefetch: warm /api/dashboard/current only once auth is - // confirmed, so it never fires on an unauthenticated session (which would - // 401-redirect). Primes the same single-use cache the Dashboard mount fetch - // consumes, so it overlaps the chunk load instead of double-fetching. - if (res.authenticated) prefetchCurrentDashboard(); + getSetupStatus() + .then(async (status) => { + if (!status.claimed) { + setBootstrap({ claimed: false, authenticated: false }); + return; + } + importDashboard().catch(() => {}); + const auth = await checkAuth(); + setBootstrap({ claimed: true, authenticated: auth.authenticated }); + if (auth.authenticated) prefetchCurrentDashboard(); }) - .catch(() => setAuthenticated(false)); + .catch(() => setBootstrap({ claimed: true, authenticated: false })); }, [demoMode]); - if (authenticated === null) { + if (bootstrap === null) { return ; } + const { claimed, authenticated } = bootstrap; + return ( + : ( + + }> + setBootstrap({ claimed: true, authenticated: true })} /> + + + ) + } /> : ( + !claimed ? : authenticated ? : ( }> - setAuthenticated(true)} /> + setBootstrap({ claimed: true, authenticated: true })} /> ) } /> : authenticated ? ( }> @@ -100,7 +110,7 @@ export default function App(): ReactElement { ) : } /> : authenticated ? ( }> diff --git a/src/pages/OwnerSetup.test.tsx b/src/pages/OwnerSetup.test.tsx new file mode 100644 index 00000000..810b9ddb --- /dev/null +++ b/src/pages/OwnerSetup.test.tsx @@ -0,0 +1,54 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const claimOwner = vi.hoisted(() => vi.fn()); + +vi.mock("../setupApi", () => ({ claimOwner })); + +const { default: OwnerSetup } = await import("./OwnerSetup"); + +describe("OwnerSetup", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("keeps mismatched passwords in the browser", async () => { + render(); + + fireEvent.change(screen.getByLabelText("Create password"), { target: { value: "first-password" } }); + fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: "different-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Claim Setpoint" })); + + expect((await screen.findByRole("alert")).textContent).toContain("Passwords do not match"); + expect(claimOwner).not.toHaveBeenCalled(); + }); + + it("claims the instance and hands off the authenticated session", async () => { + const onClaimed = vi.fn(); + claimOwner.mockResolvedValue({ claimed: true, authenticated: true }); + render(); + + fireEvent.change(screen.getByLabelText("Create password"), { target: { value: "new-owner-password" } }); + fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: "new-owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Claim Setpoint" })); + + await waitFor(() => expect(onClaimed).toHaveBeenCalledTimes(1)); + expect(claimOwner).toHaveBeenCalledWith("new-owner-password"); + }); + + it("shows the fixed server conflict without retaining the password", async () => { + claimOwner.mockRejectedValue(new Error("Instance is already claimed")); + render(); + + const password = screen.getByLabelText("Create password") as HTMLInputElement; + const confirmation = screen.getByLabelText("Confirm password") as HTMLInputElement; + fireEvent.change(password, { target: { value: "new-owner-password" } }); + fireEvent.change(confirmation, { target: { value: "new-owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Claim Setpoint" })); + + expect((await screen.findByRole("alert")).textContent).toContain("Instance is already claimed"); + expect(password.value).toBe(""); + expect(confirmation.value).toBe(""); + }); +}); diff --git a/src/pages/OwnerSetup.tsx b/src/pages/OwnerSetup.tsx new file mode 100644 index 00000000..63ecfd07 --- /dev/null +++ b/src/pages/OwnerSetup.tsx @@ -0,0 +1,151 @@ +import { useRef, useState } from "react"; +import type { FormEvent, ReactElement } from "react"; +import { KeyRound, ShieldCheck } from "lucide-react"; +import { claimOwner } from "../setupApi"; +import { publicAssetUrl } from "@/publicAsset"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +export interface OwnerSetupProps { + onClaimed: () => void; +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : "Setup could not be completed"; +} + +export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement { + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const passwordRef = useRef(null); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + if (!password || submitting) return; + if (password !== confirmation) { + setError("Passwords do not match"); + return; + } + + setSubmitting(true); + setError(null); + try { + await claimOwner(password); + onClaimed(); + } catch (error) { + setPassword(""); + setConfirmation(""); + setError(errorMessage(error)); + passwordRef.current?.focus(); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ ); +} diff --git a/src/setupApi.test.ts b/src/setupApi.test.ts new file mode 100644 index 00000000..39141317 --- /dev/null +++ b/src/setupApi.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +function jsonResponse(payload: unknown): Response { + return { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response; +} + +async function importSetupApi(demo = false) { + vi.resetModules(); + vi.stubEnv("VITE_EA_DEMO", demo ? "1" : ""); + return import("./setupApi.ts"); +} + +describe("owner setup API", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("posts the write-only password to the claim endpoint", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ claimed: true, authenticated: true })); + vi.stubGlobal("fetch", fetch); + const api = await importSetupApi(); + + await api.claimOwner("new-owner-password"); + + expect(fetch).toHaveBeenCalledWith( + "/api/auth/setup/claim", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ password: "new-owner-password" }), + }), + ); + }); + + it("keeps demo setup inert without touching the network", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const api = await importSetupApi(true); + + await expect(api.getSetupStatus()).resolves.toEqual({ claimed: true }); + await expect(api.claimOwner("must-not-leave-browser")).rejects.toThrow("DEMO_API_UNHANDLED"); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/setupApi.ts b/src/setupApi.ts new file mode 100644 index 00000000..24e83684 --- /dev/null +++ b/src/setupApi.ts @@ -0,0 +1,37 @@ +import { isDemoMode } from "./demo/config.ts"; +import type { OwnerClaimResponse, SetupStatusResponse } from "../shared/types/setup.ts"; + +function responseError(body: unknown, status: number): Error { + const message = typeof body === "object" && body !== null && "message" in body + ? String((body as { message?: unknown }).message || "") + : ""; + return new Error(message || `API error: ${status}`); +} + +async function setupFetch(path: string, options?: RequestInit): Promise { + const response = await fetch(path, { + ...options, + headers: { + "Content-Type": "application/json", + "X-Requested-With": "Setpoint", + ...options?.headers, + }, + }); + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + throw responseError(body, response.status); + } + return response.json() as Promise; +} + +export const getSetupStatus = (): Promise => ( + isDemoMode() ? Promise.resolve({ claimed: true }) : setupFetch("/api/auth/setup/status") +); + +export const claimOwner = (password: string): Promise => { + if (isDemoMode()) return Promise.reject(new Error("DEMO_API_UNHANDLED")); + return setupFetch("/api/auth/setup/claim", { + method: "POST", + body: JSON.stringify({ password }), + }); +}; From 94ff909ff20fe96584ef7aa01387658fceb78e49 Mon Sep 17 00:00:00 2001 From: ansidian Date: Fri, 17 Jul 2026 15:19:25 -0700 Subject: [PATCH 02/44] feat: add passkey modes and recovery codes --- ARCHITECTURE.md | 44 +- FLOWS.md | 15 +- README.md | 24 +- server/CLAUDE.md | 2 + server/auth/auth-mode.test.ts | 23 ++ server/auth/auth-mode.ts | 20 + server/auth/owner-bootstrap.test.ts | 5 +- server/auth/owner-claim-service.ts | 4 + server/auth/owner-store.test.ts | 30 +- server/auth/owner-store.ts | 45 ++- server/auth/recovery-code-store.test.ts | 49 +++ server/auth/recovery-code-store.ts | 67 +++ server/db/migrations.test.ts | 24 ++ server/db/migrations/031_auth_recovery.sql | 17 + server/middleware/CLAUDE.md | 2 +- server/middleware/auth.test.ts | 12 + server/middleware/auth.ts | 57 ++- server/middleware/owner-gate.test.ts | 1 + server/routes/CLAUDE.md | 2 +- server/routes/auth.test.ts | 135 ++++++- server/routes/auth.ts | 175 ++++++-- server/test-utils/auth-db.ts | 6 +- shared/types/accounts.ts | 19 + shared/types/setup.ts | 1 + src/auth/securityApi.test.ts | 20 + src/auth/securityApi.ts | 51 +++ src/components/settings/CLAUDE.md | 2 +- .../settings/cards/PasskeysCard.test.tsx | 93 ++++- .../settings/cards/PasskeysCard.tsx | 382 +++++++++++------- src/components/settings/settings-ui.tsx | 4 +- src/pages/Login.test.tsx | 35 ++ src/pages/Login.tsx | 168 +++++++- src/pages/OwnerSetup.test.tsx | 13 +- src/pages/OwnerSetup.tsx | 44 +- 34 files changed, 1340 insertions(+), 251 deletions(-) create mode 100644 server/auth/auth-mode.test.ts create mode 100644 server/auth/auth-mode.ts create mode 100644 server/auth/recovery-code-store.test.ts create mode 100644 server/auth/recovery-code-store.ts create mode 100644 server/db/migrations/031_auth_recovery.sql create mode 100644 src/auth/securityApi.test.ts create mode 100644 src/auth/securityApi.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c755ba1a..fa6fbba5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -57,7 +57,7 @@ graph TB | Weather | Pirate Weather | Forecast data | | Tasks | Todoist API | Deadline items + personal tasks | | Finance | @actual-app/api behind provider worker + EA mirrors | Budget tracking, bill management | -| Auth | bcrypt, WebAuthn passkeys, cookie sessions | Password plus passkey login, session tokens | +| Auth | bcrypt, WebAuthn passkeys, cookie sessions | Password-or-passkey default, optional strict mode, offline recovery | | Encryption | AES-256-GCM | Credentials encrypted at rest | | Scheduling | node-cron | Snapshot boundary checks and background workers | @@ -336,7 +336,7 @@ graph LR | Group | Mount | Endpoints | Key Responsibilities | |-------|-------|-----------|---------------------| -| Auth | `/api/auth` | 15 | First-run owner claim, password/passkey login, passkey management, session check/logout, scoped API tokens | +| Auth | `/api/auth` | 20 | First-run owner claim, password/passkey login, recovery and step-up, passkey management, session check/logout, scoped API tokens | | Briefing | `/api/briefing` | domain routers | Email ops (read/trash/snooze/dismiss), snapshots, FTS email search, task ops, Actual Budget | | Dashboard | `/api/dashboard` | 5 | Current dashboard envelope, current refresh/sync, health, SSE change events | | Accounts | `/api/ea` | 15 | Account CRUD, Gmail OAuth, settings, schedules, geocode, important senders | @@ -362,10 +362,10 @@ sequenceDiagram B->>S: POST /api/auth/login {password} S->>DB: SELECT password_hash FROM ea_owner singleton S->>S: bcrypt.compare(password, stored password_hash) - alt No registered passkeys + alt Default password-or-passkey mode S->>DB: INSERT ea_sessions (token, expires_at) S->>B: Set-Cookie: ea_session (httpOnly, secure, sameSite=strict) - else Registered passkeys exist + else Explicit password-plus-passkey mode S->>DB: INSERT ea_pending_auth (10-min pending password auth) S->>B: Set-Cookie: ea_pending_auth (httpOnly, secure, sameSite=strict) B->>S: POST /api/auth/passkey/authentication/options @@ -383,13 +383,14 @@ sequenceDiagram S->>B: 200 current dashboard envelope (or 401 if expired) ``` -The browser auth model has five distinct states: +The browser auth model has six distinct states: 1. **Unclaimed Instance** - no `ea_owner` singleton row. Only static/setup auth routes and `GET /healthz` are available; provider APIs and all background workers are gated. 2. **Authenticated Session** - `ea_session` cookie. The browser receives a raw 32-byte hex session token, but `ea_sessions` stores only `sha256:`. Used by the SPA and required by normal dashboard routes. Once issued, it is trusted until expiry or logout; the app does not prompt for passkey on every request. -3. **Pending Password Authentication** - `ea_pending_auth` cookie plus a row in `ea_pending_auth`. Created only after a correct password when at least one passkey is registered. It can request and verify WebAuthn authentication options, but it cannot access dashboard routes or passkey registration endpoints. +3. **Pending Passkey Authentication** - `ea_pending_auth` cookie plus a row in `ea_pending_auth`. Created after a correct password in explicit strict mode, or when default-mode passwordless passkey login begins. It can request and verify WebAuthn options but cannot access dashboard routes. 4. **Registered Passkey** - row in `ea_passkey_credentials` containing credential ID, public key, sign count, label, transports, backup state, and device type. Public key material never leaves the server in management responses. -5. **Passkey Reset** - local operator recovery via `npm run auth:reset-passkeys -- --confirm`. It clears registered passkeys, pending auth, WebAuthn challenges, and browser sessions so the next password login returns to setup mode. +5. **Recent Authentication** - the authenticated session's `authenticated_at` is within ten minutes. Required for password, passkey, recovery-code, auth-mode, and powerful API-token changes. +6. **Recovery or Operator Reset** - one offline recovery code can replace credentials in-app; the local `npm run auth:reset-passkeys -- --confirm` path remains the last-resort operator reset. Ownership is database-backed in the singleton `ea_owner` row. Fresh claims rely on the singleton primary-key invariant so exactly one concurrent insert succeeds. @@ -399,7 +400,7 @@ fails closed for partial or conflicting state. Two credential paths exist, but they no longer feed a single shared "any auth works" guard: -1. **Cookie session** - normal dashboard access after password-only setup login or password plus passkey login. +1. **Cookie session** - normal dashboard access after password, passwordless passkey, strict password-plus-passkey, or successful recovery. 2. **Scoped API token** - `Authorization: Bearer ` validated against `ea_api_tokens` (token hash, scopes, expiry). Used only by explicitly opted-in external integration endpoints (currently `POST /api/briefing/actual/quick-txn`). New tokens expire by default after 90 days unless overridden by env. Bearer requests are exempt from the `x-requested-with` CSRF check because they carry their own unforgeable secret. Production WebAuthn configuration is explicit and fail-fast: `EA_WEBAUTHN_RP_NAME`, `EA_WEBAUTHN_RP_ID`, and `EA_WEBAUTHN_ORIGIN` are required when `NODE_ENV=production`. Development defaults are `Setpoint`, `localhost`, and `http://localhost:5173`. @@ -652,12 +653,13 @@ erDiagram | `ea_news_sources` | `026_news.sql`, `029_news_retry_after.sql` | | `ea_news_topics` | `026_news.sql`, `027_news_mute_terms.sql` | | `ea_notes` | `001_ea_tables.sql`, `021_notes_archive.sql` | -| `ea_owner` | `030_owner_bootstrap.sql` | +| `ea_owner` | `030_owner_bootstrap.sql`, `031_auth_recovery.sql` | +| `ea_owner_recovery_codes` | `031_auth_recovery.sql` | | `ea_passkey_credentials` | `012_passkey_auth.sql` | | `ea_pending_auth` | `012_passkey_auth.sql` | | `ea_pinned_emails` | `022_pinned_emails.sql`, `023_pinned_emails_rebuild.sql` | | `ea_reminders` | `010_discord_reminders.sql` | -| `ea_sessions` | `001_ea_tables.sql` | +| `ea_sessions` | `001_ea_tables.sql`, `031_auth_recovery.sql` | | `ea_settings` | `001_ea_tables.sql`, `003_triage_sound_settings.sql`, `008_bill_pay_mappings.sql`, `010_discord_reminders.sql`, `020_utility_pay_links.sql`, `026_news.sql`, `028_provider_needs_reauth.sql` | | `ea_snoozed_emails` | `001_ea_tables.sql` | | `ea_todoist_items` | `001_ea_tables.sql` | @@ -740,6 +742,11 @@ The structural route table below is regenerated from `server/index.ts` and `serv | DELETE | `/api/auth/passkeys/:credentialId` | `server/routes/auth.ts` | | POST | `/api/auth/passkeys/registration/options` | `server/routes/auth.ts` | | POST | `/api/auth/passkeys/registration/verify` | `server/routes/auth.ts` | +| POST | `/api/auth/recovery` | `server/routes/auth.ts` | +| POST | `/api/auth/recovery-codes/regenerate` | `server/routes/auth.ts` | +| PATCH | `/api/auth/security/auth-mode` | `server/routes/auth.ts` | +| POST | `/api/auth/security/password` | `server/routes/auth.ts` | +| POST | `/api/auth/security/step-up/password` | `server/routes/auth.ts` | | POST | `/api/auth/setup/claim` | `server/routes/auth.ts` | | GET | `/api/auth/setup/status` | `server/routes/auth.ts` | | GET | `/api/briefing/actual/accounts` | `server/routes/briefing/bills.ts` | @@ -828,14 +835,19 @@ The structural route table below is regenerated from `server/index.ts` and `serv | Method | Path | Auth | Purpose | |--------|------|------|---------| -| POST | `/api/auth/login` | No | Password login. Creates `ea_session` only when no passkeys exist; otherwise creates pending password auth | -| POST | `/api/auth/passkey/authentication/options` | Pending password auth | Create passkey authentication challenge | -| POST | `/api/auth/passkey/authentication/verify` | Pending password auth | Verify passkey assertion and issue `ea_session` | +| POST | `/api/auth/login` | No | Password login; issues a session in default mode or pending auth in strict mode | +| POST | `/api/auth/passkey/authentication/options` | No or pending password auth | Start default-mode passwordless passkey or continue strict login | +| POST | `/api/auth/passkey/authentication/verify` | Pending passkey auth | Verify passkey assertion and issue `ea_session` | | POST | `/api/auth/passkey/authentication/cancel` | Pending password auth | Cancel pending password auth and clear challenges | | GET | `/api/auth/passkeys` | Cookie | List registered passkey metadata | -| POST | `/api/auth/passkeys/registration/options` | Cookie | Create passkey registration challenge | -| POST | `/api/auth/passkeys/registration/verify` | Cookie | Verify and store registered passkey | -| DELETE | `/api/auth/passkeys/:credentialId` | Cookie | Delete one registered passkey and rotate browser sessions | +| POST | `/api/auth/passkeys/registration/options` | Recent cookie | Create passkey registration challenge | +| POST | `/api/auth/passkeys/registration/verify` | Recent cookie | Verify and store registered passkey | +| DELETE | `/api/auth/passkeys/:credentialId` | Recent cookie | Delete one registered passkey and rotate browser sessions | +| POST | `/api/auth/security/step-up/password` | Cookie | Refresh recent-auth state after password confirmation | +| PATCH | `/api/auth/security/auth-mode` | Recent cookie | Explicitly change password-or-passkey vs. strict mode | +| POST | `/api/auth/security/password` | Recent cookie | Replace the owner password and rotate sessions | +| POST | `/api/auth/recovery-codes/regenerate` | Recent cookie | Replace and reveal offline recovery codes once | +| POST | `/api/auth/recovery` | No | Consume one recovery code and establish replacement credentials | | GET | `/api/auth/check` | Cookie | Session validation | | POST | `/api/auth/logout` | Cookie | Destroy session | diff --git a/FLOWS.md b/FLOWS.md index 6b9231dc..97c24302 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -159,10 +159,19 @@ Selection path: 1. `src/pages/OwnerSetup.tsx` — confirms the password locally and sends only the write-only password to `POST /api/auth/setup/claim`. 2. `server/auth/owner-claim-service.ts:claimInitialOwner` — rate-limited route work generates a stable UUID and bcrypt hash. 3. `server/auth/owner-store.ts:claimOwner` — `INSERT OR IGNORE` against singleton key `1`; the uniqueness invariant admits one concurrent claimant and all others receive the fixed conflict. -4. `server/middleware/auth.ts:createSession` — persists only the hashed session token; the successful browser receives the raw token in an HttpOnly cookie. -5. `server/auth/owner-context.ts:activateOwner` — exposes the claimed ID to remaining single-owner runtime modules and notifies startup gating. -6. `server/auth/owner-runtime.ts:createOwnerRuntimeGate` — starts schedulers and provider workers once, only after a stored or newly claimed owner exists. +4. `server/auth/recovery-code-store.ts:replaceRecoveryCodes` — generates eight high-entropy offline recovery codes, persists only SHA-256 hashes, and returns plaintext only in the successful claim response. +5. `server/middleware/auth.ts:createSession` — persists only the hashed session token plus its recent-auth timestamp; the successful browser receives the raw token in an HttpOnly cookie. +6. `server/auth/owner-context.ts:activateOwner` — exposes the claimed ID to remaining single-owner runtime modules and notifies startup gating. +7. `server/auth/owner-runtime.ts:createOwnerRuntimeGate` — starts schedulers and provider workers once, only after a stored or newly claimed owner exists. **Compatibility:** `server/auth/owner-bootstrap.ts:resolveOwnerBootstrap` runs after migrations and before listen. It imports an exact legacy `EA_USER_ID`/`EA_PASSWORD_HASH` pair into `ea_owner`, preserves the bcrypt hash and ID, and fails closed for partial or conflicting state. **Pre-claim boundary:** `server/middleware/owner-gate.ts` returns a fixed setup-required response for non-setup APIs. `GET /healthz` remains successful and reports only readiness plus the non-secret claimed boolean. Demo mode resolves setup as already claimed and rejects claim mutations locally without a network call. + +## 8. Owner sign-in, step-up, and offline recovery + +**Normal mode:** `ea_owner.auth_mode = password_or_passkey`. A valid password issues a session directly. Passkey options may instead create a short-lived `ea_pending_auth` binding, and successful WebAuthn verification consumes its challenge before issuing the same session type. Registering a passkey does not change this mode. + +**Strict mode:** the owner explicitly changes `auth_mode` to `password_plus_passkey` through a recent-auth-protected Security action. Password login then creates pending auth and WebAuthn completes the session. Mode, password, passkey, recovery-code, and powerful API-token mutations require `ea_sessions.authenticated_at` to be within ten minutes. + +**Recovery:** `POST /api/auth/recovery` rate-limits and atomically consumes one unused recovery-code hash. Success replaces the password, returns mode to password-or-passkey, clears passkeys, pending auth, WebAuthn challenges, and prior sessions, issues a fresh session, and returns a newly generated recovery-code set exactly once. diff --git a/README.md b/README.md index c6d37948..d1d9aa51 100644 --- a/README.md +++ b/README.md @@ -127,20 +127,24 @@ Existing installations may keep `EA_USER_ID` and `EA_PASSWORD_HASH`; startup imports that exact legacy identity once. Partial or conflicting legacy auth configuration fails closed instead of reopening public setup. -The private app uses a dashboard password plus WebAuthn passkeys. If no -registered passkey exists, a valid password creates an authenticated browser -session and Settings -> System shows setup mode. After the first passkey is -registered, future password login creates a short-lived pending password -authentication and the browser must complete passkey authentication before the -server issues the `ea_session` cookie. +The private app accepts either the owner password or a registered WebAuthn +passkey by default. Registering a passkey does not disable password login. +Settings -> System can explicitly enable strict password-plus-passkey login; +identity and access changes require a password confirmation from the last ten +minutes. + +Fresh owner claim displays eight one-time offline recovery codes. Setpoint +stores only their hashes and never returns them through normal Settings reads. +Using one code replaces the owner password, clears passkeys and pending auth, +revokes prior sessions, and displays a replacement recovery-code set once. Production startup fails fast unless `EA_WEBAUTHN_RP_NAME`, `EA_WEBAUTHN_RP_ID`, and `EA_WEBAUTHN_ORIGIN` are set. `EA_WEBAUTHN_RP_ID` is the hostname only, not a URL. `EA_WEBAUTHN_ORIGIN` must be the HTTPS origin served to the browser and must match the RP ID hostname. -If all passkeys are lost, use the local operator reset script against the -intended database: +If both normal sign-in and offline recovery are unavailable, use the local +operator reset script against the intended database: ```bash npm run auth:reset-passkeys -- --dry-run @@ -148,8 +152,8 @@ npm run auth:reset-passkeys -- --confirm ``` The reset clears registered passkeys, pending password-auth attempts, WebAuthn -challenges, and browser sessions. The next successful password login returns -the dashboard to passkey setup mode. Scoped API tokens are separate automation +challenges, and browser sessions. The next successful password login uses the +default password-or-passkey mode. Scoped API tokens are separate automation credentials and do not grant dashboard login. ### Opt-in Turso semantic search verification diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 93f1b700..bd2f6ecf 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -19,6 +19,8 @@ Composition root and cross-cutting server concerns that don't belong to a single ### `auth/` — passkey/WebAuthn and session support - `auth/passkey-store.ts` — CRUD for stored passkey credentials +- `auth/auth-mode.ts` — explicit password-or-passkey vs. strict password-plus-passkey resolution +- `auth/recovery-code-store.ts` — high-entropy recovery-code generation, hashing, replacement, status, and atomic consumption - `auth/pending-auth-store.ts` — short-lived pending-auth token issuance/lookup (WebAuthn ceremony handoff) - `auth/session-rotation.ts` — bulk session revocation (e.g. on passkey changes), clears the auth validation cache - `auth/owner-store.ts` — singleton owner persistence and atomic claim invariant diff --git a/server/auth/auth-mode.test.ts b/server/auth/auth-mode.test.ts new file mode 100644 index 00000000..84b9dcbb --- /dev/null +++ b/server/auth/auth-mode.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolvePasswordLogin } from "./auth-mode.ts"; + +describe("owner authentication mode", () => { + it("keeps password login complete by default even when passkeys exist", () => { + expect(resolvePasswordLogin("password_or_passkey", 2)).toEqual({ + authenticated: true, + passkeyRequired: false, + }); + }); + + it("requires a registered passkey only in explicit strict mode", () => { + expect(resolvePasswordLogin("password_plus_passkey", 1)).toEqual({ + authenticated: false, + passkeyRequired: true, + }); + expect(resolvePasswordLogin("password_plus_passkey", 0)).toEqual({ + authenticated: false, + passkeyRequired: false, + configurationError: true, + }); + }); +}); diff --git a/server/auth/auth-mode.ts b/server/auth/auth-mode.ts new file mode 100644 index 00000000..f30e5de8 --- /dev/null +++ b/server/auth/auth-mode.ts @@ -0,0 +1,20 @@ +export const OWNER_AUTH_MODES = ["password_or_passkey", "password_plus_passkey"] as const; +export type OwnerAuthMode = typeof OWNER_AUTH_MODES[number]; + +export function isOwnerAuthMode(value: unknown): value is OwnerAuthMode { + return typeof value === "string" && OWNER_AUTH_MODES.includes(value as OwnerAuthMode); +} + +export function resolvePasswordLogin(mode: OwnerAuthMode, passkeyCount: number) { + if (mode === "password_or_passkey") { + return { authenticated: true as const, passkeyRequired: false as const }; + } + if (passkeyCount > 0) { + return { authenticated: false as const, passkeyRequired: true as const }; + } + return { + authenticated: false as const, + passkeyRequired: false as const, + configurationError: true as const, + }; +} diff --git a/server/auth/owner-bootstrap.test.ts b/server/auth/owner-bootstrap.test.ts index 9353c9fa..ef5c3aa9 100644 --- a/server/auth/owner-bootstrap.test.ts +++ b/server/auth/owner-bootstrap.test.ts @@ -14,8 +14,9 @@ describe("owner bootstrap", () => { beforeEach(async () => { db = createClient({ url: "file::memory:" }); - const sql = readFileSync(join(__dirname, "../db/migrations/030_owner_bootstrap.sql"), "utf8"); - await db.executeMultiple(sql); + for (const migration of ["001_ea_tables.sql", "030_owner_bootstrap.sql", "031_auth_recovery.sql"]) { + await db.executeMultiple(readFileSync(join(__dirname, `../db/migrations/${migration}`), "utf8")); + } }); afterEach(() => db.close()); diff --git a/server/auth/owner-claim-service.ts b/server/auth/owner-claim-service.ts index 0a04af7d..52f2ce28 100644 --- a/server/auth/owner-claim-service.ts +++ b/server/auth/owner-claim-service.ts @@ -9,6 +9,7 @@ interface OwnerClaimStore { userId: string; passwordHash: string; claimedAt: number; + recoveryCodeHashes?: string[]; }): Promise<{ claimed: boolean }>; } @@ -18,6 +19,7 @@ interface ClaimOwnerOptions { createUserId?: () => string; hashPassword?: (password: string) => Promise; onClaimed?: (owner: OwnerRecord) => void; + recoveryCodeHashes?: string[]; } export type InitialOwnerClaimResult = @@ -33,6 +35,7 @@ export async function claimInitialOwner( createUserId = crypto.randomUUID, hashPassword = (value) => bcrypt.hash(value, 12), onClaimed = activateOwner, + recoveryCodeHashes = [], }: ClaimOwnerOptions = {}, ): Promise { if (typeof password !== "string" || password.length === 0 || password.length > 1024) { @@ -44,6 +47,7 @@ export async function claimInitialOwner( userId: createUserId(), passwordHash: await hashPassword(password), claimedAt: now(), + recoveryCodeHashes, }; const result = await store.claimOwner(input); if (!result.claimed) return { status: "conflict" }; diff --git a/server/auth/owner-store.test.ts b/server/auth/owner-store.test.ts index 495d0f66..052acb3e 100644 --- a/server/auth/owner-store.test.ts +++ b/server/auth/owner-store.test.ts @@ -12,8 +12,21 @@ describe("owner store", () => { beforeEach(async () => { db = createClient({ url: "file::memory:" }); - const sql = readFileSync(join(__dirname, "../db/migrations/030_owner_bootstrap.sql"), "utf8"); - await db.executeMultiple(sql); + for (const migration of ["001_ea_tables.sql", "030_owner_bootstrap.sql", "031_auth_recovery.sql"]) { + await db.executeMultiple(readFileSync(join(__dirname, `../db/migrations/${migration}`), "utf8")); + } + }); + + it("defaults to password-or-passkey and updates security fields explicitly", async () => { + const store = createOwnerStore(db); + await store.claimOwner({ userId: "owner-a", passwordHash: "hash-a", claimedAt: 100 }); + + await expect(store.setAuthMode("owner-a", "password_plus_passkey")).resolves.toBe(true); + await expect(store.updatePasswordHash("owner-a", "hash-b")).resolves.toBe(true); + await expect(store.getOwner()).resolves.toMatchObject({ + authMode: "password_plus_passkey", + passwordHash: "hash-b", + }); }); afterEach(() => db.close()); @@ -39,6 +52,19 @@ describe("owner store", () => { expect(["owner-a", "owner-b"]).toContain(owner?.userId); }); + it("persists initial recovery hashes in the same winning claim transaction", async () => { + const store = createOwnerStore(db); + await expect(store.claimOwner({ + userId: "owner-a", + passwordHash: "hash-a", + claimedAt: 100, + recoveryCodeHashes: ["sha256:first", "sha256:second"], + })).resolves.toEqual({ claimed: true }); + + const rows = await db.execute("SELECT code_hash FROM ea_owner_recovery_codes ORDER BY code_hash"); + expect(rows.rows.map((row) => row.code_hash)).toEqual(["sha256:first", "sha256:second"]); + }); + it("never mutates the owner after the singleton is claimed", async () => { const store = createOwnerStore(db); await store.claimOwner({ userId: "owner-a", passwordHash: "hash-a", claimedAt: 100 }); diff --git a/server/auth/owner-store.ts b/server/auth/owner-store.ts index 24871051..f4ccd862 100644 --- a/server/auth/owner-store.ts +++ b/server/auth/owner-store.ts @@ -1,5 +1,6 @@ import db from "../db/connection.ts"; import type { Client } from "@libsql/client"; +import { isOwnerAuthMode, type OwnerAuthMode } from "./auth-mode.ts"; const OWNER_SINGLETON_ID = 1; @@ -7,6 +8,7 @@ export interface OwnerRecord { singletonId: 1; userId: string; passwordHash: string; + authMode: OwnerAuthMode; claimedAt: number; } @@ -14,9 +16,10 @@ export interface OwnerClaimInput { userId: string; passwordHash: string; claimedAt: number; + recoveryCodeHashes?: string[]; } -type OwnerStoreDb = Pick; +type OwnerStoreDb = Pick; function stringValue(value: unknown): string { return typeof value === "string" ? value : String(value ?? ""); @@ -29,7 +32,7 @@ function numberValue(value: unknown): number { export function createOwnerStore(dbClient: OwnerStoreDb = db) { async function getOwner(): Promise { const result = await dbClient.execute({ - sql: `SELECT singleton_id, user_id, password_hash, claimed_at + sql: `SELECT singleton_id, user_id, password_hash, auth_mode, claimed_at FROM ea_owner WHERE singleton_id = ?`, args: [OWNER_SINGLETON_ID], @@ -40,11 +43,29 @@ export function createOwnerStore(dbClient: OwnerStoreDb = db) { singletonId: 1, userId: stringValue(row.user_id), passwordHash: stringValue(row.password_hash), + authMode: isOwnerAuthMode(row.auth_mode) ? row.auth_mode : "password_or_passkey", claimedAt: numberValue(row.claimed_at), }; } async function claimOwner(input: OwnerClaimInput): Promise<{ claimed: boolean }> { + if (input.recoveryCodeHashes?.length) { + const results = await dbClient.batch([ + { + sql: `INSERT OR IGNORE INTO ea_owner + (singleton_id, user_id, password_hash, claimed_at) + VALUES (?, ?, ?, ?)`, + args: [OWNER_SINGLETON_ID, input.userId, input.passwordHash, input.claimedAt], + }, + ...input.recoveryCodeHashes.map((codeHash) => ({ + sql: `INSERT INTO ea_owner_recovery_codes (user_id, code_hash, generated_at) + SELECT ?, ?, ? + WHERE EXISTS (SELECT 1 FROM ea_owner WHERE singleton_id = ? AND user_id = ?)`, + args: [input.userId, codeHash, input.claimedAt, OWNER_SINGLETON_ID, input.userId], + })), + ], "write"); + return { claimed: results[0]?.rowsAffected === 1 }; + } const result = await dbClient.execute({ sql: `INSERT OR IGNORE INTO ea_owner (singleton_id, user_id, password_hash, claimed_at) @@ -54,9 +75,27 @@ export function createOwnerStore(dbClient: OwnerStoreDb = db) { return { claimed: result.rowsAffected === 1 }; } - return { getOwner, claimOwner }; + async function setAuthMode(userId: string, authMode: OwnerAuthMode): Promise { + const result = await dbClient.execute({ + sql: "UPDATE ea_owner SET auth_mode = ? WHERE singleton_id = ? AND user_id = ?", + args: [authMode, OWNER_SINGLETON_ID, userId], + }); + return result.rowsAffected === 1; + } + + async function updatePasswordHash(userId: string, passwordHash: string): Promise { + const result = await dbClient.execute({ + sql: "UPDATE ea_owner SET password_hash = ? WHERE singleton_id = ? AND user_id = ?", + args: [passwordHash, OWNER_SINGLETON_ID, userId], + }); + return result.rowsAffected === 1; + } + + return { getOwner, claimOwner, setAuthMode, updatePasswordHash }; } export const ownerStore = createOwnerStore(); export const getOwner = ownerStore.getOwner; export const claimOwner = ownerStore.claimOwner; +export const setOwnerAuthMode = ownerStore.setAuthMode; +export const updateOwnerPasswordHash = ownerStore.updatePasswordHash; diff --git a/server/auth/recovery-code-store.test.ts b/server/auth/recovery-code-store.test.ts new file mode 100644 index 00000000..a31097da --- /dev/null +++ b/server/auth/recovery-code-store.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Client } from "@libsql/client"; +import { createAuthTestDb, seedOwner } from "../test-utils/auth-db.ts"; +import { + createRecoveryCodeStore, + generateRecoveryCodes, + hashRecoveryCode, +} from "./recovery-code-store.ts"; + +describe("recovery codes", () => { + let db: Client; + + beforeEach(async () => { + db = await createAuthTestDb(); + await seedOwner(db, { passwordHash: "bcrypt-hash" }); + }); + + afterEach(() => db.close()); + + it("generates unique high-entropy codes and stores only their hashes", async () => { + const codes = generateRecoveryCodes(); + expect(codes).toHaveLength(8); + expect(new Set(codes).size).toBe(8); + expect(codes.every((code) => /^SP(?:-[A-F0-9]{4}){8}$/.test(code))).toBe(true); + + const store = createRecoveryCodeStore(db); + await store.replaceRecoveryCodes("user-1", codes, 100); + const result = await db.execute("SELECT code_hash FROM ea_owner_recovery_codes"); + expect(result.rows.map((row) => row.code_hash)).toContain(hashRecoveryCode(codes[0])); + expect(JSON.stringify(result.rows)).not.toContain(codes[0]); + }); + + it("allows exactly one concurrent consumption and rejects replay", async () => { + const code = generateRecoveryCodes()[0]!; + const store = createRecoveryCodeStore(db); + await store.replaceRecoveryCodes("user-1", [code], 100); + + const results = await Promise.all([ + store.consumeRecoveryCode("user-1", code, 200), + store.consumeRecoveryCode("user-1", code, 201), + ]); + expect(results.sort()).toEqual([false, true]); + await expect(store.consumeRecoveryCode("user-1", code, 202)).resolves.toBe(false); + await expect(store.getRecoveryCodeStatus("user-1")).resolves.toEqual({ + remaining: 0, + generatedAt: 100, + }); + }); +}); diff --git a/server/auth/recovery-code-store.ts b/server/auth/recovery-code-store.ts new file mode 100644 index 00000000..389b5c92 --- /dev/null +++ b/server/auth/recovery-code-store.ts @@ -0,0 +1,67 @@ +import crypto from "crypto"; +import db from "../db/connection.ts"; +import type { Client } from "@libsql/client"; + +export const RECOVERY_CODE_COUNT = 8; + +function normalizeRecoveryCode(value: unknown): string { + return String(value || "").trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); +} + +export function hashRecoveryCode(code: unknown): string { + return `sha256:${crypto.createHash("sha256").update(normalizeRecoveryCode(code)).digest("hex")}`; +} + +export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT): string[] { + return Array.from({ length: count }, () => { + const groups = crypto.randomBytes(16).toString("hex").toUpperCase().match(/.{4}/g) || []; + return `SP-${groups.join("-")}`; + }); +} + +export function createRecoveryCodeStore(database: Client = db) { + async function replaceRecoveryCodes(userId: string, codes: string[], generatedAt = Date.now()) { + await database.batch([ + { sql: "DELETE FROM ea_owner_recovery_codes WHERE user_id = ?", args: [userId] }, + ...codes.map((code) => ({ + sql: `INSERT INTO ea_owner_recovery_codes + (user_id, code_hash, generated_at) + VALUES (?, ?, ?)`, + args: [userId, hashRecoveryCode(code), generatedAt], + })), + ], "write"); + } + + async function consumeRecoveryCode(userId: string, code: unknown, usedAt = Date.now()) { + if (!normalizeRecoveryCode(code)) return false; + const result = await database.execute({ + sql: `UPDATE ea_owner_recovery_codes + SET used_at = ? + WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, + args: [usedAt, userId, hashRecoveryCode(code)], + }); + return result.rowsAffected === 1; + } + + async function getRecoveryCodeStatus(userId: string) { + const result = await database.execute({ + sql: `SELECT COUNT(CASE WHEN used_at IS NULL THEN 1 END) AS remaining, + MAX(generated_at) AS generated_at + FROM ea_owner_recovery_codes + WHERE user_id = ?`, + args: [userId], + }); + const row = result.rows[0]; + return { + remaining: Number(row?.remaining || 0), + generatedAt: row?.generated_at == null ? null : Number(row.generated_at), + }; + } + + return { replaceRecoveryCodes, consumeRecoveryCode, getRecoveryCodeStatus }; +} + +const recoveryCodeStore = createRecoveryCodeStore(); +export const replaceRecoveryCodes = recoveryCodeStore.replaceRecoveryCodes; +export const consumeRecoveryCode = recoveryCodeStore.consumeRecoveryCode; +export const getRecoveryCodeStatus = recoveryCodeStore.getRecoveryCodeStatus; diff --git a/server/db/migrations.test.ts b/server/db/migrations.test.ts index 6beab3a8..139f099c 100644 --- a/server/db/migrations.test.ts +++ b/server/db/migrations.test.ts @@ -238,6 +238,30 @@ describe("database migrations", () => { expect(pendingIndex.rows.map((row) => row.name)).toEqual(["user_id", "expires_at"]); }); + it("adds explicit auth mode, recent-auth state, and hashed recovery storage", async () => { + db = createClient({ url: "file::memory:" }); + await applyMigrations(db, [ + "001_ea_tables.sql", + "030_owner_bootstrap.sql", + "031_auth_recovery.sql", + ]); + + const ownerColumns = await db.execute("PRAGMA table_info('ea_owner')"); + const ownerByName = new Map(ownerColumns.rows.map((row) => [row.name, row])); + expect(ownerByName.get("auth_mode")!.notnull).toBe(1); + expect(ownerByName.get("auth_mode")!.dflt_value).toBe("'password_or_passkey'"); + + const sessionColumns = await db.execute("PRAGMA table_info('ea_sessions')"); + const sessionByName = new Map(sessionColumns.rows.map((row) => [row.name, row])); + expect(sessionByName.get("authenticated_at")!.notnull).toBe(1); + expect(sessionByName.get("authenticated_at")!.dflt_value).toBe("0"); + + const recoveryColumns = await db.execute("PRAGMA table_info('ea_owner_recovery_codes')"); + const recoveryByName = new Map(recoveryColumns.rows.map((row) => [row.name, row])); + expect(recoveryByName.get("code_hash")!.notnull).toBe(1); + expect(recoveryByName.get("used_at")!.type).toBe("INTEGER"); + }); + it("adds normalized email date storage for temporal search filters", async () => { db = createClient({ url: "file::memory:" }); await applyMigrations(db, [ diff --git a/server/db/migrations/031_auth_recovery.sql b/server/db/migrations/031_auth_recovery.sql new file mode 100644 index 00000000..b0e34865 --- /dev/null +++ b/server/db/migrations/031_auth_recovery.sql @@ -0,0 +1,17 @@ +ALTER TABLE ea_owner + ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'password_or_passkey' + CHECK (auth_mode IN ('password_or_passkey', 'password_plus_passkey')); + +ALTER TABLE ea_sessions + ADD COLUMN authenticated_at INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS ea_owner_recovery_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + code_hash TEXT NOT NULL UNIQUE, + generated_at INTEGER NOT NULL, + used_at INTEGER DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ea_owner_recovery_codes_user + ON ea_owner_recovery_codes(user_id, used_at, generated_at); diff --git a/server/middleware/CLAUDE.md b/server/middleware/CLAUDE.md index 4a84fd99..8d8d0dd9 100644 --- a/server/middleware/CLAUDE.md +++ b/server/middleware/CLAUDE.md @@ -5,7 +5,7 @@ Cross-cutting Express request-pipeline middleware composed in `server/index.ts`: ## Files - `async-handler.ts` — `asyncHandler` / `wrapRouterAsync` forward async route rejections to the terminal `errorHandler` (also here, a 4-arg error middleware honoring `err.status` and the `headersSent` guard). Express 4 does not catch async rejections, so an unwrapped rejecting handler hangs the request (P1-12). -- `auth.ts` — session + API-token authentication: `validateSession` / `createSession` / `deleteSession` (hashed cookie tokens, 30-day TTL, 30s positive-validation cache), `validateBearer` (scoped `ea_api_tokens`), and the route guards `requireCookieSession`, `requireApiTokenScope`, `requireCookieSessionOrApiTokenScope`. +- `auth.ts` — session + API-token authentication: hashed cookie tokens, recent-auth timestamps and guard, 30-day TTL, 30s positive-validation cache, scoped bearer tokens, and cookie/API-token route guards. - `compression.ts` — `responseCompression`, a streaming-safe gzip built on Node `zlib` (no dependency). Decides buffer-vs-passthrough on the first write/end by Content-Type, and deliberately never buffers `text/event-stream` (Alfred + dashboard SSE). - `rate-limits.ts` — per-route spend guards for LLM/paid-API routes (bills/extract, alfred run, email-search, places); each limiter is exported as both a `makeXLimiter()` factory (fresh, test-isolated instance) and a singleton built from it (used by real route wiring), since `express-rate-limit` tracks counts per-instance. - `owner-gate.ts` — blocks all non-setup APIs until the singleton owner has been claimed; returns a fixed setup-required response. diff --git a/server/middleware/auth.test.ts b/server/middleware/auth.test.ts index 71a4c0ca..fb5bb393 100644 --- a/server/middleware/auth.test.ts +++ b/server/middleware/auth.test.ts @@ -23,6 +23,8 @@ const { validateSession, deleteSession, validateBearer, + hasRecentAuth, + markSessionRecentlyAuthenticated, __clearSessionValidationCache, } = await import("./auth.ts"); @@ -71,6 +73,16 @@ describe("auth middleware session storage", () => { expect(result.rows[0]!.expires_at).toBeGreaterThan(before + 29 * 24 * 60 * 60 * 1000); }); + it("tracks recent authentication on the hashed session without exposing the token", async () => { + const token = await createSession({ authenticatedAt: 1_000 }); + + await expect(hasRecentAuth(token, { now: 1_000 + 9 * 60_000 })).resolves.toBe(true); + await expect(hasRecentAuth(token, { now: 1_000 + 11 * 60_000 })).resolves.toBe(false); + + await markSessionRecentlyAuthenticated(token, 20_000); + await expect(hasRecentAuth(token, { now: 20_001 })).resolves.toBe(true); + }); + it("validates hashed session rows", async () => { await seedSession(currentDb(), "cookie-session"); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 4e90d036..5cfda01d 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -3,6 +3,7 @@ import db from "../db/connection.ts"; import type { Request, RequestHandler } from "express"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +export const RECENT_AUTH_MAX_AGE_MS = 10 * 60 * 1000; const SESSION_TOKEN_PREFIX = "sha256:"; // P2-27: every authenticated /api request validates the session token, which in @@ -81,7 +82,7 @@ export async function deleteSession(token: string) { sessionValidationCache.delete(hashSessionToken(token)); } -export async function createSession() { +export async function createSession({ authenticatedAt = Date.now() }: { authenticatedAt?: number } = {}) { const token = crypto.randomBytes(32).toString("hex"); const expiresAt = Date.now() + SESSION_MAX_AGE_MS; // P3-18: expired sessions are otherwise never reclaimed (the lazy delete only @@ -93,12 +94,44 @@ export async function createSession() { args: [Date.now()], }); await db.execute({ - sql: "INSERT INTO ea_sessions (token, expires_at) VALUES (?, ?)", - args: [hashSessionToken(token), expiresAt], + sql: "INSERT INTO ea_sessions (token, expires_at, authenticated_at) VALUES (?, ?, ?)", + args: [hashSessionToken(token), expiresAt, authenticatedAt], }); return token; } +export async function hasRecentAuth( + token: string | null | undefined, + { now = Date.now(), maxAgeMs = RECENT_AUTH_MAX_AGE_MS }: { now?: number; maxAgeMs?: number } = {}, +): Promise { + if (!token) return false; + const result = await db.execute({ + sql: "SELECT expires_at, authenticated_at FROM ea_sessions WHERE token IN (?, ?)", + args: [hashSessionToken(token), token], + }); + const row = result.rows[0]; + if (!row) return false; + const expiresAt = numberValue(row.expires_at); + const authenticatedAt = numberValue(row.authenticated_at); + return Boolean( + expiresAt && expiresAt >= now + && authenticatedAt && authenticatedAt <= now + && now - authenticatedAt <= maxAgeMs, + ); +} + +export async function markSessionRecentlyAuthenticated( + token: string | null | undefined, + authenticatedAt = Date.now(), +): Promise { + if (!token) return false; + const result = await db.execute({ + sql: "UPDATE ea_sessions SET authenticated_at = ? WHERE token IN (?, ?)", + args: [authenticatedAt, hashSessionToken(token), token], + }); + return result.rowsAffected > 0; +} + export async function validateSession(token: string | null | undefined): Promise { if (!token) return false; const hashedToken = hashSessionToken(token); @@ -172,6 +205,24 @@ export const requireCookieSession: RequestHandler = async (req, res, next) => { } }; +export const requireRecentAuth: RequestHandler = async (req, res, next) => { + try { + const token = req.cookies?.ea_session; + if (!await validateSession(token)) { + return res.status(401).json({ message: "Not authenticated" }); + } + if (!await hasRecentAuth(token)) { + return res.status(403).json({ + code: "STEP_UP_REQUIRED", + message: "Confirm your password or passkey to continue", + }); + } + return next(); + } catch (err) { + return next(err); + } +}; + export function requireApiTokenScope(requiredScope: string): RequestHandler { return async function requireScopedApiToken(req, res, next) { try { diff --git a/server/middleware/owner-gate.test.ts b/server/middleware/owner-gate.test.ts index 12400db7..9a648a1f 100644 --- a/server/middleware/owner-gate.test.ts +++ b/server/middleware/owner-gate.test.ts @@ -33,6 +33,7 @@ describe("claimed-instance API gate", () => { singletonId: 1, userId: "owner-1", passwordHash: "not-exposed", + authMode: "password_or_passkey", claimedAt: 1, }); diff --git a/server/routes/CLAUDE.md b/server/routes/CLAUDE.md index 0387ac77..e75b3bc2 100644 --- a/server/routes/CLAUDE.md +++ b/server/routes/CLAUDE.md @@ -5,7 +5,7 @@ The HTTP surface: Express routers that validate input, apply auth, and delegate ## Files ### Auth + accounts -- `auth.ts` — login, passkey registration, WebAuthn, session management +- `auth.ts` — owner claim, password/passkey login, recent-auth step-up, offline recovery, passkey/session management - `accounts.ts` — Gmail OAuth callback and account binding; mounts settings/reminders routers ### Briefing diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index b07d646a..1e155e90 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -13,6 +13,7 @@ import { createAuthTestDb, hashApiToken, hashSessionToken, seedOwner, seedSessio import { createPasskeyStore } from "../auth/passkey-store.ts"; import { createPendingAuthStore, hashPendingAuthToken } from "../auth/pending-auth-store.ts"; import { createWebAuthnChallengeStore } from "../auth/webauthn-challenge-store.ts"; +import { createRecoveryCodeStore } from "../auth/recovery-code-store.ts"; import { errorHandler } from "../middleware/async-handler.ts"; const testState = vi.hoisted<{ db: { current: Client | null } }>(() => ({ @@ -130,7 +131,12 @@ describe("auth routes", () => { ); expect(res.status).toBe(200); - expect(res.body).toEqual({ authenticated: true, claimed: true }); + expect(res.body).toMatchObject({ + authenticated: true, + claimed: true, + recoveryCodes: expect.arrayContaining([expect.stringMatching(/^SP-/)]), + }); + expect(res.body.recoveryCodes).toHaveLength(8); expect(setCookieHeader(res)).toContain("ea_session="); expect(ownerResult.rows).toHaveLength(1); expect(ownerResult.rows[0]!.user_id).toMatch(/^[0-9a-f-]{36}$/); @@ -172,7 +178,7 @@ describe("auth routes", () => { }); it("mints API tokens with a default expiry", async () => { - await seedSession(currentDb(), "cookie-session"); + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); const before = Date.now(); const res = await request(makeApp()) @@ -201,7 +207,7 @@ describe("auth routes", () => { }); it("does not let unauthenticated token-mint attempts consume the rate-limit budget", async () => { - await seedSession(currentDb(), "cookie-session"); + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); const app = makeApp(); // Fire more unauthenticated mint attempts than the 5/15min budget. With auth ahead of the @@ -276,8 +282,9 @@ describe("auth routes", () => { expect(res.status).toBe(500); }, 3000); - it("creates only pending auth when passkeys exist", async () => { + it("creates only pending auth when strict mode is explicitly enabled", async () => { await seedPasskey(); + await currentDb().execute("UPDATE ea_owner SET auth_mode = 'password_plus_passkey'"); const res = await request(makeApp()) .post("/api/auth/login") @@ -298,6 +305,18 @@ describe("auth routes", () => { expect(setCookieHeader(res)).toContain("ea_session=;"); }); + it("starts passwordless passkey authentication in the default mode", async () => { + await seedPasskey(); + + const res = await request(makeApp()) + .post("/api/auth/passkey/authentication/options"); + + expect(res.status).toBe(200); + expect(setCookieHeader(res)).toContain("ea_pending_auth="); + const pending = await currentDb().execute("SELECT user_id FROM ea_pending_auth"); + expect(pending.rows).toEqual([{ user_id: "user-1" }]); + }); + it("returns passkey authentication options from pending auth", async () => { await seedPasskey(); await createPendingAuthStore(currentDb()).createPendingAuth({ @@ -492,7 +511,7 @@ describe("auth routes", () => { }); it("lists registered passkeys with safe metadata only", async () => { - await seedSession(currentDb(), "cookie-session"); + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); await seedPasskey(); const res = await request(makeApp()) @@ -501,7 +520,8 @@ describe("auth routes", () => { expect(res.status).toBe(200); expect(res.body).toMatchObject({ - enforcementActive: true, + enforcementActive: false, + authMode: "password_or_passkey", passkeys: [ { credentialId: "credential-1", @@ -531,7 +551,7 @@ describe("auth routes", () => { }); it("returns passkey registration options for an authenticated session", async () => { - await seedSession(currentDb(), "cookie-session"); + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); await seedPasskey(); const res = await request(makeApp()) @@ -562,7 +582,7 @@ describe("auth routes", () => { }); it("uses the local request origin for development passkey registration options", async () => { - await seedSession(currentDb(), "cookie-session"); + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); const res = await request(makeApp()) .post("/api/auth/passkeys/registration/options") @@ -577,8 +597,8 @@ describe("auth routes", () => { expect(res.body.rp).toMatchObject({ id: "127.0.0.1" }); }); - it("verifies first passkey registration and rotates old password-only sessions", async () => { - await seedSession(currentDb(), "cookie-session"); + it("verifies first passkey registration without silently enabling strict mode", async () => { + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); await createWebAuthnChallengeStore(currentDb()).createChallenge({ userId: "user-1", challengeType: "registration", @@ -624,13 +644,16 @@ describe("auth routes", () => { }); expect(res.body.passkey).not.toHaveProperty("publicKey"); expect(passkey!.publicKey).toBe(Buffer.from([1, 2, 3]).toString("base64url")); + expect(res.body).toMatchObject({ + enforcementActive: false, + authMode: "password_or_passkey", + }); expect(sessions.rows).toHaveLength(1); - expect(oldSession.rows).toHaveLength(0); - expect(setCookieHeader(res)).toContain("ea_session="); + expect(oldSession.rows).toHaveLength(1); }); it("deletes individual passkeys with session rotation and allows final deletion", async () => { - await seedSession(currentDb(), "cookie-session"); + await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); await seedPasskey(); const deleteRes = await request(makeApp()) @@ -651,6 +674,9 @@ describe("auth routes", () => { expect(deleteRes.body).toEqual({ success: true, enforcementActive: false, + authMode: "password_or_passkey", + recentAuth: true, + recovery: { remaining: 0, generatedAt: null }, passkeys: [], }); expect(remaining).toHaveLength(0); @@ -663,6 +689,89 @@ describe("auth routes", () => { passkeySetupRecommended: true, }); }); + + it("requires recent authentication before enabling explicit strict mode", async () => { + await seedSession(currentDb(), "cookie-session"); + await seedPasskey(); + + const blocked = await request(makeApp()) + .patch("/api/auth/security/auth-mode") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ authMode: "password_plus_passkey" }); + expect(blocked.status).toBe(403); + expect(blocked.body).toMatchObject({ code: "STEP_UP_REQUIRED" }); + + const stepUp = await request(makeApp()) + .post("/api/auth/security/step-up/password") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ password: "correct-password" }); + expect(stepUp.status).toBe(200); + + const enabled = await request(makeApp()) + .patch("/api/auth/security/auth-mode") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ authMode: "password_plus_passkey" }); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ authMode: "password_plus_passkey" }); + const owner = await currentDb().execute("SELECT auth_mode FROM ea_owner"); + expect(owner.rows[0]!.auth_mode).toBe("password_plus_passkey"); + }); + + it("changes the owner password only with recent auth and rotates prior sessions", async () => { + await seedSession(currentDb(), "current-session", Date.now() + 60_000, Date.now()); + await seedSession(currentDb(), "other-session", Date.now() + 60_000, Date.now()); + + const changed = await request(makeApp()) + .post("/api/auth/security/password") + .set("Cookie", ["ea_session=current-session"]) + .send({ newPassword: "replacement-password" }); + + expect(changed.status).toBe(200); + expect(setCookieHeader(changed)).toContain("ea_session="); + expect((await currentDb().execute({ + sql: "SELECT * FROM ea_sessions WHERE token = ?", + args: [hashSessionToken("other-session")], + })).rows).toEqual([]); + const login = await request(makeApp()) + .post("/api/auth/login") + .send({ password: "replacement-password" }); + expect(login.status).toBe(200); + expect(login.body.authenticated).toBe(true); + }); + + it("consumes a recovery code once, resets credentials, and revokes prior auth state", async () => { + const recoveryCode = "SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222"; + await createRecoveryCodeStore(currentDb()).replaceRecoveryCodes("user-1", [recoveryCode], 100); + await seedSession(currentDb(), "old-session", Date.now() + 60_000, Date.now()); + await seedPasskey(); + await currentDb().execute("UPDATE ea_owner SET auth_mode = 'password_plus_passkey'"); + await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token" }); + + const recovered = await request(makeApp()) + .post("/api/auth/recovery") + .send({ recoveryCode, newPassword: "replacement-password" }); + + expect(recovered.status).toBe(200); + expect(recovered.body).toMatchObject({ + authenticated: true, + recoveryCodes: expect.arrayContaining([expect.stringMatching(/^SP-/)]), + }); + expect(recovered.body.recoveryCodes).toHaveLength(8); + const owner = await currentDb().execute("SELECT password_hash, auth_mode FROM ea_owner"); + expect(await bcrypt.compare("replacement-password", String(owner.rows[0]!.password_hash))).toBe(true); + expect(owner.rows[0]!.auth_mode).toBe("password_or_passkey"); + await expect(createPasskeyStore(currentDb()).listPasskeys("user-1")).resolves.toEqual([]); + expect((await currentDb().execute("SELECT * FROM ea_pending_auth")).rows).toEqual([]); + expect((await currentDb().execute({ + sql: "SELECT * FROM ea_sessions WHERE token = ?", + args: [hashSessionToken("old-session")], + })).rows).toEqual([]); + + const replay = await request(makeApp()) + .post("/api/auth/recovery") + .send({ recoveryCode, newPassword: "attacker-password" }); + expect(replay.status).toBe(401); + }); }); async function seedPasskey() { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 9fcd804c..bb050538 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -8,6 +8,9 @@ import { validateSession, deleteSession, requireCookieSession, + requireRecentAuth, + hasRecentAuth, + markSessionRecentlyAuthenticated, } from "../middleware/auth.ts"; import db from "../db/connection.ts"; import { wrapRouterAsync } from "../middleware/async-handler.ts"; @@ -19,11 +22,13 @@ import { readPendingAuth, consumePendingAuth, deletePendingAuth, + clearPendingAuth, } from "../auth/pending-auth-store.ts"; import { createChallenge, consumeChallenge, deleteChallengesForPendingAuth, + clearChallenges, } from "../auth/webauthn-challenge-store.ts"; import { countPasskeys, @@ -42,9 +47,17 @@ import { verifyAuthenticationCredential, } from "../auth/webauthn-service.ts"; import { resolveWebAuthnConfig } from "../auth/webauthn-config.ts"; -import { rotateSessionsForCurrentBrowser } from "../auth/session-rotation.ts"; -import { getOwner } from "../auth/owner-store.ts"; +import { revokeAllSessions, rotateSessionsForCurrentBrowser } from "../auth/session-rotation.ts"; +import { getOwner, setOwnerAuthMode, updateOwnerPasswordHash } from "../auth/owner-store.ts"; import { claimInitialOwner } from "../auth/owner-claim-service.ts"; +import { resolvePasswordLogin, isOwnerAuthMode } from "../auth/auth-mode.ts"; +import { + consumeRecoveryCode, + generateRecoveryCodes, + getRecoveryCodeStatus, + hashRecoveryCode, + replaceRecoveryCodes, +} from "../auth/recovery-code-store.ts"; const router = Router(); // P1-12: forward async-handler rejections to the terminal errorHandler so a @@ -90,6 +103,14 @@ const ownerClaimLimiter = rateLimit({ legacyHeaders: false, }); +const recoveryLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + message: { message: "Too many recovery attempts, try again later" }, + standardHeaders: true, + legacyHeaders: false, +}); + function setSessionCookie(res: Response, token: string) { res.cookie("ea_session", token, { httpOnly: true, @@ -135,12 +156,24 @@ async function clearPendingAuthState(req: Request, res: Response) { clearPendingAuthCookie(res); } +function validPassword(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 1024; +} + +async function rotateAllAuthState() { + await Promise.all([clearPendingAuth(), clearChallenges()]); + return rotateSessionsForCurrentBrowser(); +} + router.get("/setup/status", async (_req, res) => { res.json({ claimed: Boolean(await getOwner()) }); }); router.post("/setup/claim", ownerClaimLimiter, async (req, res) => { - const result = await claimInitialOwner(req.body?.password); + const recoveryCodes = generateRecoveryCodes(); + const result = await claimInitialOwner(req.body?.password, { + recoveryCodeHashes: recoveryCodes.map(hashRecoveryCode), + }); if (result.status === "invalid") { return res.status(400).json({ message: "Password is required" }); } @@ -151,7 +184,7 @@ router.post("/setup/claim", ownerClaimLimiter, async (req, res) => { const token = await createSession(); setSessionCookie(res, token); clearPendingAuthCookie(res); - return res.json({ authenticated: true, claimed: true }); + return res.json({ authenticated: true, claimed: true, recoveryCodes }); }); router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, res) => { @@ -168,7 +201,11 @@ router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, re } const registeredPasskeyCount = await countPasskeys(owner.userId); - if (registeredPasskeyCount > 0) { + const resolution = resolvePasswordLogin(owner.authMode, registeredPasskeyCount); + if (resolution.configurationError) { + return res.status(409).json({ message: "Strict authentication requires a registered passkey" }); + } + if (resolution.passkeyRequired) { const pending = await createPendingAuth({ userId: owner.userId }); setPendingAuthCookie(res, pending.token); clearSessionCookie(res); @@ -184,15 +221,25 @@ router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, re res.json({ authenticated: true, passkeyRequired: false, - passkeySetupRecommended: true, + passkeySetupRecommended: registeredPasskeyCount === 0, }); }); router.post("/passkey/authentication/options", passkeyAuthLimiter, async (req, res) => { - const pending = await readPendingAuth(req.cookies?.[PENDING_AUTH_COOKIE_NAME]); + let pending = await readPendingAuth(req.cookies?.[PENDING_AUTH_COOKIE_NAME]); if (!pending) { - clearPendingAuthCookie(res); - return res.status(401).json({ message: "Pending authentication required" }); + const owner = await getOwner(); + if (!owner) return res.status(401).json({ message: "Passkey authentication unavailable" }); + if (owner.authMode === "password_plus_passkey") { + clearPendingAuthCookie(res); + return res.status(409).json({ message: "Enter your password before using a passkey" }); + } + if (await countPasskeys(owner.userId) === 0) { + return res.status(409).json({ message: "No registered passkeys" }); + } + const created = await createPendingAuth({ userId: owner.userId }); + setPendingAuthCookie(res, created.token); + pending = created; } const passkeys = await listPasskeys(pending.userId); @@ -271,13 +318,17 @@ router.post("/passkey/authentication/cancel", passkeyAuthLimiter, async (req, re router.get("/passkeys", requireCookieSession, async (_req, res) => { const owner = await getOwner(); const passkeys = owner ? await listPasskeyMetadata(owner.userId) : []; + const recovery = owner ? await getRecoveryCodeStatus(owner.userId) : { remaining: 0, generatedAt: null }; res.json({ - enforcementActive: passkeys.length > 0, + enforcementActive: owner?.authMode === "password_plus_passkey", + authMode: owner?.authMode || "password_or_passkey", + recentAuth: await hasRecentAuth(_req.cookies?.ea_session), + recovery, passkeys, }); }); -router.post("/passkeys/registration/options", requireCookieSession, async (req, res) => { +router.post("/passkeys/registration/options", requireRecentAuth, async (req, res) => { const label = typeof req.body?.label === "string" ? req.body.label.trim() : ""; if (!label) { return res.status(400).json({ message: "label is required" }); @@ -299,7 +350,7 @@ router.post("/passkeys/registration/options", requireCookieSession, async (req, res.json(options); }); -router.post("/passkeys/registration/verify", requireCookieSession, async (req, res) => { +router.post("/passkeys/registration/verify", requireRecentAuth, async (req, res) => { const label = typeof req.body?.label === "string" ? req.body.label.trim() : ""; if (!label) { return res.status(400).json({ message: "label is required" }); @@ -309,7 +360,6 @@ router.post("/passkeys/registration/verify", requireCookieSession, async (req, r try { const owner = await getOwner(); if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); - const existingCount = await countPasskeys(owner.userId); const verification = await verifyRegistrationCredential({ response: req.body, config: webAuthnConfigForRequest(req), @@ -339,14 +389,10 @@ router.post("/passkeys/registration/verify", requireCookieSession, async (req, r credentialDeviceType: registrationInfo.credentialDeviceType, }); - if (existingCount === 0) { - const token = await rotateSessionsForCurrentBrowser(); - setSessionCookie(res, token); - } - return res.json({ passkey: toPasskeyMetadata(passkey), - enforcementActive: true, + enforcementActive: owner.authMode === "password_plus_passkey", + authMode: owner.authMode, }); } catch (error) { logDevPasskeyFailure("Passkey registration failed", error); @@ -354,7 +400,7 @@ router.post("/passkeys/registration/verify", requireCookieSession, async (req, r } }); -router.delete("/passkeys/:credentialId", requireCookieSession, async (req, res) => { +router.delete("/passkeys/:credentialId", requireRecentAuth, async (req, res) => { const credentialId = req.params.credentialId!; const owner = await getOwner(); if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); @@ -363,16 +409,99 @@ router.delete("/passkeys/:credentialId", requireCookieSession, async (req, res) return res.status(404).json({ message: "Passkey not found" }); } - const token = await rotateSessionsForCurrentBrowser(); + const remainingCount = await countPasskeys(owner.userId); + const finalAuthMode = owner.authMode === "password_plus_passkey" && remainingCount === 0 + ? "password_or_passkey" + : owner.authMode; + if (finalAuthMode !== owner.authMode) { + await setOwnerAuthMode(owner.userId, "password_or_passkey"); + } + const token = await rotateAllAuthState(); setSessionCookie(res, token); const passkeys = await listPasskeyMetadata(owner.userId); res.json({ success: true, - enforcementActive: passkeys.length > 0, + enforcementActive: finalAuthMode === "password_plus_passkey", + authMode: finalAuthMode, + recentAuth: true, + recovery: await getRecoveryCodeStatus(owner.userId), passkeys, }); }); +router.post("/security/step-up/password", requireCookieSession, async (req, res) => { + const owner = await getOwner(); + if (!owner || !validPassword(req.body?.password) + || !await bcrypt.compare(req.body.password, owner.passwordHash)) { + return res.status(401).json({ message: "Password confirmation failed" }); + } + await markSessionRecentlyAuthenticated(req.cookies?.ea_session); + return res.json({ recentAuth: true }); +}); + +router.patch("/security/auth-mode", requireRecentAuth, async (req, res) => { + const authMode = req.body?.authMode; + if (!isOwnerAuthMode(authMode)) { + return res.status(400).json({ message: "Unsupported authentication mode" }); + } + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + if (authMode === "password_plus_passkey" && await countPasskeys(owner.userId) === 0) { + return res.status(409).json({ message: "Register a passkey before enabling strict mode" }); + } + await setOwnerAuthMode(owner.userId, authMode); + const token = await rotateAllAuthState(); + setSessionCookie(res, token); + return res.json({ authMode, recentAuth: true }); +}); + +router.post("/security/password", requireRecentAuth, async (req, res) => { + if (!validPassword(req.body?.newPassword)) { + return res.status(400).json({ message: "New password is required" }); + } + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + await updateOwnerPasswordHash(owner.userId, await bcrypt.hash(req.body.newPassword, 12)); + const token = await rotateAllAuthState(); + setSessionCookie(res, token); + return res.json({ success: true, recentAuth: true }); +}); + +router.post("/recovery-codes/regenerate", requireRecentAuth, async (_req, res) => { + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const recoveryCodes = generateRecoveryCodes(); + await replaceRecoveryCodes(owner.userId, recoveryCodes); + return res.json({ recoveryCodes }); +}); + +router.post("/recovery", recoveryLimiter, async (req, res) => { + if (!validPassword(req.body?.newPassword) || typeof req.body?.recoveryCode !== "string") { + return res.status(400).json({ message: "Recovery code and new password are required" }); + } + const owner = await getOwner(); + if (!owner) return res.status(401).json({ message: "Recovery failed" }); + const newPasswordHash = await bcrypt.hash(req.body.newPassword, 12); + if (!await consumeRecoveryCode(owner.userId, req.body.recoveryCode)) { + return res.status(401).json({ message: "Recovery failed" }); + } + + await updateOwnerPasswordHash(owner.userId, newPasswordHash); + await setOwnerAuthMode(owner.userId, "password_or_passkey"); + await db.batch([ + { sql: "DELETE FROM ea_passkey_credentials WHERE user_id = ?", args: [owner.userId] }, + { sql: "DELETE FROM ea_pending_auth WHERE user_id = ?", args: [owner.userId] }, + { sql: "DELETE FROM ea_webauthn_challenges WHERE user_id = ?", args: [owner.userId] }, + ], "write"); + await revokeAllSessions(); + const recoveryCodes = generateRecoveryCodes(); + await replaceRecoveryCodes(owner.userId, recoveryCodes); + const token = await createSession(); + setSessionCookie(res, token); + clearPendingAuthCookie(res); + return res.json({ authenticated: true, recoveryCodes }); +}); + router.get("/check", timeRoute("/api/auth/check"), async (req, res) => { const token = req.cookies?.ea_session; res.json({ authenticated: await validateSession(token) }); @@ -413,7 +542,7 @@ router.get("/api-tokens", requireCookieSession, async (req, res) => { // Run requireCookieSession BEFORE tokenMintLimiter so an unauthenticated caller from the owner's // egress IP can't burn the 5/15min mint budget and lock the real user out. -router.post("/api-tokens", requireCookieSession, tokenMintLimiter, async (req, res) => { +router.post("/api-tokens", requireRecentAuth, tokenMintLimiter, async (req, res) => { const { label, scopes } = req.body || {}; if (!label || typeof label !== "string" || !label.trim()) { return res.status(400).json({ message: "label is required" }); diff --git a/server/test-utils/auth-db.ts b/server/test-utils/auth-db.ts index 38eb1f64..47eb4b59 100644 --- a/server/test-utils/auth-db.ts +++ b/server/test-utils/auth-db.ts @@ -12,6 +12,7 @@ const migrationFiles = [ "012_passkey_auth.sql", "028_provider_needs_reauth.sql", "030_owner_bootstrap.sql", + "031_auth_recovery.sql", ]; const migrationSql = migrationFiles.map((file) => @@ -49,10 +50,11 @@ export async function seedSession( db: Client, token = "cookie-session", expiresAt = Date.now() + 60_000, + authenticatedAt = 0, ) { await db.execute({ - sql: "INSERT INTO ea_sessions (token, expires_at) VALUES (?, ?)", - args: [hashSessionToken(token), expiresAt], + sql: "INSERT INTO ea_sessions (token, expires_at, authenticated_at) VALUES (?, ?, ?)", + args: [hashSessionToken(token), expiresAt, authenticatedAt], }); } diff --git a/shared/types/accounts.ts b/shared/types/accounts.ts index 9c30f072..28bbc7bf 100644 --- a/shared/types/accounts.ts +++ b/shared/types/accounts.ts @@ -77,16 +77,35 @@ export interface PasskeyMetadata { lastUsedAt: number | null; } +export type OwnerAuthMode = "password_or_passkey" | "password_plus_passkey"; + +export interface RecoveryCodeStatus { + remaining: number; + generatedAt: number | null; +} + export interface PasskeyListResponse { enforcementActive: boolean; + authMode: OwnerAuthMode; + recentAuth: boolean; + recovery: RecoveryCodeStatus; passkeys: PasskeyMetadata[]; } export interface PasskeyRegistrationResponse { enforcementActive: boolean; + authMode: OwnerAuthMode; passkey: PasskeyMetadata; } export interface PasskeyDeleteResponse extends PasskeyListResponse { success: true; } + +export interface RecoveryCodesResponse { + recoveryCodes: string[]; +} + +export interface OwnerRecoveryResponse extends RecoveryCodesResponse { + authenticated: true; +} diff --git a/shared/types/setup.ts b/shared/types/setup.ts index 3ebd9054..a3ef21d9 100644 --- a/shared/types/setup.ts +++ b/shared/types/setup.ts @@ -9,4 +9,5 @@ export interface OwnerClaimRequest { export interface OwnerClaimResponse { claimed: true; authenticated: true; + recoveryCodes: string[]; } diff --git a/src/auth/securityApi.test.ts b/src/auth/securityApi.test.ts new file mode 100644 index 00000000..32645cf7 --- /dev/null +++ b/src/auth/securityApi.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/demo/config", () => ({ isDemoMode: () => true })); + +const { recoverOwnerAccess, stepUpWithPassword } = await import("./securityApi"); + +describe("security API demo boundary", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("rejects identity mutations before any network request in demo mode", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect(recoverOwnerAccess("recovery-code", "new-password")) + .rejects.toThrow("DEMO_API_UNHANDLED"); + await expect(stepUpWithPassword("password")) + .rejects.toThrow("DEMO_API_UNHANDLED"); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auth/securityApi.ts b/src/auth/securityApi.ts new file mode 100644 index 00000000..6998bbfa --- /dev/null +++ b/src/auth/securityApi.ts @@ -0,0 +1,51 @@ +import { isDemoMode } from "@/demo/config"; +import type { + OwnerAuthMode, + OwnerRecoveryResponse, + RecoveryCodesResponse, +} from "../../shared/types/accounts"; + +async function securityFetch(path: string, options: RequestInit): Promise { + if (isDemoMode()) throw new Error("DEMO_API_UNHANDLED"); + const response = await fetch(path, { + ...options, + headers: { + "Content-Type": "application/json", + "X-Requested-With": "Setpoint", + ...options.headers, + }, + }); + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + const message = typeof body === "object" && body !== null && "message" in body + ? String((body as { message?: unknown }).message || "") + : ""; + throw new Error(message || `API error: ${response.status}`); + } + return response.json() as Promise; +} + +export const stepUpWithPassword = (password: string): Promise<{ recentAuth: true }> => securityFetch( + "/api/auth/security/step-up/password", + { method: "POST", body: JSON.stringify({ password }) }, +); + +export const updateOwnerAuthMode = (authMode: OwnerAuthMode): Promise<{ authMode: OwnerAuthMode; recentAuth: true }> => securityFetch( + "/api/auth/security/auth-mode", + { method: "PATCH", body: JSON.stringify({ authMode }) }, +); + +export const changeOwnerPassword = (newPassword: string): Promise<{ success: true; recentAuth: true }> => securityFetch( + "/api/auth/security/password", + { method: "POST", body: JSON.stringify({ newPassword }) }, +); + +export const regenerateRecoveryCodes = (): Promise => securityFetch( + "/api/auth/recovery-codes/regenerate", + { method: "POST" }, +); + +export const recoverOwnerAccess = (recoveryCode: string, newPassword: string): Promise => securityFetch( + "/api/auth/recovery", + { method: "POST", body: JSON.stringify({ recoveryCode, newPassword }) }, +); diff --git a/src/components/settings/CLAUDE.md b/src/components/settings/CLAUDE.md index d91ff141..95faab50 100644 --- a/src/components/settings/CLAUDE.md +++ b/src/components/settings/CLAUDE.md @@ -39,7 +39,7 @@ The settings surface: tabbed sections composed of cards covering accounts, integ - `cards/ActualBudgetConnectionCard.tsx` — Actual server URL/auth config, budget cache hydration - `cards/BriefingSchedulesCard.tsx` — snapshot window boundaries with FLIP reorder animation - `cards/ApiTokensCard.tsx` — API token list/create/revoke with scopes and expiry -- `cards/PasskeysCard.tsx` — passkey registration/deletion, enforcement mode +- `cards/PasskeysCard.tsx` — passkey registration/deletion, explicit auth mode, password step-up/change, and recovery-code regeneration ### Shared - `shared/ProviderModelSelect.tsx` — dual select for LLM provider + model diff --git a/src/components/settings/cards/PasskeysCard.test.tsx b/src/components/settings/cards/PasskeysCard.test.tsx index fb7a8d44..d17f7958 100644 --- a/src/components/settings/cards/PasskeysCard.test.tsx +++ b/src/components/settings/cards/PasskeysCard.test.tsx @@ -7,11 +7,18 @@ const mockApi = vi.hoisted(() => ({ verifyPasskeyRegistration: vi.fn(), deletePasskeyCredential: vi.fn(), })); +const mockSecurityApi = vi.hoisted(() => ({ + stepUpWithPassword: vi.fn(), + updateOwnerAuthMode: vi.fn(), + changeOwnerPassword: vi.fn(), + regenerateRecoveryCodes: vi.fn(), +})); const mockBrowser = vi.hoisted(() => ({ startPasskeyRegistration: vi.fn(), })); vi.mock("@/api", () => mockApi); +vi.mock("@/auth/securityApi", () => mockSecurityApi); vi.mock("@/auth/passkeyBrowser", () => mockBrowser); const { default: PasskeysCard } = await import("./PasskeysCard"); @@ -22,22 +29,42 @@ afterEach(() => { }); beforeEach(() => { - mockApi.listPasskeys.mockResolvedValue({ enforcementActive: false, passkeys: [] }); + mockApi.listPasskeys.mockResolvedValue({ + enforcementActive: false, + authMode: "password_or_passkey", + recentAuth: true, + recovery: { remaining: 0, generatedAt: null }, + passkeys: [], + }); mockApi.getPasskeyRegistrationOptions.mockResolvedValue({ challenge: "registration-challenge" }); mockBrowser.startPasskeyRegistration.mockResolvedValue({ id: "credential-1", response: {} }); mockApi.verifyPasskeyRegistration.mockResolvedValue({ - enforcementActive: true, + enforcementActive: false, + authMode: "password_or_passkey", passkey: passkeyRow({ credentialId: "credential-1", label: "MacBook Touch ID" }), }); - mockApi.deletePasskeyCredential.mockResolvedValue({ enforcementActive: false, passkeys: [] }); + mockApi.deletePasskeyCredential.mockResolvedValue({ + success: true, + enforcementActive: false, + authMode: "password_or_passkey", + recentAuth: true, + recovery: { remaining: 0, generatedAt: null }, + passkeys: [], + }); + mockSecurityApi.stepUpWithPassword.mockResolvedValue({ recentAuth: true }); + mockSecurityApi.updateOwnerAuthMode.mockResolvedValue({ authMode: "password_plus_passkey", recentAuth: true }); + mockSecurityApi.changeOwnerPassword.mockResolvedValue({ success: true, recentAuth: true }); + mockSecurityApi.regenerateRecoveryCodes.mockResolvedValue({ + recoveryCodes: ["SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222"], + }); }); describe("PasskeysCard", () => { it("shows setup mode and storage-separation guidance when no passkeys exist", async () => { render(); - expect(await screen.findByText("Setup mode")).toBeTruthy(); - expect(screen.getByText(/Future logins stay password-only until a passkey is registered/i)).toBeTruthy(); + expect(await screen.findByText("Password or passkey")).toBeTruthy(); + expect(screen.getByText(/Password stays available after you register a passkey/i)).toBeTruthy(); expect(screen.getByText(/Use a device passkey or hardware security key/i)).toBeTruthy(); expect(screen.getByPlaceholderText("MacBook Touch ID")).toBeTruthy(); }); @@ -45,7 +72,7 @@ describe("PasskeysCard", () => { it("registers a passkey through browser WebAuthn and refreshes in place", async () => { render(); - await screen.findByText("Setup mode"); + await screen.findByText("Password or passkey"); fireEvent.change(screen.getByPlaceholderText("MacBook Touch ID"), { target: { value: "MacBook Touch ID" }, @@ -61,7 +88,7 @@ describe("PasskeysCard", () => { label: "MacBook Touch ID", }); }); - expect(screen.getByText("Enforced")).toBeTruthy(); + expect(screen.getByText("Password or passkey")).toBeTruthy(); expect(screen.getByText("MacBook Touch ID")).toBeTruthy(); expect(screen.getByPlaceholderText("MacBook Touch ID").value).toBe(""); }); @@ -69,6 +96,9 @@ describe("PasskeysCard", () => { it("shows registered metadata and backup recommendation", async () => { mockApi.listPasskeys.mockResolvedValue({ enforcementActive: true, + authMode: "password_plus_passkey", + recentAuth: true, + recovery: { remaining: 4, generatedAt: Date.now() }, passkeys: [passkeyRow({ credentialId: "credential-1", label: "Security Key", @@ -80,7 +110,7 @@ describe("PasskeysCard", () => { render(); expect(await screen.findByText("Security Key")).toBeTruthy(); - expect(screen.getByText("Enforced")).toBeTruthy(); + expect(screen.getByText("Password + passkey")).toBeTruthy(); expect(screen.getByText(/Add a second passkey when practical/i)).toBeTruthy(); expect(screen.getByText("usb, nfc")).toBeTruthy(); expect(screen.getByText("Not backed up")).toBeTruthy(); @@ -89,6 +119,9 @@ describe("PasskeysCard", () => { it("deletes a passkey after explicit confirmation", async () => { mockApi.listPasskeys.mockResolvedValue({ enforcementActive: true, + authMode: "password_plus_passkey", + recentAuth: true, + recovery: { remaining: 4, generatedAt: Date.now() }, passkeys: [passkeyRow({ credentialId: "credential-1", label: "Security Key" })], }); @@ -102,9 +135,51 @@ describe("PasskeysCard", () => { await waitFor(() => { expect(mockApi.deletePasskeyCredential).toHaveBeenCalledWith("credential-1"); }); - expect(screen.getByText("Setup mode")).toBeTruthy(); + expect(screen.getByText("Password or passkey")).toBeTruthy(); expect(screen.queryByText("Security Key")).toBeNull(); }); + + it("enables strict mode only through an explicit action", async () => { + mockApi.listPasskeys.mockResolvedValue({ + enforcementActive: false, + authMode: "password_or_passkey", + recentAuth: true, + recovery: { remaining: 8, generatedAt: Date.now() }, + passkeys: [passkeyRow({ label: "Security Key" })], + }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Require password + passkey" })); + + await waitFor(() => expect(mockSecurityApi.updateOwnerAuthMode).toHaveBeenCalledWith("password_plus_passkey")); + expect(screen.getByText("Password + passkey")).toBeTruthy(); + }); + + it("unlocks sensitive controls with a recent password confirmation", async () => { + mockApi.listPasskeys.mockResolvedValue({ + enforcementActive: false, + authMode: "password_or_passkey", + recentAuth: false, + recovery: { remaining: 8, generatedAt: Date.now() }, + passkeys: [], + }); + render(); + + fireEvent.change(await screen.findByLabelText("Current password"), { target: { value: "correct-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Unlock security changes" })); + + await waitFor(() => expect(mockSecurityApi.stepUpWithPassword).toHaveBeenCalledWith("correct-password")); + expect(screen.getByPlaceholderText("MacBook Touch ID")).toBeTruthy(); + }); + + it("shows regenerated recovery codes only until acknowledged", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Generate recovery codes" })); + + expect(await screen.findByText("SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "I saved these codes" })); + expect(screen.queryByText("SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222")).toBeNull(); + }); }); function passkeyRow(overrides = {}) { diff --git a/src/components/settings/cards/PasskeysCard.tsx b/src/components/settings/cards/PasskeysCard.tsx index 055be076..e73b57f7 100644 --- a/src/components/settings/cards/PasskeysCard.tsx +++ b/src/components/settings/cards/PasskeysCard.tsx @@ -1,20 +1,21 @@ import { useEffect, useState } from "react"; -import { AlertTriangle, Fingerprint, KeyRound, Trash2 } from "lucide-react"; +import { AlertTriangle, Fingerprint, KeyRound, ShieldCheck, Trash2 } from "lucide-react"; import { deletePasskeyCredential, getPasskeyRegistrationOptions, listPasskeys, verifyPasskeyRegistration, } from "@/api"; +import { + changeOwnerPassword, + regenerateRecoveryCodes, + stepUpWithPassword, + updateOwnerAuthMode, +} from "@/auth/securityApi"; import { startPasskeyRegistration } from "@/auth/passkeyBrowser"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - FieldHint, - SectionLabel, - SettingsCard, - StatusPill, -} from "@/components/settings/settings-ui"; +import { FieldHint, SectionLabel, SettingsCard, StatusPill } from "@/components/settings/settings-ui"; import { SETTINGS_GHOST_BUTTON_CLASS, SETTINGS_PRIMARY_BUTTON_CLASS, @@ -23,17 +24,13 @@ import { } from "@/components/settings/settings-core"; import { cn } from "@/lib/utils"; import type { FormEvent } from "react"; -import type { PasskeyMetadata } from "../../../../shared/types/accounts"; +import type { OwnerAuthMode, PasskeyMetadata, RecoveryCodeStatus } from "../../../../shared/types/accounts"; const errorMessage = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback; function formatDate(ms: number | null | undefined) { if (!ms) return "never"; - return new Date(Number(ms)).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); + return new Date(Number(ms)).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); } function formatTransports(transports: string[]) { @@ -47,20 +44,25 @@ function formatBackupState(backedUp: boolean | null) { } function mergeRegisteredPasskey(passkeys: PasskeyMetadata[], passkey: PasskeyMetadata) { - return [ - passkey, - ...passkeys.filter((item) => item.credentialId !== passkey.credentialId), - ]; + return [passkey, ...passkeys.filter((item) => item.credentialId !== passkey.credentialId)]; } +const emptyRecovery: RecoveryCodeStatus = { remaining: 0, generatedAt: null }; +const BUTTON_MOTION_CLASS = "motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0"; + export default function PasskeysCard() { const [passkeys, setPasskeys] = useState(null); - const [enforcementActive, setEnforcementActive] = useState(false); + const [authMode, setAuthMode] = useState("password_or_passkey"); + const [recentAuth, setRecentAuth] = useState(false); + const [recovery, setRecovery] = useState(emptyRecovery); const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); const [label, setLabel] = useState(""); - const [registering, setRegistering] = useState(false); - const [busyCredentialId, setBusyCredentialId] = useState(null); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + const [revealedCodes, setRevealedCodes] = useState(null); + const [busyAction, setBusyAction] = useState(null); const [confirmingCredentialId, setConfirmingCredentialId] = useState(null); useEffect(() => { @@ -69,60 +71,133 @@ export default function PasskeysCard() { .then((result) => { if (cancelled) return; setPasskeys(result.passkeys || []); - setEnforcementActive(Boolean(result.enforcementActive)); + setAuthMode(result.authMode || "password_or_passkey"); + setRecentAuth(Boolean(result.recentAuth)); + setRecovery(result.recovery || emptyRecovery); }) .catch((error) => { - if (!cancelled) setLoadError(errorMessage(error, "Failed to load passkeys")); + if (!cancelled) setLoadError(errorMessage(error, "Failed to load sign-in settings")); }); return () => { cancelled = true; }; }, []); + async function handleUnlock(event: FormEvent) { + event.preventDefault(); + if (!currentPassword || busyAction) return; + setBusyAction("unlock"); + setActionError(null); + try { + await stepUpWithPassword(currentPassword); + setRecentAuth(true); + setCurrentPassword(""); + } catch (error) { + setActionError(errorMessage(error, "Password confirmation failed")); + } finally { + setBusyAction(null); + } + } + async function handleRegister(event: FormEvent) { event.preventDefault(); const trimmedLabel = label.trim(); - if (!trimmedLabel || registering) return; - setRegistering(true); + if (!trimmedLabel || busyAction || !recentAuth) return; + setBusyAction("register"); setActionError(null); try { const options = await getPasskeyRegistrationOptions(trimmedLabel); const credential = await startPasskeyRegistration(options); const result = await verifyPasskeyRegistration({ ...credential, label: trimmedLabel }); setPasskeys((current) => mergeRegisteredPasskey(current || [], result.passkey)); - setEnforcementActive(Boolean(result.enforcementActive)); + setAuthMode(result.authMode || "password_or_passkey"); setLabel(""); } catch (error) { setActionError(errorMessage(error, "Passkey registration failed")); } finally { - setRegistering(false); + setBusyAction(null); } } async function handleDelete(credentialId: string) { - setBusyCredentialId(credentialId); + setBusyAction(`delete:${credentialId}`); setActionError(null); try { const result = await deletePasskeyCredential(credentialId); setPasskeys(result.passkeys || []); - setEnforcementActive(Boolean(result.enforcementActive)); + setAuthMode(result.authMode || "password_or_passkey"); + setRecentAuth(Boolean(result.recentAuth)); + setRecovery(result.recovery || recovery); setConfirmingCredentialId(null); } catch (error) { setActionError(errorMessage(error, "Failed to delete passkey")); } finally { - setBusyCredentialId(null); + setBusyAction(null); + } + } + + async function handleModeChange() { + const nextMode: OwnerAuthMode = authMode === "password_plus_passkey" + ? "password_or_passkey" + : "password_plus_passkey"; + setBusyAction("mode"); + setActionError(null); + try { + const result = await updateOwnerAuthMode(nextMode); + setAuthMode(result.authMode); + setRecentAuth(true); + } catch (error) { + setActionError(errorMessage(error, "Could not change sign-in mode")); + } finally { + setBusyAction(null); + } + } + + async function handlePasswordChange(event: FormEvent) { + event.preventDefault(); + if (!newPassword || busyAction) return; + if (newPassword !== passwordConfirmation) { + setActionError("New passwords do not match"); + return; + } + setBusyAction("password"); + setActionError(null); + try { + await changeOwnerPassword(newPassword); + setNewPassword(""); + setPasswordConfirmation(""); + setRecentAuth(true); + } catch (error) { + setActionError(errorMessage(error, "Could not change password")); + } finally { + setBusyAction(null); + } + } + + async function handleRegenerateCodes() { + setBusyAction("recovery"); + setActionError(null); + try { + const result = await regenerateRecoveryCodes(); + setRevealedCodes(result.recoveryCodes); + setRecovery({ remaining: result.recoveryCodes.length, generatedAt: Date.now() }); + } catch (error) { + setActionError(errorMessage(error, "Could not generate recovery codes")); + } finally { + setBusyAction(null); } } const loadedPasskeys = passkeys || []; const hasPasskeys = loadedPasskeys.length > 0; + const strictMode = authMode === "password_plus_passkey"; return ( } - description="Require a registered passkey after the dashboard password. Keep at least two passkeys when possible." + description="Choose password-or-passkey access, or explicitly require both. Security changes need recent password confirmation." headerAction={( - - {enforcementActive ? "Enforced" : "Setup mode"} + + {strictMode ? "Password + passkey" : "Password or passkey"} )} > @@ -131,127 +206,160 @@ export default function PasskeysCard() {
- {enforcementActive ? ( - <> - Future logins require your password and a registered passkey. - {" "} - Add a second passkey when practical so one lost device does not lock you out. - + {strictMode ? ( + <>Future logins require your password and a registered passkey. Add a second passkey when practical. ) : ( - <> - Future logins stay password-only until a passkey is registered. - {" "} - Use a device passkey or hardware security key that is stored separately from your dashboard password. - + <>Password stays available after you register a passkey. Use a device passkey or hardware security key for passwordless sign-in. )}
-
-
- New passkey label - { - setLabel(event.target.value); - if (actionError) setActionError(null); - }} - disabled={registering} - /> -
- -
- - {actionError ? ( - {actionError} - ) : null} - {loadError ? ( - Failed to load passkeys: {loadError} + {loadError} ) : passkeys === null ? ( - Loading... - ) : !hasPasskeys ? ( -
- No passkeys registered. -
+ Loading sign-in settings… + ) : !recentAuth ? ( +
+
+
+ Current password + setCurrentPassword(event.target.value)} + disabled={busyAction === "unlock"} + /> +
+ +
+ Confirmation stays valid for ten minutes. +
) : ( -
- {loadedPasskeys.map((passkey) => { - const confirming = confirmingCredentialId === passkey.credentialId; - const busy = busyCredentialId === passkey.credentialId; - return ( -
-
-
- - {passkey.label} - - - {formatBackupState(passkey.backedUp)} - -
-
- Created {formatDate(passkey.createdAt)} - Last used {formatDate(passkey.lastUsedAt)} - {formatTransports(passkey.transports)} -
+ <> + {hasPasskeys ? ( +
+
+
Sign-in mode
+
+ {strictMode ? "Both factors are required at every login." : "Either your password or any registered passkey can sign you in."}
+
+ +
+ ) : null} - {confirming ? ( -
- - +
+
+ New passkey label + setLabel(event.target.value)} + disabled={busyAction === "register"} + /> +
+ +
+ + {!hasPasskeys ? ( +
+ No passkeys registered. +
+ ) : ( +
+ {loadedPasskeys.map((passkey) => { + const confirming = confirmingCredentialId === passkey.credentialId; + const busy = busyAction === `delete:${passkey.credentialId}`; + return ( +
+
+
+ {passkey.label} + {formatBackupState(passkey.backedUp)} +
+
+ Created {formatDate(passkey.createdAt)} + Last used {formatDate(passkey.lastUsedAt)} + {formatTransports(passkey.transports)} +
+
+ {confirming ? ( +
+ + +
+ ) : ( + + )}
- ) : ( - - )} + ); + })} +
+ )} + +
+
Change owner password
+
+
+ New password + setNewPassword(event.target.value)} disabled={busyAction === "password"} />
- ); - })} -
+
+ Confirm new password + setPasswordConfirmation(event.target.value)} disabled={busyAction === "password"} /> +
+
+ + + +
+
+
+
Offline recovery codes
+
+ {recovery.remaining > 0 ? `${recovery.remaining} unused codes remain.` : "No recovery codes are available yet."} +
+
+ +
+ {revealedCodes ? ( +
+
    + {revealedCodes.map((code) =>
  • {code}
  • )} +
+ +
+ ) : null} +
+ )} + {actionError ?
{actionError}
: null} - Deleting the final passkey returns the dashboard to setup mode for future password logins. + Recovery resets passkeys, signs out other sessions, and returns sign-in mode to password or passkey.
diff --git a/src/components/settings/settings-ui.tsx b/src/components/settings/settings-ui.tsx index 4caeb74e..e735b6b9 100644 --- a/src/components/settings/settings-ui.tsx +++ b/src/components/settings/settings-ui.tsx @@ -63,9 +63,9 @@ export function SaveStatus({ status }: { status: SettingsSaveStatus }) { return Auto-save on; } -export function SectionLabel({ children, className }: { children: ReactNode; className?: string }) { +export function SectionLabel({ children, className, htmlFor }: { children: ReactNode; className?: string; htmlFor?: string }) { return ( -
- Enter your password to continue + {phase === "recovery" + ? "Use one offline code and choose a new password" + : phase === "recovery-codes" + ? "Save the replacement codes before continuing" + : phase === "passkey" + ? "Finish the browser passkey prompt" + : "Choose your sign-in method"} @@ -158,7 +194,7 @@ export default function Login({ onLogin }: LoginProps): ReactElement { autoFocus />
- ) : ( + ) : phase === "passkey" ? (
@@ -174,6 +210,63 @@ export default function Login({ onLogin }: LoginProps): ReactElement {
+ ) : phase === "recovery" ? ( +
+
+ + setRecoveryCode(event.target.value)} + disabled={loading} + autoFocus + /> +
+
+ + setPassword(event.target.value)} + disabled={loading} + /> +
+
+ + setConfirmation(event.target.value)} + disabled={loading} + /> +
+
+ ) : ( +
+
    + {recoveryCodes.map((code) => ( +
  • + + {code} + +
  • + ))} +
+

+ These replace the code you used. Store them offline; this set will not be shown again. +

+
)} {error ? ( @@ -193,19 +286,46 @@ export default function Login({ onLogin }: LoginProps): ReactElement { ) : null} {phase === "password" ? ( - - ) : ( +
+ + + +
+ ) : phase === "passkey" ? (
+ ) : phase === "recovery" ? ( +
+ + +
+ ) : ( + )} diff --git a/src/pages/OwnerSetup.test.tsx b/src/pages/OwnerSetup.test.tsx index 810b9ddb..66e8da57 100644 --- a/src/pages/OwnerSetup.test.tsx +++ b/src/pages/OwnerSetup.test.tsx @@ -24,16 +24,23 @@ describe("OwnerSetup", () => { expect(claimOwner).not.toHaveBeenCalled(); }); - it("claims the instance and hands off the authenticated session", async () => { + it("shows recovery codes once and requires acknowledgement before handoff", async () => { const onClaimed = vi.fn(); - claimOwner.mockResolvedValue({ claimed: true, authenticated: true }); + claimOwner.mockResolvedValue({ + claimed: true, + authenticated: true, + recoveryCodes: ["SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222"], + }); render(); fireEvent.change(screen.getByLabelText("Create password"), { target: { value: "new-owner-password" } }); fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: "new-owner-password" } }); fireEvent.click(screen.getByRole("button", { name: "Claim Setpoint" })); - await waitFor(() => expect(onClaimed).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222")).toBeTruthy(); + expect(onClaimed).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "I saved these codes" })); + expect(onClaimed).toHaveBeenCalledTimes(1); expect(claimOwner).toHaveBeenCalledWith("new-owner-password"); }); diff --git a/src/pages/OwnerSetup.tsx b/src/pages/OwnerSetup.tsx index 63ecfd07..88bfc7fb 100644 --- a/src/pages/OwnerSetup.tsx +++ b/src/pages/OwnerSetup.tsx @@ -20,6 +20,7 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement const [confirmation, setConfirmation] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState(null); const passwordRef = useRef(null); async function handleSubmit(event: FormEvent): Promise { @@ -33,8 +34,10 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement setSubmitting(true); setError(null); try { - await claimOwner(password); - onClaimed(); + const result = await claimOwner(password); + setPassword(""); + setConfirmation(""); + setRecoveryCodes(result.recoveryCodes); } catch (error) { setPassword(""); setConfirmation(""); @@ -68,16 +71,45 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement
- Claim your private workspace + {recoveryCodes ? "Save your recovery codes" : "Claim your private workspace"} - Create the owner password for this Setpoint instance. The first successful claim closes public setup permanently. + {recoveryCodes + ? "Store these offline. Each code works once, and Setpoint will not show this set again." + : "Create the owner password for this Setpoint instance. The first successful claim closes public setup permanently."}
+ {recoveryCodes ? ( + <> +
+
    + {recoveryCodes.map((code) => ( +
  • + + {code} + +
  • + ))} +
+
+

+ Keep these somewhere separate from this device. Regenerating recovery codes later invalidates this set. +

+ + + ) : ( + <>
+ ) : null}
); diff --git a/src/components/settings/cards/DiscordRemindersCard.test.tsx b/src/components/settings/cards/DiscordRemindersCard.test.tsx index 0af81f3d..824b387d 100644 --- a/src/components/settings/cards/DiscordRemindersCard.test.tsx +++ b/src/components/settings/cards/DiscordRemindersCard.test.tsx @@ -19,6 +19,7 @@ describe("DiscordRemindersCard", () => { it("reflects a saved configuration from settings", () => { render(); expect(screen.getByText("Saved")).toBeTruthy(); + expect(screen.getByText(/saving does not send a message or prove delivery/i)).toBeTruthy(); }); it("saves the webhook + user id and emits the settings-changed event", async () => { @@ -35,13 +36,17 @@ describe("DiscordRemindersCard", () => { discord_user_id: "987", }); }); + expect(screen.queryByText("Test sent")).toBeNull(); }); it("clears the saved configuration and drops the Saved pill", async () => { mockApi.updateSettings.mockResolvedValue({ success: true }); - render(); + const onRefreshConnections = vi.fn(async () => {}); + render(); expect(screen.getByText("Saved")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Clear" })); + fireEvent.click(screen.getByRole("button", { name: "Remove Discord webhook" })); + expect(screen.getByText(/discord reminder delivery will stop/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Confirm remove Discord webhook" })); await waitFor(() => { expect(mockApi.updateSettings).toHaveBeenCalledWith({ discord_webhook_url: "", @@ -51,12 +56,13 @@ describe("DiscordRemindersCard", () => { await waitFor(() => { expect(screen.queryByText("Saved")).toBeNull(); }); + expect(onRefreshConnections).toHaveBeenCalledTimes(1); }); it("sends a test webhook and surfaces the Test sent status", async () => { mockApi.testDiscordReminderWebhook.mockResolvedValue({ success: true }); render(); - fireEvent.click(screen.getByRole("button", { name: /send test/i })); + fireEvent.click(screen.getByRole("button", { name: /send test reminder/i })); await waitFor(() => { expect(mockApi.testDiscordReminderWebhook).toHaveBeenCalledTimes(1); }); diff --git a/src/components/settings/cards/DiscordRemindersCard.tsx b/src/components/settings/cards/DiscordRemindersCard.tsx index ad7bf229..de0221ed 100644 --- a/src/components/settings/cards/DiscordRemindersCard.tsx +++ b/src/components/settings/cards/DiscordRemindersCard.tsx @@ -14,7 +14,7 @@ import { SETTINGS_SECONDARY_BUTTON_CLASS, } from "@/components/settings/settings-core"; import { isDemoMode } from "@/demo/config"; -import type { SettingsCardStateProps } from "../settingsTypes"; +import type { SettingsCardStateProps, SettingsConnectionRefreshProps } from "../settingsTypes"; import type { SettingsPatchRequest } from "../../../../shared/types/settings"; type DiscordTestStatus = "save-failed" | "sent" | "failed" | null; @@ -28,8 +28,12 @@ interface DiscordFormState { testStatus: DiscordTestStatus; } -export default function DiscordRemindersCard({ settings }: Pick) { +export default function DiscordRemindersCard({ + settings, + onRefreshConnections = async () => {}, +}: Pick & SettingsConnectionRefreshProps) { const demoMode = isDemoMode(); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); const [discordForm, setDiscordForm] = useState({ webhookUrl: "", userId: "", @@ -69,6 +73,7 @@ export default function DiscordRemindersCard({ settings }: Pick {}); } catch { setDiscordForm((current) => ({ ...current, saving: false, testStatus: "save-failed" })); } @@ -89,6 +94,8 @@ export default function DiscordRemindersCard({ settings }: Pick {}); } catch { setDiscordForm((current) => ({ ...current, saving: false, testStatus: "save-failed" })); } @@ -161,6 +168,9 @@ export default function DiscordRemindersCard({ settings }: Pick + + Saving does not send a message or prove delivery. Use Send test reminder when you are ready for a real Discord message. +
{discordForm.configured && !discordForm.dirty ? ( <> Saved ) : null} @@ -200,6 +210,35 @@ export default function DiscordRemindersCard({ settings }: PickSave failed : null} {demoMode ? Test not available in demo : null}
+ {confirmingRemoval ? ( +
+ + Discord reminder delivery will stop. Reminder schedules remain saved. + +
+ + +
+
+ ) : null} ); diff --git a/src/components/settings/cards/TodoistCard.test.tsx b/src/components/settings/cards/TodoistCard.test.tsx index f55cc86b..80385ba6 100644 --- a/src/components/settings/cards/TodoistCard.test.tsx +++ b/src/components/settings/cards/TodoistCard.test.tsx @@ -2,7 +2,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockApi = vi.hoisted(() => ({ - updateSettings: vi.fn(), + saveTodoistPersonalToken: vi.fn(), + disconnectTodoistConnection: vi.fn(), getTodoistConnectionStatus: vi.fn(), stageTodoistOAuthApplication: vi.fn(), importTodoistOAuthEnvironment: vi.fn(), @@ -33,6 +34,11 @@ const disconnectedStatus = { describe("TodoistCard", () => { beforeEach(() => { mockApi.getTodoistConnectionStatus.mockResolvedValue(disconnectedStatus); + mockApi.saveTodoistPersonalToken.mockResolvedValue({ + success: true, + verifiedAt: "2026-07-19T18:00:00.000Z", + }); + mockApi.disconnectTodoistConnection.mockResolvedValue({ success: true }); }); it("shows Connected and a masked placeholder when already configured", () => { @@ -42,20 +48,19 @@ describe("TodoistCard", () => { }); it("saves a freshly entered token and clears the input", async () => { - mockApi.updateSettings.mockResolvedValue({ success: true }); render(); const input = screen.getByPlaceholderText("Todoist API token"); fireEvent.change(input, { target: { value: "tok-123" } }); - fireEvent.click(screen.getByRole("button", { name: "Save" })); + fireEvent.click(screen.getByRole("button", { name: "Save & verify" })); await waitFor(() => { - expect(mockApi.updateSettings).toHaveBeenCalledWith({ todoist_api_token: "tok-123" }); + expect(mockApi.saveTodoistPersonalToken).toHaveBeenCalledWith("tok-123"); }); expect(await screen.findByText("Connected")).toBeTruthy(); }); it("keeps Save disabled until the token is edited", () => { render(); - expect(screen.getByRole("button", { name: "Save" }).disabled).toBe(true); + expect(screen.getByRole("button", { name: "Save & verify" }).disabled).toBe(true); }); it("shows a warning pill and Reconnect action when todoist_needs_reauth is true", () => { @@ -71,16 +76,16 @@ describe("TodoistCard", () => { expect(screen.queryByText(/reconnect needed/i)).toBeNull(); }); - it("disconnects explicitly by saving an empty personal token", async () => { - mockApi.updateSettings.mockResolvedValue({ success: true }); - render(); - fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); - fireEvent.click(screen.getByRole("button", { name: "Save" })); + it("keeps a rejected candidate in the write-only field without claiming a replacement", async () => { + mockApi.saveTodoistPersonalToken.mockRejectedValueOnce(new Error("Todoist personal token could not be verified")); + render(); + const input = screen.getByLabelText("Personal API token"); + fireEvent.change(input, { target: { value: "bad-token" } }); + fireEvent.click(screen.getByRole("button", { name: "Save & verify" })); - await waitFor(() => { - expect(mockApi.updateSettings).toHaveBeenCalledWith({ todoist_api_token: "" }); - }); - expect(screen.queryByText("Connected")).toBeNull(); + expect(await screen.findByText(/could not be verified/i)).toBeTruthy(); + expect((input as HTMLInputElement).value).toBe("bad-token"); + expect(mockApi.getTodoistConnectionStatus).toHaveBeenCalledTimes(1); }); it("stages advanced application credentials write-only while keeping personal tokens primary", async () => { @@ -102,4 +107,16 @@ describe("TodoistCard", () => { expect((screen.getByLabelText("Client secret") as HTMLInputElement).value).toBe(""); expect(screen.getByText(/personal token stays active until authorization succeeds/i)).toBeTruthy(); }); + + it("confirms Todoist impact before disconnecting and refreshes shared state", async () => { + const onRefreshConnections = vi.fn(async () => {}); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Disconnect Todoist" })); + expect(screen.getByText(/task and deadline sync will stop/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Confirm disconnect Todoist" })); + + await waitFor(() => expect(mockApi.disconnectTodoistConnection).toHaveBeenCalledTimes(1)); + expect(onRefreshConnections).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/settings/cards/TodoistCard.tsx b/src/components/settings/cards/TodoistCard.tsx index 2e803f6a..9c37ca28 100644 --- a/src/components/settings/cards/TodoistCard.tsx +++ b/src/components/settings/cards/TodoistCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { SiTodoist } from "@icons-pack/react-simple-icons"; -import { updateSettings } from "@/api"; +import { disconnectTodoistConnection, saveTodoistPersonalToken } from "@/api"; import { beginTodoistOAuth, getTodoistConnectionStatus, @@ -19,19 +19,25 @@ import { SETTINGS_PRIMARY_BUTTON_CLASS, SETTINGS_SECONDARY_BUTTON_CLASS, } from "@/components/settings/settings-core"; -import type { SettingsCardStateProps } from "../settingsTypes"; +import type { SettingsCardStateProps, SettingsConnectionRefreshProps } from "../settingsTypes"; import type { TodoistConnectionStatus } from "../../../../shared/types/tasks"; import { cn } from "@/lib/utils"; const BUTTON_MOTION_CLASS = "motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0"; -export default function TodoistCard({ settings }: Pick) { +export default function TodoistCard({ + settings, + onRefreshConnections = async () => {}, +}: Pick & SettingsConnectionRefreshProps) { const needsReauth = !!settings?.todoist_needs_reauth; const [todoistToken, setTodoistToken] = useState(""); const [todoistConfigured, setTodoistConfigured] = useState(false); const [todoistDirty, setTodoistDirty] = useState(false); const [todoistSavingSecret, setTodoistSavingSecret] = useState(false); + const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [todoistMessage, setTodoistMessage] = useState(null); const [oauthStatus, setOauthStatus] = useState(null); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); @@ -60,22 +66,54 @@ export default function TodoistCard({ settings }: Pick {}); try { setOauthStatus(await getTodoistConnectionStatus()); } catch { // The personal-token mutation succeeded; advanced status can recover on the next load. } + } catch { + setTodoistMessage("Todoist personal token could not be verified. The working connection was not changed."); } finally { setTodoistSavingSecret(false); } } + async function handleDisconnectTodoist() { + setDisconnecting(true); + setTodoistMessage(null); + try { + await disconnectTodoistConnection(); + sessionStorage.setItem("ea_settings_changed", "1"); + window.dispatchEvent(new CustomEvent("ea-settings-changed")); + setTodoistConfigured(false); + setTodoistDirty(false); + setTodoistToken(""); + setConfirmingDisconnect(false); + setOauthStatus((current) => current ? { + ...current, + mode: "disconnected", + configured: false, + oauthRefreshable: false, + needsReauth: false, + deliveryMode: "periodic", + } : current); + await onRefreshConnections().catch(() => {}); + } catch { + setTodoistMessage("Todoist could not be disconnected."); + } finally { + setDisconnecting(false); + } + } + async function handleSaveOAuthApplication() { setOauthBusy(true); setOauthMessage(null); @@ -148,6 +186,7 @@ export default function TodoistCard({ settings }: Pick { setTodoistToken(event.target.value); setTodoistDirty(true); + setTodoistMessage(null); }} /> @@ -176,7 +215,7 @@ export default function TodoistCard({ settings }: Pick - {todoistSavingSecret ? "Saving…" : "Save"} + {todoistSavingSecret ? "Saving & verifying…" : "Save & verify"} )} {todoistConfigured && !todoistDirty ? ( @@ -188,19 +227,46 @@ export default function TodoistCard({ settings }: Pick { - setTodoistToken(""); - setTodoistDirty(true); - setTodoistConfigured(false); - }} - className="rounded-md px-1 py-0.5 text-[11px] font-medium text-muted-foreground/75 transition-[color,background-color,transform] duration-200 hover:-translate-y-px hover:bg-danger/10 hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 active:translate-y-0 motion-reduce:transition-none motion-reduce:transform-none" + onClick={() => setConfirmingDisconnect(true)} + className="rounded-md px-1 py-0.5 text-[11px] font-medium text-muted-foreground/75 transition-[color,background-color,transform] duration-200 hover:-translate-y-px hover:bg-danger/10 hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger/60 active:translate-y-0 motion-reduce:transition-none motion-reduce:transform-none" > - Disconnect + Disconnect Todoist ) : null} + {todoistMessage ? {todoistMessage} : null} + {confirmingDisconnect ? ( +
+ + Task and deadline sync will stop. Mirrored tasks and automation settings stay available for review. + +
+ + +
+
+ ) : null} +
Advanced OAuth and webhooks diff --git a/src/components/settings/sections/ConnectionsSettingsSection.tsx b/src/components/settings/sections/ConnectionsSettingsSection.tsx index dd308ba0..1de36bf2 100644 --- a/src/components/settings/sections/ConnectionsSettingsSection.tsx +++ b/src/components/settings/sections/ConnectionsSettingsSection.tsx @@ -4,6 +4,7 @@ import type { ConnectionGroupDefinition, ConnectionRowView } from "@/components/ import type { SettingsAccountsProps, SettingsCredentialMetadataProps, + SettingsConnectionRefreshProps, SettingsState, SettingsPatch, } from "../settingsTypes"; @@ -18,7 +19,8 @@ export default function ConnectionsSettingsSection({ credentialMetadata, onCredentialMetadataChange, onRefreshCredentialMetadata, -}: SettingsAccountsProps & SettingsCredentialMetadataProps & { + onRefreshConnections, +}: SettingsAccountsProps & SettingsCredentialMetadataProps & SettingsConnectionRefreshProps & { settings: SettingsState | null; patch: SettingsPatch; connectionGroups: readonly ConnectionGroupDefinition[]; @@ -38,6 +40,7 @@ export default function ConnectionsSettingsSection({ credentialMetadata={credentialMetadata} onCredentialMetadataChange={onCredentialMetadataChange} onRefreshCredentialMetadata={onRefreshCredentialMetadata} + onRefreshConnections={onRefreshConnections} /> )} /> diff --git a/src/components/settings/settingsTypes.ts b/src/components/settings/settingsTypes.ts index 6ccc30ad..91be8c28 100644 --- a/src/components/settings/settingsTypes.ts +++ b/src/components/settings/settingsTypes.ts @@ -23,3 +23,7 @@ export interface SettingsCredentialMetadataProps { onCredentialMetadataChange: (metadata: InstanceCredentialMetadata | InstanceCredentialMetadata[]) => void; onRefreshCredentialMetadata: () => Promise; } + +export interface SettingsConnectionRefreshProps { + onRefreshConnections?: () => Promise; +} diff --git a/src/demo/demoExhaustiveness.test.ts b/src/demo/demoExhaustiveness.test.ts index 95e8848e..56170d61 100644 --- a/src/demo/demoExhaustiveness.test.ts +++ b/src/demo/demoExhaustiveness.test.ts @@ -19,6 +19,7 @@ const INTENTIONALLY_UNHANDLED_NAMES = [ "createApiToken", "disableInstanceCredential", "deletePasskeyCredential", + "disconnectTodoistConnection", "extractBillFromEmail", "getGmailAuthUrl", "getPasskeyAuthenticationOptions", @@ -28,11 +29,14 @@ const INTENTIONALLY_UNHANDLED_NAMES = [ "listApiTokens", "listPasskeys", "removeAccount", + "removeActualBudgetConnection", "reorderAccounts", "resolveBillPayMappingSample", "resolveBillPaySeed", "revokeApiToken", "sendToActualBudget", + "saveActualBudgetConnection", + "saveTodoistPersonalToken", "settleArrivalGrace", "stageGoogleOAuthApplication", "stageInstanceCredential", diff --git a/src/demo/demoMutations.test.ts b/src/demo/demoMutations.test.ts index df456cfb..5a3e35ca 100644 --- a/src/demo/demoMutations.test.ts +++ b/src/demo/demoMutations.test.ts @@ -130,6 +130,10 @@ describe("demo mode in-memory mutations", () => { await expect(api.getGmailAuthUrl()).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); await expect(api.testActualBudget(null)).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); + await expect(api.saveActualBudgetConnection({ serverURL: "https://actual.example", syncId: "demo" })).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); + await expect(api.removeActualBudgetConnection()).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); + await expect(api.saveTodoistPersonalToken("demo-token")).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); + await expect(api.disconnectTodoistConnection()).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); await expect(api.testDiscordReminderWebhook()).rejects.toMatchObject({ code: "DEMO_API_UNHANDLED" }); // P3-18: location autocomplete no longer surfaces DEMO_API_UNHANDLED; it diff --git a/src/hooks/settings/useSettingsPage.test.tsx b/src/hooks/settings/useSettingsPage.test.tsx index b78e9d70..3d88a4bf 100644 --- a/src/hooks/settings/useSettingsPage.test.tsx +++ b/src/hooks/settings/useSettingsPage.test.tsx @@ -112,6 +112,24 @@ describe("useSettingsPage debounced auto-save", () => { expect(mockApi.getCapabilities).toHaveBeenCalledTimes(1); }); + it("refreshes connection settings and capability evidence without running provider tests", async () => { + const { result } = renderHook(() => useSettingsPage(), { wrapper }); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + mockApi.getSettings.mockResolvedValueOnce({ actual_budget_configured: true }); + mockApi.getCapabilities.mockResolvedValueOnce({ + generatedAt: "2026-07-19T18:00:00.000Z", + capabilities: [{ id: "finances", state: "ready" }], + }); + + await act(async () => { await result.current.refreshConnections(); }); + + expect(mockApi.getSettings).toHaveBeenCalledTimes(2); + expect(mockApi.getCapabilities).toHaveBeenLastCalledWith(true); + expect(mockApi.getInstanceCredentials).toHaveBeenCalledTimes(1); + expect(result.current.settings).toMatchObject({ actual_budget_configured: true }); + expect(result.current.capabilities).toEqual([{ id: "finances", state: "ready" }]); + }); + it("re-queues a rejected payload so unrelated coalesced fields are not dropped", async () => { mockApi.updateSettings .mockRejectedValueOnce(new Error("400")) // first flush fails diff --git a/src/hooks/settings/useSettingsPage.ts b/src/hooks/settings/useSettingsPage.ts index fa6589a7..65352966 100644 --- a/src/hooks/settings/useSettingsPage.ts +++ b/src/hooks/settings/useSettingsPage.ts @@ -123,6 +123,15 @@ export default function useSettingsPage() { .catch(() => {}); }, []); + const refreshConnections = useCallback(async () => { + const [settingsResult, capabilityResult] = await Promise.all([ + getSettings(), + getCapabilities(true), + ]); + setSettings(settingsResult); + setCapabilities(capabilityResult.capabilities); + }, []); + const refreshInstanceCredentials = useCallback(async () => { try { const result = await getInstanceCredentials(); @@ -160,6 +169,7 @@ export default function useSettingsPage() { connections, credentialMetadata, refreshCapabilities, + refreshConnections, refreshInstanceCredentials, updateInstanceCredentialMetadata, setSettings, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 9335131d..084a0b94 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -21,6 +21,7 @@ export default function Settings() { connections, credentialMetadata, refreshInstanceCredentials, + refreshConnections, updateInstanceCredentialMetadata, setSettings, loading, @@ -154,6 +155,7 @@ export default function Settings() { credentialMetadata={credentialMetadata} onCredentialMetadataChange={updateInstanceCredentialMetadata} onRefreshCredentialMetadata={refreshInstanceCredentials} + onRefreshConnections={refreshConnections} /> ); } else if (tab === "finance") { From 23058be35a61d69afb2cc4504e5f81fb86fab2ea Mon Sep 17 00:00:00 2001 From: ansidian Date: Sun, 19 Jul 2026 12:28:15 -0700 Subject: [PATCH 30/44] feat: converge settings onboarding links --- FLOWS.md | 12 ++-- README.md | 6 +- src/components/settings/CLAUDE.md | 8 +-- .../settings/ConnectionPanelContent.test.tsx | 24 ++++++- .../settings/ConnectionPanelContent.tsx | 13 +++- .../settings/ConnectionsDirectory.test.tsx | 59 +++++++++++++++- .../settings/ConnectionsDirectory.tsx | 28 ++++++-- .../settings/cards/GmailRealtimeCard.test.tsx | 7 ++ .../settings/cards/GmailRealtimeCard.tsx | 19 ++++-- .../settings/cards/TodoistCard.test.tsx | 7 ++ src/components/settings/cards/TodoistCard.tsx | 22 ++++-- .../settings/connectionDirectoryModel.test.ts | 11 +++ .../settings/connectionDirectoryModel.ts | 10 +++ .../sections/ConnectionsSettingsSection.tsx | 10 +++ src/lib/CLAUDE.md | 2 +- src/lib/onboardingModel.test.ts | 57 +++++++++++++--- src/lib/onboardingModel.ts | 67 ++++++++++++++----- src/pages/Onboarding.test.tsx | 23 ++++++- src/pages/Onboarding.tsx | 25 +++++-- src/pages/Settings.demo.test.tsx | 2 +- src/pages/Settings.test.tsx | 46 ++++++++++++- src/pages/Settings.tsx | 30 ++++++++- 22 files changed, 411 insertions(+), 77 deletions(-) diff --git a/FLOWS.md b/FLOWS.md index 480cc630..8f705ee0 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -213,8 +213,12 @@ Selection path: 2. `server/onboarding-progress-store.ts` — reads and allowlist-updates reviewed/completed/skipped step state separately from `completed_at`; finish and reopen change only the checklist lifecycle. 3. `server/routes/onboarding.ts` — exposes authenticated `GET` and allowlisted `PATCH` mutations without accepting provider values or returning secrets. 4. `src/lib/onboardingApi.ts` — uses the authenticated API normally and an in-memory, network-free projection in demo builds. -5. `src/lib/onboardingModel.ts` — owns the locked capability order and selects the first unfinished step without consulting capability health. -6. `src/pages/Onboarding.tsx` — renders the resumable checklist, reads live `/api/capabilities` metadata, and routes configuration into the existing Settings controls so tests, OAuth, and write-only credential behavior are shared. -7. `src/App.tsx` — keeps dashboard access available while unfinished, resumes the checklist from login, and observes finish/reopen events so an explicit finish is immediately non-blocking. +5. `src/lib/onboardingModel.ts` — owns the locked capability order, allowlisted provider-specific Connections targets, first-unfinished projection, and the first persisted `reviewed` step eligible for **Continue setup**; none of these consult capability health. +6. `src/pages/Onboarding.tsx` — renders the resumable checklist, reads live `/api/capabilities` metadata, resumes an allowlisted `?step=`, and renders one explicit Connections action per provider so tests, OAuth, and write-only credential behavior are shared. +7. `src/pages/Settings.tsx` → `src/components/settings/ConnectionsDirectory.tsx` — fetches onboarding progress once at the page boundary; the directory shows **Continue setup** only for unfinished persisted `reviewed` work and never derives it from broken or disconnected services. +8. `src/components/settings/sections/ConnectionsSettingsSection.tsx` → `ConnectionPanelContent.tsx` — canonical connection hashes open the owning row; allowlisted `setup=gmail-realtime|todoist-advanced` query targets reveal and focus only that service's Advanced setup disclosure. +9. `src/App.tsx` — keeps dashboard access available while unfinished, resumes the checklist from login, and observes finish/reopen events so an explicit finish is immediately non-blocking. -**Separation:** capability degradation never reopens onboarding or changes persisted presentation progress. Finishing is permitted with every integration pending, and demo onboarding never calls setup, provider, or onboarding endpoints. +**Deep links:** base services use `/settings?tab=connections#`. Gmail realtime and Todoist advanced retain the owning connection hash and add an allowlisted `setup` query; deterministic legacy tab/card pairs are canonicalized, while ambiguous combined-card hashes are not guessed. + +**Separation:** capability degradation never reopens onboarding or changes persisted presentation progress. Untouched or skipped optional services do not produce **Continue setup**. Finishing is permitted with every integration pending, and demo onboarding/Settings use the in-memory progress adapter without calling setup, provider, or onboarding endpoints. diff --git a/README.md b/README.md index a6ef922c..2a316e46 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,8 @@ The default setup is a personal API token entered in Settings. It supports full Todoist read/write behavior and uses periodic reconciliation; OAuth is not required. -For optional OAuth refresh and real-time webhooks, open Todoist's advanced -Settings section and enter the client ID and client secret from your deployment's +For optional OAuth refresh and real-time webhooks, open **Settings → Connections +→ Todoist**, expand **Advanced OAuth and webhooks**, and enter the client ID and client secret from your deployment's Todoist Developer app. Set the OAuth callback and webhook URLs in Todoist to the canonical URLs shown there, then choose **Connect with OAuth**. Setpoint binds the callback to the initiating browser, exchanges the code server-side, encrypts the @@ -223,7 +223,7 @@ npm run dev # runs both Vite (frontend) and Express (backend) concurrentl Frontend: `http://localhost:5173` — proxies `/api/*` to Express on port 3001. -By default, `email_triage_mode = auto` resolves to `no_model` outside production, so `npm run dev` can index and show incoming mail without spending model budget. Production `auto` resolves to `real`. Change the mode under Settings → System when you intentionally want real local triage or need to pause triage job draining. +By default, `email_triage_mode = auto` resolves to `no_model` outside production, so `npm run dev` can index and show incoming mail without spending model budget. Production `auto` resolves to `real`. Change the mode under Settings → Automation when you intentionally want real local triage or need to pause triage job draining. ### Tests diff --git a/src/components/settings/CLAUDE.md b/src/components/settings/CLAUDE.md index 3dc21964..e94fc9f5 100644 --- a/src/components/settings/CLAUDE.md +++ b/src/components/settings/CLAUDE.md @@ -10,15 +10,15 @@ The settings surface: a Connections directory plus Automation, Finance, and Syst - `settings-ui.tsx` — StatusPill, SaveStatus, SettingsCard, SkeletonCard, SettingsLayout - `settingsTypes.ts` — shared Settings card state, patch, and account prop contracts - `connectionModel.ts` — fixed connection definitions plus pure service-level status projection -- `connectionDirectoryModel.ts` — canonical/legacy connection hash parsing plus directory summary/action projection -- `ConnectionsDirectory.tsx` — grouped, one-open disclosure directory synchronized to the URL hash -- `ConnectionPanelContent.tsx` — service-to-existing-control ownership mapping and expanded state evidence +- `connectionDirectoryModel.ts` — canonical/legacy connection hash parsing, allowlisted advanced targets, and directory summary/action projection +- `ConnectionsDirectory.tsx` — grouped, one-open disclosure directory synchronized to the URL hash, with progress-gated onboarding continuation +- `ConnectionPanelContent.tsx` — service-to-existing-control ownership mapping, expanded state evidence, and targeted Advanced setup routing - `ConnectionDependencyPrompt.tsx` — concise setup/repair prerequisite prompt with canonical Connections deep links - `featureDependencyModel.ts` — pure Automation/Finance visibility and AI provider-selection projection - `AccountsList.tsx` — draggable, editable provider-filtered account rows with icon/color pickers ### Sections (one per tab) -- `sections/ConnectionsSettingsSection.tsx` — directory shell that binds projected service rows to connection panels +- `sections/ConnectionsSettingsSection.tsx` — directory shell that binds projected service rows, onboarding progress, and advanced deep links to connection panels - `sections/ActualBudgetSettingsSection.tsx` — Finance behavior: bill-pay mappings, mapping tests, and utility links - `sections/EmailAutomationSettingsSection.tsx` — triage mode, sounds, AI models, extraction, lookback - `sections/SystemSettingsSection.tsx` — passkeys and API tokens diff --git a/src/components/settings/ConnectionPanelContent.test.tsx b/src/components/settings/ConnectionPanelContent.test.tsx index 7eea95ef..5bc63a4c 100644 --- a/src/components/settings/ConnectionPanelContent.test.tsx +++ b/src/components/settings/ConnectionPanelContent.test.tsx @@ -19,13 +19,17 @@ vi.mock("@/components/settings/cards/ICloudMailAccountsPanel", () => ({ default: () =>
, })); vi.mock("@/components/settings/cards/TodoistCard", () => ({ - default: () =>
, + default: ({ openAdvancedSetup }: { openAdvancedSetup?: boolean }) => ( +
+ ), })); vi.mock("@/components/settings/cards/WeatherLocationCard", () => ({ default: () =>
, })); vi.mock("@/components/settings/cards/GmailRealtimeCard", () => ({ - default: () =>
, + default: ({ openAdvancedSetup }: { openAdvancedSetup?: boolean }) => ( +
+ ), })); vi.mock("@/components/settings/cards/CoreProviderCredentialsCard", () => ({ default: ({ credentials }: { credentials: Array<{ key: string }> }) => ( @@ -50,10 +54,11 @@ function connection(id: ConnectionId): ConnectionRowView { }; } -function renderConnection(id: ConnectionId) { +function renderConnection(id: ConnectionId, setupTarget: "gmail-realtime" | "todoist-advanced" | null = null) { return render( { expect(screen.queryByTestId("icloud-account-controls")).toBeNull(); }); + it("reveals only the advanced subsection owned by the targeted service", async () => { + renderConnection("google-workspace", "gmail-realtime"); + expect((await screen.findByTestId("gmail-realtime-controls")).getAttribute("data-advanced-open")).toBe("true"); + + cleanup(); + renderConnection("todoist", "gmail-realtime"); + expect(screen.getByTestId("todoist-controls").getAttribute("data-advanced-open")).toBe("false"); + + cleanup(); + renderConnection("todoist", "todoist-advanced"); + expect(screen.getByTestId("todoist-controls").getAttribute("data-advanced-open")).toBe("true"); + }); + it("gives iCloud Mail only its account lifecycle", () => { renderConnection("icloud-mail"); diff --git a/src/components/settings/ConnectionPanelContent.tsx b/src/components/settings/ConnectionPanelContent.tsx index 40664c9b..9775f31e 100644 --- a/src/components/settings/ConnectionPanelContent.tsx +++ b/src/components/settings/ConnectionPanelContent.tsx @@ -10,6 +10,7 @@ import TodoistCard from "@/components/settings/cards/TodoistCard"; import WeatherLocationCard from "@/components/settings/cards/WeatherLocationCard"; import { FieldHint, StatusPill } from "@/components/settings/settings-ui"; import type { ConnectionRowView, ConnectionState } from "./connectionModel"; +import type { ConnectionSetupTarget } from "./connectionDirectoryModel"; import type { SettingsAccountsProps, SettingsCredentialMetadataProps, @@ -22,6 +23,7 @@ const GmailRealtimeCard = lazy(() => import("@/components/settings/cards/GmailRe type ConnectionPanelContentProps = SettingsAccountsProps & SettingsCredentialMetadataProps & SettingsConnectionRefreshProps & { connection: ConnectionRowView; + setupTarget?: ConnectionSetupTarget | null; settings: SettingsState | null; patch: SettingsPatch; }; @@ -49,6 +51,7 @@ function formatConnectionSource(source: ConnectionRowView["source"]) { export default function ConnectionPanelContent({ connection, + setupTarget = null, accounts, setAccounts, settings, @@ -73,7 +76,7 @@ export default function ConnectionPanelContent({ ); @@ -82,7 +85,13 @@ export default function ConnectionPanelContent({ controls = ; break; case "todoist": - controls = ; + controls = ( + + ); break; case "actual-budget": controls = ; diff --git a/src/components/settings/ConnectionsDirectory.test.tsx b/src/components/settings/ConnectionsDirectory.test.tsx index 8e80e973..5313ca5f 100644 --- a/src/components/settings/ConnectionsDirectory.test.tsx +++ b/src/components/settings/ConnectionsDirectory.test.tsx @@ -5,6 +5,7 @@ import { BrowserRouter } from "react-router-dom"; import { CONNECTIONS, CONNECTION_GROUPS } from "./connectionModel"; import type { ConnectionRowView, ConnectionState } from "./connectionModel"; import ConnectionsDirectory from "./ConnectionsDirectory"; +import type { OnboardingProgress } from "../../../shared/types/onboarding"; const states: Record = { "google-workspace": "connected", @@ -30,12 +31,13 @@ const rows: ConnectionRowView[] = CONNECTIONS.map((definition) => ({ lastFailedAt: null, })); -function renderDirectory() { +function renderDirectory(onboardingProgress?: OnboardingProgress | null, connectionRows = rows) { return render(
{connection.label} controls
} />
, @@ -99,6 +101,17 @@ describe("ConnectionsDirectory", () => { expect(screen.queryByTestId("panel-actual-budget")).toBeNull(); }); + it("canonicalizes a deterministic legacy tab and card hash", async () => { + window.history.replaceState({}, "", "/settings?tab=actual#actual-budget-connection"); + renderDirectory(); + + expect(await screen.findByTestId("panel-actual-budget")).toBeTruthy(); + await waitFor(() => { + expect(window.location.search).toBe("?tab=connections"); + expect(window.location.hash).toBe("#actual-budget"); + }); + }); + it("unmounts a closed panel so unsaved credential candidates are discarded", () => { function CandidatePanel() { const [candidate, setCandidate] = useState(""); @@ -129,4 +142,46 @@ describe("ConnectionsDirectory", () => { expect((screen.getByLabelText("Credential candidate") as HTMLInputElement).value).toBe(""); }); + + it("offers to continue only persisted in-progress onboarding work", () => { + const inProgress: OnboardingProgress = { + version: 1, + status: "in_progress", + steps: { ai: "reviewed" }, + completedAt: null, + updatedAt: 1, + }; + const { rerender } = renderDirectory(inProgress); + + expect(screen.getByRole("link", { name: "Continue setup" }).getAttribute("href")) + .toBe("/onboarding?step=ai"); + + rerender( + + null} + /> + , + ); + expect(screen.queryByRole("link", { name: "Continue setup" })).toBeNull(); + }); + + it("does not reopen finished onboarding when a connection later breaks", () => { + renderDirectory({ + version: 1, + status: "complete", + steps: { email_calendar: "reviewed" }, + completedAt: 2, + updatedAt: 2, + }, rows.map((row) => row.id === "google-workspace" ? { + ...row, + state: "needs_attention", + statusLabel: "Needs attention", + } : row)); + + expect(screen.queryByRole("link", { name: "Continue setup" })).toBeNull(); + }); }); diff --git a/src/components/settings/ConnectionsDirectory.tsx b/src/components/settings/ConnectionsDirectory.tsx index 54942ccc..c8a77f51 100644 --- a/src/components/settings/ConnectionsDirectory.tsx +++ b/src/components/settings/ConnectionsDirectory.tsx @@ -1,8 +1,10 @@ import { useEffect } from "react"; import type { ReactNode } from "react"; -import { AlertTriangle, CheckCircle2, ChevronDown, Circle, CircleDashed } from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { AlertTriangle, ArrowRight, CheckCircle2, ChevronDown, Circle, CircleDashed } from "lucide-react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; +import { onboardingContinueHref } from "@/lib/onboardingModel"; +import type { OnboardingProgress } from "../../../shared/types/onboarding"; import type { ConnectionGroupDefinition, ConnectionRowView, @@ -44,15 +46,17 @@ function rowMetadata(row: ConnectionRowView) { return parts.join(" · "); } -export default function ConnectionsDirectory({ groups, rows, renderPanel }: { +export default function ConnectionsDirectory({ groups, rows, onboardingProgress, renderPanel }: { groups: readonly ConnectionGroupDefinition[]; rows: readonly ConnectionRowView[]; + onboardingProgress?: OnboardingProgress | null; renderPanel: (connection: ConnectionRowView) => ReactNode; }) { const location = useLocation(); const navigate = useNavigate(); const openId = connectionIdFromHash(location.hash); const summary = connectionSummary(rows); + const continueSetupHref = onboardingProgress ? onboardingContinueHref(onboardingProgress) : null; useEffect(() => { if (!openId) return; @@ -85,10 +89,20 @@ export default function ConnectionsDirectory({ groups, rows, renderPanel }: { Connect, verify, and repair the external services Setpoint uses.

-
- {summary.connected} connected - {summary.setup} setup - {summary.attention} attention +
+
+ {summary.connected} connected + {summary.setup} setup + {summary.attention} attention +
+ {continueSetupHref ? ( + + Continue setup
diff --git a/src/components/settings/cards/GmailRealtimeCard.test.tsx b/src/components/settings/cards/GmailRealtimeCard.test.tsx index 8d972677..945eff0a 100644 --- a/src/components/settings/cards/GmailRealtimeCard.test.tsx +++ b/src/components/settings/cards/GmailRealtimeCard.test.tsx @@ -44,6 +44,13 @@ describe("GmailRealtimeCard", () => { expect(screen.getByText(/optional enhancement/i)).toBeTruthy(); }); + it("opens only its advanced disclosure when targeted by a deep link", async () => { + render(); + + const disclosure = (await screen.findByText("Advanced Pub/Sub setup")).closest("details") as HTMLDetailsElement; + expect(disclosure.open).toBe(true); + }); + it("reveals a generated callback once and lets the owner close it", async () => { api.generateGmailPubSubCallback.mockResolvedValue({ callbackUrl: "https://setpoint.example.com/api/gmail/push?token=one-time-secret", diff --git a/src/components/settings/cards/GmailRealtimeCard.tsx b/src/components/settings/cards/GmailRealtimeCard.tsx index 3ab0cde5..103f305e 100644 --- a/src/components/settings/cards/GmailRealtimeCard.tsx +++ b/src/components/settings/cards/GmailRealtimeCard.tsx @@ -15,9 +15,9 @@ import type { GmailPubSubStatus } from "../../../../shared/types/email"; import { SETTINGS_PRIMARY_BUTTON_CLASS, SETTINGS_SECONDARY_BUTTON_CLASS } from "../settings-core"; import { FieldHint, SectionLabel, SettingsCard, StatusPill } from "../settings-ui"; -const BUTTON_MOTION = "motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0"; +const BUTTON_MOTION = "min-h-11 motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0 sm:min-h-8"; -export default function GmailRealtimeCard() { +export default function GmailRealtimeCard({ openAdvancedSetup = false }: { openAdvancedSetup?: boolean }) { const demo = isDemoMode(); const [status, setStatus] = useState(null); const [topic, setTopic] = useState(""); @@ -25,6 +25,7 @@ export default function GmailRealtimeCard() { const [message, setMessage] = useState(null); const [revealedCallback, setRevealedCallback] = useState(null); const [copyMessage, setCopyMessage] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(openAdvancedSetup); const closeRef = useRef(null); useEffect(() => { @@ -40,6 +41,10 @@ export default function GmailRealtimeCard() { if (revealedCallback) closeRef.current?.focus(); }, [revealedCallback]); + useEffect(() => { + if (openAdvancedSetup) setAdvancedOpen(true); + }, [openAdvancedSetup]); + async function run(action: () => Promise, success: string) { setBusy(true); setMessage(null); @@ -86,8 +91,12 @@ export default function GmailRealtimeCard() { {demo ? Demo preview — controls are inert. : null}
{!demo ? ( -
- +
setAdvancedOpen(event.currentTarget.open)} + className="border-t border-white/[0.06] pt-4" + > + Advanced Pub/Sub setup
@@ -137,7 +146,7 @@ export default function GmailRealtimeCard() {
Copy this callback now

It includes a one-time-visible token and cannot be retrieved after this panel closes.

- +
{revealedCallback}
diff --git a/src/components/settings/cards/TodoistCard.test.tsx b/src/components/settings/cards/TodoistCard.test.tsx index 80385ba6..af678d26 100644 --- a/src/components/settings/cards/TodoistCard.test.tsx +++ b/src/components/settings/cards/TodoistCard.test.tsx @@ -108,6 +108,13 @@ describe("TodoistCard", () => { expect(screen.getByText(/personal token stays active until authorization succeeds/i)).toBeTruthy(); }); + it("opens only its advanced disclosure when targeted by a deep link", () => { + render(); + + const disclosure = screen.getByText("Advanced OAuth and webhooks").closest("details") as HTMLDetailsElement; + expect(disclosure.open).toBe(true); + }); + it("confirms Todoist impact before disconnecting and refreshes shared state", async () => { const onRefreshConnections = vi.fn(async () => {}); render(); diff --git a/src/components/settings/cards/TodoistCard.tsx b/src/components/settings/cards/TodoistCard.tsx index 9c37ca28..3d2b59e3 100644 --- a/src/components/settings/cards/TodoistCard.tsx +++ b/src/components/settings/cards/TodoistCard.tsx @@ -24,12 +24,15 @@ import type { TodoistConnectionStatus } from "../../../../shared/types/tasks"; import { cn } from "@/lib/utils"; const BUTTON_MOTION_CLASS = - "motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0"; + "min-h-11 motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0 sm:min-h-8"; export default function TodoistCard({ settings, onRefreshConnections = async () => {}, -}: Pick & SettingsConnectionRefreshProps) { + openAdvancedSetup = false, +}: Pick & SettingsConnectionRefreshProps & { + openAdvancedSetup?: boolean; +}) { const needsReauth = !!settings?.todoist_needs_reauth; const [todoistToken, setTodoistToken] = useState(""); const [todoistConfigured, setTodoistConfigured] = useState(false); @@ -43,6 +46,7 @@ export default function TodoistCard({ const [clientSecret, setClientSecret] = useState(""); const [oauthBusy, setOauthBusy] = useState(false); const [oauthMessage, setOauthMessage] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(openAdvancedSetup); useEffect(() => { if (settings?.todoist_configured) { @@ -64,6 +68,10 @@ export default function TodoistCard({ }; }, []); + useEffect(() => { + if (openAdvancedSetup) setAdvancedOpen(true); + }, [openAdvancedSetup]); + async function handleSaveTodoistSecret() { setTodoistSavingSecret(true); setTodoistMessage(null); @@ -228,7 +236,7 @@ export default function TodoistCard({ @@ -267,8 +275,12 @@ export default function TodoistCard({
) : null} -
- +
setAdvancedOpen(event.currentTarget.open)} + className="border-t border-white/[0.06] pt-4" + > + Advanced OAuth and webhooks
diff --git a/src/components/settings/connectionDirectoryModel.test.ts b/src/components/settings/connectionDirectoryModel.test.ts index 116ae251..52183714 100644 --- a/src/components/settings/connectionDirectoryModel.test.ts +++ b/src/components/settings/connectionDirectoryModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { connectionIdFromHash, + connectionSetupTargetFromSearch, connectionSummary, } from "./connectionDirectoryModel"; @@ -31,4 +32,14 @@ describe("connection directory routing", () => { { state: null }, ])).toEqual({ connected: 2, setup: 1, attention: 1 }); }); + + it.each([ + ["?tab=connections&setup=gmail-realtime", "gmail-realtime"], + ["?setup=todoist-advanced", "todoist-advanced"], + ["?setup=google-places", null], + ["?setup=unknown", null], + ["", null], + ] as const)("allowlists advanced setup target %s", (search, expected) => { + expect(connectionSetupTargetFromSearch(search)).toBe(expected); + }); }); diff --git a/src/components/settings/connectionDirectoryModel.ts b/src/components/settings/connectionDirectoryModel.ts index df809e33..468c0506 100644 --- a/src/components/settings/connectionDirectoryModel.ts +++ b/src/components/settings/connectionDirectoryModel.ts @@ -2,6 +2,9 @@ import { CONNECTIONS } from "./connectionModel"; import type { ConnectionId, ConnectionState } from "./connectionModel"; const CONNECTION_IDS = new Set(CONNECTIONS.map(({ id }) => id)); +const CONNECTION_SETUP_TARGETS = ["gmail-realtime", "todoist-advanced"] as const; + +export type ConnectionSetupTarget = typeof CONNECTION_SETUP_TARGETS[number]; const LEGACY_HASH_ALIASES: Readonly> = { "todoist-setup": "todoist", @@ -16,6 +19,13 @@ export function connectionIdFromHash(hash: string): ConnectionId | null { return LEGACY_HASH_ALIASES[value] ?? null; } +export function connectionSetupTargetFromSearch(search: string): ConnectionSetupTarget | null { + const value = new URLSearchParams(search).get("setup"); + return CONNECTION_SETUP_TARGETS.includes(value as ConnectionSetupTarget) + ? value as ConnectionSetupTarget + : null; +} + export function connectionSummary(rows: ReadonlyArray<{ state: ConnectionState | null }>) { return rows.reduce((summary, row) => { if (row.state === "connected") summary.connected += 1; diff --git a/src/components/settings/sections/ConnectionsSettingsSection.tsx b/src/components/settings/sections/ConnectionsSettingsSection.tsx index 1de36bf2..202e09e2 100644 --- a/src/components/settings/sections/ConnectionsSettingsSection.tsx +++ b/src/components/settings/sections/ConnectionsSettingsSection.tsx @@ -1,6 +1,9 @@ +import { useLocation } from "react-router-dom"; import ConnectionPanelContent from "@/components/settings/ConnectionPanelContent"; import ConnectionsDirectory from "@/components/settings/ConnectionsDirectory"; +import { connectionSetupTargetFromSearch } from "@/components/settings/connectionDirectoryModel"; import type { ConnectionGroupDefinition, ConnectionRowView } from "@/components/settings/connectionModel"; +import type { OnboardingProgress } from "../../../../shared/types/onboarding"; import type { SettingsAccountsProps, SettingsCredentialMetadataProps, @@ -16,6 +19,7 @@ export default function ConnectionsSettingsSection({ patch, connectionGroups, connections, + onboardingProgress, credentialMetadata, onCredentialMetadataChange, onRefreshCredentialMetadata, @@ -25,14 +29,20 @@ export default function ConnectionsSettingsSection({ patch: SettingsPatch; connectionGroups: readonly ConnectionGroupDefinition[]; connections: readonly ConnectionRowView[]; + onboardingProgress: OnboardingProgress | null; }) { + const location = useLocation(); + const setupTarget = connectionSetupTargetFromSearch(location.search); + return ( ( { expect(checklist.activeStepId).toBe("tasks"); }); - it("routes every setup action to its exact settings card", () => { - expect(Object.fromEntries(ONBOARDING_STEPS.map((step) => [step.id, step.settingsHref]))).toEqual({ - email_calendar: "/settings?tab=accounts#connected-accounts", - ai: "/settings?tab=briefing#ai-provider-credentials", - tasks: "/settings?tab=accounts#todoist-setup", - weather: "/settings?tab=accounts#location-provider-credentials", - finances: "/settings?tab=actual#actual-budget-connection", - notifications: "/settings?tab=accounts#discord-reminders", - advanced_delivery: "/settings?tab=accounts#gmail-realtime-delivery", + it("routes every setup action to its exact provider-owned connection panel", () => { + expect(Object.fromEntries(ONBOARDING_STEPS.map((step) => [step.id, step.targets]))).toEqual({ + email_calendar: [ + { connectionId: "google-workspace", label: "Google Workspace", href: "/settings?tab=connections#google-workspace" }, + { connectionId: "icloud-mail", label: "iCloud Mail", href: "/settings?tab=connections#icloud-mail" }, + ], + ai: [ + { connectionId: "openai", label: "OpenAI", href: "/settings?tab=connections#openai" }, + { connectionId: "anthropic", label: "Anthropic", href: "/settings?tab=connections#anthropic" }, + ], + tasks: [ + { connectionId: "todoist", label: "Todoist", href: "/settings?tab=connections#todoist" }, + ], + weather: [ + { connectionId: "pirate-weather", label: "Pirate Weather", href: "/settings?tab=connections#pirate-weather" }, + ], + finances: [ + { connectionId: "actual-budget", label: "Actual Budget", href: "/settings?tab=connections#actual-budget" }, + ], + notifications: [ + { connectionId: "discord-reminders", label: "Discord Reminders", href: "/settings?tab=connections#discord-reminders" }, + ], + advanced_delivery: [ + { connectionId: "google-workspace", label: "Gmail realtime", href: "/settings?tab=connections&setup=gmail-realtime#google-workspace" }, + { connectionId: "todoist", label: "Todoist advanced", href: "/settings?tab=connections&setup=todoist-advanced#todoist" }, + { connectionId: "google-places", label: "Google Places", href: "/settings?tab=connections#google-places" }, + ], }); }); + + it("continues only persisted in-progress onboarding work", () => { + expect(onboardingContinueHref({ + ...progress, + steps: { email_calendar: "reviewed", ai: "reviewed" }, + })).toBe("/onboarding?step=email_calendar"); + expect(onboardingContinueHref({ ...progress, steps: {} })).toBeNull(); + expect(onboardingContinueHref({ ...progress, steps: { advanced_delivery: "skipped" } })).toBeNull(); + expect(onboardingContinueHref({ + ...progress, + status: "complete", + steps: { email_calendar: "reviewed" }, + completedAt: 200, + })).toBeNull(); + }); }); diff --git a/src/lib/onboardingModel.ts b/src/lib/onboardingModel.ts index 52fab6c5..0349b3a9 100644 --- a/src/lib/onboardingModel.ts +++ b/src/lib/onboardingModel.ts @@ -10,8 +10,24 @@ export interface OnboardingStepDefinition { title: string; description: string; capabilityIds: CapabilityId[]; - settingsHref: string; - actionLabel: string; + targets: OnboardingConnectionTarget[]; +} + +export type OnboardingConnectionId = + | "google-workspace" + | "icloud-mail" + | "todoist" + | "actual-budget" + | "openai" + | "anthropic" + | "discord-reminders" + | "pirate-weather" + | "google-places"; + +export interface OnboardingConnectionTarget { + connectionId: OnboardingConnectionId; + label: string; + href: string; } export const ONBOARDING_STEPS: OnboardingStepDefinition[] = [ @@ -20,56 +36,67 @@ export const ONBOARDING_STEPS: OnboardingStepDefinition[] = [ title: "Connect email and calendar", description: "Authorize Google once for Gmail and Calendar, or add an iCloud inbox.", capabilityIds: ["email_calendar"], - settingsHref: "/settings?tab=accounts#connected-accounts", - actionLabel: "Open account connections", + targets: [ + { connectionId: "google-workspace", label: "Google Workspace", href: "/settings?tab=connections#google-workspace" }, + { connectionId: "icloud-mail", label: "iCloud Mail", href: "/settings?tab=connections#icloud-mail" }, + ], }, { id: "ai", title: "Enable AI features", - description: "Add OpenAI, Anthropic, or both. Triage and extraction model choices stay in advanced Settings.", + description: "Add OpenAI, Anthropic, or both. Triage and extraction model choices stay in Automation.", capabilityIds: ["ai"], - settingsHref: "/settings?tab=briefing#ai-provider-credentials", - actionLabel: "Open AI credentials", + targets: [ + { connectionId: "openai", label: "OpenAI", href: "/settings?tab=connections#openai" }, + { connectionId: "anthropic", label: "Anthropic", href: "/settings?tab=connections#anthropic" }, + ], }, { id: "tasks", title: "Add tasks", description: "Start with a Todoist personal token. OAuth and webhooks remain optional advanced setup.", capabilityIds: ["tasks"], - settingsHref: "/settings?tab=accounts#todoist-setup", - actionLabel: "Open Todoist setup", + targets: [ + { connectionId: "todoist", label: "Todoist", href: "/settings?tab=connections#todoist" }, + ], }, { id: "weather", title: "Add weather", description: "Choose a location and add Pirate Weather. Location search itself does not need a key.", capabilityIds: ["weather"], - settingsHref: "/settings?tab=accounts#location-provider-credentials", - actionLabel: "Open weather setup", + targets: [ + { connectionId: "pirate-weather", label: "Pirate Weather", href: "/settings?tab=connections#pirate-weather" }, + ], }, { id: "finances", title: "Connect finances", description: "Connect your existing Actual Budget server when you want bills and transactions in Setpoint.", capabilityIds: ["finances"], - settingsHref: "/settings?tab=actual#actual-budget-connection", - actionLabel: "Open Actual Budget setup", + targets: [ + { connectionId: "actual-budget", label: "Actual Budget", href: "/settings?tab=connections#actual-budget" }, + ], }, { id: "notifications", title: "Configure notifications", description: "Add a private Discord reminder destination, or leave notifications off for now.", capabilityIds: ["notifications"], - settingsHref: "/settings?tab=accounts#discord-reminders", - actionLabel: "Open notification setup", + targets: [ + { connectionId: "discord-reminders", label: "Discord Reminders", href: "/settings?tab=connections#discord-reminders" }, + ], }, { id: "advanced_delivery", title: "Optional delivery enhancements", description: "Real-time Gmail, Todoist OAuth/webhooks, and Calendar places are independent advanced options.", capabilityIds: ["gmail_realtime", "todoist_advanced", "calendar_places"], - settingsHref: "/settings?tab=accounts#gmail-realtime-delivery", - actionLabel: "Open advanced setup", + targets: [ + { connectionId: "google-workspace", label: "Gmail realtime", href: "/settings?tab=connections&setup=gmail-realtime#google-workspace" }, + { connectionId: "todoist", label: "Todoist advanced", href: "/settings?tab=connections&setup=todoist-advanced#todoist" }, + { connectionId: "google-places", label: "Google Places", href: "/settings?tab=connections#google-places" }, + ], }, ]; @@ -87,3 +114,9 @@ export function projectOnboardingChecklist(progress: OnboardingProgress) { finished: progress.status === "complete", }; } + +export function onboardingContinueHref(progress: OnboardingProgress): string | null { + if (progress.status === "complete") return null; + const inProgressStep = ONBOARDING_STEPS.find((step) => progress.steps[step.id] === "reviewed"); + return inProgressStep ? `/onboarding?step=${inProgressStep.id}` : null; +} diff --git a/src/pages/Onboarding.test.tsx b/src/pages/Onboarding.test.tsx index 97b3dfbc..9aba5b35 100644 --- a/src/pages/Onboarding.test.tsx +++ b/src/pages/Onboarding.test.tsx @@ -39,12 +39,29 @@ describe("Onboarding", () => { })); }); - it("renders the capability-led sequence and uses the existing Settings workflow", async () => { + it("renders explicit provider actions for multi-provider steps", async () => { render(); expect(await screen.findByRole("heading", { name: "Connect email and calendar" })).toBeTruthy(); - expect(screen.getByRole("link", { name: "Open account connections" }).getAttribute("href")).toBe("/settings?tab=accounts#connected-accounts"); - expect(screen.getByRole("button", { name: /Enable AI features/ })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Set up Google Workspace" }).getAttribute("href")).toBe("/settings?tab=connections#google-workspace"); + expect(screen.getByRole("link", { name: "Set up iCloud Mail" }).getAttribute("href")).toBe("/settings?tab=connections#icloud-mail"); + + fireEvent.click(screen.getByRole("button", { name: /Enable AI features/ })); + expect(await screen.findByRole("heading", { name: "Enable AI features" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Set up OpenAI" }).getAttribute("href")).toBe("/settings?tab=connections#openai"); + expect(screen.getByRole("link", { name: "Set up Anthropic" }).getAttribute("href")).toBe("/settings?tab=connections#anthropic"); + }); + + it("opens a requested onboarding step and exposes each advanced destination", async () => { + render(); + + expect(await screen.findByRole("heading", { name: "Optional delivery enhancements" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Set up Gmail realtime" }).getAttribute("href")) + .toBe("/settings?tab=connections&setup=gmail-realtime#google-workspace"); + expect(screen.getByRole("link", { name: "Set up Todoist advanced" }).getAttribute("href")) + .toBe("/settings?tab=connections&setup=todoist-advanced#todoist"); + expect(screen.getByRole("link", { name: "Set up Google Places" }).getAttribute("href")) + .toBe("/settings?tab=connections#google-places"); }); it("persists skip state and advances without requiring a provider", async () => { diff --git a/src/pages/Onboarding.tsx b/src/pages/Onboarding.tsx index 50964f01..a8f4995f 100644 --- a/src/pages/Onboarding.tsx +++ b/src/pages/Onboarding.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactElement } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { ArrowRight, Check, @@ -22,9 +22,10 @@ import type { OnboardingProgressMutation, OnboardingStepId, } from "../../shared/types/onboarding"; +import { isOnboardingStepId } from "../../shared/types/onboarding"; import { cn } from "@/lib/utils"; -const SECONDARY_BUTTON = "motion-reduce:transition-none motion-reduce:transform-none"; +const SECONDARY_BUTTON = "min-h-11 sm:min-h-8 motion-reduce:transition-none motion-reduce:transform-none"; function progressLabel(state: "pending" | "reviewed" | "completed" | "skipped") { if (state === "completed") return { label: "Reviewed", tone: "success" as const }; @@ -34,6 +35,7 @@ function progressLabel(state: "pending" | "reviewed" | "completed" | "skipped") } export default function Onboarding(): ReactElement { + const [searchParams] = useSearchParams(); const [progress, setProgress] = useState(null); const [capabilities, setCapabilities] = useState([]); const [activeId, setActiveId] = useState(ONBOARDING_STEPS[0]!.id); @@ -50,7 +52,10 @@ export default function Onboarding(): ReactElement { ]); setProgress(nextProgress); setCapabilities(status.capabilities); - setActiveId(projectOnboardingChecklist(nextProgress).activeStepId); + const requestedStep = searchParams.get("step"); + setActiveId(isOnboardingStepId(requestedStep) + ? requestedStep + : projectOnboardingChecklist(nextProgress).activeStepId); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : "Could not load onboarding"); } @@ -142,7 +147,7 @@ export default function Onboarding(): ReactElement {
- +
); }, @@ -106,6 +116,13 @@ beforeEach(() => { mockApi.getAccounts.mockResolvedValue([]); mockApi.getCapabilities.mockResolvedValue({ generatedAt: "2026-07-18T00:00:00.000Z", capabilities: [] }); mockApi.getInstanceCredentials.mockResolvedValue({ credentials: [] }); + mockApi.getOnboardingProgress.mockResolvedValue({ + version: 1, + status: "in_progress", + steps: { ai: "reviewed" }, + completedAt: null, + updatedAt: 1, + }); mockApi.getSettings.mockResolvedValue({}); mockApi.updateSettings.mockResolvedValue({ success: true }); mockApi.targetReadyDelayMs = 0; @@ -121,6 +138,14 @@ describe("Settings page", () => { expect(screen.queryByTestId("settings-connections-section")).toBeNull(); }); + it("coordinates onboarding progress once for the Connections header", async () => { + renderSettings(); + + const section = await screen.findByTestId("settings-connections-section"); + await waitFor(() => expect(section.getAttribute("data-onboarding-status")).toBe("in_progress")); + expect(mockApi.getOnboardingProgress).toHaveBeenCalledTimes(1); + }); + it("waits for a linked settings card to finish loading, then flashes it after scrolling ends", async () => { const scrollIntoView = vi.fn(); HTMLElement.prototype.scrollIntoView = scrollIntoView; @@ -150,6 +175,25 @@ describe("Settings page", () => { }); }); + it("focuses the requested Advanced setup disclosure instead of the service row", async () => { + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(performance.now()); + return 1; + }); + window.history.replaceState({}, "", "/settings?tab=connections&setup=todoist-advanced#todoist"); + + renderSettings(); + + const advancedSummary = await screen.findByText("Advanced OAuth and webhooks"); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "center", + })); + expect(document.activeElement).toBe(advancedSummary); + }); + it("renders the shared loading chrome while settings are still loading", () => { mockApi.getAccounts.mockReturnValue(new Promise(() => {})); mockApi.getSettings.mockReturnValue(new Promise(() => {})); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 084a0b94..6d7a3447 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import ConnectionsSettingsSection from "@/components/settings/sections/ConnectionsSettingsSection"; import ActualBudgetSettingsSection from "@/components/settings/sections/ActualBudgetSettingsSection"; @@ -10,9 +10,13 @@ import { SkeletonCard, } from "@/components/settings/settings-ui"; import useSettingsPage from "@/hooks/settings/useSettingsPage"; +import { getOnboardingProgress } from "@/lib/onboardingApi"; +import { connectionSetupTargetFromSearch } from "@/components/settings/connectionDirectoryModel"; +import type { OnboardingProgress } from "../../shared/types/onboarding"; export default function Settings() { const location = useLocation(); + const [onboardingProgress, setOnboardingProgress] = useState(null); const { accounts, setAccounts, @@ -31,9 +35,28 @@ export default function Settings() { patch, } = useSettingsPage(); + useEffect(() => { + let active = true; + getOnboardingProgress() + .then((progress) => { + if (active) setOnboardingProgress(progress); + }) + .catch(() => { + if (active) setOnboardingProgress(null); + }); + return () => { + active = false; + }; + }, []); + useEffect(() => { if (loading || !location.hash) return; - const targetId = location.hash.slice(1); + const setupTarget = connectionSetupTargetFromSearch(location.search); + const targetId = setupTarget === "gmail-realtime" + ? "gmail-realtime-advanced-setup" + : setupTarget === "todoist-advanced" + ? "todoist-advanced-setup" + : location.hash.slice(1); let target: HTMLElement | null = null; let settleTimer: number | null = null; let scrollFallbackTimer: number | null = null; @@ -132,7 +155,7 @@ export default function Settings() { clearScrollWait(); if (target) delete target.dataset.settingsTargetActive; }; - }, [loading, location.hash, tab]); + }, [loading, location.hash, location.search, tab]); let content = ( <> @@ -152,6 +175,7 @@ export default function Settings() { patch={patch} connectionGroups={connectionGroups} connections={connections} + onboardingProgress={onboardingProgress} credentialMetadata={credentialMetadata} onCredentialMetadataChange={updateInstanceCredentialMetadata} onRefreshCredentialMetadata={refreshInstanceCredentials} From 42554a87d2cde86b31a4897535ee6c92a7d4ade8 Mon Sep 17 00:00:00 2001 From: ansidian Date: Sun, 19 Jul 2026 18:25:08 -0700 Subject: [PATCH 31/44] feat: harden owner security and Actual archive handling --- .env.example | 3 + ARCHITECTURE.md | 64 ++- FLOWS.md | 16 +- README.md | 41 +- render.yaml | 2 + server/CLAUDE.md | 9 +- server/actual/CLAUDE.md | 3 +- server/actual/actual-budget-archive.test.ts | 76 ++++ server/actual/actual-budget-archive.ts | 130 ++++++ server/actual/actual-core.ts | 58 +-- server/actual/actual-local-metadata.test.ts | 5 +- server/actual/actual-local-metadata.ts | 14 +- server/actual/actual.test.ts | 22 + server/actual/actualMetadataSync.test.ts | 44 +- server/actual/actualMetadataSync.ts | 51 ++- server/auth/owner-bootstrap.test.ts | 8 +- server/auth/owner-claim-service.ts | 3 +- server/auth/owner-store.test.ts | 3 +- server/auth/owner-store.ts | 6 +- server/auth/passkey-store.test.ts | 14 + server/auth/passkey-store.ts | 7 +- server/auth/password-policy.ts | 12 + server/auth/pending-auth-store.test.ts | 43 +- server/auth/pending-auth-store.ts | 89 +++- server/auth/recovery-code-store.ts | 2 - server/auth/security-transition.test.ts | 82 ++++ server/auth/security-transition.ts | 57 +++ server/auth/session-cookie.ts | 46 ++ server/auth/session-rotation.test.ts | 34 -- server/auth/session-rotation.ts | 30 -- server/auth/setup-token.test.ts | 18 + server/auth/setup-token.ts | 28 ++ server/auth/webauthn-challenge-store.test.ts | 29 ++ server/auth/webauthn-challenge-store.ts | 50 ++- server/db/migrations.test.ts | 57 +++ .../038_auth_security_generation.sql | 42 ++ .../039_password_step_up_window.sql | 2 + server/index.ts | 4 +- server/middleware/CLAUDE.md | 4 +- server/middleware/auth.test.ts | 73 ++-- server/middleware/auth.ts | 296 +++++++++---- server/middleware/owner-gate.test.ts | 1 + server/routes/CLAUDE.md | 5 +- server/routes/accounts.oauth.test.ts | 3 + server/routes/alfred.test.ts | 17 +- server/routes/auth-boundaries.test.ts | 24 +- server/routes/auth-canonical-origin.ts | 41 +- server/routes/auth-security.ts | 372 ++++++++++++++++ server/routes/auth.passkeys.test.ts | 23 +- server/routes/auth.test.ts | 98 ++++- server/routes/auth.ts | 400 +++++++----------- server/routes/briefing/bills.test.ts | 8 +- server/routes/briefing/email-index.test.ts | 8 +- server/routes/briefing/snapshot.test.ts | 8 +- server/routes/dashboard.test.ts | 25 +- server/scripts/reset-passkeys.test.ts | 8 +- server/scripts/reset-passkeys.ts | 19 +- server/test-utils/auth-db.ts | 34 +- .../settings/cards/PasskeysCard.test.tsx | 88 +++- .../settings/cards/PasskeysCard.tsx | 146 +++++-- src/components/settings/settings-ui.tsx | 10 +- src/pages/Login.test.tsx | 12 + src/pages/Login.tsx | 7 + src/pages/OwnerSetup.test.tsx | 8 +- src/pages/OwnerSetup.tsx | 50 ++- src/setupApi.test.ts | 7 +- src/setupApi.ts | 8 +- 67 files changed, 2344 insertions(+), 663 deletions(-) create mode 100644 server/actual/actual-budget-archive.test.ts create mode 100644 server/actual/actual-budget-archive.ts create mode 100644 server/auth/password-policy.ts create mode 100644 server/auth/security-transition.test.ts create mode 100644 server/auth/security-transition.ts create mode 100644 server/auth/session-cookie.ts delete mode 100644 server/auth/session-rotation.test.ts delete mode 100644 server/auth/session-rotation.ts create mode 100644 server/auth/setup-token.test.ts create mode 100644 server/auth/setup-token.ts create mode 100644 server/db/migrations/038_auth_security_generation.sql create mode 100644 server/db/migrations/039_password_step_up_window.sql create mode 100644 server/routes/auth-security.ts diff --git a/.env.example b/.env.example index 4cbd73ef..aee10a78 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ TURSO_DATABASE_URL=libsql://your-ea-db.turso.io TURSO_AUTH_TOKEN= EA_ENCRYPTION_KEY= +# Required only while claiming a fresh instance. Generate at least 32 random +# characters and enter the same value on the first-run setup screen. +EA_SETUP_TOKEN= # Optional advanced host-managed provider sources. Normal setup stores these # write-only in Setpoint Settings; stored values take precedence over env values. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a6a97d95..9eae8ca3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -362,22 +362,22 @@ sequenceDiagram S->>DB: SELECT password_hash FROM ea_owner singleton S->>S: bcrypt.compare(password, stored password_hash) alt Default password-or-passkey mode - S->>DB: INSERT ea_sessions (token, expires_at) + S->>DB: INSERT ea_sessions (token, generation, auth method, password proof time, expires_at) S->>B: Set-Cookie: ea_session (httpOnly, secure, sameSite=strict) else Explicit password-plus-passkey mode - S->>DB: INSERT ea_pending_auth (10-min pending password auth) + S->>DB: INSERT ea_pending_auth (5-min password proof + security generation) S->>B: Set-Cookie: ea_pending_auth (httpOnly, secure, sameSite=strict) B->>S: POST /api/auth/passkey/authentication/options - S->>DB: INSERT ea_webauthn_challenges + S->>DB: INSERT ea_webauthn_challenges (one-time challenge + generation) S->>B: WebAuthn authentication options B->>S: POST /api/auth/passkey/authentication/verify - S->>DB: Consume challenge and update passkey usage - S->>DB: INSERT ea_sessions (token, expires_at) + S->>DB: Atomically consume challenge/pending auth and update passkey usage + S->>DB: INSERT ea_sessions only if owner generation is unchanged S->>B: Set-Cookie: ea_session, clear ea_pending_auth end B->>S: GET /api/dashboard/current (cookie) - S->>DB: SELECT FROM ea_sessions WHERE token = ? + S->>DB: JOIN ea_sessions to ea_owner on security_generation S->>S: Check expires_at > now S->>B: 200 current dashboard envelope (or 401 if expired) ``` @@ -385,18 +385,28 @@ sequenceDiagram The browser auth model has six distinct states: 1. **Unclaimed Instance** - no `ea_owner` singleton row. Only static/setup auth routes and `GET /healthz` are available; provider APIs and all background workers are gated. -2. **Authenticated Session** - `ea_session` cookie. The browser receives a raw 32-byte hex session token, but `ea_sessions` stores only `sha256:`. Used by the SPA and required by normal dashboard routes. Once issued, it is trusted until expiry or logout; the app does not prompt for passkey on every request. +2. **Authenticated Session** - `ea_session` cookie. The browser receives a raw 32-byte hex session token, but `ea_sessions` stores only `sha256:`, its authentication method, password-proof timestamp, and owner security generation. Every validation joins the session to the current owner generation, so a credential transition or operator reset invalidates every older session immediately, including across processes and even if a deletion races. Used by the SPA and required by normal dashboard routes; the app does not prompt for passkey on every request. 3. **Pending Passkey Authentication** - `ea_pending_auth` cookie plus a row in `ea_pending_auth`. Created after a correct password in explicit strict mode, or when default-mode passwordless passkey login begins. It can request and verify WebAuthn options but cannot access dashboard routes. 4. **Registered Passkey** - row in `ea_passkey_credentials` containing credential ID, public key, sign count, label, transports, backup state, and device type. Public key material never leaves the server in management responses. -5. **Recent Authentication** - the authenticated session's `authenticated_at` is within ten minutes. Required for password, passkey, recovery-code, auth-mode, canonical-domain, and powerful API-token changes. +5. **Recent Password Authentication** - the authenticated session's `password_authenticated_at` is within ten minutes. Required for password, passkey, recovery-code, auth-mode, canonical-domain, and powerful API-token changes. Passkey-only and recovery-created sessions do not satisfy this boundary until the owner confirms the current password; failed confirmations are throttled in the session row before more bcrypt work is accepted. 6. **Recovery or Operator Reset** - one offline recovery code can replace credentials in-app; the local `npm run auth:reset-passkeys -- --confirm` path remains the last-resort operator reset. +The browser keeps a separate, shorter Security Settings unlock. Sensitive controls start locked whenever the System section mounts and lock on `pagehide`; the server's recent-auth timestamp can authorize requests during that visit but never auto-opens a later section visit or restored page. + Ownership is database-backed in the singleton `ea_owner` row. Fresh claims rely on -the singleton primary-key invariant so exactly one concurrent insert succeeds. +an out-of-band `EA_SETUP_TOKEN` plus the singleton primary-key invariant so only +an authorized claimant can attempt the one concurrent insert that succeeds. Existing `EA_USER_ID` plus `EA_PASSWORD_HASH` values are an optional startup compatibility source: startup imports the exact pair when no owner exists and fails closed for partial or conflicting state. +Every sensitive credential or security mutation is a compare-and-swap +transaction against `ea_owner.security_generation`. The transaction increments +the generation, performs the mutation, and clears sessions, pending auth, and +WebAuthn challenges before commit; the initiating browser receives a replacement +session only after the commit. Offline recovery additionally revokes all scoped +API tokens. + Two credential paths exist, but they no longer feed a single shared "any auth works" guard: 1. **Cookie session** - normal dashboard access after password, passwordless passkey, strict password-plus-passkey, or successful recovery. @@ -498,8 +508,14 @@ erDiagram } ea_sessions { - text token PK "32-byte hex" + text token PK "sha256 digest" int expires_at "Unix ms, 30-day TTL" + int authenticated_at "Unix ms" + int password_authenticated_at "Unix ms or 0" + int security_generation + text auth_method + int step_up_failure_count + int step_up_blocked_until datetime created_at } @@ -656,13 +672,13 @@ erDiagram | `ea_news_topics` | `026_news.sql`, `027_news_mute_terms.sql` | | `ea_notes` | `001_ea_tables.sql`, `021_notes_archive.sql` | | `ea_onboarding_progress` | `037_onboarding_progress.sql` | -| `ea_owner` | `030_owner_bootstrap.sql`, `031_auth_recovery.sql` | +| `ea_owner` | `030_owner_bootstrap.sql`, `031_auth_recovery.sql`, `038_auth_security_generation.sql` | | `ea_owner_recovery_codes` | `031_auth_recovery.sql` | | `ea_passkey_credentials` | `012_passkey_auth.sql` | -| `ea_pending_auth` | `012_passkey_auth.sql` | +| `ea_pending_auth` | `012_passkey_auth.sql`, `038_auth_security_generation.sql` | | `ea_pinned_emails` | `022_pinned_emails.sql`, `023_pinned_emails_rebuild.sql` | | `ea_reminders` | `010_discord_reminders.sql` | -| `ea_sessions` | `001_ea_tables.sql`, `031_auth_recovery.sql` | +| `ea_sessions` | `001_ea_tables.sql`, `031_auth_recovery.sql`, `038_auth_security_generation.sql`, `039_password_step_up_window.sql` | | `ea_settings` | `001_ea_tables.sql`, `003_triage_sound_settings.sql`, `008_bill_pay_mappings.sql`, `010_discord_reminders.sql`, `020_utility_pay_links.sql`, `026_news.sql`, `028_provider_needs_reauth.sql`, `036_todoist_oauth_setup.sql` | | `ea_snoozed_emails` | `001_ea_tables.sql` | | `ea_todoist_items` | `001_ea_tables.sql` | @@ -674,7 +690,7 @@ erDiagram | `ea_triage_feedback` | `001_ea_tables.sql` | | `ea_triage_jobs` | `001_ea_tables.sql` | | `ea_triage_rules` | `001_ea_tables.sql` | -| `ea_webauthn_challenges` | `012_passkey_auth.sql` | +| `ea_webauthn_challenges` | `012_passkey_auth.sql`, `038_auth_security_generation.sql` | | `migrations` | `024_retire_legacy_ledger_rows.sql` | @@ -732,12 +748,12 @@ The structural route table below is regenerated from `server/index.ts` and `serv |--------|------|------| | GET | `/` | `server/routes/auth-canonical-origin.ts` | | PATCH | `/` | `server/routes/auth-canonical-origin.ts` | +| GET | `/api-tokens` | `server/routes/auth-security.ts` | +| POST | `/api-tokens` | `server/routes/auth-security.ts` | +| DELETE | `/api-tokens/:id` | `server/routes/auth-security.ts` | | DELETE | `/api/alfred/conversations/:id` | `server/routes/alfred.ts` | | POST | `/api/alfred/run` | `server/routes/alfred.ts` | | GET | `/api/alfred/usage` | `server/routes/alfred.ts` | -| GET | `/api/auth/api-tokens` | `server/routes/auth.ts` | -| POST | `/api/auth/api-tokens` | `server/routes/auth.ts` | -| DELETE | `/api/auth/api-tokens/:id` | `server/routes/auth.ts` | | GET | `/api/auth/check` | `server/routes/auth.ts` | | POST | `/api/auth/login` | `server/routes/auth.ts` | | POST | `/api/auth/logout` | `server/routes/auth.ts` | @@ -748,11 +764,6 @@ The structural route table below is regenerated from `server/index.ts` and `serv | DELETE | `/api/auth/passkeys/:credentialId` | `server/routes/auth.ts` | | POST | `/api/auth/passkeys/registration/options` | `server/routes/auth.ts` | | POST | `/api/auth/passkeys/registration/verify` | `server/routes/auth.ts` | -| POST | `/api/auth/recovery` | `server/routes/auth.ts` | -| POST | `/api/auth/recovery-codes/regenerate` | `server/routes/auth.ts` | -| PATCH | `/api/auth/security/auth-mode` | `server/routes/auth.ts` | -| POST | `/api/auth/security/password` | `server/routes/auth.ts` | -| POST | `/api/auth/security/step-up/password` | `server/routes/auth.ts` | | POST | `/api/auth/setup/claim` | `server/routes/auth.ts` | | GET | `/api/auth/setup/status` | `server/routes/auth.ts` | | GET | `/api/briefing/actual/accounts` | `server/routes/briefing/bills.ts` | @@ -857,9 +868,14 @@ The structural route table below is regenerated from `server/index.ts` and `serv | POST | `/api/todoist/webhook/` | `server/routes/todoist-webhook.ts` | | GET | `/email-search/usage` | `server/routes/settings.ts` | | POST | `/preview` | `server/routes/auth-canonical-origin.ts` | +| POST | `/recovery` | `server/routes/auth-security.ts` | +| POST | `/recovery-codes/regenerate` | `server/routes/auth-security.ts` | | GET | `/reminders` | `server/routes/reminders.ts` | | POST | `/reminders` | `server/routes/reminders.ts` | | DELETE | `/reminders/:id` | `server/routes/reminders.ts` | +| PATCH | `/security/auth-mode` | `server/routes/auth-security.ts` | +| POST | `/security/password` | `server/routes/auth-security.ts` | +| POST | `/security/step-up/password` | `server/routes/auth-security.ts` | | POST | `/settings/discord-reminder-test` | `server/routes/reminders.ts` | | GET | `/triage/cache-stats` | `server/routes/settings.ts` | @@ -972,6 +988,8 @@ Exact paths drift; the source of truth is `server/routes/briefing/*.ts` (per-dom | GET | `/api/briefing/actual/categories` | Category tree | | POST | `/api/briefing/actual/test` | Test connection | +Remote cache hydration streams the archive through a 128 MiB download cap, validates its central directory, entry count, compression methods, and declared expanded sizes before `adm-zip` sees it, and accepts only a path-safe local budget identifier. The in-process SDK loads that validated on-disk budget and does not receive a remote ZIP directly. + ### Accounts & Settings | Method | Path | Purpose | @@ -1008,7 +1026,7 @@ Exact paths drift; the source of truth is `server/routes/briefing/*.ts` (per-dom Token management endpoints live under `/api/auth`. Bearer tokens authenticate by `Authorization: Bearer ` and bypass the `x-requested-with` CSRF check, but they are not general dashboard auth. They are accepted only on explicitly opted-in automation endpoints, currently `POST /api/briefing/actual/quick-txn`. Raw tokens are shown once on creation; only `token_hash` is persisted, and new tokens receive a default 90-day expiry. -Passkeys and API tokens are separate auth surfaces. A registered passkey can unlock the browser session after a successful dashboard password; a scoped API token can only call specifically opted-in automation endpoints and cannot satisfy the dashboard route guard. +Passkeys and API tokens are separate auth surfaces. A registered passkey can unlock the browser directly in password-or-passkey mode or complete login after the password in strict mode; a scoped API token can only call specifically opted-in automation endpoints and cannot satisfy the dashboard route guard. ## Deployment diff --git a/FLOWS.md b/FLOWS.md index 8f705ee0..a5a6bb71 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -156,11 +156,11 @@ Selection path: **Trigger:** the SPA reads `GET /api/auth/setup/status` before normal session auth. A missing `ea_owner` singleton routes the browser to `/setup`. -1. `src/pages/OwnerSetup.tsx` — prefills the visible browser origin, requires explicit canonical-URL confirmation, confirms the password locally, and sends both to `POST /api/auth/setup/claim`. -2. `server/auth/owner-claim-service.ts:claimInitialOwner` — rate-limited route work generates a stable UUID and bcrypt hash. +1. `src/pages/OwnerSetup.tsx` — prefills the visible browser origin, requires the out-of-band deployment setup token, explicit canonical-URL confirmation, and a matching password of at least 12 characters, then sends them to `POST /api/auth/setup/claim`. +2. `server/routes/auth.ts` — rate-limits the claim and constant-time verifies `EA_SETUP_TOKEN` before any owner write; the token is never persisted or returned. `server/auth/owner-claim-service.ts:claimInitialOwner` then generates a stable UUID and bcrypt hash. 3. `server/auth/owner-store.ts:claimOwner` — one write transaction uses `INSERT OR IGNORE` against singleton key `1` and persists the confirmed origin in separate `ea_instance_metadata`; the uniqueness invariant admits one concurrent claimant and all others receive the fixed conflict. 4. `server/auth/recovery-code-store.ts:replaceRecoveryCodes` — generates eight high-entropy offline recovery codes, persists only SHA-256 hashes, and returns plaintext only in the successful claim response. -5. `server/middleware/auth.ts:createSession` — persists only the hashed session token plus its recent-auth timestamp; the successful browser receives the raw token in an HttpOnly cookie. +5. `server/middleware/auth.ts:createSession` — persists only the hashed session token plus authentication method, password-proof timestamp, and owner security generation; insertion succeeds only while that generation is current. The successful browser receives the raw token in an HttpOnly cookie. 6. `server/auth/owner-context.ts:activateOwner` — exposes the claimed ID to remaining single-owner runtime modules and notifies startup gating. 7. `server/auth/owner-runtime.ts:createOwnerRuntimeGate` — starts schedulers and provider workers once, only after a stored or newly claimed owner exists. @@ -168,15 +168,19 @@ Selection path: **Canonical origin:** `server/platform/canonical-url.ts` imports compatible legacy WebAuthn/Google callback values only when they identify one origin. Persisted state then drives WebAuthn RP values and Google, Todoist, Gmail Pub/Sub, and webhook callback projections. Security Settings previews affected passkeys and callback registrations before a recent-auth-gated change; request headers never write canonical state. -**Pre-claim boundary:** `server/middleware/owner-gate.ts` returns a fixed setup-required response for non-setup APIs. `GET /healthz` remains successful and reports only readiness plus the non-secret claimed boolean. Demo mode resolves setup as already claimed and rejects claim mutations locally without a network call. +**Pre-claim boundary:** `server/middleware/owner-gate.ts` returns a fixed setup-required response for non-setup APIs. `GET /healthz` remains successful and reports readiness only; `GET /api/auth/setup/status` is the explicit setup-state endpoint. Demo mode resolves setup as already claimed and rejects claim mutations locally without a network call. ## 8. Owner sign-in, step-up, and offline recovery **Normal mode:** `ea_owner.auth_mode = password_or_passkey`. A valid password issues a session directly. Passkey options may instead create a short-lived `ea_pending_auth` binding, and successful WebAuthn verification consumes its challenge before issuing the same session type. Registering a passkey does not change this mode. -**Strict mode:** the owner explicitly changes `auth_mode` to `password_plus_passkey` through a recent-auth-protected Security action. Password login then creates pending auth and WebAuthn completes the session. Mode, password, passkey, recovery-code, and powerful API-token mutations require `ea_sessions.authenticated_at` to be within ten minutes. +**Strict mode:** the owner explicitly changes `auth_mode` to `password_plus_passkey` through a recent-password-protected Security action. Password login then creates generation-bound pending auth and WebAuthn completes the session. Mode, password, passkey, recovery-code, canonical-origin, and powerful API-token mutations require `ea_sessions.password_authenticated_at` to be within ten minutes. A passkey-only session cannot cross that boundary; password confirmation failures are counted and blocked in the durable session row. -**Recovery:** `POST /api/auth/recovery` rate-limits and atomically consumes one unused recovery-code hash. Success replaces the password, returns mode to password-or-passkey, clears passkeys, pending auth, WebAuthn challenges, and prior sessions, issues a fresh session, and returns a newly generated recovery-code set exactly once. +**Security transitions:** each sensitive mutation compare-and-swaps `ea_owner.security_generation` inside the same write transaction as the credential change, then clears every browser session plus owner pending-auth and WebAuthn state. The initiating browser receives a new generation-bound session after commit. Atomic `DELETE ... RETURNING` consumption prevents concurrent reuse of a challenge or pending-auth token. + +**Security Settings unlock:** the System section never treats the server's remaining recent-auth window as permission to reopen sensitive password, passkey, recovery, or auth-mode controls. `PasskeysCard` starts locally locked on every mount, so switching Settings sections, navigating away and back, or refreshing requires the dashboard password again. A `pagehide` lock also clears sensitive drafts and one-time recovery-code display before a browser back/forward-cache restore. The ten-minute server window remains the request-authorization boundary only while the current section visit is open. + +**Recovery:** `POST /api/auth/recovery` rate-limits and atomically consumes one unused recovery-code hash. Success replaces the password, returns mode to password-or-passkey, clears passkeys, pending auth, WebAuthn challenges, prior sessions, and API tokens in one security transition, issues a fresh non-password-provenance session, and returns a newly generated recovery-code set exactly once. ## 9. Todoist personal token → optional OAuth and webhooks diff --git a/README.md b/README.md index 2a316e46..34962f6d 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,18 @@ For a detailed look at how everything fits together, see [ARCHITECTURE.md](ARCHI The Blueprint creates one native Node 24 web service on Render's paid Starter plan. It asks for only a [Turso](https://turso.tech/) database URL and auth token; -Render generates the 256-bit `EA_ENCRYPTION_KEY`. Starter is intentionally +Render generates the 256-bit `EA_ENCRYPTION_KEY` and a separate first-claim +`EA_SETUP_TOKEN`. Starter is intentionally always on because Setpoint's schedulers, reconciliation jobs, and reminders stop when a service sleeps. Check Render's current pricing before creating the service; the free plan is not a supported Setpoint production configuration. 1. Create a Turso database and token, then click **Deploy to Render**. 2. Enter `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` when Render prompts. -3. Wait for `/healthz` to pass, open the service URL, and claim the instance by - confirming its canonical URL and creating the owner password. +3. Wait for `/healthz` to pass, copy the generated `EA_SETUP_TOKEN` from the + service environment, then open the service URL and claim the instance by + entering that token, confirming the canonical URL, and creating an owner + password of at least 12 characters. 4. Save the one-time recovery codes offline, then use the skippable onboarding checklist to connect email/calendar, AI, tasks, weather, finances, and notifications as useful. Provider credentials are entered write-only inside @@ -86,7 +89,8 @@ key backup without the Turso database does not restore the installation. The complete template is in [`.env.example`](.env.example): - **Required production bootstrap:** `TURSO_DATABASE_URL`, - `TURSO_AUTH_TOKEN`, and a 256-bit hex or base64 `EA_ENCRYPTION_KEY`. + `TURSO_AUTH_TOKEN`, a 256-bit hex or base64 `EA_ENCRYPTION_KEY`, and a random + `EA_SETUP_TOKEN` of at least 32 characters for the one-time owner claim. - **Optional advanced provider sources:** AI, Google, Todoist, Pirate Weather, Google Places, and Gmail Pub/Sub values. Normal setup stores these write-only in Setpoint; existing host values remain supported and can be migrated from @@ -103,12 +107,15 @@ email backfill. Backfill resumes interrupted jobs by default; set ### Dashboard auth and passkey recovery -On a fresh database, open Setpoint after startup, confirm the visible canonical -URL, and create the owner password in the browser. The first successful claim -atomically creates the stable owner ID, stores only the bcrypt password hash, -persists the confirmed origin, signs that browser in, and permanently closes -public setup. Provider APIs and background workers remain disabled until the -claim succeeds. `GET /healthz` remains available for deployment readiness. +On a fresh database, open Setpoint after startup, enter the out-of-band +`EA_SETUP_TOKEN`, confirm the visible canonical URL, and create the owner +password in the browser. The first successful claim atomically creates the +stable owner ID, stores only the bcrypt password hash, persists the confirmed +origin, signs that browser in, and permanently closes public setup. The setup +token is compared in constant time and is never stored in the database or +returned by the app. Provider APIs and background workers remain disabled until +the claim succeeds. `GET /healthz` reports readiness without disclosing claim +state. Existing installations may keep `EA_USER_ID` and `EA_PASSWORD_HASH`; startup imports that exact legacy identity once. Partial or conflicting legacy auth @@ -118,12 +125,15 @@ The private app accepts either the owner password or a registered WebAuthn passkey by default. Registering a passkey does not disable password login. Settings -> System can explicitly enable strict password-plus-passkey login; identity and access changes require a password confirmation from the last ten -minutes. +minutes. A passkey-only session can use the dashboard but cannot register or +remove credentials, change the password or mode/domain, regenerate recovery +codes, or mint/revoke API tokens until that password step-up succeeds. Fresh owner claim displays eight one-time offline recovery codes. Setpoint stores only their hashes and never returns them through normal Settings reads. Using one code replaces the owner password, clears passkeys and pending auth, -revokes prior sessions, and displays a replacement recovery-code set once. +revokes prior sessions and API tokens, and displays a replacement recovery-code +set once. The confirmed canonical URL derives the WebAuthn RP ID/origin and provider callback URLs. Existing compatible `EA_WEBAUTHN_*` and `GOOGLE_REDIRECT_URI` @@ -141,9 +151,10 @@ npm run auth:reset-passkeys -- --confirm ``` The reset clears registered passkeys, pending password-auth attempts, WebAuthn -challenges, and browser sessions. The next successful password login uses the -default password-or-passkey mode. Scoped API tokens are separate automation -credentials and do not grant dashboard login. +challenges, and browser sessions, increments the owner's security generation, +and restores password-or-passkey mode. Scoped API tokens are separate automation +credentials and do not grant dashboard login; an in-app offline recovery revokes +them as part of the credential reset. ### Opt-in Turso semantic search verification diff --git a/render.yaml b/render.yaml index c9aef005..9721bde1 100644 --- a/render.yaml +++ b/render.yaml @@ -20,3 +20,5 @@ services: sync: false - key: EA_ENCRYPTION_KEY generateValue: true + - key: EA_SETUP_TOKEN + generateValue: true diff --git a/server/CLAUDE.md b/server/CLAUDE.md index d490bb15..c2b3b3d3 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -26,14 +26,17 @@ Composition root and cross-cutting server concerns that don't belong to a single - `auth/passkey-store.ts` — CRUD for stored passkey credentials - `auth/auth-mode.ts` — explicit password-or-passkey vs. strict password-plus-passkey resolution - `auth/recovery-code-store.ts` — high-entropy recovery-code generation, hashing, replacement, status, and atomic consumption -- `auth/pending-auth-store.ts` — short-lived pending-auth token issuance/lookup (WebAuthn ceremony handoff) -- `auth/session-rotation.ts` — bulk session revocation (e.g. on passkey changes), clears the auth validation cache +- `auth/pending-auth-store.ts` — generation-bound short-lived pending-auth issuance plus atomic consumption (WebAuthn ceremony handoff) +- `auth/session-cookie.ts` — centralized secure session-cookie issue/clear behavior around generation-conditional session creation +- `auth/security-transition.ts` — transactional owner-generation compare-and-swap for credential mutations plus session/pending/challenge revocation +- `auth/password-policy.ts` — existing-password verification bounds and the minimum policy for every newly chosen password +- `auth/setup-token.ts` — constant-time validation of the out-of-band first-claim deployment secret - `auth/owner-store.ts` — singleton owner persistence and atomic claim invariant - `auth/owner-bootstrap.ts` — startup resolution and fail-closed legacy env import - `auth/owner-claim-service.ts` — first-visitor password hashing and owner claim orchestration - `auth/owner-context.ts` — process-local claimed-owner context and runtime activation notifications - `auth/owner-runtime.ts` — one-shot gate that admits background work only after owner claim -- `auth/webauthn-challenge-store.ts` — short-lived WebAuthn challenge issuance/lookup +- `auth/webauthn-challenge-store.ts` — generation-bound short-lived WebAuthn challenge issuance and atomic consumption - `auth/webauthn-config.ts` — relying-party (RP) id/name/origin resolution for dev vs. production - `auth/webauthn-service.ts` — registration/authentication option + verification flows (via `@simplewebauthn/server`) diff --git a/server/actual/CLAUDE.md b/server/actual/CLAUDE.md index 9b37f638..4a8f9f83 100644 --- a/server/actual/CLAUDE.md +++ b/server/actual/CLAUDE.md @@ -5,7 +5,7 @@ Actual Budget engine integration: write paths, the forked SDK worker, and the lo ## Files - `actual.ts` — facade routing writes to lightweight/worker/SDK path by mode -- `actual-core.ts` — in-process Actual SDK ops: session lifecycle (lock/cache singletons), metadata/bill reads, schedule + transaction writes; orchestrates over actualCoreModel.ts +- `actual-core.ts` — in-process Actual SDK ops: session lifecycle (lock/cache singletons), metadata/bill reads, schedule + transaction writes; loads only an existing or bounded-validator-hydrated local budget and orchestrates over actualCoreModel.ts - `actualCoreModel.ts` — pure derivation for the SDK path: schedule classification/matching, condition building, date helpers, and the metadata/upcoming-bill projections - `actual-lightweight-writes.ts` — fast CRDT-message writes without booting the SDK; thin orchestrator over the four seam modules below - `actualWriteModel.ts` — pure strict write-date validation and CRDT sync-cursor selection @@ -21,6 +21,7 @@ Actual Budget engine integration: write paths, the forked SDK worker, and the lo - `actualMetadataModel.ts` — pure derivation: Actual date coercion, rule-condition normalization, schedule classification, and the metadata projection - `actualMetadataCacheStore.ts` — filesystem cache ops: locate the budget dir by sync id, prune zip backups, summarize disk usage - `actualMetadataSync.ts` — lightweight metadata sync engine: HTTP login/download, protobuf sync POST, and CRDT-message apply under the clock lock +- `actual-budget-archive.ts` — hostile-archive boundary for lightweight downloads: compressed/expanded size, entry-count, structure, encryption, compression-method, and path-safe budget-ID checks before `adm-zip` parsing or filesystem writes - `actual-metadata-projection.ts` — DB projection of Actual metadata with TTL for fast reads - `actual-bill-occurrences.ts` — expands Actual schedules into dated bill occurrences with paid status - `actual-amount-condition.ts` — single source of truth for interpreting an Actual `amount` schedule condition (scalar cents vs `isbetween` range) diff --git a/server/actual/actual-budget-archive.test.ts b/server/actual/actual-budget-archive.test.ts new file mode 100644 index 00000000..9b980a3d --- /dev/null +++ b/server/actual/actual-budget-archive.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_ACTUAL_ARCHIVE_ENTRY_BYTES, + assertSafeActualBudgetArchive, + validateActualBudgetId, +} from "./actual-budget-archive.ts"; + +function zipWithDeclaredEntry({ + name = "db.sqlite", + compressedSize = 0, + uncompressedSize = 0, +}: { + name?: string; + compressedSize?: number; + uncompressedSize?: number; +} = {}): Buffer { + const nameBuffer = Buffer.from(name); + const localHeader = Buffer.alloc(30 + nameBuffer.length + compressedSize); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt32LE(compressedSize, 18); + localHeader.writeUInt32LE(uncompressedSize, 22); + localHeader.writeUInt16LE(nameBuffer.length, 26); + nameBuffer.copy(localHeader, 30); + + const centralDirectory = Buffer.alloc(46 + nameBuffer.length); + centralDirectory.writeUInt32LE(0x02014b50, 0); + centralDirectory.writeUInt16LE(20, 6); + centralDirectory.writeUInt32LE(compressedSize, 20); + centralDirectory.writeUInt32LE(uncompressedSize, 24); + centralDirectory.writeUInt16LE(nameBuffer.length, 28); + centralDirectory.writeUInt32LE(0, 42); + nameBuffer.copy(centralDirectory, 46); + + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(1, 8); + end.writeUInt16LE(1, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localHeader.length, 16); + return Buffer.concat([localHeader, centralDirectory, end]); +} + +describe("assertSafeActualBudgetArchive", () => { + it("accepts a structurally bounded archive", () => { + expect(() => assertSafeActualBudgetArchive(zipWithDeclaredEntry())).not.toThrow(); + }); + + it("rejects a tiny archive that declares a zip-bomb-sized entry", () => { + const archive = zipWithDeclaredEntry({ + uncompressedSize: MAX_ACTUAL_ARCHIVE_ENTRY_BYTES + 1, + }); + + expect(() => assertSafeActualBudgetArchive(archive)).toThrow(/expanded size limit/); + }); + + it("rejects central-directory offsets that point outside the archive", () => { + const archive = zipWithDeclaredEntry(); + archive.writeUInt32LE(archive.length + 100, archive.length - 6); + + expect(() => assertSafeActualBudgetArchive(archive)).toThrow(/central directory/); + }); +}); + +describe("validateActualBudgetId", () => { + it("accepts an Actual local-cache identifier", () => { + expect(validateActualBudgetId("My-Finances-d8e502a")).toBe("My-Finances-d8e502a"); + }); + + it.each(["", ".", "..", "../outside", "..\\outside", "C:\\outside", "budget/name"])( + "rejects a path-capable budget identifier: %s", + (budgetId) => { + expect(() => validateActualBudgetId(budgetId)).toThrow(/budget identifier/); + }, + ); +}); diff --git a/server/actual/actual-budget-archive.ts b/server/actual/actual-budget-archive.ts new file mode 100644 index 00000000..d37540d2 --- /dev/null +++ b/server/actual/actual-budget-archive.ts @@ -0,0 +1,130 @@ +const ZIP_LOCAL_FILE_HEADER = 0x04034b50; +const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50; +const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const ZIP64_UINT16 = 0xffff; +const ZIP64_UINT32 = 0xffffffff; + +export const MAX_ACTUAL_ARCHIVE_BYTES = 128 * 1024 * 1024; +export const MAX_ACTUAL_ARCHIVE_ENTRY_BYTES = 256 * 1024 * 1024; +const MAX_ACTUAL_ARCHIVE_EXPANDED_BYTES = 256 * 1024 * 1024; +const MAX_ACTUAL_ARCHIVE_ENTRIES = 128; +const SAFE_ACTUAL_BUDGET_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function unsafeArchive(reason: string): Error { + return Object.assign(new Error(`Actual Budget archive is unsafe: ${reason}`), { status: 502 }); +} + +export function validateActualBudgetId(value: unknown): string { + if (typeof value !== "string" || !SAFE_ACTUAL_BUDGET_ID.test(value)) { + throw unsafeArchive("invalid budget identifier"); + } + return value; +} + +function findEndOfCentralDirectory(archive: Buffer): number { + const minimumOffset = Math.max(0, archive.length - 22 - ZIP64_UINT16); + for (let offset = archive.length - 22; offset >= minimumOffset; offset -= 1) { + if (archive.readUInt32LE(offset) !== ZIP_END_OF_CENTRAL_DIRECTORY) continue; + const commentLength = archive.readUInt16LE(offset + 20); + if (offset + 22 + commentLength === archive.length) return offset; + } + throw unsafeArchive("missing central directory"); +} + +export function assertSafeActualBudgetArchive(archive: Buffer): void { + if (archive.length > MAX_ACTUAL_ARCHIVE_BYTES) { + throw unsafeArchive("download size limit exceeded"); + } + if (archive.length < 22) throw unsafeArchive("missing central directory"); + + const endOffset = findEndOfCentralDirectory(archive); + const diskNumber = archive.readUInt16LE(endOffset + 4); + const centralDirectoryDisk = archive.readUInt16LE(endOffset + 6); + const entriesOnDisk = archive.readUInt16LE(endOffset + 8); + const entryCount = archive.readUInt16LE(endOffset + 10); + const centralDirectorySize = archive.readUInt32LE(endOffset + 12); + const centralDirectoryOffset = archive.readUInt32LE(endOffset + 16); + + if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) { + throw unsafeArchive("multi-disk ZIP files are not supported"); + } + if ( + entryCount === ZIP64_UINT16 + || centralDirectorySize === ZIP64_UINT32 + || centralDirectoryOffset === ZIP64_UINT32 + ) { + throw unsafeArchive("ZIP64 files are not supported"); + } + if (entryCount > MAX_ACTUAL_ARCHIVE_ENTRIES) { + throw unsafeArchive("entry count limit exceeded"); + } + + const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize; + if ( + centralDirectoryOffset > endOffset + || centralDirectoryEnd > endOffset + || centralDirectoryEnd < centralDirectoryOffset + ) { + throw unsafeArchive("invalid central directory bounds"); + } + + let offset = centralDirectoryOffset; + let expandedBytes = 0; + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > centralDirectoryEnd || archive.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_HEADER) { + throw unsafeArchive("invalid central directory entry"); + } + + const flags = archive.readUInt16LE(offset + 8); + const compressionMethod = archive.readUInt16LE(offset + 10); + const compressedSize = archive.readUInt32LE(offset + 20); + const uncompressedSize = archive.readUInt32LE(offset + 24); + const fileNameLength = archive.readUInt16LE(offset + 28); + const extraLength = archive.readUInt16LE(offset + 30); + const commentLength = archive.readUInt16LE(offset + 32); + const localHeaderOffset = archive.readUInt32LE(offset + 42); + + if ( + compressedSize === ZIP64_UINT32 + || uncompressedSize === ZIP64_UINT32 + || localHeaderOffset === ZIP64_UINT32 + ) { + throw unsafeArchive("ZIP64 entries are not supported"); + } + if ((flags & 0x1) !== 0) throw unsafeArchive("encrypted entries are not supported"); + if (compressionMethod !== 0 && compressionMethod !== 8) { + throw unsafeArchive("unsupported compression method"); + } + if (uncompressedSize > MAX_ACTUAL_ARCHIVE_ENTRY_BYTES) { + throw unsafeArchive("entry expanded size limit exceeded"); + } + expandedBytes += uncompressedSize; + if (expandedBytes > MAX_ACTUAL_ARCHIVE_EXPANDED_BYTES) { + throw unsafeArchive("total expanded size limit exceeded"); + } + + const nextOffset = offset + 46 + fileNameLength + extraLength + commentLength; + if (nextOffset > centralDirectoryEnd || nextOffset < offset) { + throw unsafeArchive("invalid central directory entry bounds"); + } + if ( + localHeaderOffset + 30 > centralDirectoryOffset + || archive.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER + ) { + throw unsafeArchive("invalid local file header"); + } + const localFileNameLength = archive.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = archive.readUInt16LE(localHeaderOffset + 28); + const compressedDataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraLength; + const compressedDataEnd = compressedDataOffset + compressedSize; + if (compressedDataEnd > centralDirectoryOffset || compressedDataEnd < compressedDataOffset) { + throw unsafeArchive("compressed entry exceeds archive bounds"); + } + + offset = nextOffset; + } + + if (offset !== centralDirectoryEnd) { + throw unsafeArchive("central directory size does not match its entries"); + } +} diff --git a/server/actual/actual-core.ts b/server/actual/actual-core.ts index 21d2d0a7..a89f6d5c 100644 --- a/server/actual/actual-core.ts +++ b/server/actual/actual-core.ts @@ -4,6 +4,7 @@ import { filterBillSchedulesForRange } from "./actual-bill-occurrences.ts"; import { actualDataDir, findLocalBudgetDir, + hydrateLocalActualCache, pruneActualBudgetBackups, } from "./actual-local-metadata.ts"; import { @@ -33,8 +34,8 @@ import type { type ActualError = Error & { status?: number; code?: string }; interface SdkActualConfig extends ActualConfig { dataDir: string; - localBudgetId: string | null; - localBudgetDir: string | null; + localBudgetId: string; + localBudgetDir: string; } interface ActiveBudget extends SdkActualConfig { key: string; @@ -83,7 +84,6 @@ interface ActualSdk { init(options: { serverURL: string; password?: string | null; dataDir?: string }): Promise; shutdown(): Promise; loadBudget(id: string): Promise<{ error?: string } | void>; - downloadBudget(syncId: string, options?: { password: string }): Promise; getBudgets(): Promise>; getAccounts(): Promise; getPayees(): Promise; @@ -137,7 +137,7 @@ async function maybePruneBackups(budgetDir: string): Promise { console.warn("[EA] Actual local backup pruning failed:", err instanceof Error ? err.message : err); }); } -function allowColdActualDownload(): boolean { +function allowColdActualHydration(): boolean { return process.env.NODE_ENV !== "production" || process.env.EA_ACTUAL_ALLOW_COLD_SDK_DOWNLOAD === "1"; } @@ -156,14 +156,33 @@ async function ensureActualBudget(userId: string): Promise { const baseConfig = await getActualConfig(userId); const dataDir = actualDataDir(); const localBudget = await findLocalBudgetDir(baseConfig.syncId, { dataDir }).catch((err: unknown) => { - console.warn("[EA] Actual local budget lookup failed; falling back to cold download path:", err instanceof Error ? err.message : err); + console.warn("[EA] Actual local budget lookup failed; falling back to bounded cache hydration:", err instanceof Error ? err.message : err); return null; }); + let localBudgetId = localBudget?.metadata?.id || null; + let localBudgetDir = localBudget?.budgetDir || null; + if (!localBudgetId || !localBudgetDir) { + if (!allowColdActualHydration()) { + throw Object.assign(new Error("Actual local budget cache is unavailable; refusing cold Actual download in production"), { + status: 503, + code: "ACTUAL_LOCAL_BUDGET_REQUIRED", + }); + } + const hydrated = await hydrateLocalActualCache(userId, { dataDir, forceDownload: true }); + localBudgetId = typeof hydrated.budgetId === "string" && hydrated.budgetId ? hydrated.budgetId : null; + localBudgetDir = typeof hydrated.budgetDir === "string" && hydrated.budgetDir ? hydrated.budgetDir : null; + if (!localBudgetId || !localBudgetDir) { + throw Object.assign(new Error("Actual cache hydration completed without a loadable local budget"), { + status: 502, + code: "ACTUAL_LOCAL_BUDGET_HYDRATION_FAILED", + }); + } + } const config: SdkActualConfig = { ...baseConfig, dataDir, - localBudgetId: localBudget?.metadata?.id || null, - localBudgetDir: localBudget?.budgetDir || null, + localBudgetId, + localBudgetDir, }; const key = actualSessionKey(config); if (activeBudget?.key === key) return config; @@ -172,25 +191,12 @@ async function ensureActualBudget(userId: string): Promise { } try { await sdk.init({ serverURL: config.serverURL, password: config.password, dataDir }); - if (config.localBudgetId) { - const result = await sdk.loadBudget(config.localBudgetId); - if (result?.error) { - throw Object.assign(new Error(`Actual local budget load failed: ${result.error}`), { - status: 503, - code: "ACTUAL_LOCAL_BUDGET_LOAD_FAILED", - }); - } - } else { - if (!allowColdActualDownload()) { - throw Object.assign(new Error("Actual local budget cache is unavailable; refusing cold Actual download in production"), { - status: 503, - code: "ACTUAL_LOCAL_BUDGET_REQUIRED", - }); - } - await sdk.downloadBudget( - config.syncId, - config.password ? { password: config.password } : undefined, - ); + const result = await sdk.loadBudget(config.localBudgetId); + if (result?.error) { + throw Object.assign(new Error(`Actual local budget load failed: ${result.error}`), { + status: 503, + code: "ACTUAL_LOCAL_BUDGET_LOAD_FAILED", + }); } activeBudget = { key, ...config, loadedAt: new Date().toISOString() }; return config; diff --git a/server/actual/actual-local-metadata.test.ts b/server/actual/actual-local-metadata.test.ts index bc714c7e..d255d550 100644 --- a/server/actual/actual-local-metadata.test.ts +++ b/server/actual/actual-local-metadata.test.ts @@ -288,10 +288,7 @@ describe("readLocalActualMetadata", () => { { timestamp: new Timestamp(2004, 0, makeClientId()), dataset: "transactions", row: "txn-remote", column: "schedule", value: "S:sched-1" }, { timestamp: new Timestamp(2005, 0, makeClientId()), dataset: "transactions", row: "txn-remote", column: "tombstone", value: "N:0" }, ]; - const fetchMock = vi.fn().mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => syncResponseBuffer(remoteMessages), - }); + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(syncResponseBuffer(remoteMessages))); global.fetch = fetchMock as unknown as typeof fetch; const syncResult = await syncDownloadedBudget({ diff --git a/server/actual/actual-local-metadata.ts b/server/actual/actual-local-metadata.ts index 7bdc6e29..b6111918 100644 --- a/server/actual/actual-local-metadata.ts +++ b/server/actual/actual-local-metadata.ts @@ -21,6 +21,7 @@ import { fetchActualBuffer, syncDownloadedBudget, } from "./actualMetadataSync.ts"; +import { assertSafeActualBudgetArchive, validateActualBudgetId } from "./actual-budget-archive.ts"; import { mkdir, writeFile } from "fs/promises"; import path from "path"; import db from "../db/connection.ts"; @@ -153,23 +154,28 @@ async function downloadBudgetZip(config: ActualConfig, { dataDir = actualDataDir token, fileId, }); + assertSafeActualBudgetArchive(buffer); const zip = new AdmZip(buffer); - const dbEntry = zip.getEntries().find((entry) => entry.entryName.includes("db.sqlite")); - const metaEntry = zip.getEntries().find((entry) => entry.entryName.includes("metadata.json")); + const entries = zip.getEntries(); + const dbEntries = entries.filter((entry) => entry.entryName.split(/[\\/]/).at(-1) === "db.sqlite"); + const metaEntries = entries.filter((entry) => entry.entryName.split(/[\\/]/).at(-1) === "metadata.json"); + const dbEntry = dbEntries.length === 1 ? dbEntries[0] : null; + const metaEntry = metaEntries.length === 1 ? metaEntries[0] : null; if (!dbEntry || !metaEntry) { throw Object.assign(new Error("Actual Budget download did not include db.sqlite and metadata.json"), { status: 502 }); } const parsedMetadata = JSON.parse(zip.readAsText(metaEntry)) as BudgetMetadata; + const budgetId = validateActualBudgetId(parsedMetadata.id); const metadata: LocalBudget["metadata"] = { ...parsedMetadata, - id: String(parsedMetadata.id || ""), + id: budgetId, cloudFileId: fileId, groupId: file.groupId || config.syncId, lastUploaded: new Date().toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" }), encryptKeyId: null, }; - const budgetDir = path.join(dataDir, metadata.id || ""); + const budgetDir = path.join(dataDir, budgetId); await mkdir(budgetDir, { recursive: true }); const databaseBuffer = zip.readFile(dbEntry); if (!databaseBuffer) throw Object.assign(new Error("Actual Budget download did not include a readable db.sqlite"), { status: 502 }); diff --git a/server/actual/actual.test.ts b/server/actual/actual.test.ts index 9cc6874c..565d96be 100644 --- a/server/actual/actual.test.ts +++ b/server/actual/actual.test.ts @@ -102,6 +102,12 @@ actualApiState.reset(); const actualLocalMock = vi.hoisted(() => ({ actualDataDir: vi.fn(() => process.cwd()), findLocalBudgetDir: vi.fn().mockResolvedValue(null), + hydrateLocalActualCache: vi.fn().mockResolvedValue({ + success: true, + hydrated: true, + budgetId: "Budget-Hydrated", + budgetDir: "/var/ea-actual/Budget-Hydrated", + }), pruneActualBudgetBackups: vi.fn().mockResolvedValue({ removed: 0, kept: 0 }), readLocalActualMetadata: vi.fn(), })); @@ -365,6 +371,22 @@ describe("actual.ts sendBill mutex", () => { expect(actualLocalMock.pruneActualBudgetBackups).toHaveBeenCalledWith("/var/ea-actual/Budget-Local"); }); + it("hydrates a missing development cache through the bounded downloader instead of the SDK archive path", async () => { + actualLocalMock.actualDataDir.mockReturnValue("/var/ea-actual"); + const { sendBill } = await import("./actual-core.ts"); + const actualApi = await importActualApiMock(); + + await sendBill({ type: "expense", payee: "U.S. Bank", amount: 42.25, due_date: "2026-05-10", account_id: "a1" }, "user1"); + + expect(actualLocalMock.hydrateLocalActualCache).toHaveBeenCalledWith("user1", { + dataDir: "/var/ea-actual", + forceDownload: true, + }); + expect(actualApi.loadBudget).toHaveBeenCalledWith("Budget-Hydrated"); + expect(actualApi.downloadBudget).not.toHaveBeenCalled(); + expect(actualLocalMock.pruneActualBudgetBackups).toHaveBeenCalledWith("/var/ea-actual/Budget-Hydrated"); + }); + it("refuses a production bill pay write when the local Actual cache is missing", async () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = "production"; diff --git a/server/actual/actualMetadataSync.test.ts b/server/actual/actualMetadataSync.test.ts index 086224aa..233fa81c 100644 --- a/server/actual/actualMetadataSync.test.ts +++ b/server/actual/actualMetadataSync.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MessageEnvelopeSchema, MessageSchema, @@ -14,10 +14,52 @@ import { decodeSyncResponse, deserializeSyncValue, encodeSyncRequest, + fetchActualBuffer, + fetchActualJson, messageInsertQuery, quoteIdent, + readBoundedResponseBody, } from "./actualMetadataSync.ts"; +describe("readBoundedResponseBody", () => { + it("rejects a streamed response as soon as it crosses the byte limit", async () => { + const response = new Response(new Uint8Array([1, 2, 3, 4, 5])); + + await expect(readBoundedResponseBody(response, 4)).rejects.toThrow(/download exceeded/); + }); + + it("rejects an oversized declared content length before reading the body", async () => { + const response = new Response(new Uint8Array([1]), { + headers: { "Content-Length": "100" }, + }); + + await expect(readBoundedResponseBody(response, 4)).rejects.toThrow(/download exceeded/); + }); + + it("also bounds error responses from file downloads", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(new Uint8Array(65_537), { + status: 502, + }))); + try { + await expect(fetchActualBuffer("https://actual.example/file", { + token: "token", + fileId: "file-id", + })).rejects.toThrow(/download exceeded/); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("bounds JSON responses from the remote Actual server", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(new Uint8Array(2 * 1024 * 1024 + 1)))); + try { + await expect(fetchActualJson("https://actual.example/file-list")).rejects.toThrow(/download exceeded/); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + describe("deserializeSyncValue", () => { it("decodes the Actual sync value type prefixes", () => { expect(deserializeSyncValue("0:")).toBeNull(); diff --git a/server/actual/actualMetadataSync.ts b/server/actual/actualMetadataSync.ts index 8aff2e63..a64c63ad 100644 --- a/server/actual/actualMetadataSync.ts +++ b/server/actual/actualMetadataSync.ts @@ -25,6 +25,7 @@ import { import { writeFile } from "fs/promises"; import path from "path"; import { withActualClockLock } from "./actual-clock-lock.ts"; +import { MAX_ACTUAL_ARCHIVE_BYTES } from "./actual-budget-archive.ts"; import type { ActualConfig } from "../../shared/types/actual.ts"; interface FetchActualOptions { @@ -56,6 +57,9 @@ interface DecodedSyncResponse { } const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_ACTUAL_JSON_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_ACTUAL_SYNC_RESPONSE_BYTES = 64 * 1024 * 1024; +const MAX_ACTUAL_ERROR_RESPONSE_BYTES = 64 * 1024; function timeoutMs(): number { const value = Number(process.env.EA_ACTUAL_LIGHTWEIGHT_TIMEOUT_MS); @@ -77,8 +81,9 @@ export async function fetchActualJson(url: string, { token = null, }, ...(body ? { body: JSON.stringify(body) } : {}), }); - text = await response.text(); + text = (await readBoundedResponseBody(response, MAX_ACTUAL_JSON_RESPONSE_BYTES)).toString("utf8"); } catch (err: unknown) { + if (typeof err === "object" && err !== null && "status" in err) throw err; throw Object.assign(new Error(err instanceof Error && err.name === "AbortError" ? "Actual Budget lightweight metadata request timed out" : "Actual Budget server is unreachable"), { status: 502 }); @@ -105,17 +110,52 @@ export async function fetchActualBuffer(url: string, { token, fileId }: { token: }, }); if (!response.ok) { - const text = await response.text().catch(() => ""); + const body = await readBoundedResponseBody(response, MAX_ACTUAL_ERROR_RESPONSE_BYTES); + const text = body.toString("utf8", 0, 120); throw Object.assign(new Error(`Actual Budget file download failed: ${text.slice(0, 120) || response.status}`), { status: response.status >= 500 ? 502 : 400, }); } - return Buffer.from(await response.arrayBuffer()); + return readBoundedResponseBody(response, MAX_ACTUAL_ARCHIVE_BYTES); } finally { clearTimeout(timer); } } +export async function readBoundedResponseBody(response: Response, maxBytes: number): Promise { + const declaredLength = response.headers.get("content-length"); + if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) { + throw Object.assign(new Error(`Actual Budget file download exceeded the ${maxBytes}-byte limit`), { status: 502 }); + } + + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length > maxBytes) { + throw Object.assign(new Error(`Actual Budget file download exceeded the ${maxBytes}-byte limit`), { status: 502 }); + } + return buffer; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + throw Object.assign(new Error(`Actual Budget file download exceeded the ${maxBytes}-byte limit`), { status: 502 }); + } + chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength)); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes); +} + export async function loginActual(config: ActualConfig): Promise { if (!config.password) { throw Object.assign(new Error("Actual Budget password is required for lightweight metadata download"), { status: 400 }); @@ -208,12 +248,13 @@ async function postActualSync(config: ActualConfig, token: string, { metadata, s body: buffer, }); if (!response.ok) { - const text = await response.text().catch(() => ""); + const body = await readBoundedResponseBody(response, MAX_ACTUAL_ERROR_RESPONSE_BYTES); + const text = body.toString("utf8", 0, 120); throw Object.assign(new Error(`Actual Budget lightweight sync failed: ${text.slice(0, 120) || response.status}`), { status: response.status >= 500 ? 502 : 400, }); } - return decodeSyncResponse(await response.arrayBuffer()); + return decodeSyncResponse(await readBoundedResponseBody(response, MAX_ACTUAL_SYNC_RESPONSE_BYTES)); } catch (err: unknown) { if (typeof err === "object" && err !== null && "status" in err) throw err; throw Object.assign(new Error(err instanceof Error && err.name === "AbortError" diff --git a/server/auth/owner-bootstrap.test.ts b/server/auth/owner-bootstrap.test.ts index ef5c3aa9..6a54a725 100644 --- a/server/auth/owner-bootstrap.test.ts +++ b/server/auth/owner-bootstrap.test.ts @@ -14,7 +14,13 @@ describe("owner bootstrap", () => { beforeEach(async () => { db = createClient({ url: "file::memory:" }); - for (const migration of ["001_ea_tables.sql", "030_owner_bootstrap.sql", "031_auth_recovery.sql"]) { + for (const migration of [ + "001_ea_tables.sql", + "012_passkey_auth.sql", + "030_owner_bootstrap.sql", + "031_auth_recovery.sql", + "038_auth_security_generation.sql", + ]) { await db.executeMultiple(readFileSync(join(__dirname, `../db/migrations/${migration}`), "utf8")); } }); diff --git a/server/auth/owner-claim-service.ts b/server/auth/owner-claim-service.ts index 4e794df3..a6475793 100644 --- a/server/auth/owner-claim-service.ts +++ b/server/auth/owner-claim-service.ts @@ -2,6 +2,7 @@ import bcrypt from "bcrypt"; import crypto from "crypto"; import { activateOwner } from "./owner-context.ts"; import { ownerStore, type OwnerRecord } from "./owner-store.ts"; +import { isAcceptableNewPassword } from "./password-policy.ts"; interface OwnerClaimStore { getOwner(): Promise; @@ -41,7 +42,7 @@ export async function claimInitialOwner( canonicalOrigin, }: ClaimOwnerOptions = {}, ): Promise { - if (typeof password !== "string" || password.length === 0 || password.length > 1024) { + if (!isAcceptableNewPassword(password)) { return { status: "invalid" }; } if (await store.getOwner()) return { status: "conflict" }; diff --git a/server/auth/owner-store.test.ts b/server/auth/owner-store.test.ts index cfc994ec..56bd03eb 100644 --- a/server/auth/owner-store.test.ts +++ b/server/auth/owner-store.test.ts @@ -12,7 +12,7 @@ describe("owner store", () => { beforeEach(async () => { db = createClient({ url: "file::memory:" }); - for (const migration of ["001_ea_tables.sql", "030_owner_bootstrap.sql", "031_auth_recovery.sql", "032_canonical_url.sql"]) { + for (const migration of ["001_ea_tables.sql", "012_passkey_auth.sql", "030_owner_bootstrap.sql", "031_auth_recovery.sql", "032_canonical_url.sql", "038_auth_security_generation.sql"]) { await db.executeMultiple(readFileSync(join(__dirname, `../db/migrations/${migration}`), "utf8")); } }); @@ -26,6 +26,7 @@ describe("owner store", () => { await expect(store.getOwner()).resolves.toMatchObject({ authMode: "password_plus_passkey", passwordHash: "hash-b", + securityGeneration: 1, }); }); diff --git a/server/auth/owner-store.ts b/server/auth/owner-store.ts index 3ca974a0..35bcea3d 100644 --- a/server/auth/owner-store.ts +++ b/server/auth/owner-store.ts @@ -9,6 +9,7 @@ export interface OwnerRecord { userId: string; passwordHash: string; authMode: OwnerAuthMode; + securityGeneration: number; claimedAt: number; } @@ -33,7 +34,7 @@ function numberValue(value: unknown): number { export function createOwnerStore(dbClient: OwnerStoreDb = db) { async function getOwner(): Promise { const result = await dbClient.execute({ - sql: `SELECT singleton_id, user_id, password_hash, auth_mode, claimed_at + sql: `SELECT singleton_id, user_id, password_hash, auth_mode, security_generation, claimed_at FROM ea_owner WHERE singleton_id = ?`, args: [OWNER_SINGLETON_ID], @@ -45,6 +46,7 @@ export function createOwnerStore(dbClient: OwnerStoreDb = db) { userId: stringValue(row.user_id), passwordHash: stringValue(row.password_hash), authMode: isOwnerAuthMode(row.auth_mode) ? row.auth_mode : "password_or_passkey", + securityGeneration: numberValue(row.security_generation), claimedAt: numberValue(row.claimed_at), }; } @@ -109,5 +111,3 @@ export function createOwnerStore(dbClient: OwnerStoreDb = db) { export const ownerStore = createOwnerStore(); export const getOwner = ownerStore.getOwner; -export const setOwnerAuthMode = ownerStore.setAuthMode; -export const updateOwnerPasswordHash = ownerStore.updatePasswordHash; diff --git a/server/auth/passkey-store.test.ts b/server/auth/passkey-store.test.ts index 00f3d31c..2f5954f6 100644 --- a/server/auth/passkey-store.test.ts +++ b/server/auth/passkey-store.test.ts @@ -70,4 +70,18 @@ describe("passkey store", () => { await expect(store.deletePasskey("credential-1", "user-1")).resolves.toBe(1); await expect(store.countPasskeys("user-1")).resolves.toBe(0); }); + + it("never regresses an authenticator sign counter", async () => { + await store.createPasskey({ + userId: "user-1", + credentialId: "credential-1", + label: "Security Key", + publicKey: "public-key", + signCount: 8, + }); + + await store.updatePasskeyUsage("credential-1", { signCount: 7 }); + + await expect(store.getPasskeyByCredentialId("credential-1")).resolves.toMatchObject({ signCount: 8 }); + }); }); diff --git a/server/auth/passkey-store.ts b/server/auth/passkey-store.ts index 1c11acae..cfb1d513 100644 --- a/server/auth/passkey-store.ts +++ b/server/auth/passkey-store.ts @@ -17,6 +17,7 @@ export type StoredPasskeyCredential = { }; export type PasskeyMetadata = Omit; +type PasskeyDb = Pick; type CreatePasskeyInput = Partial<{ userId: string; @@ -86,7 +87,7 @@ export function toPasskeyMetadata(credential: StoredPasskeyCredential | null): P return metadata; } -export function createPasskeyStore(database: Client = db) { +export function createPasskeyStore(database: PasskeyDb = db) { async function countPasskeys(userId: string) { const result = await database.execute({ sql: "SELECT COUNT(*) AS count FROM ea_passkey_credentials WHERE user_id = ?", @@ -166,7 +167,7 @@ export function createPasskeyStore(database: Client = db) { const assignments = ["last_used_at = ?"]; const args: Value[] = [lastUsedAt]; if (signCount !== undefined) { - assignments.push("sign_count = ?"); + assignments.push("sign_count = MAX(sign_count, ?)"); args.push(Number(signCount)); } if (transports !== undefined) { @@ -222,6 +223,4 @@ export const countPasskeys = passkeyStore.countPasskeys; export const listPasskeys = passkeyStore.listPasskeys; export const listPasskeyMetadata = passkeyStore.listPasskeyMetadata; export const getPasskeyByCredentialId = passkeyStore.getPasskeyByCredentialId; -export const createPasskey = passkeyStore.createPasskey; export const updatePasskeyUsage = passkeyStore.updatePasskeyUsage; -export const deletePasskey = passkeyStore.deletePasskey; diff --git a/server/auth/password-policy.ts b/server/auth/password-policy.ts new file mode 100644 index 00000000..8882a452 --- /dev/null +++ b/server/auth/password-policy.ts @@ -0,0 +1,12 @@ +export const MIN_NEW_PASSWORD_LENGTH = 12; +export const MAX_PASSWORD_LENGTH = 1024; + +export function isVerifiablePassword(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= MAX_PASSWORD_LENGTH; +} + +export function isAcceptableNewPassword(value: unknown): value is string { + return typeof value === "string" + && value.length >= MIN_NEW_PASSWORD_LENGTH + && value.length <= MAX_PASSWORD_LENGTH; +} diff --git a/server/auth/pending-auth-store.test.ts b/server/auth/pending-auth-store.test.ts index 8a2e14d7..9826397a 100644 --- a/server/auth/pending-auth-store.test.ts +++ b/server/auth/pending-auth-store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createAuthTestDb } from "../test-utils/auth-db.ts"; +import { createAuthTestDb, seedOwner } from "../test-utils/auth-db.ts"; import { createPendingAuthStore, hashPendingAuthToken, @@ -12,6 +12,7 @@ describe("pending auth store", () => { beforeEach(async () => { db = await createAuthTestDb(); + await seedOwner(db, { passwordHash: "hash" }); store = createPendingAuthStore(db); }); @@ -24,15 +25,22 @@ describe("pending auth store", () => { userId: "user-1", token: "raw-pending-token", now: 1_000, + securityGeneration: 1, + passwordAuthenticatedAt: 900, + expectedAuthMode: "password_or_passkey", }); - const rows = await db.execute("SELECT token_hash, user_id, expires_at FROM ea_pending_auth"); + const rows = await db.execute( + "SELECT token_hash, user_id, expires_at, security_generation, password_authenticated_at FROM ea_pending_auth", + ); expect(rows.rows[0]).toMatchObject({ token_hash: hashPendingAuthToken("raw-pending-token"), user_id: "user-1", expires_at: 301_000, + security_generation: 1, + password_authenticated_at: 900, }); - expect(rows.rows[0]!.token_hash).not.toBe(pending.token); + expect(rows.rows[0]!.token_hash).not.toBe(pending!.token); await expect(store.readPendingAuth("raw-pending-token", { now: 2_000 })).resolves.toMatchObject({ tokenHash: hashPendingAuthToken("raw-pending-token"), @@ -47,6 +55,7 @@ describe("pending auth store", () => { token: "expired-token", now: 1_000, ttlMs: 100, + securityGeneration: 1, }); await expect(store.readPendingAuth("expired-token", { now: 1_101 })).resolves.toBeNull(); @@ -60,6 +69,7 @@ describe("pending auth store", () => { userId: "user-1", token: "one-time-token", now: 1_000, + securityGeneration: 1, }); await expect(store.consumePendingAuth("one-time-token", { now: 2_000 })).resolves.toMatchObject({ @@ -67,4 +77,31 @@ describe("pending auth store", () => { }); await expect(store.consumePendingAuth("one-time-token", { now: 2_000 })).resolves.toBeNull(); }); + + it("allows only one concurrent consumer", async () => { + await store.createPendingAuth({ + userId: "user-1", + token: "concurrent-token", + now: 1_000, + securityGeneration: 1, + }); + + const results = await Promise.all([ + store.consumePendingAuth("concurrent-token", { now: 2_000 }), + store.consumePendingAuth("concurrent-token", { now: 2_000 }), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + }); + + it("does not create pending auth after the expected mode or generation changes", async () => { + await db.execute("UPDATE ea_owner SET auth_mode = 'password_plus_passkey', security_generation = 2"); + + await expect(store.createPendingAuth({ + userId: "user-1", + securityGeneration: 1, + expectedAuthMode: "password_or_passkey", + })).resolves.toBeNull(); + expect((await db.execute("SELECT * FROM ea_pending_auth")).rows).toEqual([]); + }); }); diff --git a/server/auth/pending-auth-store.ts b/server/auth/pending-auth-store.ts index 642643ba..c09e42a3 100644 --- a/server/auth/pending-auth-store.ts +++ b/server/auth/pending-auth-store.ts @@ -2,6 +2,7 @@ import crypto from "crypto"; import db from "../db/connection.ts"; import type { Client, Row } from "@libsql/client"; import type { CookieOptions } from "express"; +import type { OwnerAuthMode } from "./auth-mode.ts"; export const PENDING_AUTH_COOKIE_NAME = "ea_pending_auth"; export const PENDING_AUTH_TTL_MS = 5 * 60 * 1000; @@ -12,14 +13,19 @@ export type PendingAuth = { userId: string; createdAt: number; expiresAt: number; + securityGeneration: number; + passwordAuthenticatedAt: number; }; -type PendingAuthInput = Partial<{ +type PendingAuthInput = { userId: string; - now: number; - ttlMs: number; - token: string; -}>; + securityGeneration: number; + passwordAuthenticatedAt?: number; + expectedAuthMode?: OwnerAuthMode; + now?: number; + ttlMs?: number; + token?: string; +}; export function hashPendingAuthToken(raw: unknown) { return TOKEN_HASH_PREFIX + crypto.createHash("sha256").update(String(raw || "")).digest("hex"); @@ -45,6 +51,8 @@ function mapPendingAuth(row: Row | undefined): PendingAuth | null { userId: String(row.user_id || ""), createdAt: Number(row.created_at), expiresAt: Number(row.expires_at), + securityGeneration: Number(row.security_generation), + passwordAuthenticatedAt: Number(row.password_authenticated_at || 0), }; } @@ -56,25 +64,67 @@ export function createPendingAuthStore(database: Client = db) { }); } - async function createPendingAuth({ userId, now = Date.now(), ttlMs = PENDING_AUTH_TTL_MS, token }: PendingAuthInput = {}) { + async function createPendingAuth({ + userId, + securityGeneration, + passwordAuthenticatedAt = 0, + expectedAuthMode, + now = Date.now(), + ttlMs = PENDING_AUTH_TTL_MS, + token, + }: PendingAuthInput) { if (!userId) throw new Error("userId is required"); + if (!Number.isInteger(securityGeneration) || securityGeneration < 1) { + throw new Error("securityGeneration is required"); + } const rawToken = token || crypto.randomBytes(32).toString("base64url"); const tokenHash = hashPendingAuthToken(rawToken); const expiresAt = now + ttlMs; await deleteExpired(now); - await database.execute({ - sql: `INSERT INTO ea_pending_auth (token_hash, user_id, created_at, expires_at) - VALUES (?, ?, ?, ?)`, - args: [tokenHash, userId, now, expiresAt], + const modeClause = expectedAuthMode ? " AND auth_mode = ?" : ""; + const inserted = await database.execute({ + sql: `INSERT INTO ea_pending_auth + (token_hash, user_id, created_at, expires_at, security_generation, password_authenticated_at) + SELECT ?, ?, ?, ?, ?, ? + FROM ea_owner + WHERE singleton_id = 1 + AND user_id = ? + AND security_generation = ?${modeClause}`, + args: [ + tokenHash, + userId, + now, + expiresAt, + securityGeneration, + passwordAuthenticatedAt, + userId, + securityGeneration, + ...(expectedAuthMode ? [expectedAuthMode] : []), + ], }); - return { token: rawToken, tokenHash, userId, createdAt: now, expiresAt }; + if (inserted.rowsAffected !== 1) return null; + return { + token: rawToken, + tokenHash, + userId, + createdAt: now, + expiresAt, + securityGeneration, + passwordAuthenticatedAt, + }; } async function readPendingAuth(rawToken: string | null | undefined, { now = Date.now() }: { now?: number } = {}) { if (!rawToken) return null; const tokenHash = hashPendingAuthToken(rawToken); const result = await database.execute({ - sql: "SELECT token_hash, user_id, created_at, expires_at FROM ea_pending_auth WHERE token_hash = ?", + sql: `SELECT p.token_hash, p.user_id, p.created_at, p.expires_at, + p.security_generation, p.password_authenticated_at + FROM ea_pending_auth p + JOIN ea_owner o + ON o.user_id = p.user_id + AND o.security_generation = p.security_generation + WHERE p.token_hash = ?`, args: [tokenHash], }); const row = result.rows[0]; @@ -90,12 +140,16 @@ export function createPendingAuthStore(database: Client = db) { } async function consumePendingAuth(rawToken: string | null | undefined, { now = Date.now() }: { now?: number } = {}) { - const pendingAuth = await readPendingAuth(rawToken, { now }); - if (!pendingAuth) return null; - await database.execute({ - sql: "DELETE FROM ea_pending_auth WHERE token_hash = ?", - args: [pendingAuth.tokenHash], + if (!rawToken) return null; + const result = await database.execute({ + sql: `DELETE FROM ea_pending_auth + WHERE token_hash = ? + RETURNING token_hash, user_id, created_at, expires_at, + security_generation, password_authenticated_at`, + args: [hashPendingAuthToken(rawToken)], }); + const pendingAuth = mapPendingAuth(result.rows[0]); + if (!pendingAuth || pendingAuth.expiresAt <= now) return null; return pendingAuth; } @@ -135,4 +189,3 @@ export const createPendingAuth = pendingAuthStore.createPendingAuth; export const readPendingAuth = pendingAuthStore.readPendingAuth; export const consumePendingAuth = pendingAuthStore.consumePendingAuth; export const deletePendingAuth = pendingAuthStore.deletePendingAuth; -export const clearPendingAuth = pendingAuthStore.clearPendingAuth; diff --git a/server/auth/recovery-code-store.ts b/server/auth/recovery-code-store.ts index 389b5c92..b31fc43e 100644 --- a/server/auth/recovery-code-store.ts +++ b/server/auth/recovery-code-store.ts @@ -62,6 +62,4 @@ export function createRecoveryCodeStore(database: Client = db) { } const recoveryCodeStore = createRecoveryCodeStore(); -export const replaceRecoveryCodes = recoveryCodeStore.replaceRecoveryCodes; -export const consumeRecoveryCode = recoveryCodeStore.consumeRecoveryCode; export const getRecoveryCodeStatus = recoveryCodeStore.getRecoveryCodeStatus; diff --git a/server/auth/security-transition.test.ts b/server/auth/security-transition.test.ts new file mode 100644 index 00000000..be7e6eed --- /dev/null +++ b/server/auth/security-transition.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Client } from "@libsql/client"; +import { createAuthTestDb, hashApiToken, seedOwner, seedSession } from "../test-utils/auth-db.ts"; +import { createPendingAuthStore } from "./pending-auth-store.ts"; +import { createWebAuthnChallengeStore } from "./webauthn-challenge-store.ts"; +import { createOwnerSecurityTransitionService } from "./security-transition.ts"; + +describe("owner security transitions", () => { + let db: Client; + + beforeEach(async () => { + db = await createAuthTestDb(); + await seedOwner(db, { passwordHash: "old-hash" }); + }); + + afterEach(() => db.close()); + + it("atomically mutates owner security state, increments generation, and revokes auth state", async () => { + await seedSession(db, "old-session", Date.now() + 60_000, Date.now()); + await createPendingAuthStore(db).createPendingAuth({ userId: "user-1", token: "pending", securityGeneration: 1 }); + await createWebAuthnChallengeStore(db).createChallenge({ + userId: "user-1", + challengeType: "authentication", + challenge: "challenge", + securityGeneration: 1, + }); + await db.execute({ + sql: `INSERT INTO ea_api_tokens (token_hash, label, scopes, created_at, expires_at) + VALUES (?, 'Phone', '["actual:write"]', 1, 9999999999999)`, + args: [hashApiToken("token")], + }); + + const service = createOwnerSecurityTransitionService(db); + const nextGeneration = await service.transition({ + userId: "user-1", + expectedGeneration: 1, + revokeApiTokens: true, + mutate: async (tx) => { + await tx.execute({ + sql: "UPDATE ea_owner SET password_hash = ? WHERE singleton_id = 1", + args: ["new-hash"], + }); + }, + }); + + expect(nextGeneration).toBe(2); + expect((await db.execute("SELECT password_hash, security_generation FROM ea_owner")).rows) + .toEqual([{ password_hash: "new-hash", security_generation: 2 }]); + expect((await db.execute("SELECT * FROM ea_sessions")).rows).toEqual([]); + expect((await db.execute("SELECT * FROM ea_pending_auth")).rows).toEqual([]); + expect((await db.execute("SELECT * FROM ea_webauthn_challenges")).rows).toEqual([]); + expect((await db.execute("SELECT * FROM ea_api_tokens")).rows).toEqual([]); + }); + + it("rejects a stale generation without running the mutation", async () => { + const service = createOwnerSecurityTransitionService(db); + let mutated = false; + + await expect(service.transition({ + userId: "user-1", + expectedGeneration: 0, + mutate: async () => { mutated = true; }, + })).resolves.toBeNull(); + + expect(mutated).toBe(false); + expect((await db.execute("SELECT security_generation FROM ea_owner")).rows) + .toEqual([{ security_generation: 1 }]); + }); + + it("rolls back the generation bump when the mutation fails", async () => { + const service = createOwnerSecurityTransitionService(db); + + await expect(service.transition({ + userId: "user-1", + expectedGeneration: 1, + mutate: async () => { throw new Error("mutation failed"); }, + })).rejects.toThrow("mutation failed"); + + expect((await db.execute("SELECT security_generation FROM ea_owner")).rows) + .toEqual([{ security_generation: 1 }]); + }); +}); diff --git a/server/auth/security-transition.ts b/server/auth/security-transition.ts new file mode 100644 index 00000000..797ee79e --- /dev/null +++ b/server/auth/security-transition.ts @@ -0,0 +1,57 @@ +import db from "../db/connection.ts"; +import type { Client, Transaction } from "@libsql/client"; + +type SecurityTransitionDb = Pick; + +type SecurityTransitionInput = { + userId: string; + expectedGeneration: number; + mutate: (tx: Transaction, nextGeneration: number) => Promise; + revokeApiTokens?: boolean; +}; + +export function createOwnerSecurityTransitionService(database: SecurityTransitionDb = db) { + async function transition({ + userId, + expectedGeneration, + mutate, + revokeApiTokens = false, + }: SecurityTransitionInput): Promise { + const tx = await database.transaction("write"); + try { + const bumped = await tx.execute({ + sql: `UPDATE ea_owner + SET security_generation = security_generation + 1 + WHERE singleton_id = 1 + AND user_id = ? + AND security_generation = ? + RETURNING security_generation`, + args: [userId, expectedGeneration], + }); + const nextGeneration = Number(bumped.rows[0]?.security_generation || 0); + if (!nextGeneration) { + await tx.rollback(); + return null; + } + + await mutate(tx, nextGeneration); + await tx.execute({ sql: "DELETE FROM ea_sessions", args: [] }); + await tx.execute({ sql: "DELETE FROM ea_pending_auth WHERE user_id = ?", args: [userId] }); + await tx.execute({ sql: "DELETE FROM ea_webauthn_challenges WHERE user_id = ?", args: [userId] }); + if (revokeApiTokens) { + await tx.execute({ sql: "DELETE FROM ea_api_tokens", args: [] }); + } + await tx.commit(); + return nextGeneration; + } catch (error) { + if (!tx.closed) await tx.rollback().catch(() => {}); + throw error; + } finally { + tx.close(); + } + } + + return { transition }; +} + +export const ownerSecurityTransitionService = createOwnerSecurityTransitionService(); diff --git a/server/auth/session-cookie.ts b/server/auth/session-cookie.ts new file mode 100644 index 00000000..5d3cc348 --- /dev/null +++ b/server/auth/session-cookie.ts @@ -0,0 +1,46 @@ +import type { Response } from "express"; +import { createSession, type SessionAuthMethod } from "../middleware/auth.ts"; + +const SESSION_COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +export function setSessionCookie(res: Response, token: string) { + res.cookie("ea_session", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: SESSION_COOKIE_MAX_AGE_MS, + path: "/", + }); +} + +export function clearSessionCookie(res: Response) { + res.clearCookie("ea_session", { path: "/" }); +} + +export async function issueSessionCookie( + res: Response, + { + securityGeneration, + authMethod, + authenticatedAt = Date.now(), + passwordAuthenticatedAt, + }: { + securityGeneration: number; + authMethod: SessionAuthMethod; + authenticatedAt?: number; + passwordAuthenticatedAt?: number; + }, +): Promise { + const token = await createSession({ + securityGeneration, + authMethod, + authenticatedAt, + passwordAuthenticatedAt, + }); + if (!token) { + clearSessionCookie(res); + return false; + } + setSessionCookie(res, token); + return true; +} diff --git a/server/auth/session-rotation.test.ts b/server/auth/session-rotation.test.ts deleted file mode 100644 index f36ecbd0..00000000 --- a/server/auth/session-rotation.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createAuthTestDb, seedSession } from "../test-utils/auth-db.ts"; -import { createSessionRotation } from "./session-rotation.ts"; -import type { Client } from "@libsql/client"; - -describe("session rotation helper", () => { - let db: Client; - - beforeEach(async () => { - db = await createAuthTestDb(); - }); - - afterEach(async () => { - db.close(); - }); - - it("revokes all sessions before issuing the current browser replacement token", async () => { - await seedSession(db, "old-session-1"); - await seedSession(db, "old-session-2"); - const createSession = vi.fn(async () => { - const rows = await db.execute("SELECT token FROM ea_sessions"); - expect(rows.rows).toHaveLength(0); - await seedSession(db, "fresh-session"); - return "fresh-session"; - }); - const rotation = createSessionRotation(db, createSession); - - await expect(rotation.rotateSessionsForCurrentBrowser()).resolves.toBe("fresh-session"); - expect(createSession).toHaveBeenCalledTimes(1); - - const rows = await db.execute("SELECT token FROM ea_sessions"); - expect(rows.rows).toHaveLength(1); - }); -}); diff --git a/server/auth/session-rotation.ts b/server/auth/session-rotation.ts deleted file mode 100644 index 3e6d0626..00000000 --- a/server/auth/session-rotation.ts +++ /dev/null @@ -1,30 +0,0 @@ -import db from "../db/connection.ts"; -import { createSession, __clearSessionValidationCache } from "../middleware/auth.ts"; -import type { Client } from "@libsql/client"; - -export function createSessionRotation( - database: Client = db, - createSessionToken: () => Promise = createSession, -) { - async function revokeAllSessions() { - await database.execute("DELETE FROM ea_sessions"); - // P2-27: this wipes every session row, so drop the whole validation cache to - // avoid a stale positive surviving a passkey-driven revocation. - __clearSessionValidationCache(); - } - - async function rotateSessionsForCurrentBrowser() { - await revokeAllSessions(); - return createSessionToken(); - } - - return { - revokeAllSessions, - rotateSessionsForCurrentBrowser, - }; -} - -const sessionRotation = createSessionRotation(); - -export const revokeAllSessions = sessionRotation.revokeAllSessions; -export const rotateSessionsForCurrentBrowser = sessionRotation.rotateSessionsForCurrentBrowser; diff --git a/server/auth/setup-token.test.ts b/server/auth/setup-token.test.ts new file mode 100644 index 00000000..aa4d86f5 --- /dev/null +++ b/server/auth/setup-token.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { verifySetupToken } from "./setup-token.ts"; + +describe("setup token verification", () => { + it("accepts only the exact configured high-entropy token", () => { + const configured = "setup-secret-with-at-least-32-characters"; + + expect(verifySetupToken(configured, configured)).toEqual({ configured: true, verified: true }); + expect(verifySetupToken("wrong-token-with-at-least-32-characters", configured)) + .toEqual({ configured: true, verified: false }); + expect(verifySetupToken(undefined, configured)).toEqual({ configured: true, verified: false }); + }); + + it("fails closed when the deployment secret is missing or too short", () => { + expect(verifySetupToken("anything", undefined)).toEqual({ configured: false, verified: false }); + expect(verifySetupToken("anything", "short-token")).toEqual({ configured: false, verified: false }); + }); +}); diff --git a/server/auth/setup-token.ts b/server/auth/setup-token.ts new file mode 100644 index 00000000..67ce1c79 --- /dev/null +++ b/server/auth/setup-token.ts @@ -0,0 +1,28 @@ +import crypto from "crypto"; + +export const MIN_SETUP_TOKEN_LENGTH = 32; + +type SetupTokenVerification = { + configured: boolean; + verified: boolean; +}; + +function digest(value: string): Buffer { + return crypto.createHash("sha256").update(value, "utf8").digest(); +} + +export function verifySetupToken( + submitted: unknown, + configured: string | undefined, +): SetupTokenVerification { + if (typeof configured !== "string" || configured.length < MIN_SETUP_TOKEN_LENGTH) { + return { configured: false, verified: false }; + } + if (typeof submitted !== "string" || !submitted) { + return { configured: true, verified: false }; + } + return { + configured: true, + verified: crypto.timingSafeEqual(digest(submitted), digest(configured)), + }; +} diff --git a/server/auth/webauthn-challenge-store.test.ts b/server/auth/webauthn-challenge-store.test.ts index 2f4528b8..4ffd9fe0 100644 --- a/server/auth/webauthn-challenge-store.test.ts +++ b/server/auth/webauthn-challenge-store.test.ts @@ -26,6 +26,7 @@ describe("WebAuthn challenge store", () => { pendingAuthHash: "sha256:pending", challenge: "raw-challenge", now: 2_000, + securityGeneration: 1, }); const rows = await db.execute("SELECT * FROM ea_webauthn_challenges"); @@ -35,6 +36,7 @@ describe("WebAuthn challenge store", () => { challenge_type: "authentication", pending_auth_hash: "sha256:pending", expires_at: 302_000, + security_generation: 1, }); expect(rows.rows[0]!.challenge_hash).not.toBe("raw-challenge"); }); @@ -46,6 +48,7 @@ describe("WebAuthn challenge store", () => { credentialId: "credential-1", challenge: "registration-challenge", now: 1_000, + securityGeneration: 1, }); await expect(store.consumeChallenge("registration-challenge", { @@ -71,6 +74,7 @@ describe("WebAuthn challenge store", () => { challenge: "expired-challenge", now: 1_000, ttlMs: 100, + securityGeneration: 1, }); await expect(store.consumeChallenge("expired-challenge", { @@ -82,4 +86,29 @@ describe("WebAuthn challenge store", () => { const rows = await db.execute("SELECT challenge_hash FROM ea_webauthn_challenges"); expect(rows.rows).toHaveLength(0); }); + + it("allows only one concurrent consumer", async () => { + await store.createChallenge({ + userId: "user-1", + challengeType: "authentication", + challenge: "concurrent-challenge", + now: 1_000, + securityGeneration: 1, + }); + + const results = await Promise.all([ + store.consumeChallenge("concurrent-challenge", { + userId: "user-1", + challengeType: "authentication", + now: 1_100, + }), + store.consumeChallenge("concurrent-challenge", { + userId: "user-1", + challengeType: "authentication", + now: 1_100, + }), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + }); }); diff --git a/server/auth/webauthn-challenge-store.ts b/server/auth/webauthn-challenge-store.ts index 6a473124..d3b3b0da 100644 --- a/server/auth/webauthn-challenge-store.ts +++ b/server/auth/webauthn-challenge-store.ts @@ -15,17 +15,19 @@ export type StoredWebAuthnChallenge = { credentialId: string | null; createdAt: number; expiresAt: number; + securityGeneration: number; }; -type CreateChallengeInput = Partial<{ +type CreateChallengeInput = { userId: string; challengeType: WebAuthnChallengeType; - pendingAuthHash: string | null; - credentialId: string | null; - now: number; - ttlMs: number; - challenge: string; -}>; + securityGeneration: number; + pendingAuthHash?: string | null; + credentialId?: string | null; + now?: number; + ttlMs?: number; + challenge?: string; +}; export function hashWebAuthnChallenge(raw: unknown) { return CHALLENGE_HASH_PREFIX + crypto.createHash("sha256").update(String(raw || "")).digest("hex"); @@ -47,6 +49,7 @@ function mapChallenge(row: Row | undefined): StoredWebAuthnChallenge | null { credentialId: row.credential_id ? String(row.credential_id) : null, createdAt: Number(row.created_at), expiresAt: Number(row.expires_at), + securityGeneration: Number(row.security_generation), }; } @@ -61,13 +64,17 @@ export function createWebAuthnChallengeStore(database: Client = db) { async function createChallenge({ userId, challengeType, + securityGeneration, pendingAuthHash = null, credentialId = null, now = Date.now(), ttlMs = WEBAUTHN_CHALLENGE_TTL_MS, challenge, - }: CreateChallengeInput = {}) { + }: CreateChallengeInput) { if (!userId) throw new Error("userId is required"); + if (!Number.isInteger(securityGeneration) || securityGeneration < 1) { + throw new Error("securityGeneration is required"); + } assertChallengeType(challengeType); const rawChallenge = challenge || crypto.randomBytes(32).toString("base64url"); const challengeHash = hashWebAuthnChallenge(rawChallenge); @@ -75,9 +82,19 @@ export function createWebAuthnChallengeStore(database: Client = db) { await deleteExpired(now); await database.execute({ sql: `INSERT INTO ea_webauthn_challenges - (challenge_hash, user_id, challenge_type, pending_auth_hash, credential_id, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - args: [challengeHash, userId, challengeType, pendingAuthHash, credentialId, now, expiresAt], + (challenge_hash, user_id, challenge_type, pending_auth_hash, credential_id, + created_at, expires_at, security_generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + challengeHash, + userId, + challengeType, + pendingAuthHash, + credentialId, + now, + expiresAt, + securityGeneration, + ], }); return { challenge: rawChallenge, @@ -88,6 +105,7 @@ export function createWebAuthnChallengeStore(database: Client = db) { credentialId, createdAt: now, expiresAt, + securityGeneration, }; } @@ -103,17 +121,14 @@ export function createWebAuthnChallengeStore(database: Client = db) { if (challengeType) assertChallengeType(challengeType); const challengeHash = hashWebAuthnChallenge(rawChallenge); const result = await database.execute({ - sql: "SELECT * FROM ea_webauthn_challenges WHERE challenge_hash = ?", + sql: `DELETE FROM ea_webauthn_challenges + WHERE challenge_hash = ? + RETURNING *`, args: [challengeHash], }); const row = result.rows[0]; if (!row) return null; - await database.execute({ - sql: "DELETE FROM ea_webauthn_challenges WHERE challenge_hash = ?", - args: [challengeHash], - }); - if (Number(row.expires_at) <= now) return null; if (userId && row.user_id !== userId) return null; if (challengeType && row.challenge_type !== challengeType) return null; @@ -145,5 +160,4 @@ const challengeStore = createWebAuthnChallengeStore(); export const createChallenge = challengeStore.createChallenge; export const consumeChallenge = challengeStore.consumeChallenge; -export const clearChallenges = challengeStore.clearChallenges; export const deleteChallengesForPendingAuth = challengeStore.deleteChallengesForPendingAuth; diff --git a/server/db/migrations.test.ts b/server/db/migrations.test.ts index 52406234..e3240524 100644 --- a/server/db/migrations.test.ts +++ b/server/db/migrations.test.ts @@ -552,4 +552,61 @@ describe("database migrations", () => { "UPDATE ea_gmail_pubsub_config SET token_disabled = 1 WHERE singleton_id = 1", )).rejects.toThrow(); }); + + it("adds generation and authentication provenance while invalidating pending ceremonies", async () => { + db = createClient({ url: "file::memory:" }); + await applyMigrations(db, [ + "001_ea_tables.sql", + "012_passkey_auth.sql", + "030_owner_bootstrap.sql", + "031_auth_recovery.sql", + ]); + await db.execute(`INSERT INTO ea_owner (singleton_id, user_id, password_hash, claimed_at) + VALUES (1, 'owner-1', 'hash', 100)`); + await db.execute(`INSERT INTO ea_sessions (token, expires_at, authenticated_at) + VALUES ('session', 999999, 500)`); + await db.execute(`INSERT INTO ea_pending_auth (token_hash, user_id, created_at, expires_at) + VALUES ('pending', 'owner-1', 100, 999999)`); + await db.execute(`INSERT INTO ea_webauthn_challenges + (challenge_hash, user_id, challenge_type, created_at, expires_at) + VALUES ('challenge', 'owner-1', 'authentication', 100, 999999)`); + + await applyMigrations(db, [ + "038_auth_security_generation.sql", + "039_password_step_up_window.sql", + ]); + + const owner = await db.execute("SELECT security_generation FROM ea_owner"); + const session = await db.execute( + `SELECT security_generation, auth_method, password_authenticated_at, + step_up_failure_count, step_up_blocked_until + , step_up_window_started_at + FROM ea_sessions`, + ); + expect(owner.rows).toEqual([{ security_generation: 1 }]); + expect(session.rows).toEqual([{ + security_generation: 1, + auth_method: "legacy", + password_authenticated_at: 0, + step_up_failure_count: 0, + step_up_blocked_until: 0, + step_up_window_started_at: 0, + }]); + expect((await db.execute("SELECT * FROM ea_pending_auth")).rows).toEqual([]); + expect((await db.execute("SELECT * FROM ea_webauthn_challenges")).rows).toEqual([]); + }); + + it("adds password step-up window state in a forward migration", async () => { + db = createClient({ url: "file::memory:" }); + await db.execute(`CREATE TABLE ea_sessions ( + token TEXT PRIMARY KEY, + step_up_failure_count INTEGER NOT NULL DEFAULT 0, + step_up_blocked_until INTEGER NOT NULL DEFAULT 0 + )`); + + await applyMigrations(db, ["039_password_step_up_window.sql"]); + + const columns = await db.execute("PRAGMA table_info('ea_sessions')"); + expect(columns.rows.map((row) => row.name)).toContain("step_up_window_started_at"); + }); }); diff --git a/server/db/migrations/038_auth_security_generation.sql b/server/db/migrations/038_auth_security_generation.sql new file mode 100644 index 00000000..e4f62ea1 --- /dev/null +++ b/server/db/migrations/038_auth_security_generation.sql @@ -0,0 +1,42 @@ +ALTER TABLE ea_owner + ADD COLUMN security_generation INTEGER NOT NULL DEFAULT 1 + CHECK (security_generation > 0); + +ALTER TABLE ea_sessions + ADD COLUMN security_generation INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE ea_sessions + ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'legacy' + CHECK (auth_method IN ('legacy', 'password', 'passkey', 'password_plus_passkey', 'recovery')); + +ALTER TABLE ea_sessions + ADD COLUMN password_authenticated_at INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE ea_sessions + ADD COLUMN step_up_failure_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE ea_sessions + ADD COLUMN step_up_blocked_until INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE ea_pending_auth + ADD COLUMN security_generation INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE ea_pending_auth + ADD COLUMN password_authenticated_at INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE ea_webauthn_challenges + ADD COLUMN security_generation INTEGER NOT NULL DEFAULT 0; + +-- Existing browser sessions remain valid for ordinary application access, but +-- deliberately receive no password-authentication provenance. Their next +-- security mutation therefore requires an explicit password step-up. +UPDATE ea_sessions + SET security_generation = COALESCE( + (SELECT security_generation FROM ea_owner WHERE singleton_id = 1), + 0 + ); + +-- Pre-migration ceremonies have no trustworthy generation or factor +-- provenance. They are short-lived and safe to restart. +DELETE FROM ea_webauthn_challenges; +DELETE FROM ea_pending_auth; diff --git a/server/db/migrations/039_password_step_up_window.sql b/server/db/migrations/039_password_step_up_window.sql new file mode 100644 index 00000000..089ab16b --- /dev/null +++ b/server/db/migrations/039_password_step_up_window.sql @@ -0,0 +1,2 @@ +ALTER TABLE ea_sessions + ADD COLUMN step_up_window_started_at INTEGER NOT NULL DEFAULT 0; diff --git a/server/index.ts b/server/index.ts index ec20713f..4ddbdb2c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -82,7 +82,7 @@ applySecurityMiddleware(app); // routes and installProductionFrontend so both API and asset payloads shrink. app.use(responseCompression()); app.get("/healthz", (_req, res) => { - res.json({ status: "ok", claimed: Boolean(getActiveOwner()) }); + res.json({ status: "ok" }); }); app.use("/api", requireClaimedInstance); app.use("/api/todoist/webhook", express.raw({ type: "*/*" }), todoistWebhookRoutes); @@ -97,8 +97,6 @@ app.use("/api", (req, res, next) => { return next(); } if (req.path === "/gmail/push") return next(); - if (req.path === "/auth/login") return next(); - if (req.path === "/auth/setup/claim") return next(); if (req.headers.authorization?.startsWith("Bearer ")) return next(); if (req.headers["x-requested-with"] !== "Setpoint") { return res.status(403).json({ message: "Forbidden" }); diff --git a/server/middleware/CLAUDE.md b/server/middleware/CLAUDE.md index 8d8d0dd9..9916a500 100644 --- a/server/middleware/CLAUDE.md +++ b/server/middleware/CLAUDE.md @@ -5,7 +5,7 @@ Cross-cutting Express request-pipeline middleware composed in `server/index.ts`: ## Files - `async-handler.ts` — `asyncHandler` / `wrapRouterAsync` forward async route rejections to the terminal `errorHandler` (also here, a 4-arg error middleware honoring `err.status` and the `headersSent` guard). Express 4 does not catch async rejections, so an unwrapped rejecting handler hangs the request (P1-12). -- `auth.ts` — session + API-token authentication: hashed cookie tokens, recent-auth timestamps and guard, 30-day TTL, 30s positive-validation cache, scoped bearer tokens, and cookie/API-token route guards. +- `auth.ts` — session + API-token authentication: hashed cookie tokens, DB-checked owner security-generation binding on every request, factor provenance and password-specific recent-auth guard, durable per-session password-step-up throttling, 30-day TTL, scoped bearer tokens, and cookie/API-token route guards. - `compression.ts` — `responseCompression`, a streaming-safe gzip built on Node `zlib` (no dependency). Decides buffer-vs-passthrough on the first write/end by Content-Type, and deliberately never buffers `text/event-stream` (Alfred + dashboard SSE). - `rate-limits.ts` — per-route spend guards for LLM/paid-API routes (bills/extract, alfred run, email-search, places); each limiter is exported as both a `makeXLimiter()` factory (fresh, test-isolated instance) and a singleton built from it (used by real route wiring), since `express-rate-limit` tracks counts per-instance. - `owner-gate.ts` — blocks all non-setup APIs until the singleton owner has been claimed; returns a fixed setup-required response. @@ -16,7 +16,7 @@ Cross-cutting Express request-pipeline middleware composed in `server/index.ts`: - `wrapRouterAsync` wraps only verb handlers, NOT `router.use()` — async middleware mounted via `use` (e.g. `requireCookieSession`) must guard itself with try/catch and forward faults via `next(err)` (P1-12). - Auth guards return 401/403 for auth failures but forward DB/transport faults to `errorHandler` (a 500) rather than rejecting and hanging. -- The session-validation cache stores only positive, unexpired results; negatives and expirations always fall through to the DB. It is invalidated on logout and bounded by the 30s TTL. +- Session validation rechecks the owner security generation in the DB on every request so recovery/reset on another process cannot leave a stale positive authentication window. ## Related diff --git a/server/middleware/auth.test.ts b/server/middleware/auth.test.ts index fb5bb393..2ae94f83 100644 --- a/server/middleware/auth.test.ts +++ b/server/middleware/auth.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import crypto from "crypto"; -import { createAuthTestDb, hashApiToken, hashSessionToken, seedSession } from "../test-utils/auth-db.ts"; +import { createAuthTestDb, hashApiToken, hashSessionToken, seedOwner, seedSession } from "../test-utils/auth-db.ts"; import type { Client, InStatement } from "@libsql/client"; const testState = vi.hoisted<{ db: { current: Client | null } }>(() => ({ @@ -23,9 +23,8 @@ const { validateSession, deleteSession, validateBearer, - hasRecentAuth, - markSessionRecentlyAuthenticated, - __clearSessionValidationCache, + hasRecentPasswordAuth, + markSessionPasswordAuthenticated, } = await import("./auth.ts"); async function seedApiToken( @@ -42,7 +41,7 @@ async function seedApiToken( describe("auth middleware session storage", () => { beforeEach(async () => { testState.db.current = await createAuthTestDb(); - __clearSessionValidationCache(); + await seedOwner(currentDb(), { passwordHash: "hash" }); }); afterEach(async () => { @@ -57,7 +56,11 @@ describe("auth middleware session storage", () => { Buffer.alloc(size, 1) )) as typeof crypto.randomBytes); - const rawToken = await createSession(); + const rawToken = await createSession({ + securityGeneration: 1, + authMethod: "password", + passwordAuthenticatedAt: 1_000, + }); const expectedRaw = Buffer.alloc(32, 1).toString("hex"); const expectedStored = hashSessionToken(expectedRaw); @@ -73,14 +76,30 @@ describe("auth middleware session storage", () => { expect(result.rows[0]!.expires_at).toBeGreaterThan(before + 29 * 24 * 60 * 60 * 1000); }); - it("tracks recent authentication on the hashed session without exposing the token", async () => { - const token = await createSession({ authenticatedAt: 1_000 }); + it("does not treat a passkey as password proof and records an explicit password step-up", async () => { + const token = await createSession({ + authenticatedAt: 1_000, + passwordAuthenticatedAt: 0, + authMethod: "passkey", + securityGeneration: 1, + }); - await expect(hasRecentAuth(token, { now: 1_000 + 9 * 60_000 })).resolves.toBe(true); - await expect(hasRecentAuth(token, { now: 1_000 + 11 * 60_000 })).resolves.toBe(false); + await expect(hasRecentPasswordAuth(token, { now: 1_001 })).resolves.toBe(false); - await markSessionRecentlyAuthenticated(token, 20_000); - await expect(hasRecentAuth(token, { now: 20_001 })).resolves.toBe(true); + await markSessionPasswordAuthenticated(token, 20_000); + await expect(hasRecentPasswordAuth(token, { now: 20_001 })).resolves.toBe(true); + await expect(hasRecentPasswordAuth(token, { now: 20_000 + 11 * 60_000 })).resolves.toBe(false); + }); + + it("refuses to issue a session against a stale owner security generation", async () => { + await currentDb().execute("UPDATE ea_owner SET security_generation = 2"); + + await expect(createSession({ + securityGeneration: 1, + authMethod: "password", + passwordAuthenticatedAt: Date.now(), + })).resolves.toBeNull(); + expect((await currentDb().execute("SELECT token FROM ea_sessions")).rows).toEqual([]); }); it("validates hashed session rows", async () => { @@ -98,8 +117,8 @@ describe("auth middleware session storage", () => { it("accepts raw session rows and migrates them to hashed storage", async () => { await currentDb().execute({ - sql: "INSERT INTO ea_sessions (token, expires_at) VALUES (?, ?)", - args: ["raw-session", Date.now() + 60_000], + sql: "INSERT INTO ea_sessions (token, expires_at, security_generation) VALUES (?, ?, ?)", + args: ["raw-session", Date.now() + 60_000, 1], }); const ok = await validateSession("raw-session"); @@ -165,7 +184,7 @@ describe("auth middleware session storage", () => { args: [hashSessionToken("stale-session"), Date.now() - 60_000], }); - await createSession(); + await createSession({ securityGeneration: 1, authMethod: "password" }); const rows = await currentDb().execute({ sql: "SELECT token FROM ea_sessions WHERE token = ?", @@ -174,25 +193,21 @@ describe("auth middleware session storage", () => { expect(rows.rows).toHaveLength(0); }); - it("serves a repeat validation from cache without re-querying the database (P2-27)", async () => { - await seedSession(currentDb(), "cached-session"); - const spy = vi.spyOn(currentDb(), "execute"); + it("rechecks owner generation so a rotation in another process revokes a validated session", async () => { + await seedSession(currentDb(), "externally-revoked-session"); - expect(await validateSession("cached-session")).toBe(true); - const callsAfterFirst = spy.mock.calls.length; - expect(callsAfterFirst).toBeGreaterThan(0); + expect(await validateSession("externally-revoked-session")).toBe(true); + await currentDb().execute("UPDATE ea_owner SET security_generation = security_generation + 1"); - expect(await validateSession("cached-session")).toBe(true); - // Second validation is a cache hit: no additional DB round-trips. - expect(spy.mock.calls.length).toBe(callsAfterFirst); + expect(await validateSession("externally-revoked-session")).toBe(false); }); - it("invalidates the session cache on logout so a revoked token stops validating (P2-27)", async () => { - await seedSession(currentDb(), "logout-cache-session"); + it("stops validating a session after logout", async () => { + await seedSession(currentDb(), "logout-session"); - expect(await validateSession("logout-cache-session")).toBe(true); // populates cache - await deleteSession("logout-cache-session"); // deletes row AND clears cache entry + expect(await validateSession("logout-session")).toBe(true); + await deleteSession("logout-session"); - expect(await validateSession("logout-cache-session")).toBe(false); + expect(await validateSession("logout-session")).toBe(false); }); }); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 5db63953..eb9fd54a 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -4,26 +4,21 @@ import type { Request, RequestHandler } from "express"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days export const RECENT_AUTH_MAX_AGE_MS = 10 * 60 * 1000; +export const PASSWORD_STEP_UP_WINDOW_MS = 15 * 60 * 1000; +export const PASSWORD_STEP_UP_MAX_FAILURES = 5; const SESSION_TOKEN_PREFIX = "sha256:"; -// P2-27: every authenticated /api request validates the session token, which in -// production is a remote Turso round-trip. For a single user the token is static -// between ~monthly logins, so memoize a positive validation for a short TTL keyed -// by the hashed token. Only positive, unexpired results are cached; negatives and -// expirations always fall through to the DB. Invalidated on logout (deleteSession) -// and on full revocation (session-rotation); the TTL bounds any missed -// invalidation to a few tens of seconds. -const SESSION_CACHE_TTL_MS = 30_000; -type SessionCacheEntry = { expiresAt: number; cachedAt: number }; +export type SessionAuthMethod = "legacy" | "password" | "passkey" | "password_plus_passkey" | "recovery"; +export type SessionSecurityContext = { + expiresAt: number; + authenticatedAt: number; + passwordAuthenticatedAt: number; + securityGeneration: number; + authMethod: SessionAuthMethod; +}; export type ApiTokenContext = { id: string | number; scopes: string[] }; type RequestWithApiToken = Request & { apiToken?: ApiTokenContext }; -const sessionValidationCache = new Map(); - -export function __clearSessionValidationCache() { - sessionValidationCache.clear(); -} - export function hashToken(raw: string) { return crypto.createHash("sha256").update(raw).digest("hex"); } @@ -79,10 +74,21 @@ export async function deleteSession(token: string) { sql: "DELETE FROM ea_sessions WHERE token IN (?, ?)", args: [token, hashSessionToken(token)], }); - sessionValidationCache.delete(hashSessionToken(token)); } -export async function createSession({ authenticatedAt = Date.now() }: { authenticatedAt?: number } = {}) { +export async function createSession({ + securityGeneration, + authMethod, + authenticatedAt = Date.now(), + passwordAuthenticatedAt = authMethod === "password" || authMethod === "password_plus_passkey" + ? authenticatedAt + : 0, +}: { + securityGeneration: number; + authMethod: SessionAuthMethod; + authenticatedAt?: number; + passwordAuthenticatedAt?: number; +}): Promise { const token = crypto.randomBytes(32).toString("hex"); const expiresAt = Date.now() + SESSION_MAX_AGE_MS; // P3-18: expired sessions are otherwise never reclaimed (the lazy delete only @@ -93,95 +99,207 @@ export async function createSession({ authenticatedAt = Date.now() }: { authenti sql: "DELETE FROM ea_sessions WHERE expires_at < ?", args: [Date.now()], }); - await db.execute({ - sql: "INSERT INTO ea_sessions (token, expires_at, authenticated_at) VALUES (?, ?, ?)", - args: [hashSessionToken(token), expiresAt, authenticatedAt], + const inserted = await db.execute({ + sql: `INSERT INTO ea_sessions + (token, expires_at, authenticated_at, password_authenticated_at, + security_generation, auth_method) + SELECT ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM ea_owner + WHERE singleton_id = 1 AND security_generation = ? + )`, + args: [ + hashSessionToken(token), + expiresAt, + authenticatedAt, + passwordAuthenticatedAt, + securityGeneration, + authMethod, + securityGeneration, + ], + }); + return inserted.rowsAffected === 1 ? token : null; +} + +function mapSessionContext(row: Record | undefined): SessionSecurityContext | null { + if (!row) return null; + const expiresAt = numberValue(row.expires_at); + const authenticatedAt = numberValue(row.authenticated_at); + const passwordAuthenticatedAt = numberValue(row.password_authenticated_at); + const securityGeneration = numberValue(row.security_generation); + const authMethod = stringValue(row.auth_method) as SessionAuthMethod | null; + if (!expiresAt || authenticatedAt === null || passwordAuthenticatedAt === null + || !securityGeneration || !authMethod) return null; + return { expiresAt, authenticatedAt, passwordAuthenticatedAt, securityGeneration, authMethod }; +} + +export async function getSessionSecurityContext( + token: string | null | undefined, +): Promise { + if (!token) return null; + const hashedToken = hashSessionToken(token); + const nowMs = Date.now(); + + const selectSession = async (storedToken: string) => db.execute({ + sql: `SELECT s.expires_at, s.authenticated_at, s.password_authenticated_at, + s.security_generation, s.auth_method + FROM ea_sessions s + JOIN ea_owner o + ON o.singleton_id = 1 + AND o.security_generation = s.security_generation + WHERE s.token = ?`, + args: [storedToken], }); - return token; + + let result = await selectSession(hashedToken); + let storedToken = hashedToken; + if (!result.rows.length) { + result = await selectSession(token); + storedToken = token; + } + const context = mapSessionContext(result.rows[0] as Record | undefined); + if (!context) return null; + if (nowMs > context.expiresAt) { + await db.execute({ sql: "DELETE FROM ea_sessions WHERE token = ?", args: [storedToken] }); + return null; + } + if (storedToken === token) { + await db.execute({ + sql: "UPDATE ea_sessions SET token = ? WHERE token = ?", + args: [hashedToken, token], + }).catch((err: unknown) => console.error("[EA] session hash migration failed:", errorMessage(err))); + } + return context; } -export async function hasRecentAuth( +export async function hasRecentPasswordAuth( token: string | null | undefined, { now = Date.now(), maxAgeMs = RECENT_AUTH_MAX_AGE_MS }: { now?: number; maxAgeMs?: number } = {}, ): Promise { - if (!token) return false; - const result = await db.execute({ - sql: "SELECT expires_at, authenticated_at FROM ea_sessions WHERE token IN (?, ?)", - args: [hashSessionToken(token), token], - }); - const row = result.rows[0]; - if (!row) return false; - const expiresAt = numberValue(row.expires_at); - const authenticatedAt = numberValue(row.authenticated_at); + const context = await getSessionSecurityContext(token); + if (!context) return false; + const { expiresAt, passwordAuthenticatedAt } = context; return Boolean( - expiresAt && expiresAt >= now - && authenticatedAt && authenticatedAt <= now - && now - authenticatedAt <= maxAgeMs, + expiresAt >= now + && passwordAuthenticatedAt > 0 + && passwordAuthenticatedAt <= now + && now - passwordAuthenticatedAt <= maxAgeMs, ); } -export async function markSessionRecentlyAuthenticated( +export async function markSessionPasswordAuthenticated( token: string | null | undefined, authenticatedAt = Date.now(), ): Promise { if (!token) return false; const result = await db.execute({ - sql: "UPDATE ea_sessions SET authenticated_at = ? WHERE token IN (?, ?)", - args: [authenticatedAt, hashSessionToken(token), token], + sql: `UPDATE ea_sessions + SET authenticated_at = ?, + password_authenticated_at = ?, + auth_method = 'password', + step_up_failure_count = 0, + step_up_blocked_until = 0, + step_up_window_started_at = 0 + WHERE token IN (?, ?) + AND security_generation = ( + SELECT security_generation FROM ea_owner WHERE singleton_id = 1 + )`, + args: [authenticatedAt, authenticatedAt, hashSessionToken(token), token], }); return result.rowsAffected > 0; } -export async function validateSession(token: string | null | undefined): Promise { - if (!token) return false; - const hashedToken = hashSessionToken(token); - - // P2-27: serve a recent positive validation from the in-process cache, skipping - // the remote Turso SELECT. A cached entry is honored only within the TTL and - // only while still unexpired; otherwise drop it and fall through to the DB - // (which also runs the legacy-token migration and lazy expiry cleanup). - const nowMs = Date.now(); - const cached = sessionValidationCache.get(hashedToken); - if (cached && nowMs - cached.cachedAt < SESSION_CACHE_TTL_MS) { - if (nowMs <= cached.expiresAt) return true; - sessionValidationCache.delete(hashedToken); - } +export type PasswordStepUpThrottle = { + failureCount: number; + blockedUntil: number; +}; - let result = await db.execute({ - sql: "SELECT expires_at FROM ea_sessions WHERE token = ?", - args: [hashedToken], +export async function getPasswordStepUpThrottle( + token: string | null | undefined, + now = Date.now(), +): Promise { + if (!token) return null; + const result = await db.execute({ + sql: `SELECT s.step_up_failure_count, s.step_up_blocked_until, s.step_up_window_started_at + FROM ea_sessions s + JOIN ea_owner o + ON o.singleton_id = 1 + AND o.security_generation = s.security_generation + WHERE s.token IN (?, ?)`, + args: [hashSessionToken(token), token], }); - let storedToken = hashedToken; - - if (!result.rows.length) { - result = await db.execute({ - sql: "SELECT expires_at FROM ea_sessions WHERE token = ?", - args: [token], - }); - storedToken = token; - } - if (!result.rows.length) return false; - const expiresAt = numberValue(result.rows[0]!.expires_at); - if (!expiresAt || Date.now() > expiresAt) { - // Lazy cleanup — delete expired session + const row = result.rows[0]; + if (!row) return null; + const windowStartedAt = Number(row.step_up_window_started_at || 0); + const blockedUntil = Number(row.step_up_blocked_until || 0); + if ((windowStartedAt > 0 && windowStartedAt <= now - PASSWORD_STEP_UP_WINDOW_MS) + || (blockedUntil > 0 && blockedUntil <= now)) { await db.execute({ - sql: "DELETE FROM ea_sessions WHERE token = ?", - args: [storedToken], + sql: `UPDATE ea_sessions + SET step_up_failure_count = 0, + step_up_blocked_until = 0, + step_up_window_started_at = 0 + WHERE token IN (?, ?)`, + args: [hashSessionToken(token), token], }); - sessionValidationCache.delete(hashedToken); - return false; + return { failureCount: 0, blockedUntil: 0 }; } - if (storedToken === token) { - await db.execute({ - sql: "UPDATE ea_sessions SET token = ? WHERE token = ?", - args: [hashedToken, token], - }).catch((err: unknown) => console.error("[EA] session hash migration failed:", errorMessage(err))); - } - sessionValidationCache.set(hashedToken, { - expiresAt, - cachedAt: Date.now(), + return { + failureCount: Number(row.step_up_failure_count || 0), + blockedUntil, + }; +} + +export async function recordPasswordStepUpFailure( + token: string | null | undefined, + now = Date.now(), +): Promise { + if (!token) return null; + const windowCutoff = now - PASSWORD_STEP_UP_WINDOW_MS; + const blockedUntil = now + PASSWORD_STEP_UP_WINDOW_MS; + const result = await db.execute({ + sql: `UPDATE ea_sessions + SET step_up_failure_count = CASE + WHEN step_up_window_started_at = 0 OR step_up_window_started_at <= ? THEN 1 + ELSE step_up_failure_count + 1 + END, + step_up_window_started_at = CASE + WHEN step_up_window_started_at = 0 OR step_up_window_started_at <= ? THEN ? + ELSE step_up_window_started_at + END, + step_up_blocked_until = CASE + WHEN (CASE + WHEN step_up_window_started_at = 0 OR step_up_window_started_at <= ? THEN 1 + ELSE step_up_failure_count + 1 + END) >= ? THEN ? + ELSE 0 + END + WHERE token IN (?, ?) + AND security_generation = ( + SELECT security_generation FROM ea_owner WHERE singleton_id = 1 + ) + RETURNING step_up_failure_count, step_up_blocked_until`, + args: [ + windowCutoff, + windowCutoff, + now, + windowCutoff, + PASSWORD_STEP_UP_MAX_FAILURES, + blockedUntil, + hashSessionToken(token), + token, + ], }); - return true; + const row = result.rows[0]; + if (!row) return null; + return { + failureCount: Number(row.step_up_failure_count || 0), + blockedUntil: Number(row.step_up_blocked_until || 0), + }; +} + +export async function validateSession(token: string | null | undefined): Promise { + return Boolean(await getSessionSecurityContext(token)); } function getBearerToken(req: Request) { @@ -192,7 +310,9 @@ function getBearerToken(req: Request) { export const requireCookieSession: RequestHandler = async (req, res, next) => { try { - if (await validateSession(req.cookies?.ea_session)) { + const context = await getSessionSecurityContext(req.cookies?.ea_session); + if (context) { + res.locals.authSession = context; return next(); } return res.status(401).json({ message: "Not authenticated" }); @@ -205,18 +325,20 @@ export const requireCookieSession: RequestHandler = async (req, res, next) => { } }; -export const requireRecentAuth: RequestHandler = async (req, res, next) => { +export const requireRecentPasswordAuth: RequestHandler = async (req, res, next) => { try { const token = req.cookies?.ea_session; - if (!await validateSession(token)) { + const context = await getSessionSecurityContext(token); + if (!context) { return res.status(401).json({ message: "Not authenticated" }); } - if (!await hasRecentAuth(token)) { + if (!await hasRecentPasswordAuth(token)) { return res.status(403).json({ - code: "STEP_UP_REQUIRED", - message: "Confirm your password or passkey to continue", + code: "PASSWORD_STEP_UP_REQUIRED", + message: "Confirm your password to continue", }); } + res.locals.authSession = context; return next(); } catch (err) { return next(err); diff --git a/server/middleware/owner-gate.test.ts b/server/middleware/owner-gate.test.ts index 75c3b104..899df18e 100644 --- a/server/middleware/owner-gate.test.ts +++ b/server/middleware/owner-gate.test.ts @@ -34,6 +34,7 @@ describe("claimed-instance API gate", () => { userId: "owner-1", passwordHash: "not-exposed", authMode: "password_or_passkey", + securityGeneration: 1, claimedAt: 1, }); diff --git a/server/routes/CLAUDE.md b/server/routes/CLAUDE.md index 98940e66..1625fd4e 100644 --- a/server/routes/CLAUDE.md +++ b/server/routes/CLAUDE.md @@ -5,8 +5,9 @@ The HTTP surface: Express routers that validate input, apply auth, and delegate ## Files ### Auth + accounts -- `auth.ts` — owner claim, password/passkey login, recent-auth step-up, offline recovery, passkey/session management -- `auth-canonical-origin.ts` — canonical-domain status, impact preview, and recent-auth-gated mutation +- `auth.ts` — setup-token owner claim, password/passkey login, passkey registration/deletion, session check/logout, and the mount point for the security subrouter +- `auth-security.ts` — password step-up, canonical/auth-mode/password/recovery mutations, and scoped API-token management +- `auth-canonical-origin.ts` — canonical-domain status, impact preview, and password-step-up/generation-gated mutation - `accounts.ts` — Gmail OAuth callback and account binding; mounts settings/reminders routers ### Briefing diff --git a/server/routes/accounts.oauth.test.ts b/server/routes/accounts.oauth.test.ts index 385c9ef6..3d7444db 100644 --- a/server/routes/accounts.oauth.test.ts +++ b/server/routes/accounts.oauth.test.ts @@ -8,6 +8,7 @@ import type { Client, InStatement, TransactionMode } from "@libsql/client"; import { createAuthTestDb, seedGmailAccount, + seedOwner, seedSession, } from "../test-utils/auth-db.ts"; import type { AccountSummary } from "../../shared/types/accounts.ts"; @@ -104,6 +105,7 @@ describe("accounts Gmail OAuth binding", () => { beforeEach(async () => { vi.clearAllMocks(); testState.db.current = await createAuthTestDb(); + await seedOwner(currentDb(), { passwordHash: "unused-test-hash" }); await seedSession(currentDb(), "cookie-session"); }); @@ -266,6 +268,7 @@ describe("GET /accounts needs_reauth", () => { beforeEach(async () => { vi.clearAllMocks(); testState.db.current = await createAuthTestDb(); + await seedOwner(currentDb(), { passwordHash: "unused-test-hash" }); await seedSession(currentDb(), "cookie-session"); }); diff --git a/server/routes/alfred.test.ts b/server/routes/alfred.test.ts index 6752b0f0..a70fc368 100644 --- a/server/routes/alfred.test.ts +++ b/server/routes/alfred.test.ts @@ -31,10 +31,25 @@ function hashSessionToken(raw: string): string { async function createMigratedDb(): Promise { const db = createClient({ url: "file::memory:" }); await db.executeMultiple(` + CREATE TABLE ea_owner ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + user_id TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + auth_mode TEXT NOT NULL DEFAULT 'password_or_passkey', + security_generation INTEGER NOT NULL DEFAULT 1, + claimed_at INTEGER NOT NULL + ); CREATE TABLE ea_sessions ( token TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + authenticated_at INTEGER NOT NULL DEFAULT 0, + password_authenticated_at INTEGER NOT NULL DEFAULT 0, + security_generation INTEGER NOT NULL DEFAULT 1, + auth_method TEXT NOT NULL DEFAULT 'legacy' ); + INSERT INTO ea_owner + (singleton_id, user_id, password_hash, auth_mode, security_generation, claimed_at) + VALUES (1, 'user-1', 'unused-test-hash', 'password_or_passkey', 1, 1); `); await db.execute({ sql: "INSERT INTO ea_sessions (token, expires_at) VALUES (?, ?)", diff --git a/server/routes/auth-boundaries.test.ts b/server/routes/auth-boundaries.test.ts index dd9de40a..7e0798a9 100644 --- a/server/routes/auth-boundaries.test.ts +++ b/server/routes/auth-boundaries.test.ts @@ -137,9 +137,25 @@ function makeApp() { async function createMigratedDb() { const db = createClient({ url: "file::memory:" }); await db.executeMultiple(` + CREATE TABLE ea_owner ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + user_id TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + auth_mode TEXT NOT NULL DEFAULT 'password_or_passkey', + security_generation INTEGER NOT NULL DEFAULT 1, + claimed_at INTEGER NOT NULL + ); + CREATE TABLE ea_sessions ( token TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + authenticated_at INTEGER NOT NULL DEFAULT 0, + password_authenticated_at INTEGER NOT NULL DEFAULT 0, + security_generation INTEGER NOT NULL DEFAULT 1, + auth_method TEXT NOT NULL DEFAULT 'password', + step_up_failure_count INTEGER NOT NULL DEFAULT 0, + step_up_blocked_until INTEGER NOT NULL DEFAULT 0, + step_up_window_started_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE ea_api_tokens ( @@ -216,6 +232,12 @@ async function createMigratedDb() { sort_order INTEGER DEFAULT 0 ); `); + await db.execute({ + sql: `INSERT INTO ea_owner + (singleton_id, user_id, password_hash, auth_mode, security_generation, claimed_at) + VALUES (1, ?, ?, 'password_or_passkey', 1, ?)`, + args: ["user-1", "test-password-hash", Date.now()], + }); await db.execute({ sql: "INSERT INTO ea_settings (user_id, email_triage_mode) VALUES (?, ?)", args: ["user-1", "auto"], diff --git a/server/routes/auth-canonical-origin.ts b/server/routes/auth-canonical-origin.ts index 6c2bfa79..1e1fcd5b 100644 --- a/server/routes/auth-canonical-origin.ts +++ b/server/routes/auth-canonical-origin.ts @@ -1,15 +1,19 @@ import { Router } from "express"; import { - hasRecentAuth, + hasRecentPasswordAuth, requireCookieSession, - requireRecentAuth, + requireRecentPasswordAuth, + type SessionSecurityContext, } from "../middleware/auth.ts"; import { wrapRouterAsync } from "../middleware/async-handler.ts"; import { countPasskeys } from "../auth/passkey-store.ts"; import { getOwner } from "../auth/owner-store.ts"; +import { ownerSecurityTransitionService } from "../auth/security-transition.ts"; +import { clearSessionCookie, issueSessionCookie } from "../auth/session-cookie.ts"; import { buildCanonicalOriginImpact, canonicalUrlService, + createCanonicalUrlService, normalizeCanonicalOrigin, } from "../platform/canonical-url.ts"; @@ -36,7 +40,7 @@ router.get("/", requireCookieSession, async (req, res) => { if (!currentOrigin) return res.status(409).json({ message: "Canonical URL is not configured" }); return res.json({ ...buildCanonicalOriginImpact(currentOrigin, currentOrigin, 0), - recentAuth: await hasRecentAuth(req.cookies?.ea_session), + recentAuth: await hasRecentPasswordAuth(req.cookies?.ea_session), }); }); @@ -46,11 +50,38 @@ router.post("/preview", requireCookieSession, async (req, res) => { return res.json(await buildImpact(proposedOrigin)); }); -router.patch("/", requireRecentAuth, async (req, res) => { +router.patch("/", requireRecentPasswordAuth, async (req, res) => { const proposedOrigin = requestedOrigin(req.body?.canonicalOrigin); if (!proposedOrigin) return res.status(400).json({ message: "Canonical URL is invalid" }); + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = res.locals.authSession as SessionSecurityContext | undefined; + if (!session || owner.securityGeneration !== session.securityGeneration) { + clearSessionCookie(res); + return res.status(409).json({ + code: "SECURITY_STATE_CHANGED", + message: "Security state changed; sign in and try again", + }); + } const impact = await buildImpact(proposedOrigin); - await canonicalUrlService.setConfirmedOrigin(impact.proposedOrigin); + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + await createCanonicalUrlService(tx).setConfirmedOrigin(impact.proposedOrigin); + }, + }); + if (!nextGeneration || !await issueSessionCookie(res, { + securityGeneration: nextGeneration, + authMethod: "password", + passwordAuthenticatedAt: session.passwordAuthenticatedAt, + })) { + clearSessionCookie(res); + return res.status(409).json({ + code: "SECURITY_STATE_CHANGED", + message: "Security state changed; sign in and try again", + }); + } return res.json(impact); }); diff --git a/server/routes/auth-security.ts b/server/routes/auth-security.ts new file mode 100644 index 00000000..3877e844 --- /dev/null +++ b/server/routes/auth-security.ts @@ -0,0 +1,372 @@ +import { Router } from "express"; +import type { Response } from "express"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import rateLimit from "express-rate-limit"; +import db from "../db/connection.ts"; +import { + getPasswordStepUpThrottle, + markSessionPasswordAuthenticated, + recordPasswordStepUpFailure, + requireCookieSession, + requireRecentPasswordAuth, + type SessionSecurityContext, +} from "../middleware/auth.ts"; +import { wrapRouterAsync } from "../middleware/async-handler.ts"; +import { isOwnerAuthMode } from "../auth/auth-mode.ts"; +import { countPasskeys } from "../auth/passkey-store.ts"; +import { getOwner } from "../auth/owner-store.ts"; +import { + isAcceptableNewPassword, + isVerifiablePassword, + MIN_NEW_PASSWORD_LENGTH, +} from "../auth/password-policy.ts"; +import { + generateRecoveryCodes, + hashRecoveryCode, +} from "../auth/recovery-code-store.ts"; +import { ownerSecurityTransitionService } from "../auth/security-transition.ts"; +import { + clearSessionCookie, + issueSessionCookie, +} from "../auth/session-cookie.ts"; +import { PENDING_AUTH_COOKIE_NAME } from "../auth/pending-auth-store.ts"; +import canonicalOriginRoutes from "./auth-canonical-origin.ts"; + +const router = Router(); +wrapRouterAsync(router); + +const API_TOKEN_TTL_DAYS = Number.parseInt(process.env.EA_API_TOKEN_TTL_DAYS || "90", 10) || 90; +const API_TOKEN_TTL_MS = API_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000; +const KNOWN_SCOPES = new Set(["actual:write"]); + +class RecoveryFailedError extends Error {} + +const tokenMintLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + message: { message: "Too many token creations, try again later" }, + standardHeaders: true, + legacyHeaders: false, +}); + +const recoveryLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + message: { message: "Too many recovery attempts, try again later" }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, +}); + +const stepUpIpLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: { message: "Too many password confirmation attempts, try again later" }, + standardHeaders: true, + legacyHeaders: false, +}); + +function passwordSessionContext(res: Response): SessionSecurityContext { + const context = res.locals.authSession as SessionSecurityContext | undefined; + if (!context) throw new Error("Password-authenticated session context is missing"); + return context; +} + +function staleSecurityState(res: Response) { + clearSessionCookie(res); + return res.status(409).json({ + code: "SECURITY_STATE_CHANGED", + message: "Security state changed; sign in and try again", + }); +} + +async function issueReplacementPasswordSession( + res: Response, + nextGeneration: number, + previous: SessionSecurityContext, +): Promise { + return issueSessionCookie(res, { + securityGeneration: nextGeneration, + authMethod: "password", + passwordAuthenticatedAt: previous.passwordAuthenticatedAt, + }); +} + +router.post("/security/step-up/password", requireCookieSession, stepUpIpLimiter, async (req, res) => { + const throttle = await getPasswordStepUpThrottle(req.cookies?.ea_session); + if (!throttle) return res.status(401).json({ message: "Not authenticated" }); + if (throttle.blockedUntil > Date.now()) { + return res.status(429).json({ message: "Too many password confirmation attempts, try again later" }); + } + const owner = await getOwner(); + if (!owner || !isVerifiablePassword(req.body?.password) + || !await bcrypt.compare(req.body.password, owner.passwordHash)) { + const failed = await recordPasswordStepUpFailure(req.cookies?.ea_session); + if (failed?.blockedUntil && failed.blockedUntil > Date.now()) { + return res.status(429).json({ message: "Too many password confirmation attempts, try again later" }); + } + return res.status(401).json({ message: "Password confirmation failed" }); + } + await markSessionPasswordAuthenticated(req.cookies?.ea_session); + return res.json({ recentAuth: true }); +}); + +router.use("/security/canonical-origin", canonicalOriginRoutes); + +router.patch("/security/auth-mode", requireRecentPasswordAuth, async (req, res) => { + const authMode = req.body?.authMode; + if (!isOwnerAuthMode(authMode)) { + return res.status(400).json({ message: "Unsupported authentication mode" }); + } + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); + if (authMode === "password_plus_passkey" && await countPasskeys(owner.userId) === 0) { + return res.status(409).json({ message: "Register a passkey before enabling strict mode" }); + } + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + await tx.execute({ + sql: "UPDATE ea_owner SET auth_mode = ? WHERE singleton_id = 1 AND user_id = ?", + args: [authMode, owner.userId], + }); + }, + }); + if (!nextGeneration) return staleSecurityState(res); + if (!await issueReplacementPasswordSession(res, nextGeneration, session)) { + return staleSecurityState(res); + } + return res.json({ authMode, recentAuth: true }); +}); + +router.post("/security/password", requireRecentPasswordAuth, async (req, res) => { + if (!isAcceptableNewPassword(req.body?.newPassword)) { + return res.status(400).json({ message: `New password must be at least ${MIN_NEW_PASSWORD_LENGTH} characters` }); + } + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); + const passwordHash = await bcrypt.hash(req.body.newPassword, 12); + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + await tx.execute({ + sql: "UPDATE ea_owner SET password_hash = ? WHERE singleton_id = 1 AND user_id = ?", + args: [passwordHash, owner.userId], + }); + }, + }); + if (!nextGeneration) return staleSecurityState(res); + if (!await issueSessionCookie(res, { + securityGeneration: nextGeneration, + authMethod: "password", + passwordAuthenticatedAt: Date.now(), + })) return staleSecurityState(res); + return res.json({ success: true, recentAuth: true }); +}); + +router.post("/recovery-codes/regenerate", requireRecentPasswordAuth, async (_req, res) => { + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); + const recoveryCodes = generateRecoveryCodes(); + const generatedAt = Date.now(); + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + await tx.execute({ + sql: "DELETE FROM ea_owner_recovery_codes WHERE user_id = ?", + args: [owner.userId], + }); + for (const code of recoveryCodes) { + await tx.execute({ + sql: `INSERT INTO ea_owner_recovery_codes (user_id, code_hash, generated_at) + VALUES (?, ?, ?)`, + args: [owner.userId, hashRecoveryCode(code), generatedAt], + }); + } + }, + }); + if (!nextGeneration) return staleSecurityState(res); + if (!await issueReplacementPasswordSession(res, nextGeneration, session)) { + return staleSecurityState(res); + } + return res.json({ recoveryCodes }); +}); + +router.post("/recovery", recoveryLimiter, async (req, res) => { + if (!isAcceptableNewPassword(req.body?.newPassword) || typeof req.body?.recoveryCode !== "string") { + return res.status(400).json({ message: `Recovery code and a new password of at least ${MIN_NEW_PASSWORD_LENGTH} characters are required` }); + } + const owner = await getOwner(); + if (!owner) return res.status(401).json({ message: "Recovery failed" }); + const newPasswordHash = await bcrypt.hash(req.body.newPassword, 12); + const recoveryCodes = generateRecoveryCodes(); + const generatedAt = Date.now(); + let nextGeneration: number | null; + try { + nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: owner.securityGeneration, + revokeApiTokens: true, + mutate: async (tx) => { + const consumed = await tx.execute({ + sql: `UPDATE ea_owner_recovery_codes + SET used_at = ? + WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, + args: [generatedAt, owner.userId, hashRecoveryCode(req.body.recoveryCode)], + }); + if (consumed.rowsAffected !== 1) throw new RecoveryFailedError(); + await tx.execute({ + sql: `UPDATE ea_owner + SET password_hash = ?, auth_mode = 'password_or_passkey' + WHERE singleton_id = 1 AND user_id = ?`, + args: [newPasswordHash, owner.userId], + }); + await tx.execute({ + sql: "DELETE FROM ea_passkey_credentials WHERE user_id = ?", + args: [owner.userId], + }); + await tx.execute({ + sql: "DELETE FROM ea_owner_recovery_codes WHERE user_id = ?", + args: [owner.userId], + }); + for (const code of recoveryCodes) { + await tx.execute({ + sql: `INSERT INTO ea_owner_recovery_codes (user_id, code_hash, generated_at) + VALUES (?, ?, ?)`, + args: [owner.userId, hashRecoveryCode(code), generatedAt], + }); + } + }, + }); + } catch (error) { + if (error instanceof RecoveryFailedError) { + return res.status(401).json({ message: "Recovery failed" }); + } + throw error; + } + if (!nextGeneration) return res.status(401).json({ message: "Recovery failed" }); + if (!await issueSessionCookie(res, { + securityGeneration: nextGeneration, + authMethod: "recovery", + passwordAuthenticatedAt: 0, + })) return res.status(401).json({ message: "Recovery failed" }); + res.clearCookie(PENDING_AUTH_COOKIE_NAME, { path: "/" }); + return res.json({ authenticated: true, recoveryCodes }); +}); + +router.get("/api-tokens", requireCookieSession, async (_req, res) => { + try { + const result = await db.execute({ + sql: "SELECT id, label, scopes, created_at, last_used_at, expires_at FROM ea_api_tokens ORDER BY created_at DESC", + args: [], + }); + const rows = result.rows.map((row) => ({ + id: row.id, + label: row.label, + scopes: safeParseScopes(row.scopes), + created_at: row.created_at, + last_used_at: row.last_used_at, + expires_at: row.expires_at, + })); + res.json(rows); + } catch (error) { + console.error("Error listing api tokens:", error); + res.status(500).json({ message: "Failed to list tokens" }); + } +}); + +// Authenticate before consuming the per-IP mint budget so outsiders cannot +// lock the owner out of token creation from a shared egress address. +router.post("/api-tokens", requireRecentPasswordAuth, tokenMintLimiter, async (req, res) => { + const { label, scopes } = req.body || {}; + if (!label || typeof label !== "string" || !label.trim()) { + return res.status(400).json({ message: "label is required" }); + } + const requestedScopes = Array.isArray(scopes) && scopes.length ? scopes : ["actual:write"]; + const invalid = requestedScopes.filter((scope) => !KNOWN_SCOPES.has(scope)); + if (invalid.length) { + return res.status(400).json({ message: `Unknown scopes: ${invalid.join(", ")}` }); + } + + try { + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); + const raw = `eatk_${crypto.randomBytes(32).toString("base64url")}`; + const hash = crypto.createHash("sha256").update(raw).digest("hex"); + const expiresAt = Date.now() + API_TOKEN_TTL_MS; + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + await tx.execute({ + sql: "INSERT INTO ea_api_tokens (token_hash, label, scopes, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", + args: [hash, label.trim(), JSON.stringify(requestedScopes), Date.now(), expiresAt], + }); + }, + }); + if (!nextGeneration) return staleSecurityState(res); + if (!await issueReplacementPasswordSession(res, nextGeneration, session)) { + return staleSecurityState(res); + } + res.json({ token: raw, label: label.trim(), scopes: requestedScopes, expires_at: expiresAt }); + } catch (error) { + console.error("Error creating api token:", error); + res.status(500).json({ message: "Failed to create token" }); + } +}); + +router.delete("/api-tokens/:id", requireRecentPasswordAuth, async (req, res) => { + const id = Number.parseInt(req.params.id!, 10); + if (!Number.isFinite(id)) { + return res.status(400).json({ message: "invalid id" }); + } + try { + const owner = await getOwner(); + if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); + const existing = await db.execute({ sql: "SELECT id FROM ea_api_tokens WHERE id = ?", args: [id] }); + if (!existing.rows.length) return res.status(404).json({ message: "Token not found" }); + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + await tx.execute({ sql: "DELETE FROM ea_api_tokens WHERE id = ?", args: [id] }); + }, + }); + if (!nextGeneration) return staleSecurityState(res); + if (!await issueReplacementPasswordSession(res, nextGeneration, session)) { + return staleSecurityState(res); + } + res.json({ success: true }); + } catch (error) { + console.error("Error deleting api token:", error); + res.status(500).json({ message: "Failed to delete token" }); + } +}); + +function safeParseScopes(raw: unknown): string[] { + if (typeof raw !== "string") return []; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((scope): scope is string => typeof scope === "string") + : []; + } catch { + return []; + } +} + +export default router; diff --git a/server/routes/auth.passkeys.test.ts b/server/routes/auth.passkeys.test.ts index a01059f5..de7c8505 100644 --- a/server/routes/auth.passkeys.test.ts +++ b/server/routes/auth.passkeys.test.ts @@ -56,6 +56,7 @@ vi.mock("../db/connection.ts", () => ({ statements: Parameters[0], mode?: TransactionMode, ) => currentDb().batch(statements, mode), + transaction: (mode?: TransactionMode) => currentDb().transaction(mode), }, })); vi.mock("@simplewebauthn/server", () => webAuthnMocks); @@ -65,7 +66,7 @@ process.env.NODE_ENV = "test"; process.env.EA_USER_ID = "user-1"; process.env.EA_PASSWORD_HASH = authPasswordHash; const authRoutes = (await import("./auth.ts")).default; -const { requireCookieSession, __clearSessionValidationCache } = await import("../middleware/auth.ts"); +const { requireCookieSession } = await import("../middleware/auth.ts"); function makeApp() { const app = express(); @@ -86,10 +87,6 @@ describe("auth passkey routes", () => { beforeEach(async () => { testState.db.current = await createAuthTestDb(); await seedOwner(currentDb(), { passwordHash: authPasswordHash }); - // P2-27: validateSession now memoizes positive results in a module-level cache; - // clear it between tests so each starts from a clean DB-backed state (otherwise - // a prior test's cached "cookie-session" masks this test's DB-error path). - __clearSessionValidationCache(); // Full reset (not mockClear) so any sibling-leaked implementation/return on // these shared webAuthn fns is wiped, then reinstate this file's defaults. // mockClear only resets call history and would carry a leaked mockResolvedValue @@ -132,6 +129,7 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); const res = await request(makeApp()) @@ -160,12 +158,14 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); await createWebAuthnChallengeStore(currentDb()).createChallenge({ userId: "user-1", challengeType: "authentication", pendingAuthHash: hashPendingAuthToken("pending-token"), challenge: "auth-challenge", + securityGeneration: 1, }); webAuthnMocks.verifyAuthenticationResponse.mockImplementation(async ({ expectedChallenge }) => { expect(await expectedChallenge("auth-challenge")).toBe(true); @@ -208,12 +208,14 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); await createWebAuthnChallengeStore(currentDb()).createChallenge({ userId: "user-1", challengeType: "authentication", pendingAuthHash: hashPendingAuthToken("pending-token"), challenge: "auth-challenge", + securityGeneration: 1, }); webAuthnMocks.verifyAuthenticationResponse.mockImplementation(async ({ expectedChallenge }) => { expect(await expectedChallenge("auth-challenge")).toBe(true); @@ -241,12 +243,14 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); await createWebAuthnChallengeStore(currentDb()).createChallenge({ userId: "user-1", challengeType: "registration", pendingAuthHash: hashPendingAuthToken("pending-token"), challenge: "registration-challenge", + securityGeneration: 1, }); webAuthnMocks.verifyAuthenticationResponse.mockImplementation(async ({ expectedChallenge }) => { if (!(await expectedChallenge("registration-challenge"))) { @@ -279,6 +283,7 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); const res = await request(makeApp()) @@ -299,12 +304,14 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); await createWebAuthnChallengeStore(currentDb()).createChallenge({ userId: "user-1", challengeType: "authentication", pendingAuthHash: hashPendingAuthToken("pending-token"), challenge: "auth-challenge", + securityGeneration: 1, }); const res = await request(makeApp()) @@ -350,6 +357,7 @@ describe("auth passkey routes", () => { await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); const res = await request(makeApp()) @@ -408,12 +416,13 @@ describe("auth passkey routes", () => { expect(res.body.rp).toMatchObject({ id: "127.0.0.1" }); }); - it("verifies first passkey registration without silently enabling strict mode", async () => { + it("verifies first passkey registration, rotates sessions, and does not silently enable strict mode", async () => { await seedSession(currentDb(), "cookie-session", Date.now() + 60_000, Date.now()); await createWebAuthnChallengeStore(currentDb()).createChallenge({ userId: "user-1", challengeType: "registration", challenge: "registration-challenge", + securityGeneration: 1, }); webAuthnMocks.verifyRegistrationResponse.mockImplementation(async ({ expectedChallenge }) => { expect(await expectedChallenge("registration-challenge")).toBe(true); @@ -460,7 +469,7 @@ describe("auth passkey routes", () => { authMode: "password_or_passkey", }); expect(sessions.rows).toHaveLength(1); - expect(oldSession.rows).toHaveLength(1); + expect(oldSession.rows).toHaveLength(0); }); it("deletes individual passkeys with session rotation and allows final deletion", async () => { diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 1b5a2e2a..6912fa9f 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -56,6 +56,7 @@ vi.mock("../db/connection.ts", () => ({ statements: Parameters[0], mode?: TransactionMode, ) => currentDb().batch(statements, mode), + transaction: (mode?: TransactionMode) => currentDb().transaction(mode), }, })); vi.mock("@simplewebauthn/server", () => webAuthnMocks); @@ -64,8 +65,9 @@ const authPasswordHash = bcrypt.hashSync("correct-password", 4); process.env.NODE_ENV = "test"; process.env.EA_USER_ID = "user-1"; process.env.EA_PASSWORD_HASH = authPasswordHash; +process.env.EA_SETUP_TOKEN = "test-setup-token-with-at-least-32-characters"; const authRoutes = (await import("./auth.ts")).default; -const { requireCookieSession, __clearSessionValidationCache } = await import("../middleware/auth.ts"); +const { requireCookieSession } = await import("../middleware/auth.ts"); function makeApp() { const app = express(); @@ -86,10 +88,6 @@ describe("auth routes", () => { beforeEach(async () => { testState.db.current = await createAuthTestDb(); await seedOwner(currentDb(), { passwordHash: authPasswordHash }); - // P2-27: validateSession now memoizes positive results in a module-level cache; - // clear it between tests so each starts from a clean DB-backed state (otherwise - // a prior test's cached "cookie-session" masks this test's DB-error path). - __clearSessionValidationCache(); // Full reset (not mockClear) so any sibling-leaked implementation/return on // these shared webAuthn fns is wiped, then reinstate this file's defaults. // mockClear only resets call history and would carry a leaked mockResolvedValue @@ -108,6 +106,7 @@ describe("auth routes", () => { process.env.NODE_ENV = "test"; process.env.EA_USER_ID = "user-1"; process.env.EA_PASSWORD_HASH = authPasswordHash; + process.env.EA_SETUP_TOKEN = "test-setup-token-with-at-least-32-characters"; }); it("exposes only whether public setup is still available", async () => { @@ -124,7 +123,7 @@ describe("auth routes", () => { const res = await request(makeApp()) .post("/api/auth/setup/claim") - .send({ password: "new-owner-password", canonicalOrigin: "https://setpoint.example.com" }); + .send({ setupToken: process.env.EA_SETUP_TOKEN, password: "new-owner-password", canonicalOrigin: "https://setpoint.example.com" }); const ownerResult = await currentDb().execute( "SELECT user_id, password_hash, claimed_at FROM ea_owner", ); @@ -151,7 +150,7 @@ describe("auth routes", () => { const res = await request(makeApp()) .post("/api/auth/setup/claim") - .send({ password: "new-owner-password", canonicalOrigin: "http://attacker.example.com/path" }); + .send({ setupToken: process.env.EA_SETUP_TOKEN, password: "new-owner-password", canonicalOrigin: "http://attacker.example.com/path" }); expect(res.status).toBe(400); expect(res.body).toEqual({ message: "Canonical URL is invalid" }); @@ -164,7 +163,7 @@ describe("auth routes", () => { const res = await request(makeApp()) .post("/api/auth/setup/claim") - .send({ password: "replacement-password", canonicalOrigin: "https://setpoint.example.com" }); + .send({ setupToken: process.env.EA_SETUP_TOKEN, password: "replacement-password", canonicalOrigin: "https://setpoint.example.com" }); const after = await currentDb().execute("SELECT * FROM ea_owner"); expect(res.status).toBe(409); @@ -177,8 +176,8 @@ describe("auth routes", () => { const app = makeApp(); const responses = await Promise.all([ - request(app).post("/api/auth/setup/claim").send({ password: "first-owner-password", canonicalOrigin: "https://first.example.com" }), - request(app).post("/api/auth/setup/claim").send({ password: "second-owner-password", canonicalOrigin: "https://second.example.com" }), + request(app).post("/api/auth/setup/claim").send({ setupToken: process.env.EA_SETUP_TOKEN, password: "first-owner-password", canonicalOrigin: "https://first.example.com" }), + request(app).post("/api/auth/setup/claim").send({ setupToken: process.env.EA_SETUP_TOKEN, password: "second-owner-password", canonicalOrigin: "https://second.example.com" }), ]); expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); @@ -250,6 +249,38 @@ describe("auth routes", () => { expect(tokens.rows[0]!.label).toBe("Phone"); }); + it("does not allow a fresh instance to be claimed without the deployment setup secret", async () => { + await currentDb().execute("DELETE FROM ea_owner"); + + const res = await request(makeApp()) + .post("/api/auth/setup/claim") + .send({ + setupToken: "wrong-setup-token-with-at-least-32-characters", + password: "new-owner-password", + canonicalOrigin: "https://setpoint.example.com", + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ message: "Setup token is invalid" }); + expect((await currentDb().execute("SELECT * FROM ea_owner")).rows).toEqual([]); + }); + + it("does not authorize security mutations from a recent passkey-only session", async () => { + await seedSession(currentDb(), "passkey-session", Date.now() + 60_000, Date.now(), { + authMethod: "passkey", + passwordAuthenticatedAt: 0, + }); + + const res = await request(makeApp()) + .post("/api/auth/api-tokens") + .set("Cookie", ["ea_session=passkey-session"]) + .send({ label: "Persistence", scopes: ["actual:write"] }); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "PASSWORD_STEP_UP_REQUIRED" }); + expect((await currentDb().execute("SELECT * FROM ea_api_tokens")).rows).toEqual([]); + }); + it("creates a session and recommends setup when no passkeys exist", async () => { const res = await request(makeApp()) .post("/api/auth/login") @@ -330,7 +361,7 @@ describe("auth routes", () => { .set("Cookie", ["ea_session=cookie-session"]) .send({ authMode: "password_plus_passkey" }); expect(blocked.status).toBe(403); - expect(blocked.body).toMatchObject({ code: "STEP_UP_REQUIRED" }); + expect(blocked.body).toMatchObject({ code: "PASSWORD_STEP_UP_REQUIRED" }); const stepUp = await request(makeApp()) .post("/api/auth/security/step-up/password") @@ -348,6 +379,31 @@ describe("auth routes", () => { expect(owner.rows[0]!.auth_mode).toBe("password_plus_passkey"); }); + it("persistently throttles repeated password step-up failures for the session", async () => { + await seedSession(currentDb(), "cookie-session"); + const app = makeApp(); + + for (let attempt = 0; attempt < 4; attempt += 1) { + const failed = await request(app) + .post("/api/auth/security/step-up/password") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ password: "wrong-password" }); + expect(failed.status).toBe(401); + } + + const blocked = await request(app) + .post("/api/auth/security/step-up/password") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ password: "wrong-password" }); + expect(blocked.status).toBe(429); + + const stillBlocked = await request(app) + .post("/api/auth/security/step-up/password") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ password: "correct-password" }); + expect(stillBlocked.status).toBe(429); + }); + it("changes the owner password only with recent auth and rotates prior sessions", async () => { await seedSession(currentDb(), "current-session", Date.now() + 60_000, Date.now()); await seedSession(currentDb(), "other-session", Date.now() + 60_000, Date.now()); @@ -399,9 +455,13 @@ describe("auth routes", () => { .set("Cookie", ["ea_session=cookie-session"]) .send({ canonicalOrigin: "https://new.example.com" }); expect(blocked.status).toBe(403); - expect(blocked.body).toMatchObject({ code: "STEP_UP_REQUIRED" }); + expect(blocked.body).toMatchObject({ code: "PASSWORD_STEP_UP_REQUIRED" }); - await currentDb().execute("UPDATE ea_sessions SET authenticated_at = ?", [Date.now()]); + const stepUp = await request(makeApp()) + .post("/api/auth/security/step-up/password") + .set("Cookie", ["ea_session=cookie-session"]) + .send({ password: "correct-password" }); + expect(stepUp.status).toBe(200); const changed = await request(makeApp()) .patch("/api/auth/security/canonical-origin") .set("Cookie", ["ea_session=cookie-session"]) @@ -418,7 +478,16 @@ describe("auth routes", () => { await seedSession(currentDb(), "old-session", Date.now() + 60_000, Date.now()); await seedPasskey(); await currentDb().execute("UPDATE ea_owner SET auth_mode = 'password_plus_passkey'"); - await createPendingAuthStore(currentDb()).createPendingAuth({ userId: "user-1", token: "pending-token" }); + await createPendingAuthStore(currentDb()).createPendingAuth({ + userId: "user-1", + token: "pending-token", + securityGeneration: 1, + }); + await currentDb().execute({ + sql: `INSERT INTO ea_api_tokens (token_hash, label, scopes, created_at, expires_at) + VALUES (?, 'Phone', '["actual:write"]', 1, 9999999999999)`, + args: [hashApiToken("surviving-token")], + }); const recovered = await request(makeApp()) .post("/api/auth/recovery") @@ -439,6 +508,7 @@ describe("auth routes", () => { sql: "SELECT * FROM ea_sessions WHERE token = ?", args: [hashSessionToken("old-session")], })).rows).toEqual([]); + expect((await currentDb().execute("SELECT * FROM ea_api_tokens")).rows).toEqual([]); const replay = await request(makeApp()) .post("/api/auth/recovery") diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 36dcc469..e4eb8a6e 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,18 +1,15 @@ import { Router } from "express"; import type { Request, Response } from "express"; import bcrypt from "bcrypt"; -import crypto from "crypto"; import rateLimit from "express-rate-limit"; import { - createSession, validateSession, deleteSession, requireCookieSession, - requireRecentAuth, - hasRecentAuth, - markSessionRecentlyAuthenticated, + requireRecentPasswordAuth, + hasRecentPasswordAuth, + type SessionSecurityContext, } from "../middleware/auth.ts"; -import db from "../db/connection.ts"; import { wrapRouterAsync } from "../middleware/async-handler.ts"; import { timeRoute } from "../timing.ts"; import { @@ -22,17 +19,15 @@ import { readPendingAuth, consumePendingAuth, deletePendingAuth, - clearPendingAuth, } from "../auth/pending-auth-store.ts"; -import { createChallenge, consumeChallenge, deleteChallengesForPendingAuth, clearChallenges } from "../auth/webauthn-challenge-store.ts"; +import { createChallenge, consumeChallenge, deleteChallengesForPendingAuth } from "../auth/webauthn-challenge-store.ts"; import { countPasskeys, listPasskeys, listPasskeyMetadata, getPasskeyByCredentialId, - createPasskey, + createPasskeyStore, updatePasskeyUsage, - deletePasskey, toPasskeyMetadata, } from "../auth/passkey-store.ts"; import { @@ -42,32 +37,22 @@ import { verifyAuthenticationCredential, } from "../auth/webauthn-service.ts"; import { resolveWebAuthnConfig } from "../auth/webauthn-config.ts"; -import { revokeAllSessions, rotateSessionsForCurrentBrowser } from "../auth/session-rotation.ts"; -import { getOwner, setOwnerAuthMode, updateOwnerPasswordHash } from "../auth/owner-store.ts"; +import { getOwner } from "../auth/owner-store.ts"; import { claimInitialOwner } from "../auth/owner-claim-service.ts"; -import { resolvePasswordLogin, isOwnerAuthMode } from "../auth/auth-mode.ts"; -import { consumeRecoveryCode, generateRecoveryCodes, getRecoveryCodeStatus, hashRecoveryCode, replaceRecoveryCodes } from "../auth/recovery-code-store.ts"; +import { isAcceptableNewPassword, isVerifiablePassword, MIN_NEW_PASSWORD_LENGTH } from "../auth/password-policy.ts"; +import { verifySetupToken } from "../auth/setup-token.ts"; +import { resolvePasswordLogin } from "../auth/auth-mode.ts"; +import { generateRecoveryCodes, getRecoveryCodeStatus, hashRecoveryCode } from "../auth/recovery-code-store.ts"; import { canonicalUrlService, normalizeCanonicalOrigin } from "../platform/canonical-url.ts"; -import canonicalOriginRoutes from "./auth-canonical-origin.ts"; +import { ownerSecurityTransitionService } from "../auth/security-transition.ts"; +import { clearSessionCookie, issueSessionCookie } from "../auth/session-cookie.ts"; +import authSecurityRoutes from "./auth-security.ts"; const router = Router(); // P1-12: forward async-handler rejections to the terminal errorHandler so a // transient DB/crypto failure returns a 500 instead of hanging the request -// (notably the CSRF-exempt /login). Must run before any route is registered. +// (notably /login). Must run before any route is registered. wrapRouterAsync(router); -const API_TOKEN_TTL_DAYS = Number.parseInt(process.env.EA_API_TOKEN_TTL_DAYS || "90", 10) || 90; -const API_TOKEN_TTL_MS = API_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000; - -const KNOWN_SCOPES = new Set(["actual:write"]); - -// Rate limit token minting: 5 creations per 15 minutes per IP -const tokenMintLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 5, - message: { message: "Too many token creations, try again later" }, - standardHeaders: true, - legacyHeaders: false, -}); // Rate limit login: 5 attempts per 15 minutes per IP const loginLimiter = rateLimit({ @@ -92,29 +77,9 @@ const ownerClaimLimiter = rateLimit({ message: { message: "Too many setup attempts, try again later" }, standardHeaders: true, legacyHeaders: false, + skipSuccessfulRequests: true, }); -const recoveryLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 5, - message: { message: "Too many recovery attempts, try again later" }, - standardHeaders: true, - legacyHeaders: false, -}); - -function setSessionCookie(res: Response, token: string) { - res.cookie("ea_session", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - maxAge: 30 * 24 * 60 * 60 * 1000, - path: "/", - }); -} - -function clearSessionCookie(res: Response) { - res.clearCookie("ea_session", { path: "/" }); -} function setPendingAuthCookie(res: Response, token: string) { res.cookie(PENDING_AUTH_COOKIE_NAME, token, buildPendingAuthCookieOptions()); @@ -148,13 +113,31 @@ async function clearPendingAuthState(req: Request, res: Response) { clearPendingAuthCookie(res); } -function validPassword(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.length <= 1024; +function passwordSessionContext(res: Response): SessionSecurityContext { + const context = res.locals.authSession as SessionSecurityContext | undefined; + if (!context) throw new Error("Password-authenticated session context is missing"); + return context; } -async function rotateAllAuthState() { - await Promise.all([clearPendingAuth(), clearChallenges()]); - return rotateSessionsForCurrentBrowser(); +function staleSecurityState(res: Response) { + clearSessionCookie(res); + clearPendingAuthCookie(res); + return res.status(409).json({ + code: "SECURITY_STATE_CHANGED", + message: "Security state changed; sign in and try again", + }); +} + +async function issueReplacementPasswordSession( + res: Response, + nextGeneration: number, + previous: SessionSecurityContext, +): Promise { + return issueSessionCookie(res, { + securityGeneration: nextGeneration, + authMethod: "password", + passwordAuthenticatedAt: previous.passwordAuthenticatedAt, + }); } router.get("/setup/status", async (_req, res) => { @@ -162,6 +145,19 @@ router.get("/setup/status", async (_req, res) => { }); router.post("/setup/claim", ownerClaimLimiter, async (req, res) => { + if (await getOwner()) { + return res.status(409).json({ message: "Instance is already claimed" }); + } + const setupToken = verifySetupToken(req.body?.setupToken, process.env.EA_SETUP_TOKEN); + if (!setupToken.configured) { + return res.status(503).json({ message: "Setup is unavailable until EA_SETUP_TOKEN is configured" }); + } + if (!setupToken.verified) { + return res.status(403).json({ message: "Setup token is invalid" }); + } + if (!isAcceptableNewPassword(req.body?.password)) { + return res.status(400).json({ message: `Password must be at least ${MIN_NEW_PASSWORD_LENGTH} characters` }); + } let canonicalOrigin: string; try { canonicalOrigin = normalizeCanonicalOrigin(req.body?.canonicalOrigin); @@ -174,14 +170,18 @@ router.post("/setup/claim", ownerClaimLimiter, async (req, res) => { canonicalOrigin, }); if (result.status === "invalid") { - return res.status(400).json({ message: "Password is required" }); + return res.status(400).json({ message: `Password must be at least ${MIN_NEW_PASSWORD_LENGTH} characters` }); } if (result.status === "conflict") { return res.status(409).json({ message: "Instance is already claimed" }); } - const token = await createSession(); - setSessionCookie(res, token); + if (!await issueSessionCookie(res, { + securityGeneration: result.owner.securityGeneration, + authMethod: "password", + })) { + return staleSecurityState(res); + } clearPendingAuthCookie(res); return res.json({ authenticated: true, claimed: true, recoveryCodes }); }); @@ -190,7 +190,7 @@ router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, re const { password } = req.body; const owner = await getOwner(); - if (!owner || !password) { + if (!owner || !isVerifiablePassword(password)) { return res.status(401).json({ message: "Invalid password" }); } @@ -205,7 +205,17 @@ router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, re return res.status(409).json({ message: "Strict authentication requires a registered passkey" }); } if (resolution.passkeyRequired) { - const pending = await createPendingAuth({ userId: owner.userId }); + const passwordAuthenticatedAt = Date.now(); + const pending = await createPendingAuth({ + userId: owner.userId, + securityGeneration: owner.securityGeneration, + passwordAuthenticatedAt, + expectedAuthMode: "password_plus_passkey", + }); + if (!pending) { + clearPendingAuthCookie(res); + return res.status(409).json({ message: "Security state changed; try signing in again" }); + } setPendingAuthCookie(res, pending.token); clearSessionCookie(res); return res.json({ @@ -214,8 +224,12 @@ router.post("/login", timeRoute("/api/auth/login"), loginLimiter, async (req, re }); } - const token = await createSession(); - setSessionCookie(res, token); + if (!await issueSessionCookie(res, { + securityGeneration: owner.securityGeneration, + authMethod: "password", + })) { + return staleSecurityState(res); + } clearPendingAuthCookie(res); res.json({ authenticated: true, @@ -236,7 +250,16 @@ router.post("/passkey/authentication/options", passkeyAuthLimiter, async (req, r if (await countPasskeys(owner.userId) === 0) { return res.status(409).json({ message: "No registered passkeys" }); } - const created = await createPendingAuth({ userId: owner.userId }); + const created = await createPendingAuth({ + userId: owner.userId, + securityGeneration: owner.securityGeneration, + passwordAuthenticatedAt: 0, + expectedAuthMode: "password_or_passkey", + }); + if (!created) { + clearPendingAuthCookie(res); + return res.status(409).json({ message: "Security state changed; try signing in again" }); + } setPendingAuthCookie(res, created.token); pending = created; } @@ -250,6 +273,7 @@ router.post("/passkey/authentication/options", passkeyAuthLimiter, async (req, r userId: pending.userId, challengeType: "authentication", pendingAuthHash: pending.tokenHash, + securityGeneration: pending.securityGeneration, }); const options = await buildAuthenticationOptions({ passkeys, @@ -284,7 +308,8 @@ router.post("/passkey/authentication/verify", passkeyAuthLimiter, async (req, re userId: pending.userId, challengeType: "authentication", }); - return consumedChallenge?.pendingAuthHash === pending.tokenHash; + return consumedChallenge?.pendingAuthHash === pending.tokenHash + && consumedChallenge.securityGeneration === pending.securityGeneration; }, }); @@ -293,14 +318,26 @@ router.post("/passkey/authentication/verify", passkeyAuthLimiter, async (req, re } const authInfo = verification.authenticationInfo || {}; - await updatePasskeyUsage(passkey.credentialId, { + const updatedPasskey = await updatePasskeyUsage(passkey.credentialId, { signCount: authInfo.newCounter, backedUp: authInfo.credentialBackedUp, credentialDeviceType: authInfo.credentialDeviceType, }); - await consumePendingAuth(pendingToken); - const token = await createSession(); - setSessionCookie(res, token); + const consumedPending = await consumePendingAuth(pendingToken); + if (!updatedPasskey || !consumedPending + || consumedPending.securityGeneration !== pending.securityGeneration) { + clearPendingAuthCookie(res); + return res.status(401).json({ message: "Passkey verification failed" }); + } + const authMethod = pending.passwordAuthenticatedAt > 0 ? "password_plus_passkey" : "passkey"; + if (!await issueSessionCookie(res, { + securityGeneration: pending.securityGeneration, + authMethod, + passwordAuthenticatedAt: pending.passwordAuthenticatedAt, + })) { + clearPendingAuthCookie(res); + return res.status(409).json({ message: "Security state changed; try signing in again" }); + } clearPendingAuthCookie(res); return res.json({ authenticated: true }); } catch (error) { @@ -321,13 +358,13 @@ router.get("/passkeys", requireCookieSession, async (_req, res) => { res.json({ enforcementActive: owner?.authMode === "password_plus_passkey", authMode: owner?.authMode || "password_or_passkey", - recentAuth: await hasRecentAuth(_req.cookies?.ea_session), + recentAuth: await hasRecentPasswordAuth(_req.cookies?.ea_session), recovery, passkeys, }); }); -router.post("/passkeys/registration/options", requireRecentAuth, async (req, res) => { +router.post("/passkeys/registration/options", requireRecentPasswordAuth, async (req, res) => { const label = typeof req.body?.label === "string" ? req.body.label.trim() : ""; if (!label) { return res.status(400).json({ message: "label is required" }); @@ -335,10 +372,13 @@ router.post("/passkeys/registration/options", requireRecentAuth, async (req, res const owner = await getOwner(); if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); const existingPasskeys = await listPasskeys(owner.userId); const challenge = await createChallenge({ userId: owner.userId, challengeType: "registration", + securityGeneration: session.securityGeneration, }); const options = await buildRegistrationOptions({ userId: owner.userId, @@ -349,7 +389,7 @@ router.post("/passkeys/registration/options", requireRecentAuth, async (req, res res.json(options); }); -router.post("/passkeys/registration/verify", requireRecentAuth, async (req, res) => { +router.post("/passkeys/registration/verify", requireRecentPasswordAuth, async (req, res) => { const label = typeof req.body?.label === "string" ? req.body.label.trim() : ""; if (!label) { return res.status(400).json({ message: "label is required" }); @@ -359,6 +399,8 @@ router.post("/passkeys/registration/verify", requireRecentAuth, async (req, res) try { const owner = await getOwner(); if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); const verification = await verifyRegistrationCredential({ response: req.body, config: await webAuthnConfigForRequest(req), @@ -367,7 +409,10 @@ router.post("/passkeys/registration/verify", requireRecentAuth, async (req, res) userId: owner.userId, challengeType: "registration", }); - return Boolean(consumedChallenge); + return Boolean( + consumedChallenge + && consumedChallenge.securityGeneration === session.securityGeneration + ); }, }); @@ -377,16 +422,27 @@ router.post("/passkeys/registration/verify", requireRecentAuth, async (req, res) const registrationInfo = verification.registrationInfo; const credential = registrationInfo.credential; - const passkey = await createPasskey({ + let passkey = null; + const nextGeneration = await ownerSecurityTransitionService.transition({ userId: owner.userId, - credentialId: credential.id, - label, - publicKey: Buffer.from(credential.publicKey).toString("base64url"), - signCount: credential.counter, - transports: credential.transports || req.body?.response?.transports || req.body?.transports || [], - backedUp: registrationInfo.credentialBackedUp, - credentialDeviceType: registrationInfo.credentialDeviceType, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + passkey = await createPasskeyStore(tx).createPasskey({ + userId: owner.userId, + credentialId: credential.id, + label, + publicKey: Buffer.from(credential.publicKey).toString("base64url"), + signCount: credential.counter, + transports: credential.transports || req.body?.response?.transports || req.body?.transports || [], + backedUp: registrationInfo.credentialBackedUp, + credentialDeviceType: registrationInfo.credentialDeviceType, + }); + }, }); + if (!nextGeneration || !passkey) return staleSecurityState(res); + if (!await issueReplacementPasswordSession(res, nextGeneration, session)) { + return staleSecurityState(res); + } return res.json({ passkey: toPasskeyMetadata(passkey), @@ -399,24 +455,40 @@ router.post("/passkeys/registration/verify", requireRecentAuth, async (req, res) } }); -router.delete("/passkeys/:credentialId", requireRecentAuth, async (req, res) => { +router.delete("/passkeys/:credentialId", requireRecentPasswordAuth, async (req, res) => { const credentialId = req.params.credentialId!; const owner = await getOwner(); if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); - const deleted = await deletePasskey(credentialId, owner.userId); - if (!deleted) { + const existingPasskey = await getPasskeyByCredentialId(credentialId); + if (!existingPasskey || existingPasskey.userId !== owner.userId) { return res.status(404).json({ message: "Passkey not found" }); } - - const remainingCount = await countPasskeys(owner.userId); + const session = passwordSessionContext(res); + if (owner.securityGeneration !== session.securityGeneration) return staleSecurityState(res); + let remainingCount = 0; + const nextGeneration = await ownerSecurityTransitionService.transition({ + userId: owner.userId, + expectedGeneration: session.securityGeneration, + mutate: async (tx) => { + const store = createPasskeyStore(tx); + const deleted = await store.deletePasskey(credentialId, owner.userId); + if (!deleted) throw new Error("Passkey not found"); + remainingCount = await store.countPasskeys(owner.userId); + if (owner.authMode === "password_plus_passkey" && remainingCount === 0) { + await tx.execute({ + sql: "UPDATE ea_owner SET auth_mode = 'password_or_passkey' WHERE singleton_id = 1 AND user_id = ?", + args: [owner.userId], + }); + } + }, + }); + if (!nextGeneration) return staleSecurityState(res); const finalAuthMode = owner.authMode === "password_plus_passkey" && remainingCount === 0 ? "password_or_passkey" : owner.authMode; - if (finalAuthMode !== owner.authMode) { - await setOwnerAuthMode(owner.userId, "password_or_passkey"); + if (!await issueReplacementPasswordSession(res, nextGeneration, session)) { + return staleSecurityState(res); } - const token = await rotateAllAuthState(); - setSessionCookie(res, token); const passkeys = await listPasskeyMetadata(owner.userId); res.json({ success: true, @@ -428,80 +500,7 @@ router.delete("/passkeys/:credentialId", requireRecentAuth, async (req, res) => }); }); -router.post("/security/step-up/password", requireCookieSession, async (req, res) => { - const owner = await getOwner(); - if (!owner || !validPassword(req.body?.password) - || !await bcrypt.compare(req.body.password, owner.passwordHash)) { - return res.status(401).json({ message: "Password confirmation failed" }); - } - await markSessionRecentlyAuthenticated(req.cookies?.ea_session); - return res.json({ recentAuth: true }); -}); - -router.use("/security/canonical-origin", canonicalOriginRoutes); - -router.patch("/security/auth-mode", requireRecentAuth, async (req, res) => { - const authMode = req.body?.authMode; - if (!isOwnerAuthMode(authMode)) { - return res.status(400).json({ message: "Unsupported authentication mode" }); - } - const owner = await getOwner(); - if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); - if (authMode === "password_plus_passkey" && await countPasskeys(owner.userId) === 0) { - return res.status(409).json({ message: "Register a passkey before enabling strict mode" }); - } - await setOwnerAuthMode(owner.userId, authMode); - const token = await rotateAllAuthState(); - setSessionCookie(res, token); - return res.json({ authMode, recentAuth: true }); -}); - -router.post("/security/password", requireRecentAuth, async (req, res) => { - if (!validPassword(req.body?.newPassword)) { - return res.status(400).json({ message: "New password is required" }); - } - const owner = await getOwner(); - if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); - await updateOwnerPasswordHash(owner.userId, await bcrypt.hash(req.body.newPassword, 12)); - const token = await rotateAllAuthState(); - setSessionCookie(res, token); - return res.json({ success: true, recentAuth: true }); -}); - -router.post("/recovery-codes/regenerate", requireRecentAuth, async (_req, res) => { - const owner = await getOwner(); - if (!owner) return res.status(409).json({ message: "Instance is not claimed" }); - const recoveryCodes = generateRecoveryCodes(); - await replaceRecoveryCodes(owner.userId, recoveryCodes); - return res.json({ recoveryCodes }); -}); - -router.post("/recovery", recoveryLimiter, async (req, res) => { - if (!validPassword(req.body?.newPassword) || typeof req.body?.recoveryCode !== "string") { - return res.status(400).json({ message: "Recovery code and new password are required" }); - } - const owner = await getOwner(); - if (!owner) return res.status(401).json({ message: "Recovery failed" }); - const newPasswordHash = await bcrypt.hash(req.body.newPassword, 12); - if (!await consumeRecoveryCode(owner.userId, req.body.recoveryCode)) { - return res.status(401).json({ message: "Recovery failed" }); - } - - await updateOwnerPasswordHash(owner.userId, newPasswordHash); - await setOwnerAuthMode(owner.userId, "password_or_passkey"); - await db.batch([ - { sql: "DELETE FROM ea_passkey_credentials WHERE user_id = ?", args: [owner.userId] }, - { sql: "DELETE FROM ea_pending_auth WHERE user_id = ?", args: [owner.userId] }, - { sql: "DELETE FROM ea_webauthn_challenges WHERE user_id = ?", args: [owner.userId] }, - ], "write"); - await revokeAllSessions(); - const recoveryCodes = generateRecoveryCodes(); - await replaceRecoveryCodes(owner.userId, recoveryCodes); - const token = await createSession(); - setSessionCookie(res, token); - clearPendingAuthCookie(res); - return res.json({ authenticated: true, recoveryCodes }); -}); +router.use(authSecurityRoutes); router.get("/check", timeRoute("/api/auth/check"), async (req, res) => { const token = req.cookies?.ea_session; @@ -518,79 +517,4 @@ router.post("/logout", async (req, res) => { res.json({ authenticated: false }); }); -// --- API tokens (for iOS Shortcuts etc.) --- - -router.get("/api-tokens", requireCookieSession, async (_req, res) => { - try { - const result = await db.execute({ - sql: "SELECT id, label, scopes, created_at, last_used_at, expires_at FROM ea_api_tokens ORDER BY created_at DESC", - args: [], - }); - const rows = result.rows.map((r) => ({ - id: r.id, - label: r.label, - scopes: safeParseScopes(r.scopes), - created_at: r.created_at, - last_used_at: r.last_used_at, - expires_at: r.expires_at, - })); - res.json(rows); - } catch (err) { - console.error("Error listing api tokens:", err); - res.status(500).json({ message: "Failed to list tokens" }); - } -}); - -// Run requireCookieSession BEFORE tokenMintLimiter so an unauthenticated caller from the owner's -// egress IP can't burn the 5/15min mint budget and lock the real user out. -router.post("/api-tokens", requireRecentAuth, tokenMintLimiter, async (req, res) => { - const { label, scopes } = req.body || {}; - if (!label || typeof label !== "string" || !label.trim()) { - return res.status(400).json({ message: "label is required" }); - } - const requestedScopes = Array.isArray(scopes) && scopes.length ? scopes : ["actual:write"]; - const invalid = requestedScopes.filter((s) => !KNOWN_SCOPES.has(s)); - if (invalid.length) { - return res.status(400).json({ message: `Unknown scopes: ${invalid.join(", ")}` }); - } - - try { - const raw = "eatk_" + crypto.randomBytes(32).toString("base64url"); - const hash = crypto.createHash("sha256").update(raw).digest("hex"); - const expiresAt = Date.now() + API_TOKEN_TTL_MS; - await db.execute({ - sql: "INSERT INTO ea_api_tokens (token_hash, label, scopes, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", - args: [hash, label.trim(), JSON.stringify(requestedScopes), Date.now(), expiresAt], - }); - res.json({ token: raw, label: label.trim(), scopes: requestedScopes, expires_at: expiresAt }); - } catch (err) { - console.error("Error creating api token:", err); - res.status(500).json({ message: "Failed to create token" }); - } -}); - -router.delete("/api-tokens/:id", requireCookieSession, async (req, res) => { - const id = parseInt(req.params.id!, 10); - if (!Number.isFinite(id)) { - return res.status(400).json({ message: "invalid id" }); - } - try { - await db.execute({ sql: "DELETE FROM ea_api_tokens WHERE id = ?", args: [id] }); - res.json({ success: true }); - } catch (err) { - console.error("Error deleting api token:", err); - res.status(500).json({ message: "Failed to delete token" }); - } -}); - -function safeParseScopes(raw: unknown): string[] { - if (typeof raw !== "string") return []; - try { - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter((scope): scope is string => typeof scope === "string") - : []; - } catch { return []; } -} - export default router; diff --git a/server/routes/briefing/bills.test.ts b/server/routes/briefing/bills.test.ts index cb888bd0..d6671b70 100644 --- a/server/routes/briefing/bills.test.ts +++ b/server/routes/briefing/bills.test.ts @@ -57,7 +57,13 @@ beforeEach(() => { mockDb.execute.mockImplementation(async ({ sql, args }) => { if (sql.includes("FROM ea_sessions")) { return args[0] === cookieSessionHash - ? { rows: [{ expires_at: Date.now() + 60_000 }] } + ? { rows: [{ + expires_at: Date.now() + 60_000, + authenticated_at: 0, + password_authenticated_at: 0, + security_generation: 1, + auth_method: "legacy", + }] } : { rows: [] }; } return { rows: [] }; diff --git a/server/routes/briefing/email-index.test.ts b/server/routes/briefing/email-index.test.ts index fd0e0381..d7076d83 100644 --- a/server/routes/briefing/email-index.test.ts +++ b/server/routes/briefing/email-index.test.ts @@ -43,7 +43,13 @@ function setSessionRow() { mockDb.execute.mockImplementation(async ({ sql, args }) => { if (sql.includes("FROM ea_sessions")) { return args[0] === cookieSessionHash - ? { rows: [{ expires_at: Date.now() + 60_000 }] } + ? { rows: [{ + expires_at: Date.now() + 60_000, + authenticated_at: 0, + password_authenticated_at: 0, + security_generation: 1, + auth_method: "legacy", + }] } : { rows: [] }; } return { rows: [] }; diff --git a/server/routes/briefing/snapshot.test.ts b/server/routes/briefing/snapshot.test.ts index 0dfce96e..a3bf972c 100644 --- a/server/routes/briefing/snapshot.test.ts +++ b/server/routes/briefing/snapshot.test.ts @@ -85,7 +85,13 @@ beforeEach(() => { mockDb.execute.mockImplementation(async ({ sql, args }) => { if (sql.includes("FROM ea_sessions")) { return args[0] === cookieSessionHash - ? { rows: [{ expires_at: Date.now() + 60_000 }] } + ? { rows: [{ + expires_at: Date.now() + 60_000, + authenticated_at: 0, + password_authenticated_at: 0, + security_generation: 1, + auth_method: "legacy", + }] } : { rows: [] }; } return { rows: [] }; diff --git a/server/routes/dashboard.test.ts b/server/routes/dashboard.test.ts index 684d8c77..fa52a144 100644 --- a/server/routes/dashboard.test.ts +++ b/server/routes/dashboard.test.ts @@ -42,7 +42,6 @@ process.env.EA_USER_ID = "u1"; const { default: router } = await import("./dashboard.ts"); const { clearCurrentDashboardEventSubscribers } = await import("../dashboard/current-events.ts"); -const { __clearSessionValidationCache } = await import("../middleware/auth.ts"); function makeApp(): Express { const app = express(); @@ -70,10 +69,25 @@ function hashSessionToken(raw: string): string { async function createMigratedDb() { const db = createClient({ url: "file::memory:" }); await db.executeMultiple(` + CREATE TABLE ea_owner ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + user_id TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + auth_mode TEXT NOT NULL DEFAULT 'password_or_passkey', + security_generation INTEGER NOT NULL DEFAULT 1, + claimed_at INTEGER NOT NULL + ); CREATE TABLE ea_sessions ( token TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + authenticated_at INTEGER NOT NULL DEFAULT 0, + password_authenticated_at INTEGER NOT NULL DEFAULT 0, + security_generation INTEGER NOT NULL DEFAULT 1, + auth_method TEXT NOT NULL DEFAULT 'legacy' ); + INSERT INTO ea_owner + (singleton_id, user_id, password_hash, auth_mode, security_generation, claimed_at) + VALUES (1, 'u1', 'unused-test-hash', 'password_or_passkey', 1, 1); `); await db.execute({ sql: "INSERT INTO ea_sessions (token, expires_at) VALUES (?, ?)", @@ -90,13 +104,6 @@ describe("dashboard routes", () => { beforeEach(async () => { testState.db.current = await createMigratedDb(); clearCurrentDashboardEventSubscribers(); - // auth.js keeps a module-level, 30s-TTL positive sessionValidationCache keyed - // by the hashed cookie token. A sibling test that authenticates "cookie-session" - // leaves a positive entry behind; without this reset a later test could be served - // a stale positive validation from cache instead of re-reading this test's DB, - // making an unauthenticated/revoked request wrongly pass. Clear it so every test - // re-validates against its own freshly migrated session table. - __clearSessionValidationCache(); testState.getCurrentDashboard.mockReset().mockResolvedValue({ weather: { temp: 71 } }); testState.getDashboardSystemHealth.mockReset().mockResolvedValue({ systemStatus: { state: "current" } }); testState.requestCurrentDashboardRefresh.mockReset().mockResolvedValue({ diff --git a/server/scripts/reset-passkeys.test.ts b/server/scripts/reset-passkeys.test.ts index 378923d9..def7e4fa 100644 --- a/server/scripts/reset-passkeys.test.ts +++ b/server/scripts/reset-passkeys.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createAuthTestDb, seedSession } from "../test-utils/auth-db.ts"; +import { createAuthTestDb, seedOwner, seedSession } from "../test-utils/auth-db.ts"; import { createPasskeyStore } from "../auth/passkey-store.ts"; import { createPendingAuthStore } from "../auth/pending-auth-store.ts"; import { createWebAuthnChallengeStore } from "../auth/webauthn-challenge-store.ts"; @@ -51,10 +51,14 @@ describe("reset passkeys script", () => { await expect(tableCount(db, "ea_pending_auth")).resolves.toBe(0); await expect(tableCount(db, "ea_webauthn_challenges")).resolves.toBe(0); await expect(tableCount(db, "ea_sessions")).resolves.toBe(0); + expect((await db.execute("SELECT auth_mode, security_generation FROM ea_owner")).rows) + .toEqual([{ auth_mode: "password_or_passkey", security_generation: 2 }]); }); }); async function seedResetRows(db: Client) { + await seedOwner(db, { passwordHash: "hash" }); + await db.execute("UPDATE ea_owner SET auth_mode = 'password_plus_passkey'"); await createPasskeyStore(db).createPasskey({ userId: "user-1", credentialId: "credential-1", @@ -64,11 +68,13 @@ async function seedResetRows(db: Client) { await createPendingAuthStore(db).createPendingAuth({ userId: "user-1", token: "pending-token", + securityGeneration: 1, }); await createWebAuthnChallengeStore(db).createChallenge({ userId: "user-1", challengeType: "authentication", challenge: "challenge", + securityGeneration: 1, }); await seedSession(db, "cookie-session"); } diff --git a/server/scripts/reset-passkeys.ts b/server/scripts/reset-passkeys.ts index d9478389..1718a7a3 100644 --- a/server/scripts/reset-passkeys.ts +++ b/server/scripts/reset-passkeys.ts @@ -24,7 +24,7 @@ export function parseArgs(args: string[] = process.argv.slice(2)): Required = db, + database: Pick = db, options: ResetOptions = parseArgs(), ) { if (!options.dryRun && !options.confirm) { @@ -41,8 +41,21 @@ export async function runPasskeyReset( return { dryRun: true, counts }; } - for (const table of PASSKEY_RESET_TABLES) { - await database.execute(`DELETE FROM ${table}`); + const tx = await database.transaction("write"); + try { + await tx.execute(`UPDATE ea_owner + SET auth_mode = 'password_or_passkey', + security_generation = security_generation + 1 + WHERE singleton_id = 1`); + for (const table of PASSKEY_RESET_TABLES) { + await tx.execute(`DELETE FROM ${table}`); + } + await tx.commit(); + } catch (error) { + if (!tx.closed) await tx.rollback().catch(() => {}); + throw error; + } finally { + tx.close(); } return { dryRun: false, counts }; diff --git a/server/test-utils/auth-db.ts b/server/test-utils/auth-db.ts index e5bb1725..28b8f048 100644 --- a/server/test-utils/auth-db.ts +++ b/server/test-utils/auth-db.ts @@ -3,6 +3,7 @@ import crypto from "crypto"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import { createTestTempDir, removeTempDirSync } from "./temp-dir.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const migrationsDir = join(__dirname, "../db/migrations"); @@ -16,6 +17,8 @@ const migrationFiles = [ "032_canonical_url.sql", "033_instance_credentials.sql", "034_google_oauth_binding.sql", + "038_auth_security_generation.sql", + "039_password_step_up_window.sql", ]; const migrationSql = migrationFiles.map((file) => @@ -42,7 +45,13 @@ export function hashApiToken(raw: string) { } export async function createAuthTestDb() { - const db = createClient({ url: "file::memory:" }); + const tempDir = await createTestTempDir("auth-db-"); + const db = createClient({ url: `file:${join(tempDir, "auth.db")}` }); + const close = db.close.bind(db); + db.close = () => { + close(); + removeTempDirSync(tempDir); + }; for (const sql of migrationSql) { await db.executeMultiple(sql); } @@ -54,10 +63,29 @@ export async function seedSession( token = "cookie-session", expiresAt = Date.now() + 60_000, authenticatedAt = 0, + { + securityGeneration = 1, + authMethod = authenticatedAt > 0 ? "password" : "legacy", + passwordAuthenticatedAt = authenticatedAt, + }: { + securityGeneration?: number; + authMethod?: "legacy" | "password" | "passkey" | "password_plus_passkey" | "recovery"; + passwordAuthenticatedAt?: number; + } = {}, ) { await db.execute({ - sql: "INSERT INTO ea_sessions (token, expires_at, authenticated_at) VALUES (?, ?, ?)", - args: [hashSessionToken(token), expiresAt, authenticatedAt], + sql: `INSERT INTO ea_sessions + (token, expires_at, authenticated_at, password_authenticated_at, + security_generation, auth_method) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [ + hashSessionToken(token), + expiresAt, + authenticatedAt, + passwordAuthenticatedAt, + securityGeneration, + authMethod, + ], }); } diff --git a/src/components/settings/cards/PasskeysCard.test.tsx b/src/components/settings/cards/PasskeysCard.test.tsx index d17f7958..e22b65e3 100644 --- a/src/components/settings/cards/PasskeysCard.test.tsx +++ b/src/components/settings/cards/PasskeysCard.test.tsx @@ -60,12 +60,47 @@ beforeEach(() => { }); describe("PasskeysCard", () => { + it("starts locked even when the server session is still recently authenticated", async () => { + render(); + + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + expect(screen.queryByPlaceholderText("MacBook Touch ID")).toBeNull(); + expect(screen.getByRole("radio", { name: /Password or passkey/i }).disabled).toBe(true); + }); + + it("locks an open security panel when the page is leaving", async () => { + render(); + await unlockSecurityChanges(); + expect(screen.getByPlaceholderText("MacBook Touch ID")).toBeTruthy(); + + fireEvent(window, new Event("pagehide")); + + expect(screen.getByLabelText("Current password")).toBeTruthy(); + expect(screen.queryByPlaceholderText("MacBook Touch ID")).toBeNull(); + }); + + it("requires another unlock after the security section unmounts and remounts", async () => { + const firstVisit = render(); + await unlockSecurityChanges(); + expect(screen.getByPlaceholderText("MacBook Touch ID")).toBeTruthy(); + + firstVisit.unmount(); + render(); + + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + expect(screen.queryByPlaceholderText("MacBook Touch ID")).toBeNull(); + }); + it("shows setup mode and storage-separation guidance when no passkeys exist", async () => { render(); expect(await screen.findByText("Password or passkey")).toBeTruthy(); + expect(screen.getByRole("radio", { name: /Password or passkey/i }).checked).toBe(true); + expect(screen.getByRole("radio", { name: /Password \+ passkey/i }).disabled).toBe(true); expect(screen.getByText(/Password stays available after you register a passkey/i)).toBeTruthy(); expect(screen.getByText(/Use a device passkey or hardware security key/i)).toBeTruthy(); + await unlockSecurityChanges(); + expect(screen.getByText(/Add at least one passkey before requiring both factors/i)).toBeTruthy(); expect(screen.getByPlaceholderText("MacBook Touch ID")).toBeTruthy(); }); @@ -73,6 +108,7 @@ describe("PasskeysCard", () => { render(); await screen.findByText("Password or passkey"); + await unlockSecurityChanges(); fireEvent.change(screen.getByPlaceholderText("MacBook Touch ID"), { target: { value: "MacBook Touch ID" }, @@ -109,7 +145,8 @@ describe("PasskeysCard", () => { render(); - expect(await screen.findByText("Security Key")).toBeTruthy(); + await unlockSecurityChanges(); + expect(screen.getByText("Security Key")).toBeTruthy(); expect(screen.getByText("Password + passkey")).toBeTruthy(); expect(screen.getByText(/Add a second passkey when practical/i)).toBeTruthy(); expect(screen.getByText("usb, nfc")).toBeTruthy(); @@ -127,7 +164,8 @@ describe("PasskeysCard", () => { render(); - expect(await screen.findByText("Security Key")).toBeTruthy(); + await unlockSecurityChanges(); + expect(screen.getByText("Security Key")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Delete Security Key" })); fireEvent.click(screen.getByRole("button", { name: "Confirm delete" })); @@ -149,7 +187,8 @@ describe("PasskeysCard", () => { }); render(); - fireEvent.click(await screen.findByRole("button", { name: "Require password + passkey" })); + await unlockSecurityChanges(); + fireEvent.click(screen.getByRole("radio", { name: /Password \+ passkey/i })); await waitFor(() => expect(mockSecurityApi.updateOwnerAuthMode).toHaveBeenCalledWith("password_plus_passkey")); expect(screen.getByText("Password + passkey")).toBeTruthy(); @@ -172,14 +211,49 @@ describe("PasskeysCard", () => { expect(screen.getByPlaceholderText("MacBook Touch ID")).toBeTruthy(); }); + it("keeps both sign-in mode choices visible before recent password confirmation", async () => { + mockApi.listPasskeys.mockResolvedValue({ + enforcementActive: false, + authMode: "password_or_passkey", + recentAuth: false, + recovery: { remaining: 8, generatedAt: Date.now() }, + passkeys: [passkeyRow({ label: "Security Key" })], + }); + render(); + + const relaxedMode = await screen.findByRole("radio", { name: /Password or passkey/i }); + const strictMode = screen.getByRole("radio", { name: /Password \+ passkey/i }); + expect(relaxedMode.disabled).toBe(true); + expect(strictMode.disabled).toBe(true); + expect(screen.getByText(/Confirm your password below to change this mode/i)).toBeTruthy(); + + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "correct-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Unlock security changes" })); + + await waitFor(() => expect(strictMode.disabled).toBe(false)); + }); + it("shows regenerated recovery codes only until acknowledged", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Generate recovery codes" })); + await unlockSecurityChanges(); + fireEvent.click(screen.getByRole("button", { name: "Generate recovery codes" })); expect(await screen.findByText("SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "I saved these codes" })); expect(screen.queryByText("SP-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF-1111-2222")).toBeNull(); }); + + it("rejects a short replacement password before calling the security API", async () => { + render(); + await unlockSecurityChanges(); + + fireEvent.change(screen.getByLabelText("New password"), { target: { value: "too-short" } }); + fireEvent.change(screen.getByLabelText("Confirm new password"), { target: { value: "too-short" } }); + fireEvent.click(screen.getByRole("button", { name: "Change password" })); + + expect(await screen.findByText(/at least 12 characters/i)).toBeTruthy(); + expect(mockSecurityApi.changeOwnerPassword).not.toHaveBeenCalled(); + }); }); function passkeyRow(overrides = {}) { @@ -194,3 +268,9 @@ function passkeyRow(overrides = {}) { ...overrides, }; } + +async function unlockSecurityChanges() { + fireEvent.change(await screen.findByLabelText("Current password"), { target: { value: "correct-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Unlock security changes" })); + await waitFor(() => expect(mockSecurityApi.stepUpWithPassword).toHaveBeenCalledWith("correct-password")); +} diff --git a/src/components/settings/cards/PasskeysCard.tsx b/src/components/settings/cards/PasskeysCard.tsx index e73b57f7..3c46cc5c 100644 --- a/src/components/settings/cards/PasskeysCard.tsx +++ b/src/components/settings/cards/PasskeysCard.tsx @@ -67,18 +67,32 @@ export default function PasskeysCard() { useEffect(() => { let cancelled = false; + function lockForPageLeave() { + setRecentAuth(false); + setCurrentPassword(""); + setNewPassword(""); + setPasswordConfirmation(""); + setRevealedCodes(null); + setConfirmingCredentialId(null); + setActionError(null); + setBusyAction(null); + } + + window.addEventListener("pagehide", lockForPageLeave); listPasskeys() .then((result) => { if (cancelled) return; setPasskeys(result.passkeys || []); setAuthMode(result.authMode || "password_or_passkey"); - setRecentAuth(Boolean(result.recentAuth)); setRecovery(result.recovery || emptyRecovery); }) .catch((error) => { if (!cancelled) setLoadError(errorMessage(error, "Failed to load sign-in settings")); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + window.removeEventListener("pagehide", lockForPageLeave); + }; }, []); async function handleUnlock(event: FormEvent) { @@ -134,10 +148,8 @@ export default function PasskeysCard() { } } - async function handleModeChange() { - const nextMode: OwnerAuthMode = authMode === "password_plus_passkey" - ? "password_or_passkey" - : "password_plus_passkey"; + async function handleModeChange(nextMode: OwnerAuthMode) { + if (nextMode === authMode || busyAction) return; setBusyAction("mode"); setActionError(null); try { @@ -154,6 +166,10 @@ export default function PasskeysCard() { async function handlePasswordChange(event: FormEvent) { event.preventDefault(); if (!newPassword || busyAction) return; + if (newPassword.length < 12) { + setActionError("New password must be at least 12 characters"); + return; + } if (newPassword !== passwordConfirmation) { setActionError("New passwords do not match"); return; @@ -189,17 +205,13 @@ export default function PasskeysCard() { const loadedPasskeys = passkeys || []; const hasPasskeys = loadedPasskeys.length > 0; const strictMode = authMode === "password_plus_passkey"; + const modeBusy = busyAction === "mode"; return ( } - description="Choose password-or-passkey access, or explicitly require both. Security changes need recent password confirmation." - headerAction={( - - {strictMode ? "Password + passkey" : "Password or passkey"} - - )} + description="Choose password-or-passkey access, or explicitly require both. Confirm your password each time you open this section." >
@@ -215,6 +227,78 @@ export default function PasskeysCard() {
+ {passkeys !== null && !loadError ? ( +
+ Sign-in mode +
+ + + +
+
+ + {!recentAuth + ? "Confirm your password below to change this mode." + : !hasPasskeys + ? "Add at least one passkey before requiring both factors." + : modeBusy + ? "Saving sign-in mode…" + : "Changes apply to future sign-ins."} + +
+
+ ) : null} + {loadError ? ( {loadError} ) : passkeys === null ? ( @@ -237,25 +321,10 @@ export default function PasskeysCard() { {busyAction === "unlock" ? "Unlocking…" : "Unlock security changes"}
- Confirmation stays valid for ten minutes. + Unlocked until you leave the System section. ) : ( <> - {hasPasskeys ? ( -
-
-
Sign-in mode
-
- {strictMode ? "Both factors are required at every login." : "Either your password or any registered passkey can sign you in."} -
-
- -
- ) : null} -
New passkey label @@ -320,13 +389,30 @@ export default function PasskeysCard() {
New password - setNewPassword(event.target.value)} disabled={busyAction === "password"} /> + setNewPassword(event.target.value)} + disabled={busyAction === "password"} + />
Confirm new password - setPasswordConfirmation(event.target.value)} disabled={busyAction === "password"} /> + setPasswordConfirmation(event.target.value)} + disabled={busyAction === "password"} + />
+ Use at least 12 characters. diff --git a/src/components/settings/settings-ui.tsx b/src/components/settings/settings-ui.tsx index 76f8888a..500f5c79 100644 --- a/src/components/settings/settings-ui.tsx +++ b/src/components/settings/settings-ui.tsx @@ -25,7 +25,7 @@ export function StatusPill({ tone = "neutral", className, children }: { tone?: S return (
-
+
{title} @@ -118,7 +118,11 @@ export function SettingsCard({ id, ready = true, title, icon, description, child

) : null}
- {headerAction} + {headerAction ? ( +
+ {headerAction} +
+ ) : null}
diff --git a/src/pages/Login.test.tsx b/src/pages/Login.test.tsx index eaf378a1..73676b49 100644 --- a/src/pages/Login.test.tsx +++ b/src/pages/Login.test.tsx @@ -153,6 +153,18 @@ describe("Login passkey flow", () => { fireEvent.click(screen.getByRole("button", { name: "I saved these codes" })); expect(onLogin).toHaveBeenCalledTimes(1); }); + + it("rejects a short recovery password before calling the recovery API", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Recover access" })); + fireEvent.change(screen.getByLabelText("Recovery code"), { target: { value: "SP-OLD-CODE" } }); + fireEvent.change(screen.getByLabelText("New password"), { target: { value: "too-short" } }); + fireEvent.change(screen.getByLabelText("Confirm new password"), { target: { value: "too-short" } }); + fireEvent.click(screen.getByRole("button", { name: "Reset access" })); + + expect(await screen.findByText(/at least 12 characters/i)).toBeTruthy(); + expect(securityApiMocks.recoverOwnerAccess).not.toHaveBeenCalled(); + }); }); async function submitPassword(password: string): Promise { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index bccf92b5..9a59cef9 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -82,6 +82,10 @@ export default function Login({ onLogin }: LoginProps): ReactElement { if (loading || locked) return; if (phase === "recovery") { if (!recoveryCode || !password) return; + if (password.length < 12) { + setError("New password must be at least 12 characters"); + return; + } if (password !== confirmation) { setError("Passwords do not match"); return; @@ -233,6 +237,7 @@ export default function Login({ onLogin }: LoginProps): ReactElement { id="recovery-password" type="password" autoComplete="new-password" + minLength={12} value={password} onChange={(event) => setPassword(event.target.value)} disabled={loading} @@ -246,11 +251,13 @@ export default function Login({ onLogin }: LoginProps): ReactElement { id="recovery-password-confirmation" type="password" autoComplete="new-password" + minLength={12} value={confirmation} onChange={(event) => setConfirmation(event.target.value)} disabled={loading} />
+

Use at least 12 characters.

) : (
diff --git a/src/pages/OwnerSetup.test.tsx b/src/pages/OwnerSetup.test.tsx index a1941bb8..29ef8bf6 100644 --- a/src/pages/OwnerSetup.test.tsx +++ b/src/pages/OwnerSetup.test.tsx @@ -16,6 +16,7 @@ describe("OwnerSetup", () => { it("keeps mismatched passwords in the browser", async () => { render(); + fireEvent.change(screen.getByLabelText("Deployment setup token"), { target: { value: "deployment-setup-token" } }); fireEvent.change(screen.getByLabelText("Create password"), { target: { value: "first-password" } }); fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: "different-password" } }); fireEvent.click(screen.getByRole("checkbox", { name: /confirm this is the canonical/i })); @@ -34,6 +35,7 @@ describe("OwnerSetup", () => { }); render(); + fireEvent.change(screen.getByLabelText("Deployment setup token"), { target: { value: "deployment-setup-token" } }); fireEvent.change(screen.getByLabelText("Create password"), { target: { value: "new-owner-password" } }); fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: "new-owner-password" } }); fireEvent.click(screen.getByRole("checkbox", { name: /confirm this is the canonical/i })); @@ -43,13 +45,14 @@ describe("OwnerSetup", () => { expect(onClaimed).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "I saved these codes" })); expect(onClaimed).toHaveBeenCalledTimes(1); - expect(claimOwner).toHaveBeenCalledWith("new-owner-password", window.location.origin); + expect(claimOwner).toHaveBeenCalledWith("deployment-setup-token", "new-owner-password", window.location.origin); }); it("prefills the visible browser origin and requires explicit confirmation", () => { render(); expect(screen.getByLabelText("Canonical Setpoint URL").value).toBe(window.location.origin); + fireEvent.change(screen.getByLabelText("Deployment setup token"), { target: { value: "deployment-setup-token" } }); fireEvent.change(screen.getByLabelText("Create password"), { target: { value: "new-owner-password" } }); fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: "new-owner-password" } }); expect(screen.getByRole("button", { name: "Claim Setpoint" }).disabled).toBe(true); @@ -59,14 +62,17 @@ describe("OwnerSetup", () => { claimOwner.mockRejectedValue(new Error("Instance is already claimed")); render(); + const setupToken = screen.getByLabelText("Deployment setup token") as HTMLInputElement; const password = screen.getByLabelText("Create password") as HTMLInputElement; const confirmation = screen.getByLabelText("Confirm password") as HTMLInputElement; + fireEvent.change(setupToken, { target: { value: "deployment-setup-token" } }); fireEvent.change(password, { target: { value: "new-owner-password" } }); fireEvent.change(confirmation, { target: { value: "new-owner-password" } }); fireEvent.click(screen.getByRole("checkbox", { name: /confirm this is the canonical/i })); fireEvent.click(screen.getByRole("button", { name: "Claim Setpoint" })); expect((await screen.findByRole("alert")).textContent).toContain("Instance is already claimed"); + expect(setupToken.value).toBe(""); expect(password.value).toBe(""); expect(confirmation.value).toBe(""); }); diff --git a/src/pages/OwnerSetup.tsx b/src/pages/OwnerSetup.tsx index 1fcdbaba..a165a3dd 100644 --- a/src/pages/OwnerSetup.tsx +++ b/src/pages/OwnerSetup.tsx @@ -16,6 +16,7 @@ function errorMessage(error: unknown): string { } export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement { + const [setupToken, setSetupToken] = useState(""); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [canonicalOrigin, setCanonicalOrigin] = useState(() => window.location.origin); @@ -23,11 +24,15 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [recoveryCodes, setRecoveryCodes] = useState(null); - const passwordRef = useRef(null); + const setupTokenRef = useRef(null); async function handleSubmit(event: FormEvent): Promise { event.preventDefault(); - if (!password || !canonicalOrigin || !originConfirmed || submitting) return; + if (!setupToken || !password || !canonicalOrigin || !originConfirmed || submitting) return; + if (password.length < 12) { + setError("Password must be at least 12 characters"); + return; + } if (password !== confirmation) { setError("Passwords do not match"); return; @@ -36,15 +41,17 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement setSubmitting(true); setError(null); try { - const result = await claimOwner(password, canonicalOrigin); + const result = await claimOwner(setupToken, password, canonicalOrigin); + setSetupToken(""); setPassword(""); setConfirmation(""); setRecoveryCodes(result.recoveryCodes); } catch (error) { + setSetupToken(""); setPassword(""); setConfirmation(""); setError(errorMessage(error)); - passwordRef.current?.focus(); + setupTokenRef.current?.focus(); } finally { setSubmitting(false); } @@ -78,7 +85,7 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement {recoveryCodes ? "Store these offline. Each code works once, and Setpoint will not show this set again." - : "Create the owner password for this Setpoint instance. The first successful claim closes public setup permanently."} + : "Prove access to this deployment, then create its single owner. A successful claim closes setup permanently."}
@@ -118,11 +125,33 @@ export default function OwnerSetup({ onClaimed }: OwnerSetupProps): ReactElement Single-owner access

- Your password is hashed on the server and never returned to this browser. + Use the setup token generated by your host. It is checked once and never stored by Setpoint.

+
+ + { + setSetupToken(event.target.value); + if (error) setError(null); + }} + /> +

+ Copy `EA_SETUP_TOKEN` from your deployment's secret environment settings. +

+
{ setPassword(event.target.value); if (error) setError(null); }} /> +

+ Use at least 12 characters. A password manager-generated passphrase is recommended. +

) : null} +
); diff --git a/src/components/settings/cards/GmailRealtimeCard.test.tsx b/src/components/settings/cards/GmailRealtimeCard.test.tsx index 945eff0a..ca6d4636 100644 --- a/src/components/settings/cards/GmailRealtimeCard.test.tsx +++ b/src/components/settings/cards/GmailRealtimeCard.test.tsx @@ -10,8 +10,12 @@ const api = vi.hoisted(() => ({ testGmailPubSubWatches: vi.fn(), useHostGmailPubSubToken: vi.fn(), })); +const security = vi.hoisted(() => ({ + stepUpWithPassword: vi.fn(), +})); vi.mock("@/lib/gmailPubSubSetupApi", () => api); +vi.mock("@/auth/securityApi", () => security); const { default: GmailRealtimeCard } = await import("./GmailRealtimeCard"); const periodicStatus = { @@ -28,6 +32,7 @@ const periodicStatus = { beforeEach(() => { api.getGmailPubSubStatus.mockResolvedValue(periodicStatus); + security.stepUpWithPassword.mockResolvedValue({ recentAuth: true }); vi.stubGlobal("confirm", vi.fn(() => true)); }); @@ -85,4 +90,45 @@ describe("GmailRealtimeCard", () => { expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/existing Pub\/Sub subscription/i)); await waitFor(() => expect(api.generateGmailPubSubCallback).toHaveBeenCalledTimes(1)); }); + + it("preserves the topic while password step-up retries the save", async () => { + api.setGmailPubSubTopic + .mockRejectedValueOnce(Object.assign(new Error("Confirm your password"), { + code: "PASSWORD_STEP_UP_REQUIRED", + status: 403, + })) + .mockResolvedValueOnce(periodicStatus.topic); + render(); + const input = await screen.findByLabelText("Google Cloud topic") as HTMLInputElement; + fireEvent.change(input, { target: { value: "projects/private/topics/gmail" } }); + fireEvent.click(screen.getByRole("button", { name: "Save topic" })); + + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + expect(input.value).toBe("projects/private/topics/gmail"); + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirm and retry" })); + + await waitFor(() => expect(api.setGmailPubSubTopic).toHaveBeenCalledTimes(2)); + expect(security.stepUpWithPassword).toHaveBeenCalledWith("owner-password"); + await waitFor(() => expect(input.value).toBe("")); + }); + + it("describes host-token migration as a copy with an explicit Render cleanup boundary", async () => { + const environmentStatus = { + ...periodicStatus, + pushToken: { source: "environment", configured: true }, + } as const; + const storedStatus = { + ...environmentStatus, + pushToken: { source: "stored", configured: true }, + } as const; + api.getGmailPubSubStatus.mockResolvedValue(environmentStatus); + api.importGmailPubSubEnvironmentToken.mockResolvedValue(storedStatus); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Copy into Setpoint" })); + + await waitFor(() => expect(api.importGmailPubSubEnvironmentToken).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(/render variable still remains/i)).toBeTruthy(); + }); }); diff --git a/src/components/settings/cards/GmailRealtimeCard.tsx b/src/components/settings/cards/GmailRealtimeCard.tsx index 103f305e..cb7a7eb2 100644 --- a/src/components/settings/cards/GmailRealtimeCard.tsx +++ b/src/components/settings/cards/GmailRealtimeCard.tsx @@ -14,6 +14,13 @@ import { import type { GmailPubSubStatus } from "../../../../shared/types/email"; import { SETTINGS_PRIMARY_BUTTON_CLASS, SETTINGS_SECONDARY_BUTTON_CLASS } from "../settings-core"; import { FieldHint, SectionLabel, SettingsCard, StatusPill } from "../settings-ui"; +import { + SensitiveActionStepUp, +} from "../SensitiveActionStepUp"; +import { + isPasswordStepUpRequired, + useSensitiveActionStepUp, +} from "../sensitiveActionStepUpModel"; const BUTTON_MOTION = "min-h-11 motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0 sm:min-h-8"; @@ -27,6 +34,8 @@ export default function GmailRealtimeCard({ openAdvancedSetup = false }: { openA const [copyMessage, setCopyMessage] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(openAdvancedSetup); const closeRef = useRef(null); + const stepUp = useSensitiveActionStepUp(); + const credentialActionLocked = Boolean(stepUp.pendingLabel); useEffect(() => { if (demo) return; @@ -45,35 +54,58 @@ export default function GmailRealtimeCard({ openAdvancedSetup = false }: { openA if (openAdvancedSetup) setAdvancedOpen(true); }, [openAdvancedSetup]); - async function run(action: () => Promise, success: string) { - setBusy(true); - setMessage(null); - try { - setStatus(await action()); - setMessage(success); - } catch { - setMessage("The Gmail real-time configuration could not be updated."); - } finally { - setBusy(false); - } + async function run(action: () => Promise, success: string, label: string) { + await stepUp.run(async () => { + setBusy(true); + setMessage(null); + try { + setStatus(await action()); + setMessage(success); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setMessage("The Gmail real-time configuration could not be updated."); + } finally { + setBusy(false); + } + }, label); } async function handleGenerate() { if (status?.pushToken.configured && !window.confirm( "Regenerating invalidates the existing Pub/Sub subscription callback token. Update the external subscription immediately or real-time delivery will stop.", )) return; - setBusy(true); - setMessage(null); - try { - const result = await generateGmailPubSubCallback(); - setStatus(result.status); - setCopyMessage(null); - setRevealedCallback(result.callbackUrl); - } catch { - setMessage("A callback could not be generated."); - } finally { - setBusy(false); - } + await stepUp.run(async () => { + setBusy(true); + setMessage(null); + try { + const result = await generateGmailPubSubCallback(); + setStatus(result.status); + setCopyMessage(null); + setRevealedCallback(result.callbackUrl); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setMessage("A callback could not be generated."); + } finally { + setBusy(false); + } + }, status?.pushToken.configured ? "regenerating the Gmail callback" : "generating the Gmail callback"); + } + + async function handleTestWatches() { + await stepUp.run(async () => { + setBusy(true); + setMessage(null); + try { + const result = await testGmailPubSubWatches(); + setMessage(result.ok ? `Watch registration succeeded for ${result.registered} account(s).` : "Watch registration needs attention."); + setStatus(await getGmailPubSubStatus()); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setMessage("Watch registration needs attention."); + } finally { + setBusy(false); + } + }, "testing the Gmail watches"); } const periodic = !status?.configured; @@ -102,38 +134,36 @@ export default function GmailRealtimeCard({ openAdvancedSetup = false }: { openA
Google Cloud topic - setTopic(event.target.value)} placeholder="projects/project-id/topics/gmail" /> + setTopic(event.target.value)} placeholder="projects/project-id/topics/gmail" /> Saving a topic does not expose or replace the callback token.
- - + - + {status?.pushToken.source === "environment" ? ( - + ) : null} {status?.pushToken.configured ? ( - ) : null}
{status?.callbackUrl ? Callback base: {status.callbackUrl} : null} {message ? {message} : null} +
) : null} diff --git a/src/components/settings/cards/GoogleOAuthCredentialsCard.test.tsx b/src/components/settings/cards/GoogleOAuthCredentialsCard.test.tsx index cd0c05b5..e878c8fb 100644 --- a/src/components/settings/cards/GoogleOAuthCredentialsCard.test.tsx +++ b/src/components/settings/cards/GoogleOAuthCredentialsCard.test.tsx @@ -4,14 +4,17 @@ import { useState } from "react"; import type { InstanceCredentialMetadata } from "../../../../shared/types/instance-credentials"; const mockApi = vi.hoisted(() => ({ - disableInstanceCredential: vi.fn(), + disableGoogleOAuthApplication: vi.fn(), getGmailAuthUrl: vi.fn(), getInstanceCredentials: vi.fn(), - importInstanceCredentialEnvironment: vi.fn(), + importGoogleOAuthEnvironment: vi.fn(), stageGoogleOAuthApplication: vi.fn(), - useHostInstanceCredential: vi.fn(), + useHostGoogleOAuthApplication: vi.fn(), +})); +const mockSecurity = vi.hoisted(() => ({ + getCanonicalOriginStatus: vi.fn(), + stepUpWithPassword: vi.fn(), })); -const mockSecurity = vi.hoisted(() => ({ getCanonicalOriginStatus: vi.fn() })); vi.mock("@/api", () => mockApi); vi.mock("@/auth/securityApi", () => mockSecurity); @@ -75,6 +78,7 @@ beforeEach(() => { mockSecurity.getCanonicalOriginStatus.mockResolvedValue({ callbacks: [{ provider: "Google OAuth", nextUrl: "https://setpoint.example/api/ea/accounts/gmail/callback" }], }); + mockSecurity.stepUpWithPassword.mockResolvedValue({ recentAuth: true }); }); describe("GoogleOAuthCredentialsCard", () => { @@ -112,20 +116,67 @@ describe("GoogleOAuthCredentialsCard", () => { expect(screen.getByText(/active application remains in use/i)).toBeTruthy(); }); - it("migrates both environment values without placing either value in browser state", async () => { + it("copies both environment values atomically and explains the Render cleanup boundary", async () => { const environment = absent.map((item) => ({ ...item, source: "environment" as const, activeConfigured: true })); const stored = environment.map((item) => ({ ...item, source: "stored" as const })); - mockApi.getInstanceCredentials.mockResolvedValueOnce({ credentials: stored, rootKey: {} }); - mockApi.importInstanceCredentialEnvironment.mockResolvedValue(stored[0]); + mockApi.importGoogleOAuthEnvironment.mockResolvedValue({ credentials: stored }); renderCard(environment); await screen.findByText("Host environment"); - fireEvent.click(screen.getByRole("button", { name: "Move into Setpoint" })); + fireEvent.click(screen.getByRole("button", { name: "Copy into Setpoint" })); - await waitFor(() => expect(mockApi.importInstanceCredentialEnvironment).toHaveBeenCalledTimes(2)); - expect(mockApi.importInstanceCredentialEnvironment).toHaveBeenCalledWith("google.oauth_client_id"); - expect(mockApi.importInstanceCredentialEnvironment).toHaveBeenCalledWith("google.oauth_client_secret"); + await waitFor(() => expect(mockApi.importGoogleOAuthEnvironment).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(/render variables still remain/i)).toBeTruthy(); expect((screen.getByLabelText("Client ID") as HTMLInputElement).value).toBe(""); expect((screen.getByLabelText("Client secret") as HTMLInputElement).value).toBe(""); }); + + it("requires inline confirmation before atomically disabling the pair", async () => { + const stored = absent.map((item) => ({ ...item, source: "stored" as const, activeConfigured: true })); + const disabled = stored.map((item) => ({ ...item, source: "disabled" as const, activeConfigured: false })); + mockApi.disableGoogleOAuthApplication.mockResolvedValue({ credentials: disabled }); + + renderCard(stored); + fireEvent.click(await screen.findByRole("button", { name: "Remove and disable" })); + + expect(mockApi.disableGoogleOAuthApplication).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Confirm remove Google credentials" })); + await waitFor(() => expect(mockApi.disableGoogleOAuthApplication).toHaveBeenCalledTimes(1)); + }); + + it("keeps the candidate in place while password step-up retries the save", async () => { + const pending = absent.map((item, index) => ({ + ...item, + pendingConfigured: true, + validationState: "pending" as const, + version: index + 1, + })); + mockApi.stageGoogleOAuthApplication + .mockRejectedValueOnce(Object.assign(new Error("Confirm your password"), { + code: "PASSWORD_STEP_UP_REQUIRED", + status: 403, + })) + .mockResolvedValueOnce({ + credentials: pending, + candidateVersions: { clientId: 1, clientSecret: 2 }, + }); + + renderCard(); + const clientId = await screen.findByLabelText("Client ID") as HTMLInputElement; + const clientSecret = screen.getByLabelText("Client secret") as HTMLInputElement; + fireEvent.change(clientId, { target: { value: "client-id-private" } }); + fireEvent.change(clientSecret, { target: { value: "client-secret-private" } }); + fireEvent.click(screen.getByRole("button", { name: "Save application" })); + + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + expect(clientId.value).toBe("client-id-private"); + expect(clientSecret.value).toBe("client-secret-private"); + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirm and retry" })); + + await waitFor(() => expect(mockApi.stageGoogleOAuthApplication).toHaveBeenCalledTimes(2)); + expect(mockSecurity.stepUpWithPassword).toHaveBeenCalledWith("owner-password"); + await waitFor(() => expect(clientId.value).toBe("")); + expect(clientSecret.value).toBe(""); + }); }); diff --git a/src/components/settings/cards/GoogleOAuthCredentialsCard.tsx b/src/components/settings/cards/GoogleOAuthCredentialsCard.tsx index 99911acd..23a9e1d2 100644 --- a/src/components/settings/cards/GoogleOAuthCredentialsCard.tsx +++ b/src/components/settings/cards/GoogleOAuthCredentialsCard.tsx @@ -2,11 +2,11 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { KeyRound } from "lucide-react"; import { - disableInstanceCredential, + disableGoogleOAuthApplication, getGmailAuthUrl, - importInstanceCredentialEnvironment, + importGoogleOAuthEnvironment, stageGoogleOAuthApplication, - useHostInstanceCredential as restoreHostInstanceCredential, + useHostGoogleOAuthApplication as restoreHostGoogleOAuthApplication, } from "@/api"; import { getCanonicalOriginStatus } from "@/auth/securityApi"; import { isDemoMode } from "@/demo/config"; @@ -21,6 +21,13 @@ import { import type { InstanceCredentialMetadata } from "../../../../shared/types/instance-credentials"; import { formatCredentialTimestamp } from "./coreCredentialModel"; import type { SettingsCredentialMetadataProps } from "../settingsTypes"; +import { + SensitiveActionStepUp, +} from "../SensitiveActionStepUp"; +import { + isPasswordStepUpRequired, + useSensitiveActionStepUp, +} from "../sensitiveActionStepUpModel"; const CLIENT_ID_KEY = "google.oauth_client_id"; const CLIENT_SECRET_KEY = "google.oauth_client_secret"; @@ -49,7 +56,10 @@ export default function GoogleOAuthCredentialsCard({ const [busy, setBusy] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); + const [confirmingDisable, setConfirmingDisable] = useState(false); const clientIdRef = useRef(null); + const stepUp = useSensitiveActionStepUp(); + const credentialActionLocked = Boolean(stepUp.pendingLabel); function restoreFormFocus() { requestAnimationFrame(() => clientIdRef.current?.focus()); @@ -75,38 +85,61 @@ export default function GoogleOAuthCredentialsCard({ async function saveCandidate(event: FormEvent) { event.preventDefault(); if (!clientId || !clientSecret || busy) return; - setBusy("save"); setMessage(null); setError(null); - try { - const result = await stageGoogleOAuthApplication(clientId, clientSecret); - setClientId(""); setClientSecret(""); - onCredentialMetadataChange(result.credentials); - setMessage("Pending application saved. Connect Google to validate it; the active application remains in use until authorization succeeds."); - } catch { - setClientId(""); setClientSecret(""); - setError("The Google application candidate could not be saved."); - await onRefreshCredentialMetadata().catch(() => {}); - } finally { setBusy(null); restoreFormFocus(); } + const candidate = { clientId, clientSecret }; + await stepUp.run(async () => { + let shouldRestoreFocus = true; + setBusy("save"); setMessage(null); setError(null); + try { + const result = await stageGoogleOAuthApplication(candidate.clientId, candidate.clientSecret); + setClientId(""); setClientSecret(""); + onCredentialMetadataChange(result.credentials); + setMessage("Pending application saved. Connect Google to validate it; the active application remains in use until authorization succeeds."); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) { + shouldRestoreFocus = false; + throw caught; + } + setError("The Google application candidate could not be saved."); + await onRefreshCredentialMetadata().catch(() => {}); + } finally { + setBusy(null); + if (shouldRestoreFocus) restoreFormFocus(); + } + }, "saving this Google application"); } async function sourceAction(action: "import" | "disable" | "host") { - setBusy(action); setMessage(null); setError(null); - try { - const keys = [CLIENT_ID_KEY, CLIENT_SECRET_KEY]; - await Promise.all(keys.map((key) => action === "import" - ? importInstanceCredentialEnvironment(key) - : action === "disable" - ? disableInstanceCredential(key) - : restoreHostInstanceCredential(key))); - await onRefreshCredentialMetadata(); - setMessage(action === "import" - ? "Host-managed Google application credentials moved into encrypted Setpoint storage." - : action === "disable" - ? "Stored and pending Google application credentials removed; host fallback is disabled." - : "Host-managed Google application credentials are active again."); - } catch { - setError("The Google application source could not be changed. No credential values were exposed."); - await onRefreshCredentialMetadata().catch(() => {}); - } finally { setBusy(null); restoreFormFocus(); } + await stepUp.run(async () => { + let shouldRestoreFocus = true; + setBusy(action); setMessage(null); setError(null); + try { + const result = action === "import" + ? await importGoogleOAuthEnvironment() + : action === "disable" + ? await disableGoogleOAuthApplication() + : await restoreHostGoogleOAuthApplication(); + onCredentialMetadataChange(result.credentials); + setMessage(action === "import" + ? "Copied into encrypted Setpoint storage. The Render variables still remain. Back up EA_ENCRYPTION_KEY, remove both Google variables in Render, redeploy, then verify Google before considering the migration complete." + : action === "disable" + ? "Stored and pending Google application credentials removed; host fallback is disabled." + : "Host-managed Google application credentials are active again."); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) { + shouldRestoreFocus = false; + throw caught; + } + setError("The Google application source could not be changed. No credential values were exposed."); + await onRefreshCredentialMetadata().catch(() => {}); + } finally { + setBusy(null); + if (shouldRestoreFocus) restoreFormFocus(); + } + }, action === "import" + ? "copying the Google credentials into Setpoint" + : action === "disable" + ? "removing the Google credentials" + : "restoring the host-managed Google credentials"); } async function connectGoogle() { @@ -154,36 +187,66 @@ export default function GoogleOAuthCredentialsCard({
Client ID - setClientId(event.target.value)} disabled={Boolean(busy)} /> + setClientId(event.target.value)} disabled={Boolean(busy) || credentialActionLocked} />
Client secret - setClientSecret(event.target.value)} disabled={Boolean(busy)} /> + setClientSecret(event.target.value)} disabled={Boolean(busy) || credentialActionLocked} />
- - {allEnvironment ? ( - ) : null} {anyConfigured && !allDisabled ? ( - ) : null} {allDisabled ? ( - ) : null}
+ {confirmingDisable ? ( +
+

+ This deletes both stored and pending Google application credentials and blocks host fallback. Google connections cannot be renewed until credentials are restored. +

+
+ + +
+
+ ) : null} + {callbackUrl ? (
Authorized redirect URI
diff --git a/src/components/settings/cards/TodoistCard.test.tsx b/src/components/settings/cards/TodoistCard.test.tsx index af678d26..bcfd03d6 100644 --- a/src/components/settings/cards/TodoistCard.test.tsx +++ b/src/components/settings/cards/TodoistCard.test.tsx @@ -9,9 +9,13 @@ const mockApi = vi.hoisted(() => ({ importTodoistOAuthEnvironment: vi.fn(), beginTodoistOAuth: vi.fn(), })); +const mockSecurity = vi.hoisted(() => ({ + stepUpWithPassword: vi.fn(), +})); vi.mock("@/api", () => mockApi); vi.mock("@/lib/todoistSetupApi", () => mockApi); +vi.mock("@/auth/securityApi", () => mockSecurity); const { default: TodoistCard } = await import("./TodoistCard"); @@ -39,6 +43,7 @@ describe("TodoistCard", () => { verifiedAt: "2026-07-19T18:00:00.000Z", }); mockApi.disconnectTodoistConnection.mockResolvedValue({ success: true }); + mockSecurity.stepUpWithPassword.mockResolvedValue({ recentAuth: true }); }); it("shows Connected and a masked placeholder when already configured", () => { @@ -88,6 +93,29 @@ describe("TodoistCard", () => { expect(mockApi.getTodoistConnectionStatus).toHaveBeenCalledTimes(1); }); + it("preserves a personal token while password step-up retries the save", async () => { + mockApi.saveTodoistPersonalToken + .mockRejectedValueOnce(Object.assign(new Error("Confirm your password"), { + code: "PASSWORD_STEP_UP_REQUIRED", + status: 403, + })) + .mockResolvedValueOnce({ success: true, verifiedAt: "2026-07-19T18:00:00.000Z" }); + render(); + const input = screen.getByLabelText("Personal API token") as HTMLInputElement; + fireEvent.change(input, { target: { value: "tok-private" } }); + fireEvent.click(screen.getByRole("button", { name: "Save & verify" })); + + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + expect(input.value).toBe("tok-private"); + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirm and retry" })); + + await waitFor(() => expect(mockApi.saveTodoistPersonalToken).toHaveBeenCalledTimes(2)); + expect(mockApi.saveTodoistPersonalToken).toHaveBeenLastCalledWith("tok-private"); + expect(mockSecurity.stepUpWithPassword).toHaveBeenCalledWith("owner-password"); + await waitFor(() => expect(input.value).toBe("")); + }); + it("stages advanced application credentials write-only while keeping personal tokens primary", async () => { mockApi.stageTodoistOAuthApplication.mockResolvedValue({ credentials: [] }); render(); @@ -108,6 +136,52 @@ describe("TodoistCard", () => { expect(screen.getByText(/personal token stays active until authorization succeeds/i)).toBeTruthy(); }); + it("preserves the OAuth pair while password step-up retries staging", async () => { + mockApi.stageTodoistOAuthApplication + .mockRejectedValueOnce(Object.assign(new Error("Confirm your password"), { + code: "PASSWORD_STEP_UP_REQUIRED", + status: 403, + })) + .mockResolvedValueOnce({ credentials: [] }); + render(); + const clientId = screen.getByLabelText("Client ID") as HTMLInputElement; + const clientSecret = screen.getByLabelText("Client secret") as HTMLInputElement; + fireEvent.change(clientId, { target: { value: "client-id" } }); + fireEvent.change(clientSecret, { target: { value: "client-secret" } }); + fireEvent.click(screen.getByRole("button", { name: "Save app credentials" })); + + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + expect(clientId.value).toBe("client-id"); + expect(clientSecret.value).toBe("client-secret"); + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirm and retry" })); + + await waitFor(() => expect(mockApi.stageTodoistOAuthApplication).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(clientId.value).toBe("")); + expect(clientSecret.value).toBe(""); + }); + + it("copies the OAuth pair and explains the Render cleanup boundary", async () => { + const environmentStatus = { + ...disconnectedStatus, + application: { configured: true, source: "environment", pendingConfigured: false }, + }; + const storedStatus = { + ...environmentStatus, + application: { configured: true, source: "stored", pendingConfigured: false }, + }; + mockApi.getTodoistConnectionStatus + .mockResolvedValueOnce(environmentStatus) + .mockResolvedValueOnce(storedStatus); + mockApi.importTodoistOAuthEnvironment.mockResolvedValue({ credentials: [] }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Copy into Setpoint" })); + + await waitFor(() => expect(mockApi.importTodoistOAuthEnvironment).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(/render variables still remain/i)).toBeTruthy(); + }); + it("opens only its advanced disclosure when targeted by a deep link", () => { render(); diff --git a/src/components/settings/cards/TodoistCard.tsx b/src/components/settings/cards/TodoistCard.tsx index 3d2b59e3..46b77d59 100644 --- a/src/components/settings/cards/TodoistCard.tsx +++ b/src/components/settings/cards/TodoistCard.tsx @@ -22,6 +22,13 @@ import { import type { SettingsCardStateProps, SettingsConnectionRefreshProps } from "../settingsTypes"; import type { TodoistConnectionStatus } from "../../../../shared/types/tasks"; import { cn } from "@/lib/utils"; +import { + SensitiveActionStepUp, +} from "../SensitiveActionStepUp"; +import { + isPasswordStepUpRequired, + useSensitiveActionStepUp, +} from "../sensitiveActionStepUpModel"; const BUTTON_MOTION_CLASS = "min-h-11 motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0 sm:min-h-8"; @@ -47,6 +54,8 @@ export default function TodoistCard({ const [oauthBusy, setOauthBusy] = useState(false); const [oauthMessage, setOauthMessage] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(openAdvancedSetup); + const stepUp = useSensitiveActionStepUp(); + const credentialActionLocked = Boolean(stepUp.pendingLabel); useEffect(() => { if (settings?.todoist_configured) { @@ -73,102 +82,119 @@ export default function TodoistCard({ }, [openAdvancedSetup]); async function handleSaveTodoistSecret() { - setTodoistSavingSecret(true); - setTodoistMessage(null); - try { - await saveTodoistPersonalToken(todoistToken); - sessionStorage.setItem("ea_settings_changed", "1"); - window.dispatchEvent(new CustomEvent("ea-settings-changed")); - setTodoistConfigured(true); - setTodoistDirty(false); - setTodoistToken(""); - await onRefreshConnections().catch(() => {}); + const candidate = todoistToken; + await stepUp.run(async () => { + setTodoistSavingSecret(true); + setTodoistMessage(null); try { - setOauthStatus(await getTodoistConnectionStatus()); - } catch { - // The personal-token mutation succeeded; advanced status can recover on the next load. + await saveTodoistPersonalToken(candidate); + sessionStorage.setItem("ea_settings_changed", "1"); + window.dispatchEvent(new CustomEvent("ea-settings-changed")); + setTodoistConfigured(true); + setTodoistDirty(false); + setTodoistToken(""); + await onRefreshConnections().catch(() => {}); + try { + setOauthStatus(await getTodoistConnectionStatus()); + } catch { + // The personal-token mutation succeeded; advanced status can recover on the next load. + } + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setTodoistMessage("Todoist personal token could not be verified. The working connection was not changed."); + } finally { + setTodoistSavingSecret(false); } - } catch { - setTodoistMessage("Todoist personal token could not be verified. The working connection was not changed."); - } finally { - setTodoistSavingSecret(false); - } + }, "saving the Todoist personal token"); } async function handleDisconnectTodoist() { - setDisconnecting(true); - setTodoistMessage(null); - try { - await disconnectTodoistConnection(); - sessionStorage.setItem("ea_settings_changed", "1"); - window.dispatchEvent(new CustomEvent("ea-settings-changed")); - setTodoistConfigured(false); - setTodoistDirty(false); - setTodoistToken(""); - setConfirmingDisconnect(false); - setOauthStatus((current) => current ? { - ...current, - mode: "disconnected", - configured: false, - oauthRefreshable: false, - needsReauth: false, - deliveryMode: "periodic", - } : current); - await onRefreshConnections().catch(() => {}); - } catch { - setTodoistMessage("Todoist could not be disconnected."); - } finally { - setDisconnecting(false); - } - } - - async function handleSaveOAuthApplication() { - setOauthBusy(true); - setOauthMessage(null); - try { - await stageTodoistOAuthApplication({ clientId, clientSecret }); - setClientId(""); - setClientSecret(""); + await stepUp.run(async () => { + setDisconnecting(true); + setTodoistMessage(null); try { - setOauthStatus(await getTodoistConnectionStatus()); - } catch { + await disconnectTodoistConnection(); + sessionStorage.setItem("ea_settings_changed", "1"); + window.dispatchEvent(new CustomEvent("ea-settings-changed")); + setTodoistConfigured(false); + setTodoistDirty(false); + setTodoistToken(""); + setConfirmingDisconnect(false); setOauthStatus((current) => current ? { ...current, - application: { ...current.application, pendingConfigured: true }, + mode: "disconnected", + configured: false, + oauthRefreshable: false, + needsReauth: false, + deliveryMode: "periodic", } : current); + await onRefreshConnections().catch(() => {}); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setTodoistMessage("Todoist could not be disconnected."); + } finally { + setDisconnecting(false); } - setOauthMessage("Application credentials saved as a pending candidate. Connect to validate them."); - } catch { - setOauthMessage("Application credentials could not be saved."); - } finally { - setOauthBusy(false); - } + }, "disconnecting Todoist"); + } + + async function handleSaveOAuthApplication() { + const candidate = { clientId, clientSecret }; + await stepUp.run(async () => { + setOauthBusy(true); + setOauthMessage(null); + try { + await stageTodoistOAuthApplication(candidate); + setClientId(""); + setClientSecret(""); + try { + setOauthStatus(await getTodoistConnectionStatus()); + } catch { + setOauthStatus((current) => current ? { + ...current, + application: { ...current.application, pendingConfigured: true }, + } : current); + } + setOauthMessage("Application credentials saved as a pending candidate. Connect to validate them."); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setOauthMessage("Application credentials could not be saved."); + } finally { + setOauthBusy(false); + } + }, "saving the Todoist OAuth application"); } async function handleImportEnvironment() { - setOauthBusy(true); - setOauthMessage(null); - try { - await importTodoistOAuthEnvironment(); - setOauthStatus(await getTodoistConnectionStatus()); - setOauthMessage("Host-managed Todoist credentials were migrated into Setpoint."); - } catch { - setOauthMessage("Host-managed Todoist credentials could not be migrated."); - } finally { - setOauthBusy(false); - } + await stepUp.run(async () => { + setOauthBusy(true); + setOauthMessage(null); + try { + await importTodoistOAuthEnvironment(); + setOauthStatus(await getTodoistConnectionStatus()); + setOauthMessage("Copied into encrypted Setpoint storage. The Render variables still remain. Back up EA_ENCRYPTION_KEY, remove both Todoist OAuth variables in Render, redeploy, then verify Todoist before considering the migration complete."); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setOauthMessage("Host-managed Todoist credentials could not be copied."); + } finally { + setOauthBusy(false); + } + }, "copying the Todoist OAuth credentials into Setpoint"); } async function handleBeginOAuth() { - setOauthBusy(true); - setOauthMessage(null); - try { - const { url } = await beginTodoistOAuth(); - window.location.assign(url); - } catch { - setOauthMessage("Todoist authorization could not be started."); - setOauthBusy(false); - } + await stepUp.run(async () => { + setOauthBusy(true); + setOauthMessage(null); + try { + const { url } = await beginTodoistOAuth(); + window.location.assign(url); + } catch (caught) { + setOauthBusy(false); + if (isPasswordStepUpRequired(caught)) throw caught; + setOauthMessage("Todoist authorization could not be started."); + } + }, "starting Todoist authorization"); } return ( @@ -191,6 +217,7 @@ export default function TodoistCard({ : "Todoist API token" } value={todoistToken} + disabled={credentialActionLocked} onChange={(event) => { setTodoistToken(event.target.value); setTodoistDirty(true); @@ -220,7 +247,7 @@ export default function TodoistCard({
) : null} + +
setAdvancedOpen(event.currentTarget.open)} @@ -302,6 +332,7 @@ export default function TodoistCard({ setClientId(event.target.value)} placeholder="Todoist app client ID" @@ -313,6 +344,7 @@ export default function TodoistCard({ id="todoist-client-secret" type="password" value={clientSecret} + disabled={credentialActionLocked} autoComplete="new-password" onChange={(event) => setClientSecret(event.target.value)} placeholder="Todoist app client secret" @@ -324,7 +356,7 @@ export default function TodoistCard({ ) : null} {oauthStatus?.mode === "oauth" ? ( diff --git a/src/components/settings/sensitiveActionStepUpModel.ts b/src/components/settings/sensitiveActionStepUpModel.ts new file mode 100644 index 00000000..ceffc8b8 --- /dev/null +++ b/src/components/settings/sensitiveActionStepUpModel.ts @@ -0,0 +1,73 @@ +import { useRef, useState } from "react"; +import type { FormEvent } from "react"; +import { stepUpWithPassword } from "@/auth/securityApi"; + +type DeferredSensitiveAction = { + action: () => Promise; + label: string; +}; + +export function isPasswordStepUpRequired(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "PASSWORD_STEP_UP_REQUIRED"; +} + +export function useSensitiveActionStepUp() { + const pendingRef = useRef(null); + const [pendingLabel, setPendingLabel] = useState(null); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function clear() { + pendingRef.current = null; + setPendingLabel(null); + setPassword(""); + setError(null); + } + + async function run(action: () => Promise, label: string): Promise { + try { + await action(); + return true; + } catch (caught) { + if (!isPasswordStepUpRequired(caught)) throw caught; + pendingRef.current = { action, label }; + setPendingLabel(label); + setError(null); + return false; + } + } + + async function unlock(event: FormEvent) { + event.preventDefault(); + const pending = pendingRef.current; + if (!pending || !password || busy) return; + setBusy(true); + setError(null); + try { + await stepUpWithPassword(password); + const completed = await run(pending.action, pending.label); + if (completed) clear(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Password confirmation failed"); + } finally { + setBusy(false); + } + } + + return { + pendingLabel, + password, + setPassword, + busy, + error, + run, + unlock, + cancel: clear, + }; +} + +export type SensitiveActionStepUpState = ReturnType; diff --git a/src/demo/demoExhaustiveness.test.ts b/src/demo/demoExhaustiveness.test.ts index 56170d61..24a00eb2 100644 --- a/src/demo/demoExhaustiveness.test.ts +++ b/src/demo/demoExhaustiveness.test.ts @@ -17,6 +17,7 @@ const INTENTIONALLY_UNHANDLED_NAMES = [ "cancelPasskeyAuthentication", "addICloudAccount", "createApiToken", + "disableGoogleOAuthApplication", "disableInstanceCredential", "deletePasskeyCredential", "disconnectTodoistConnection", @@ -25,6 +26,7 @@ const INTENTIONALLY_UNHANDLED_NAMES = [ "getPasskeyAuthenticationOptions", "getPasskeyRegistrationOptions", "hydrateActualBudgetCache", + "importGoogleOAuthEnvironment", "importInstanceCredentialEnvironment", "listApiTokens", "listPasskeys", @@ -44,6 +46,7 @@ const INTENTIONALLY_UNHANDLED_NAMES = [ "testDiscordReminderWebhook", "testInstanceCredential", "updateAccount", + "useHostGoogleOAuthApplication", "verifyPasskeyAuthentication", "verifyPasskeyRegistration", "useHostInstanceCredential", From 14f50f69d2b92972d0fd73a3d0b96b06cadee288 Mon Sep 17 00:00:00 2001 From: ansidian Date: Mon, 20 Jul 2026 14:22:31 -0700 Subject: [PATCH 34/44] fix: bind credential ciphertext to record context --- server/actual/actual-connection-settings.ts | 6 +- server/actual/actual-connection-test.ts | 6 +- server/actual/actual-core.ts | 6 +- server/actual/actual-local-metadata.ts | 6 +- server/calendar/calendar-google-client.ts | 7 +- server/db/migrate-encryption.test.ts | 1 + server/db/migrate-encryption.ts | 10 ++- server/email/email-backfill-worker.ts | 3 +- server/email/email-fetch.ts | 6 +- server/email/email-provider-adapters.ts | 6 +- server/email/email-service.ts | 6 +- server/email/gmail.ts | 12 ++- .../platform/credential-encryption-context.ts | 25 ++++++ .../encrypted-credential-inventory.ts | 87 +++++++++++++++++++ server/platform/encryption.test.ts | 79 ++++++++++++----- server/platform/encryption.ts | 42 +++++++-- .../platform/instance-credential-service.ts | 18 ++-- server/platform/root-key-health.test.ts | 6 +- server/platform/root-key-health.ts | 22 +---- server/reminders/reminder-scheduler.ts | 7 +- server/routes/accounts.ts | 8 +- server/routes/reminders.ts | 6 +- server/routes/settings.ts | 8 +- server/tasks/todoist-personal-token.ts | 6 +- server/tasks/todoist-token.ts | 36 ++++++-- 25 files changed, 342 insertions(+), 83 deletions(-) create mode 100644 server/platform/credential-encryption-context.ts create mode 100644 server/platform/encrypted-credential-inventory.ts diff --git a/server/actual/actual-connection-settings.ts b/server/actual/actual-connection-settings.ts index 5d190674..b668b761 100644 --- a/server/actual/actual-connection-settings.ts +++ b/server/actual/actual-connection-settings.ts @@ -1,6 +1,7 @@ import type { Client } from "@libsql/client"; import db from "../db/connection.ts"; import { encrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import { ActualPasswordRequiredForServerChangeError, isSameActualServerUrl, @@ -20,7 +21,10 @@ export async function saveActualConnectionCandidate( candidate: ActualConnectionCandidate, { dbClient = db, - encryptValue = encrypt, + encryptValue = (value) => encrypt( + value, + settingsCredentialContext(userId, "actual_budget_password_encrypted"), + ), testConnection = testActualConnectionHttp, now = () => new Date(), }: { diff --git a/server/actual/actual-connection-test.ts b/server/actual/actual-connection-test.ts index 82a948a9..a914beb7 100644 --- a/server/actual/actual-connection-test.ts +++ b/server/actual/actual-connection-test.ts @@ -1,4 +1,5 @@ import { decrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import db from "../db/connection.ts"; import type { ActualConfig } from "../../shared/types/actual.ts"; @@ -64,7 +65,10 @@ async function getActualConfig(userId: string): Promise { return { serverURL: trimServerUrl(settings.actual_budget_url), password: settings.actual_budget_password_encrypted - ? decrypt(String(settings.actual_budget_password_encrypted)) + ? decrypt( + String(settings.actual_budget_password_encrypted), + settingsCredentialContext(userId, "actual_budget_password_encrypted"), + ) : null, syncId: String(settings.actual_budget_sync_id), }; diff --git a/server/actual/actual-core.ts b/server/actual/actual-core.ts index ba93ef3b..58da26e5 100644 --- a/server/actual/actual-core.ts +++ b/server/actual/actual-core.ts @@ -1,5 +1,6 @@ import actualApi from "@actual-app/api"; import { decrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import { filterBillSchedulesForRange } from "./actual-bill-occurrences.ts"; import { actualDataDir, @@ -115,7 +116,10 @@ async function getActualConfig(userId: string): Promise { return { serverURL: String(settings.actual_budget_url).replace(/\/+$/, ""), password: settings.actual_budget_password_encrypted - ? decrypt(String(settings.actual_budget_password_encrypted)) + ? decrypt( + String(settings.actual_budget_password_encrypted), + settingsCredentialContext(userId, "actual_budget_password_encrypted"), + ) : null, syncId: String(settings.actual_budget_sync_id), }; diff --git a/server/actual/actual-local-metadata.ts b/server/actual/actual-local-metadata.ts index b6111918..ec057edb 100644 --- a/server/actual/actual-local-metadata.ts +++ b/server/actual/actual-local-metadata.ts @@ -26,6 +26,7 @@ import { mkdir, writeFile } from "fs/promises"; import path from "path"; import db from "../db/connection.ts"; import { decrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import type { ActualConfig, ActualMetadata } from "../../shared/types/actual.ts"; interface LocalBudget { @@ -84,7 +85,10 @@ export async function getActualConfig(userId: string, { dbClient = db }: LocalAc return { serverURL: trimServerUrl(settings.actual_budget_url), password: settings.actual_budget_password_encrypted - ? decrypt(String(settings.actual_budget_password_encrypted)) + ? decrypt( + String(settings.actual_budget_password_encrypted), + settingsCredentialContext(userId, "actual_budget_password_encrypted"), + ) : null, syncId: String(settings.actual_budget_sync_id), }; diff --git a/server/calendar/calendar-google-client.ts b/server/calendar/calendar-google-client.ts index d1e33567..6c34a691 100644 --- a/server/calendar/calendar-google-client.ts +++ b/server/calendar/calendar-google-client.ts @@ -1,5 +1,6 @@ import db from "../db/connection.ts"; import { decrypt, encrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { fetchWithTimeout } from "../platform/fetch-with-timeout.ts"; import { isInvalidGrantError, markAccountNeedsReauth, clearAccountNeedsReauth } from "../platform/provider-reauth.ts"; import type { @@ -125,7 +126,9 @@ async function getAccountCredentials(account: StoredCalendarAccount): Promise ({ }, })); vi.mock("../platform/encryption.ts", () => ({ + credentialEncryptionContext: vi.fn((table, field, recordId) => ({ table, field, recordId })), encrypt: vi.fn((value) => `gcm:${value}`), decrypt: vi.fn((value) => value.replace(/^aabb:/, "")), })); diff --git a/server/db/migrate-encryption.ts b/server/db/migrate-encryption.ts index e9f900fa..c50ddcaf 100644 --- a/server/db/migrate-encryption.ts +++ b/server/db/migrate-encryption.ts @@ -1,5 +1,10 @@ import db from "./connection.ts"; import { encrypt, decrypt } from "../platform/encryption.ts"; +import { + accountCredentialContext, + settingsCredentialContext, + type EncryptedSettingsField, +} from "../platform/credential-encryption-context.ts"; import type { Row } from "@libsql/client"; // One-shot rewrite of CBC-encrypted column values into GCM format. @@ -45,7 +50,10 @@ async function rewriteColumn({ table, idCol, valCol }: EncryptionTarget) { }); for (const row of rows) { const { id, val } = encryptedRowValues(row); - const rewrapped = encrypt(decrypt(val)); + const context = table === "ea_accounts" + ? accountCredentialContext(id) + : settingsCredentialContext(id, valCol as EncryptedSettingsField); + const rewrapped = encrypt(decrypt(val, context), context); await db.execute({ sql: `UPDATE ${table} SET ${valCol} = ? WHERE ${idCol} = ?`, args: [rewrapped, id], diff --git a/server/email/email-backfill-worker.ts b/server/email/email-backfill-worker.ts index 2a67897c..5fad216c 100644 --- a/server/email/email-backfill-worker.ts +++ b/server/email/email-backfill-worker.ts @@ -1,5 +1,6 @@ import db from "../db/connection.ts"; import { decrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { fetchEmailsInRange as fetchGmailEmailsInRange } from "./gmail.ts"; import { fetchEmailsInRange as fetchIcloudEmailsInRange } from "./icloud.ts"; import { indexEmails, queueEmailIndexBackfill } from "./email-index.ts"; @@ -196,7 +197,7 @@ async function fetchProviderWindow(account: ConfiguredEmailAccount, _state: Back // unbounded array into memory (P3-46). return fetchIcloudEmailsInRange( account, - decrypt(account.credentials_encrypted), + decrypt(account.credentials_encrypted, accountCredentialContext(account.id)), { start: window.start!, end: window.end!, diff --git a/server/email/email-fetch.ts b/server/email/email-fetch.ts index d3e50a3f..8c979736 100644 --- a/server/email/email-fetch.ts +++ b/server/email/email-fetch.ts @@ -1,4 +1,5 @@ import { decrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { fetchEmails as fetchGmailEmails } from "./gmail.ts"; import { fetchEmails as fetchIcloudEmails } from "./icloud.ts"; import type { NormalizedFetchedEmail } from "../../shared/types/email.ts"; @@ -23,7 +24,10 @@ export async function fetchAllEmails( // decrypt() must be inside the try too — a corrupt/rotated key throwing here // would otherwise reject the whole Promise.all and sink healthy Gmail results. try { - const password = decrypt(account.credentials_encrypted); + const password = decrypt( + account.credentials_encrypted, + accountCredentialContext(account.id), + ); return await fetchIcloudEmails(account, password, hoursBack); } catch (err) { console.error(`iCloud fetch failed for ${account.email}:`, emailErrorMessage(err)); diff --git a/server/email/email-provider-adapters.ts b/server/email/email-provider-adapters.ts index a164a9c7..f9dcf506 100644 --- a/server/email/email-provider-adapters.ts +++ b/server/email/email-provider-adapters.ts @@ -1,5 +1,6 @@ import db from "../db/connection.ts"; import { decrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { fetchEmailBody as fetchGmailBody, markAsRead as gmailMarkAsRead, @@ -132,7 +133,10 @@ async function resolveProviderAdapter( const found = await findAccountByUid(userId, uid); if (!found?.account) throw notFoundError(uid); if (found.type === "icloud") { - const password = decrypt(found.account.credentials_encrypted); + const password = decrypt( + found.account.credentials_encrypted, + accountCredentialContext(found.account.id), + ); return { type: "icloud", account: found.account, diff --git a/server/email/email-service.ts b/server/email/email-service.ts index 4dc9d1fe..f0f0476e 100644 --- a/server/email/email-service.ts +++ b/server/email/email-service.ts @@ -1,5 +1,6 @@ import db from "../db/connection.ts"; import { decrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { batchMarkAsRead as gmailBatchMarkAsRead, snoozeAtGmail, @@ -495,7 +496,10 @@ export async function markAllRead(userId: string, uids: string | string[]): Prom } for (const { account, uids: accUids } of groupedIcloud.values()) { - const password = decrypt(account.credentials_encrypted); + const password = decrypt( + account.credentials_encrypted, + accountCredentialContext(account.id), + ); ops.push({ provider: "icloud", uids: accUids, diff --git a/server/email/gmail.ts b/server/email/gmail.ts index f0f38ab1..8aba388c 100644 --- a/server/email/gmail.ts +++ b/server/email/gmail.ts @@ -1,6 +1,7 @@ import { simpleParser } from "mailparser"; import db from "../db/connection.ts"; import { encrypt, decrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { htmlToPlainText } from "./html-to-text.ts"; import { findCanonicalGmailAccount, normalizeEmailAddress } from "../platform/account-canonical.ts"; import { fetchWithTimeout } from "../platform/fetch-with-timeout.ts"; @@ -172,7 +173,7 @@ export async function handleCallback( userId, email, canonical?.label || email, - encrypt(JSON.stringify(credentials)), + encrypt(JSON.stringify(credentials), accountCredentialContext(targetAccountId)), nextSort, ], }); @@ -181,8 +182,10 @@ export async function handleCallback( } async function getValidToken(account: ConfiguredEmailAccount): Promise { - const credentials = JSON.parse(decrypt(account.credentials_encrypted)) as GmailCredentials; const canonicalAccountId = account.canonical_id || account.id; + const credentials = JSON.parse( + decrypt(account.credentials_encrypted, accountCredentialContext(canonicalAccountId)), + ) as GmailCredentials; // Refresh if the token expires within 5 minutes. Treat a non-finite/null // expires_at as already-expired so a malformed stored credential forces a @@ -221,7 +224,10 @@ async function getValidToken(account: ConfiguredEmailAccount): Promise { await db.execute({ sql: `UPDATE ea_accounts SET credentials_encrypted = ?, updated_at = datetime('now') WHERE id = ?`, - args: [encrypt(JSON.stringify(credentials)), canonicalAccountId], + args: [ + encrypt(JSON.stringify(credentials), accountCredentialContext(canonicalAccountId)), + canonicalAccountId, + ], }); if (account.needs_reauth) { diff --git a/server/platform/credential-encryption-context.ts b/server/platform/credential-encryption-context.ts new file mode 100644 index 00000000..e8fe6a15 --- /dev/null +++ b/server/platform/credential-encryption-context.ts @@ -0,0 +1,25 @@ +import type { CredentialEncryptionContext } from "./encryption.ts"; +import type { InstanceCredentialKey } from "./instance-credential-registry.ts"; + +export type EncryptedSettingsField = + | "actual_budget_password_encrypted" + | "todoist_api_token_encrypted" + | "todoist_oauth_refresh_token_encrypted" + | "discord_webhook_url_encrypted"; + +export function accountCredentialContext(accountId: string): CredentialEncryptionContext { + return { table: "ea_accounts", field: "credentials_encrypted", recordId: accountId }; +} + +export function settingsCredentialContext( + userId: string, + field: EncryptedSettingsField, +): CredentialEncryptionContext { + return { table: "ea_settings", field, recordId: userId }; +} + +export function instanceCredentialContext( + key: InstanceCredentialKey, +): CredentialEncryptionContext { + return { table: "ea_instance_credentials", field: "credential_value", recordId: key }; +} diff --git a/server/platform/encrypted-credential-inventory.ts b/server/platform/encrypted-credential-inventory.ts new file mode 100644 index 00000000..a66c5a6e --- /dev/null +++ b/server/platform/encrypted-credential-inventory.ts @@ -0,0 +1,87 @@ +import type { Client } from "@libsql/client"; +import type { CredentialEncryptionContext } from "./encryption.ts"; +import { + accountCredentialContext, + instanceCredentialContext, + settingsCredentialContext, + type EncryptedSettingsField, +} from "./credential-encryption-context.ts"; +import { isInstanceCredentialKey } from "./instance-credential-registry.ts"; + +type InventoryDb = Pick; + +export type EncryptedCredentialTarget = Readonly<{ + name: string; + selectSql: string; + updateSql: string; + context(recordId: string): CredentialEncryptionContext; +}>; + +function settingsTarget(field: EncryptedSettingsField): EncryptedCredentialTarget { + return { + name: `ea_settings.${field}`, + selectSql: `SELECT user_id AS record_id, ${field} AS value FROM ea_settings WHERE ${field} IS NOT NULL`, + updateSql: `UPDATE ea_settings SET ${field} = ? WHERE user_id = ? AND ${field} = ?`, + context: (recordId) => settingsCredentialContext(recordId, field), + }; +} + +function instanceContext(recordId: string): CredentialEncryptionContext { + if (!isInstanceCredentialKey(recordId)) { + throw new Error("Encrypted credential inventory contains an unsupported key"); + } + return instanceCredentialContext(recordId); +} + +export const ENCRYPTED_CREDENTIAL_TARGETS: readonly EncryptedCredentialTarget[] = [ + { + name: "ea_accounts.credentials_encrypted", + selectSql: "SELECT id AS record_id, credentials_encrypted AS value FROM ea_accounts WHERE credentials_encrypted IS NOT NULL", + updateSql: "UPDATE ea_accounts SET credentials_encrypted = ? WHERE id = ? AND credentials_encrypted = ?", + context: accountCredentialContext, + }, + settingsTarget("actual_budget_password_encrypted"), + settingsTarget("todoist_api_token_encrypted"), + settingsTarget("todoist_oauth_refresh_token_encrypted"), + settingsTarget("discord_webhook_url_encrypted"), + { + name: "ea_instance_credentials.active_value_encrypted", + selectSql: "SELECT credential_key AS record_id, active_value_encrypted AS value FROM ea_instance_credentials WHERE active_value_encrypted IS NOT NULL", + updateSql: "UPDATE ea_instance_credentials SET active_value_encrypted = ? WHERE credential_key = ? AND active_value_encrypted = ?", + context: instanceContext, + }, + { + name: "ea_instance_credentials.pending_value_encrypted", + selectSql: "SELECT credential_key AS record_id, pending_value_encrypted AS value FROM ea_instance_credentials WHERE pending_value_encrypted IS NOT NULL", + updateSql: "UPDATE ea_instance_credentials SET pending_value_encrypted = ? WHERE credential_key = ? AND pending_value_encrypted = ?", + context: instanceContext, + }, +] as const; + +export type EncryptedCredentialRecord = Readonly<{ + target: EncryptedCredentialTarget; + recordId: string; + ciphertext: string; + context: CredentialEncryptionContext; +}>; + +export async function readEncryptedCredentialInventory( + dbClient: InventoryDb, +): Promise { + const records: EncryptedCredentialRecord[] = []; + for (const target of ENCRYPTED_CREDENTIAL_TARGETS) { + const result = await dbClient.execute(target.selectSql); + for (const row of result.rows) { + if (typeof row.record_id !== "string" || typeof row.value !== "string") { + throw new Error("Encrypted credential inventory contains an invalid row"); + } + records.push({ + target, + recordId: row.record_id, + ciphertext: row.value, + context: target.context(row.record_id), + }); + } + } + return records; +} diff --git a/server/platform/encryption.test.ts b/server/platform/encryption.test.ts index def61641..77cf842a 100644 --- a/server/platform/encryption.test.ts +++ b/server/platform/encryption.test.ts @@ -8,12 +8,15 @@ process.env.EA_ENCRYPTION_KEY = TEST_KEY; const { createEncryption, + credentialEncryptionContext, decrypt, encrypt, getRootKeyHealth, parseRootEncryptionKey, } = await import("./encryption.ts"); +const TEST_CONTEXT = credentialEncryptionContext("ea_settings", "actual_budget_password", "owner-1"); + // Helper: encrypt using the CBC algorithm to generate compatibility test fixtures. function cbcEncrypt(plaintext: string) { const iv = crypto.randomBytes(16); @@ -36,10 +39,10 @@ describe("encryption", () => { it("accepts Render-style base64 256-bit keys without changing ciphertext format", () => { const base64Key = Buffer.from(TEST_KEY, "hex").toString("base64"); const base64Encryption = createEncryption(() => base64Key); - const encrypted = base64Encryption.encrypt("render-secret"); - expect(encrypted).toMatch(/^gcm:/); - expect(base64Encryption.decrypt(encrypted)).toBe("render-secret"); - expect(base64Encryption.decrypt(encrypt("existing-ciphertext"))).toBe("existing-ciphertext"); + const encrypted = base64Encryption.encrypt("render-secret", TEST_CONTEXT); + expect(encrypted).toMatch(/^gcm:v2:/); + expect(base64Encryption.decrypt(encrypted, TEST_CONTEXT)).toBe("render-secret"); + expect(base64Encryption.decrypt(encrypt("existing-ciphertext", TEST_CONTEXT), TEST_CONTEXT)).toBe("existing-ciphertext"); }); it("rejects malformed and wrong-length keys deterministically", () => { @@ -68,20 +71,20 @@ describe("encryption", () => { describe("GCM round-trip", () => { it("encrypt then decrypt returns the original plaintext", () => { const secret = "test-secret"; - const encrypted = encrypt(secret); - expect(decrypt(encrypted)).toBe(secret); + const encrypted = encrypt(secret, TEST_CONTEXT); + expect(decrypt(encrypted, TEST_CONTEXT)).toBe(secret); }); it("encrypted output starts with gcm: prefix", () => { - const encrypted = encrypt("test-secret"); - expect(encrypted.startsWith("gcm:")).toBe(true); + const encrypted = encrypt("test-secret", TEST_CONTEXT); + expect(encrypted.startsWith("gcm:v2:")).toBe(true); }); }); describe("GCM format structure", () => { it("matches gcm:iv(24hex):ciphertext(hex):tag(32hex) pattern", () => { - const encrypted = encrypt("test-data"); - expect(encrypted).toMatch(/^gcm:[a-f0-9]{24}:[a-f0-9]+:[a-f0-9]{32}$/); + const encrypted = encrypt("test-data", TEST_CONTEXT); + expect(encrypted).toMatch(/^gcm:v2:[a-f0-9]{24}:[a-f0-9]+:[a-f0-9]{32}$/); }); }); @@ -90,38 +93,38 @@ describe("encryption", () => { const cbcEncrypted = cbcEncrypt("cbc-secret-value"); // CBC format has no prefix, just iv:ciphertext expect(cbcEncrypted).not.toMatch(/^gcm:/); - expect(() => decrypt(cbcEncrypted)).toThrow( + expect(() => decrypt(cbcEncrypted, TEST_CONTEXT)).toThrow( "[Encryption] Legacy CBC ciphertext is no longer supported; re-save the credential", ); }); it("still round-trips GCM values after CBC rejection is added", () => { const secret = "still-works"; - expect(decrypt(encrypt(secret))).toBe(secret); + expect(decrypt(encrypt(secret, TEST_CONTEXT), TEST_CONTEXT)).toBe(secret); }); }); describe("tampered GCM ciphertext", () => { it("throws when ciphertext portion is tampered", () => { - const encrypted = encrypt("sensitive-data"); + const encrypted = encrypt("sensitive-data", TEST_CONTEXT); const parts = encrypted.split(":"); // Flip a character in the ciphertext portion (index 2) const tampered = parts[2]!.split(""); tampered[0] = tampered[0] === "a" ? "b" : "a"; parts[2] = tampered.join(""); const tamperedStr = parts.join(":"); - expect(() => decrypt(tamperedStr)).toThrow(); + expect(() => decrypt(tamperedStr, TEST_CONTEXT)).toThrow(); }); it("throws when auth tag is tampered", () => { - const encrypted = encrypt("sensitive-data"); + const encrypted = encrypt("sensitive-data", TEST_CONTEXT); const parts = encrypted.split(":"); // Flip a character in the auth tag portion (index 3) const tampered = parts[3]!.split(""); tampered[0] = tampered[0] === "a" ? "b" : "a"; parts[3] = tampered.join(""); const tamperedStr = parts.join(":"); - expect(() => decrypt(tamperedStr)).toThrow(); + expect(() => decrypt(tamperedStr, TEST_CONTEXT)).toThrow(); }); }); @@ -133,7 +136,7 @@ describe("encryption", () => { try { // @ts-expect-error Vitest query suffix intentionally creates a fresh module instance. const freshModule = await import("./encryption.ts?nokey-encrypt"); - expect(() => freshModule.encrypt("test")).toThrow("EA_ENCRYPTION_KEY not set"); + expect(() => freshModule.encrypt("test", TEST_CONTEXT)).toThrow("EA_ENCRYPTION_KEY not set"); } finally { process.env.EA_ENCRYPTION_KEY = origKey; } @@ -146,7 +149,7 @@ describe("encryption", () => { try { // @ts-expect-error Vitest query suffix intentionally creates a fresh module instance. const freshModule = await import("./encryption.ts?nokey-decrypt"); - expect(() => freshModule.decrypt("gcm:aabbcc:ddeeff:001122")).toThrow("EA_ENCRYPTION_KEY not set"); + expect(() => freshModule.decrypt("gcm:aabbcc:ddeeff:001122", TEST_CONTEXT)).toThrow("EA_ENCRYPTION_KEY not set"); } finally { process.env.EA_ENCRYPTION_KEY = origKey; } @@ -155,8 +158,44 @@ describe("encryption", () => { describe("empty string round-trip", () => { it("encrypt then decrypt returns empty string", () => { - const encrypted = encrypt(""); - expect(decrypt(encrypted)).toBe(""); + const encrypted = encrypt("", TEST_CONTEXT); + expect(decrypt(encrypted, TEST_CONTEXT)).toBe(""); + }); + }); + + describe("AAD-bound v2 context", () => { + it("rejects ciphertext moved to a different record", () => { + const encrypted = encrypt("sensitive-data", TEST_CONTEXT); + const otherContext = credentialEncryptionContext("ea_settings", "actual_budget_password", "owner-2"); + + expect(() => decrypt(encrypted, otherContext)).toThrow( + "Encrypted credential is invalid or cannot be decrypted", + ); + }); + + it("rejects ciphertext moved to a different field", () => { + const encrypted = encrypt("sensitive-data", TEST_CONTEXT); + const otherContext = credentialEncryptionContext("ea_settings", "discord_webhook_url", "owner-1"); + + expect(() => decrypt(encrypted, otherContext)).toThrow( + "Encrypted credential is invalid or cannot be decrypted", + ); + }); + + it("continues to read unversioned GCM ciphertext during migration", () => { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(TEST_KEY, "hex"), iv); + const body = Buffer.concat([cipher.update("legacy-gcm", "utf8"), cipher.final()]); + const legacy = `gcm:${iv.toString("hex")}:${body.toString("hex")}:${cipher.getAuthTag().toString("hex")}`; + + expect(decrypt(legacy, TEST_CONTEXT)).toBe("legacy-gcm"); + }); + + it("rejects unknown ciphertext versions", () => { + const encrypted = encrypt("sensitive-data", TEST_CONTEXT).replace("gcm:v2:", "gcm:v3:"); + expect(() => decrypt(encrypted, TEST_CONTEXT)).toThrow( + "Encrypted credential is invalid or cannot be decrypted", + ); }); }); }); diff --git a/server/platform/encryption.ts b/server/platform/encryption.ts index 560df97e..21f8a4af 100644 --- a/server/platform/encryption.ts +++ b/server/platform/encryption.ts @@ -9,6 +9,30 @@ export type RootKeyHealth = { fingerprint: string | null; }; +export type CredentialEncryptionContext = Readonly<{ + table: string; + field: string; + recordId: string; +}>; + +export function credentialEncryptionContext( + table: string, + field: string, + recordId: string, +): CredentialEncryptionContext { + return { table, field, recordId }; +} + +function aadFor(context: CredentialEncryptionContext): Buffer { + return Buffer.from(JSON.stringify([ + "setpoint-credential", + 2, + context.table, + context.field, + context.recordId, + ]), "utf8"); +} + export function parseRootEncryptionKey(value: string | undefined): Buffer { if (!value) throw new Error("EA_ENCRYPTION_KEY not set"); if (/^[a-fA-F0-9]{64}$/.test(value)) return Buffer.from(value, "hex"); @@ -43,16 +67,17 @@ export function createEncryption( return parseRootEncryptionKey(getRootKey()); } - function encryptValue(plaintext: string) { + function encryptValue(plaintext: string, context: CredentialEncryptionContext) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv); + cipher.setAAD(aadFor(context)); let encrypted = cipher.update(plaintext, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); - return "gcm:" + iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex"); + return "gcm:v2:" + iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex"); } - function decryptValue(ciphertext: string) { + function decryptValue(ciphertext: string, context: CredentialEncryptionContext) { const rootKey = key(); if (!ciphertext.startsWith("gcm:")) { throw new Error( @@ -61,8 +86,14 @@ export function createEncryption( } try { const parts = ciphertext.split(":"); - if (parts.length !== 4) throw new Error(DECRYPTION_ERROR_MESSAGE); - const [, ivHex, encryptedHex, authTagHex] = parts; + const versioned = parts[1] === "v2"; + if ((!versioned && parts.length !== 4) || (versioned && parts.length !== 5)) { + throw new Error(DECRYPTION_ERROR_MESSAGE); + } + const [, maybeVersion, maybeIv, maybeEncrypted, maybeTag] = parts; + const ivHex = versioned ? maybeIv : maybeVersion; + const encryptedHex = versioned ? maybeEncrypted : maybeIv; + const authTagHex = versioned ? maybeTag : maybeEncrypted; if (!/^[a-f0-9]{24}$/i.test(ivHex!) || !/^[a-f0-9]*$/i.test(encryptedHex!) || !/^[a-f0-9]{32}$/i.test(authTagHex!)) { throw new Error(DECRYPTION_ERROR_MESSAGE); } @@ -71,6 +102,7 @@ export function createEncryption( rootKey, Buffer.from(ivHex!, "hex"), ); + if (versioned) decipher.setAAD(aadFor(context)); decipher.setAuthTag(Buffer.from(authTagHex!, "hex")); let decrypted = decipher.update(encryptedHex!, "hex", "utf8"); decrypted += decipher.final("utf8"); diff --git a/server/platform/instance-credential-service.ts b/server/platform/instance-credential-service.ts index 173aa10c..4962d79b 100644 --- a/server/platform/instance-credential-service.ts +++ b/server/platform/instance-credential-service.ts @@ -8,6 +8,7 @@ import { createEncryption, getRootKeyHealth, } from "./encryption.ts"; +import { instanceCredentialContext } from "./credential-encryption-context.ts"; import { getInstanceCredentialDefinition, listInstanceCredentialDefinitions, @@ -96,7 +97,7 @@ export function createInstanceCredentialService({ const key = requireKey(inputKey); const record = await store.get(key); if (record?.activeValueEncrypted) { - return { key, source: "stored", value: encryption.decrypt(record.activeValueEncrypted) }; + return { key, source: "stored", value: encryption.decrypt(record.activeValueEncrypted, instanceCredentialContext(key)) }; } if (record?.disabled) return { key, source: "disabled", value: null }; const fallback = environmentValue(key, environment); @@ -108,7 +109,7 @@ export function createInstanceCredentialService({ const key = requireKey(inputKey); const record = await store.get(key); if (!record?.pendingValueEncrypted) return null; - return { value: encryption.decrypt(record.pendingValueEncrypted), version: record.version }; + return { value: encryption.decrypt(record.pendingValueEncrypted, instanceCredentialContext(key)), version: record.version }; } function metadataFor( @@ -142,8 +143,9 @@ export function createInstanceCredentialService({ if (!health.valid) return { ...health, decryptability: "unavailable" }; try { for (const record of records) { - if (record.activeValueEncrypted) encryption.decrypt(record.activeValueEncrypted); - if (record.pendingValueEncrypted) encryption.decrypt(record.pendingValueEncrypted); + const context = instanceCredentialContext(record.key); + if (record.activeValueEncrypted) encryption.decrypt(record.activeValueEncrypted, context); + if (record.pendingValueEncrypted) encryption.decrypt(record.pendingValueEncrypted, context); } return { ...health, decryptability: "ok" }; } catch { @@ -171,7 +173,7 @@ export function createInstanceCredentialService({ async function stagePending(inputKey: string, value: string): Promise { const key = requireKey(inputKey); - const record = await store.stagePending(key, encryption.encrypt(value)); + const record = await store.stagePending(key, encryption.encrypt(value, instanceCredentialContext(key))); publish({ key, reason: "pending_staged" }); return metadataFor(key, record); } @@ -181,7 +183,7 @@ export function createInstanceCredentialService({ ): Promise { const supported = entries.map((entry) => ({ key: requireKey(entry.key), - encryptedValue: encryption.encrypt(entry.value), + encryptedValue: encryption.encrypt(entry.value, instanceCredentialContext(requireKey(entry.key))), })); const records = await store.stagePendingGroup(supported); for (const record of records) publish({ key: record.key, reason: "pending_staged" }); @@ -252,7 +254,7 @@ export function createInstanceCredentialService({ const key = requireKey(inputKey); const value = environmentValue(key, environment); if (value === null) throw new HostCredentialUnavailableError(); - const record = await store.importActive(key, encryption.encrypt(value)); + const record = await store.importActive(key, encryption.encrypt(value, instanceCredentialContext(key))); publish({ key, reason: "environment_imported" }); return metadataFor(key, record); } @@ -262,7 +264,7 @@ export function createInstanceCredentialService({ const key = requireKey(inputKey); const value = environmentValue(key, environment); if (value === null) throw new HostCredentialUnavailableError(); - return { key, encryptedValue: encryption.encrypt(value) }; + return { key, encryptedValue: encryption.encrypt(value, instanceCredentialContext(key)) }; }); const records = await store.importActiveGroup(entries); for (const record of records) publish({ key: record.key, reason: "environment_imported" }); diff --git a/server/platform/root-key-health.test.ts b/server/platform/root-key-health.test.ts index 7284a4f0..7e195743 100644 --- a/server/platform/root-key-health.test.ts +++ b/server/platform/root-key-health.test.ts @@ -11,14 +11,16 @@ describe("root key health", () => { beforeEach(async () => { db = createClient({ url: "file::memory:" }); await db.executeMultiple(` - CREATE TABLE ea_accounts (credentials_encrypted TEXT); + CREATE TABLE ea_accounts (id TEXT PRIMARY KEY, credentials_encrypted TEXT); CREATE TABLE ea_settings ( + user_id TEXT PRIMARY KEY, actual_budget_password_encrypted TEXT, todoist_api_token_encrypted TEXT, todoist_oauth_refresh_token_encrypted TEXT, discord_webhook_url_encrypted TEXT ); CREATE TABLE ea_instance_credentials ( + credential_key TEXT PRIMARY KEY, active_value_encrypted TEXT, pending_value_encrypted TEXT ); @@ -28,7 +30,7 @@ describe("root key health", () => { afterEach(() => db.close()); it("fails closed with a fixed error when existing ciphertext is not decryptable", async () => { - await db.execute("INSERT INTO ea_accounts (credentials_encrypted) VALUES ('gcm:bad')"); + await db.execute("INSERT INTO ea_accounts (id, credentials_encrypted) VALUES ('account-1', 'gcm:bad')"); const service = createRootKeyHealthService({ dbClient: db, environment: { EA_ENCRYPTION_KEY: ROOT_KEY }, diff --git a/server/platform/root-key-health.ts b/server/platform/root-key-health.ts index 2ae41655..f6a69512 100644 --- a/server/platform/root-key-health.ts +++ b/server/platform/root-key-health.ts @@ -2,25 +2,10 @@ import db from "../db/connection.ts"; import type { Client } from "@libsql/client"; import type { RootKeyHealthMetadata } from "../../shared/types/instance-credentials.ts"; import { createEncryption, getRootKeyHealth } from "./encryption.ts"; +import { readEncryptedCredentialInventory } from "./encrypted-credential-inventory.ts"; type RootKeyHealthDb = Pick; -const ENCRYPTED_VALUE_QUERIES = [ - "SELECT credentials_encrypted AS value FROM ea_accounts WHERE credentials_encrypted IS NOT NULL", - `SELECT actual_budget_password_encrypted AS value FROM ea_settings - WHERE actual_budget_password_encrypted IS NOT NULL`, - `SELECT todoist_api_token_encrypted AS value FROM ea_settings - WHERE todoist_api_token_encrypted IS NOT NULL`, - `SELECT todoist_oauth_refresh_token_encrypted AS value FROM ea_settings - WHERE todoist_oauth_refresh_token_encrypted IS NOT NULL`, - `SELECT discord_webhook_url_encrypted AS value FROM ea_settings - WHERE discord_webhook_url_encrypted IS NOT NULL`, - `SELECT active_value_encrypted AS value FROM ea_instance_credentials - WHERE active_value_encrypted IS NOT NULL`, - `SELECT pending_value_encrypted AS value FROM ea_instance_credentials - WHERE pending_value_encrypted IS NOT NULL`, -] as const; - export function createRootKeyHealthService({ dbClient = db, environment = process.env, @@ -34,9 +19,8 @@ export function createRootKeyHealthService({ const health = getRootKeyHealth(environment.EA_ENCRYPTION_KEY); if (!health.valid) return { ...health, decryptability: "unavailable" }; try { - for (const sql of ENCRYPTED_VALUE_QUERIES) { - const result = await dbClient.execute(sql); - for (const row of result.rows) encryption.decrypt(String(row.value)); + for (const record of await readEncryptedCredentialInventory(dbClient)) { + encryption.decrypt(record.ciphertext, record.context); } return { ...health, decryptability: "ok" }; } catch { diff --git a/server/reminders/reminder-scheduler.ts b/server/reminders/reminder-scheduler.ts index e815acfa..35cb3bb4 100644 --- a/server/reminders/reminder-scheduler.ts +++ b/server/reminders/reminder-scheduler.ts @@ -2,6 +2,7 @@ import db from "../db/connection.ts"; import type { Client } from "@libsql/client"; import { publishCurrentDashboardEvent } from "../dashboard/current-events.ts"; import { decrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import { formatDiscordReminderPayload, sendDiscordWebhook } from "./discord-reminders.ts"; import type { DiscordWebhookPayload } from "./discord-reminders.ts"; import { computeReminderState } from "./reminder-model.ts"; @@ -97,7 +98,7 @@ export async function processDueReminderBatch({ now = new Date(), limit = 10, dbClient = db, - decryptFn = decrypt, + decryptFn, sendFn = null, }: ProcessDueReminderBatchOptions = {}): Promise { const nowIso = new Date(now).toISOString(); @@ -119,7 +120,9 @@ export async function processDueReminderBatch({ }; const getDiscordWebhookUrl = (userId: string, encrypted: string): string => { if (webhookByUser.has(userId)) return webhookByUser.get(userId)!; - const url = decryptFn(encrypted); + const url = decryptFn + ? decryptFn(encrypted) + : decrypt(encrypted, settingsCredentialContext(userId, "discord_webhook_url_encrypted")); webhookByUser.set(userId, url); return url; }; diff --git a/server/routes/accounts.ts b/server/routes/accounts.ts index 654b2075..56c94763 100644 --- a/server/routes/accounts.ts +++ b/server/routes/accounts.ts @@ -6,6 +6,7 @@ import db from "../db/connection.ts"; import { hashToken, requireCookieSession } from "../middleware/auth.ts"; import { wrapRouterAsync } from "../middleware/async-handler.ts"; import { encrypt, decrypt } from "../platform/encryption.ts"; +import { accountCredentialContext } from "../platform/credential-encryption-context.ts"; import { getAuthUrl, handleCallback, testConnection as testGmail } from "../email/gmail.ts"; import { testConnection as testIcloud } from "../email/icloud.ts"; import type { ConfiguredEmailAccount } from "../email/email-provider-types.ts"; @@ -258,7 +259,7 @@ router.post, ICloudAccountResponse | ErrorResponse, ICloud email, label || email, color || "#a259ff", - encrypt(password), + encrypt(password, accountCredentialContext(accountId)), nextSort, ], }); @@ -284,7 +285,10 @@ router.post<{ id: string }, AccountMutationResponse | ErrorResponse>("/accounts/ const account = result.rows[0]!; if (account.type === "gmail") await testGmail(account as unknown as ConfiguredEmailAccount); else if (account.type === "icloud") - await testIcloud(String(account.email), decrypt(String(account.credentials_encrypted))); + await testIcloud( + String(account.email), + decrypt(String(account.credentials_encrypted), accountCredentialContext(String(account.id))), + ); res.json({ success: true }); } catch (err) { console.error("Error testing account:", err); diff --git a/server/routes/reminders.ts b/server/routes/reminders.ts index d57e098c..6c7099da 100644 --- a/server/routes/reminders.ts +++ b/server/routes/reminders.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import type { Request, Response } from "express"; import db from "../db/connection.ts"; import { decrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import { formatGenericDiscordTestPayload, sendDiscordWebhook, @@ -67,7 +68,10 @@ router.post("/settings/discord-reminder-test", async (_req: Request, res: Respon discordUserId: settings.discord_user_id == null ? null : String(settings.discord_user_id), }); const delivery = await sendDiscordWebhook( - decrypt(String(settings.discord_webhook_url_encrypted)), + decrypt( + String(settings.discord_webhook_url_encrypted), + settingsCredentialContext(userId, "discord_webhook_url_encrypted"), + ), payload, ); if (delivery.ok) { diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 5b99540e..5c40a9a7 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from "express"; import type { Value } from "@libsql/client"; import db from "../db/connection.ts"; import { encrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import { geocodeLocation } from "../platform/weather.ts"; import { initScheduler } from "../scheduler.ts"; import { @@ -304,7 +305,12 @@ router.put, SettingsMutationResponse | ErrorResponse, Sett return res.status(400).json({ message: validation.message! }); } updates.push("discord_webhook_url_encrypted = ?"); - args.push(validation.value ? encrypt(validation.value) : null); + args.push(validation.value + ? encrypt( + validation.value, + settingsCredentialContext(userId, "discord_webhook_url_encrypted"), + ) + : null); } if (discord_user_id !== undefined) { const trimmedUserId = String(discord_user_id || "").trim(); diff --git a/server/tasks/todoist-personal-token.ts b/server/tasks/todoist-personal-token.ts index 5c627182..fd14db8b 100644 --- a/server/tasks/todoist-personal-token.ts +++ b/server/tasks/todoist-personal-token.ts @@ -1,6 +1,7 @@ import type { Client } from "@libsql/client"; import db from "../db/connection.ts"; import { encrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; import { fetchTodoistSyncResources } from "./todoist-api.ts"; type TodoistPersonalTokenValidator = (token: string) => Promise; @@ -27,7 +28,10 @@ export async function saveTodoistPersonalTokenCandidate( tokenValue: string, { dbClient = db, - encryptValue = encrypt, + encryptValue = (value) => encrypt( + value, + settingsCredentialContext(userId, "todoist_api_token_encrypted"), + ), validateToken = validateTodoistPersonalToken, now = new Date(), }: { diff --git a/server/tasks/todoist-token.ts b/server/tasks/todoist-token.ts index 6a975ad7..d10fca03 100644 --- a/server/tasks/todoist-token.ts +++ b/server/tasks/todoist-token.ts @@ -1,5 +1,9 @@ import db from "../db/connection.ts"; import { decrypt, encrypt } from "../platform/encryption.ts"; +import { + settingsCredentialContext, + type EncryptedSettingsField, +} from "../platform/credential-encryption-context.ts"; import { fetchWithTimeout } from "../platform/fetch-with-timeout.ts"; import { isInvalidGrantError, markTodoistNeedsReauth, clearTodoistNeedsReauth } from "../platform/provider-reauth.ts"; import type { Client } from "@libsql/client"; @@ -60,12 +64,20 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function encrypted(value: string | null | undefined): string | null { - return value ? encrypt(value) : null; +function encrypted( + value: string | null | undefined, + userId: string, + field: EncryptedSettingsField, +): string | null { + return value ? encrypt(value, settingsCredentialContext(userId, field)) : null; } -function decrypted(value: string | null | undefined): string | null { - return value ? decrypt(value) : null; +function decrypted( + value: string | null | undefined, + userId: string, + field: EncryptedSettingsField, +): string | null { + return value ? decrypt(value, settingsCredentialContext(userId, field)) : null; } function expiresAtFromResponse(response: TodoistOAuthTokenResponse, now: Date): string | null { @@ -166,7 +178,7 @@ async function persistTodoistOAuthTokenResponse(userId: string, response: Todois }): Promise<{ accessToken: string; expiresAt: string | null }> { const expiresAt = expiresAtFromResponse(response, now); const refreshTokenEncrypted = response.refresh_token - ? encrypted(response.refresh_token) + ? encrypted(response.refresh_token, userId, "todoist_oauth_refresh_token_encrypted") : existingRefreshTokenEncrypted; await dbClient.execute({ @@ -179,7 +191,7 @@ async function persistTodoistOAuthTokenResponse(userId: string, response: Todois todoist_connection_mode = 'oauth' WHERE user_id = ?`, args: [ - encrypted(response.access_token), + encrypted(response.access_token, userId, "todoist_api_token_encrypted"), refreshTokenEncrypted, expiresAt, response.scope || null, @@ -228,7 +240,11 @@ export async function getTodoistApiToken(userId: string, { } = {}): Promise { const settings = await loadTodoistTokenSettings(userId, dbClient); if (!settings) return null; - const accessToken = decrypted(settings?.todoist_api_token_encrypted); + const accessToken = decrypted( + settings?.todoist_api_token_encrypted, + userId, + "todoist_api_token_encrypted", + ); if (!accessToken) return null; const refreshTokenEncrypted = settings?.todoist_oauth_refresh_token_encrypted || null; @@ -249,7 +265,11 @@ export async function getTodoistApiToken(userId: string, { } : await todoistOAuthCredentialManager.resolveActive(); response = await refreshTodoistOAuthToken({ - refreshToken: decrypted(refreshTokenEncrypted), + refreshToken: decrypted( + refreshTokenEncrypted, + userId, + "todoist_oauth_refresh_token_encrypted", + ), credentials, fetchFn, }); From 44c959dbdd2f91099556c3358b8d339b9c030f2e Mon Sep 17 00:00:00 2001 From: ansidian Date: Mon, 20 Jul 2026 14:22:52 -0700 Subject: [PATCH 35/44] fix: harden gmail callback token verification --- server/email/gmail-pubsub.test.ts | 82 ++++++++++++++++++++++++++++++- server/email/gmail-pubsub.ts | 24 +++++++-- server/routes/gmail-push.test.ts | 16 ++++++ server/routes/gmail-push.ts | 9 +++- 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/server/email/gmail-pubsub.test.ts b/server/email/gmail-pubsub.test.ts index 0651bfaf..52c62aa1 100644 --- a/server/email/gmail-pubsub.test.ts +++ b/server/email/gmail-pubsub.test.ts @@ -32,7 +32,7 @@ function makeHarness(environment: Record = {}) { environment, randomToken: () => "generated-once", }); - return { service, credentialService, getRow: () => row }; + return { service, credentialService, dbClient, getRow: () => row }; } describe("Gmail Pub/Sub configuration", () => { @@ -75,6 +75,86 @@ describe("Gmail Pub/Sub configuration", () => { expect(await rotating.verifyToken("second-token")).toBe(true); }); + it("uses one narrow authoritative database read per verification", async () => { + const { service, dbClient } = makeHarness({ GMAIL_PUBSUB_PUSH_TOKEN: "legacy-secret" }); + + await expect(service.verifyToken("legacy-secret")).resolves.toBe(true); + + expect(dbClient.execute).toHaveBeenCalledTimes(1); + const statement = dbClient.execute.mock.calls[0]?.[0]; + expect(statement).toMatchObject({ args: [] }); + const sql = typeof statement === "string" ? statement : statement?.sql ?? ""; + // The selected columns are the callback authorization contract: status and + // watch diagnostics must not add payload or duplicate reads to this hot path. + expect(sql.replace(/\s+/g, " ").trim()).toBe( + "SELECT push_token_hash, token_disabled FROM ea_gmail_pubsub_config WHERE singleton_id = 1", + ); + }); + + it("keeps the authoritative read when rejecting a missing candidate", async () => { + const { service, dbClient } = makeHarness(); + + await expect(service.verifyToken("")).resolves.toBe(false); + + expect(dbClient.execute).toHaveBeenCalledTimes(1); + }); + + it("reads token and watch status once when projecting configuration status", async () => { + const { service, dbClient } = makeHarness(); + + await service.getStatus(); + + const configReads = dbClient.execute.mock.calls.filter(([statement]) => { + const sql = typeof statement === "string" ? statement : statement.sql; + return sql.includes("FROM ea_gmail_pubsub_config"); + }); + expect(configReads).toHaveLength(1); + }); + + it("observes token transitions immediately across service instances sharing the database", async () => { + let row: { push_token_hash: string | null; token_disabled: number } | null = null; + const dbClient = { + execute: vi.fn(async (statement: { sql: string; args?: unknown[] }) => { + if (statement.sql.includes("SELECT push_token_hash")) return { rows: row ? [row] : [] }; + if (statement.sql.includes("INSERT INTO ea_gmail_pubsub_config")) { + row = { + push_token_hash: statement.args?.[0] ? String(statement.args[0]) : null, + token_disabled: Number(statement.args?.[1]), + }; + return { rows: [], rowsAffected: 1 }; + } + throw new Error(`Unexpected SQL: ${statement.sql}`); + }), + }; + const base = makeHarness(); + const createInstance = (token: string) => createGmailPubSubService({ + dbClient: dbClient as never, + credentialService: base.credentialService as never, + canonicalUrlResolver: async () => "https://setpoint.example.com/api/gmail/push", + environment: { GMAIL_PUBSUB_PUSH_TOKEN: "host-token" }, + randomToken: () => token, + }); + const writer = createInstance("stored-token"); + const verifier = createInstance("unused-token"); + + await writer.generateCallback(); + await expect(verifier.verifyToken("stored-token")).resolves.toBe(true); + + await writer.importEnvironmentToken(); + await expect(verifier.verifyToken("stored-token")).resolves.toBe(false); + await expect(verifier.verifyToken("host-token")).resolves.toBe(true); + + await writer.generateCallback(); + await expect(verifier.verifyToken("host-token")).resolves.toBe(false); + await expect(verifier.verifyToken("stored-token")).resolves.toBe(true); + + await writer.revokeToken(); + await expect(verifier.verifyToken("stored-token")).resolves.toBe(false); + + await writer.useHostToken(); + await expect(verifier.verifyToken("host-token")).resolves.toBe(true); + }); + it("imports an environment token as a hash without returning plaintext and supports revocation", async () => { const { service, getRow } = makeHarness({ GMAIL_PUBSUB_PUSH_TOKEN: "legacy-secret" }); diff --git a/server/email/gmail-pubsub.ts b/server/email/gmail-pubsub.ts index 226725a4..04e32ca6 100644 --- a/server/email/gmail-pubsub.ts +++ b/server/email/gmail-pubsub.ts @@ -18,6 +18,8 @@ type TokenRow = { errorCode: string | null; }; +type VerificationRow = Pick; + const runtimeCredentialService = { async resolve(key: string) { return (await import("../platform/instance-credential-service.ts")).instanceCredentialService.resolve(key); @@ -89,6 +91,19 @@ export function createGmailPubSubService({ } : null; } + async function readVerificationRow(): Promise { + const result = await dbClient.execute({ + sql: `SELECT push_token_hash, token_disabled + FROM ea_gmail_pubsub_config WHERE singleton_id = 1`, + args: [], + }); + const row = result.rows[0]; + return row ? { + pushTokenHash: row.push_token_hash ? String(row.push_token_hash) : null, + tokenDisabled: Number(row.token_disabled) === 1, + } : null; + } + async function writeToken(pushTokenHash: string | null, tokenDisabled: boolean): Promise { await dbClient.execute({ sql: `INSERT INTO ea_gmail_pubsub_config @@ -102,20 +117,19 @@ export function createGmailPubSubService({ }); } - async function tokenSource(): Promise<"stored" | "environment" | "disabled" | "absent"> { - const row = await readTokenRow(); + function tokenSource(row: VerificationRow | null): "stored" | "environment" | "disabled" | "absent" { if (row?.pushTokenHash) return "stored"; if (row?.tokenDisabled) return "disabled"; return environment.GMAIL_PUBSUB_PUSH_TOKEN ? "environment" : "absent"; } async function getStatus() { - const [topic, callbackUrl, pushTokenSource, tokenRow] = await Promise.all([ + const [topic, callbackUrl, tokenRow] = await Promise.all([ credentialService.resolve("gmail.pubsub_topic"), canonicalUrlResolver(), - tokenSource(), readTokenRow(), ]); + const pushTokenSource = tokenSource(tokenRow); const configured = Boolean(topic.value) && (pushTokenSource === "stored" || pushTokenSource === "environment"); return { configured, @@ -179,8 +193,8 @@ export function createGmailPubSubService({ } async function verifyToken(candidate: string): Promise { + const row = await readVerificationRow(); if (!candidate) return false; - const row = await readTokenRow(); if (row?.pushTokenHash) return safeHashEqual(candidate, row.pushTokenHash); if (row?.tokenDisabled) return false; const legacyToken = environment.GMAIL_PUBSUB_PUSH_TOKEN; diff --git a/server/routes/gmail-push.test.ts b/server/routes/gmail-push.test.ts index 3485483a..b6e3abfc 100644 --- a/server/routes/gmail-push.test.ts +++ b/server/routes/gmail-push.test.ts @@ -141,4 +141,20 @@ describe("Gmail Pub/Sub push route", () => { expect((await request(app).post("/api/gmail/push?token=rotated").send({ message: { data: "abc" } })).status).toBe(200); expect(pubSubApi.verifyToken).toHaveBeenCalledTimes(2); }); + + it("fails closed with a retryable response when authoritative token verification is unavailable", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => undefined); + pubSubApi.verifyToken.mockRejectedValueOnce(new Error("shared database unavailable")); + try { + const res = await request(makeApp()) + .post("/api/gmail/push?token=do-not-log-this-token") + .send({ message: { data: "abc" } }); + + expect(res.status).toBe(503); + expect(gmailSyncApi.enqueueHistorySyncFromPubSub).not.toHaveBeenCalled(); + expect(JSON.stringify(log.mock.calls)).not.toContain("do-not-log-this-token"); + } finally { + log.mockRestore(); + } + }); }); diff --git a/server/routes/gmail-push.ts b/server/routes/gmail-push.ts index a7bc2810..f4371b6d 100644 --- a/server/routes/gmail-push.ts +++ b/server/routes/gmail-push.ts @@ -15,7 +15,14 @@ function bearerToken(req: Request): string { export function createGmailPushRouter(pubSubService: GmailPubSubService = gmailPubSubService) { const router = Router(); router.post("/push", async (req, res) => { - if (!await pubSubService.verifyToken(bearerToken(req))) { + let verified = false; + try { + verified = await pubSubService.verifyToken(bearerToken(req)); + } catch { + console.error("[Gmail Push] Token verification unavailable"); + return res.status(503).json({ message: "Gmail Pub/Sub verification unavailable" }); + } + if (!verified) { return res.status(401).json({ message: "Unauthorized" }); } From b5ce91e9ce9c1b2cd51f4d0a86b5616e496be78e Mon Sep 17 00:00:00 2001 From: ansidian Date: Mon, 20 Jul 2026 14:23:01 -0700 Subject: [PATCH 36/44] fix: replace actual archive zip dependency --- package-lock.json | 12 -- package.json | 2 - server/actual/CLAUDE.md | 2 +- server/actual/actual-budget-archive.test.ts | 190 ++++++++++++++++---- server/actual/actual-budget-archive.ts | 108 ++++++++++- server/actual/actual-local-metadata.test.ts | 80 +++++++++ server/actual/actual-local-metadata.ts | 21 +-- 7 files changed, 343 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index a964201d..e73c80d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.0", "@tailwindcss/vite": "^4.2.2", - "adm-zip": "^0.5.17", "bcrypt": "^6.0.0", "chrono-node": "^2.9.0", "class-variance-authority": "^0.7.1", @@ -59,7 +58,6 @@ "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", "@testing-library/react": "^16.3.2", - "@types/adm-zip": "^0.5.8", "@types/bcrypt": "^6.0.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.25", @@ -3853,16 +3851,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/adm-zip": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", - "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/package.json b/package.json index d6a4cc09..d0dede3e 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.0", "@tailwindcss/vite": "^4.2.2", - "adm-zip": "^0.5.17", "bcrypt": "^6.0.0", "chrono-node": "^2.9.0", "class-variance-authority": "^0.7.1", @@ -100,7 +99,6 @@ "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", "@testing-library/react": "^16.3.2", - "@types/adm-zip": "^0.5.8", "@types/bcrypt": "^6.0.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.25", diff --git a/server/actual/CLAUDE.md b/server/actual/CLAUDE.md index 4a8f9f83..1cbcecca 100644 --- a/server/actual/CLAUDE.md +++ b/server/actual/CLAUDE.md @@ -21,7 +21,7 @@ Actual Budget engine integration: write paths, the forked SDK worker, and the lo - `actualMetadataModel.ts` — pure derivation: Actual date coercion, rule-condition normalization, schedule classification, and the metadata projection - `actualMetadataCacheStore.ts` — filesystem cache ops: locate the budget dir by sync id, prune zip backups, summarize disk usage - `actualMetadataSync.ts` — lightweight metadata sync engine: HTTP login/download, protobuf sync POST, and CRDT-message apply under the clock lock -- `actual-budget-archive.ts` — hostile-archive boundary for lightweight downloads: compressed/expanded size, entry-count, structure, encryption, compression-method, and path-safe budget-ID checks before `adm-zip` parsing or filesystem writes +- `actual-budget-archive.ts` — bounded native stored/deflate reader for lightweight downloads: validates compressed/expanded size, entry count, local/central structure, encryption, CRC, and path-safe budget IDs, and returns only `db.sqlite` plus `metadata.json` - `actual-metadata-projection.ts` — DB projection of Actual metadata with TTL for fast reads - `actual-bill-occurrences.ts` — expands Actual schedules into dated bill occurrences with paid status - `actual-amount-condition.ts` — single source of truth for interpreting an Actual `amount` schedule condition (scalar cents vs `isbetween` range) diff --git a/server/actual/actual-budget-archive.test.ts b/server/actual/actual-budget-archive.test.ts index 9b980a3d..53610fd5 100644 --- a/server/actual/actual-budget-archive.test.ts +++ b/server/actual/actual-budget-archive.test.ts @@ -1,64 +1,180 @@ +import { crc32, deflateRawSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { MAX_ACTUAL_ARCHIVE_ENTRY_BYTES, - assertSafeActualBudgetArchive, + readActualBudgetArchive, validateActualBudgetId, } from "./actual-budget-archive.ts"; -function zipWithDeclaredEntry({ - name = "db.sqlite", - compressedSize = 0, - uncompressedSize = 0, -}: { - name?: string; - compressedSize?: number; +interface ZipEntryFixture { + name: string; + data?: Buffer; + method?: 0 | 8; + flags?: number; + localFlags?: number; + localMethod?: number; + crc?: number; uncompressedSize?: number; -} = {}): Buffer { - const nameBuffer = Buffer.from(name); - const localHeader = Buffer.alloc(30 + nameBuffer.length + compressedSize); - localHeader.writeUInt32LE(0x04034b50, 0); - localHeader.writeUInt16LE(20, 4); - localHeader.writeUInt32LE(compressedSize, 18); - localHeader.writeUInt32LE(uncompressedSize, 22); - localHeader.writeUInt16LE(nameBuffer.length, 26); - nameBuffer.copy(localHeader, 30); - - const centralDirectory = Buffer.alloc(46 + nameBuffer.length); - centralDirectory.writeUInt32LE(0x02014b50, 0); - centralDirectory.writeUInt16LE(20, 6); - centralDirectory.writeUInt32LE(compressedSize, 20); - centralDirectory.writeUInt32LE(uncompressedSize, 24); - centralDirectory.writeUInt16LE(nameBuffer.length, 28); - centralDirectory.writeUInt32LE(0, 42); - nameBuffer.copy(centralDirectory, 46); +} + +function zipWithEntries(entries: ZipEntryFixture[]): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let localOffset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name); + const data = entry.data ?? Buffer.alloc(0); + const method = entry.method ?? 0; + const compressed = method === 8 ? deflateRawSync(data) : data; + const checksum = entry.crc ?? crc32(data); + const uncompressedSize = entry.uncompressedSize ?? data.length; + const flags = entry.flags ?? 0; + + const local = Buffer.alloc(30 + name.length + compressed.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(entry.localFlags ?? flags, 6); + local.writeUInt16LE(entry.localMethod ?? method, 8); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(uncompressedSize, 22); + local.writeUInt16LE(name.length, 26); + name.copy(local, 30); + compressed.copy(local, 30 + name.length); + + const central = Buffer.alloc(46 + name.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(flags, 8); + central.writeUInt16LE(method, 10); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(uncompressedSize, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(localOffset, 42); + name.copy(central, 46); + localParts.push(local); + centralParts.push(central); + localOffset += local.length; + } + + const centralDirectory = Buffer.concat(centralParts); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); - end.writeUInt16LE(1, 8); - end.writeUInt16LE(1, 10); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralDirectory.length, 12); - end.writeUInt32LE(localHeader.length, 16); - return Buffer.concat([localHeader, centralDirectory, end]); + end.writeUInt32LE(localOffset, 16); + return Buffer.concat([...localParts, centralDirectory, end]); } -describe("assertSafeActualBudgetArchive", () => { +describe("readActualBudgetArchive", () => { + it("reads stored and deflated target files without exposing other entries", () => { + const archive = zipWithEntries([ + { name: "budget/db.sqlite", data: Buffer.from("sqlite"), method: 0 }, + { name: "budget/metadata.json", data: Buffer.from('{"id":"Budget-1"}'), method: 8 }, + { name: "budget/notes.txt", data: Buffer.from("not exposed"), method: 8 }, + ]); + + expect(readActualBudgetArchive(archive)).toEqual({ + database: Buffer.from("sqlite"), + metadata: Buffer.from('{"id":"Budget-1"}'), + }); + }); + + it("rejects an entry whose expanded data does not match its CRC", () => { + const archive = zipWithEntries([ + { name: "db.sqlite", data: Buffer.from("sqlite"), crc: 123 }, + { name: "metadata.json", data: Buffer.from("{}") }, + ]); + + expect(() => readActualBudgetArchive(archive)).toThrow(/CRC/); + }); + + it("rejects an entry whose actual expanded length differs from its headers", () => { + const archive = zipWithEntries([ + { name: "db.sqlite", data: Buffer.from("sqlite"), uncompressedSize: 99 }, + { name: "metadata.json", data: Buffer.from("{}") }, + ]); + + expect(() => readActualBudgetArchive(archive)).toThrow(/expanded size/); + }); + + it("rejects duplicate target basenames", () => { + const archive = zipWithEntries([ + { name: "one/db.sqlite", data: Buffer.from("one") }, + { name: "two/db.sqlite", data: Buffer.from("two") }, + { name: "metadata.json", data: Buffer.from("{}") }, + ]); + + expect(() => readActualBudgetArchive(archive)).toThrow(/exactly one db.sqlite and metadata.json/); + }); + + it("rejects disagreement between central and local headers", () => { + const archive = zipWithEntries([ + { name: "db.sqlite", data: Buffer.from("sqlite"), localMethod: 8 }, + { name: "metadata.json", data: Buffer.from("{}") }, + ]); + + expect(() => readActualBudgetArchive(archive)).toThrow(/local file header does not match/); + }); + + it.each([ + { label: "encrypted", flags: 0x1, message: /encrypted/ }, + { label: "data-descriptor", flags: 0x8, message: /data descriptors/ }, + ])("rejects $label entries", ({ flags, message }) => { + const archive = zipWithEntries([ + { name: "db.sqlite", flags }, + { name: "metadata.json" }, + ]); + + expect(() => readActualBudgetArchive(archive)).toThrow(message); + }); + + it("rejects unsupported compression methods", () => { + const archive = zipWithEntries([ + { name: "db.sqlite" }, + { name: "metadata.json" }, + ]); + archive.writeUInt16LE(12, archive.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])) + 10); + + expect(() => readActualBudgetArchive(archive)).toThrow(/unsupported compression method/); + }); + + it("rejects archives missing either required target", () => { + const archive = zipWithEntries([{ name: "db.sqlite", data: Buffer.from("sqlite") }]); + + expect(() => readActualBudgetArchive(archive)).toThrow(/exactly one db.sqlite and metadata.json/); + }); +}); + +describe("readActualBudgetArchive bounds", () => { it("accepts a structurally bounded archive", () => { - expect(() => assertSafeActualBudgetArchive(zipWithDeclaredEntry())).not.toThrow(); + const archive = zipWithEntries([ + { name: "db.sqlite" }, + { name: "metadata.json" }, + ]); + + expect(() => readActualBudgetArchive(archive)).not.toThrow(); }); it("rejects a tiny archive that declares a zip-bomb-sized entry", () => { - const archive = zipWithDeclaredEntry({ + const archive = zipWithEntries([{ + name: "db.sqlite", uncompressedSize: MAX_ACTUAL_ARCHIVE_ENTRY_BYTES + 1, - }); + }, { name: "metadata.json" }]); - expect(() => assertSafeActualBudgetArchive(archive)).toThrow(/expanded size limit/); + expect(() => readActualBudgetArchive(archive)).toThrow(/expanded size limit/); }); it("rejects central-directory offsets that point outside the archive", () => { - const archive = zipWithDeclaredEntry(); + const archive = zipWithEntries([{ name: "db.sqlite" }, { name: "metadata.json" }]); archive.writeUInt32LE(archive.length + 100, archive.length - 6); - expect(() => assertSafeActualBudgetArchive(archive)).toThrow(/central directory/); + expect(() => readActualBudgetArchive(archive)).toThrow(/central directory/); }); }); diff --git a/server/actual/actual-budget-archive.ts b/server/actual/actual-budget-archive.ts index d37540d2..8c15cb4b 100644 --- a/server/actual/actual-budget-archive.ts +++ b/server/actual/actual-budget-archive.ts @@ -1,8 +1,12 @@ +import { crc32, inflateRawSync } from "node:zlib"; + const ZIP_LOCAL_FILE_HEADER = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50; const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; const ZIP64_UINT16 = 0xffff; const ZIP64_UINT32 = 0xffffffff; +const ZIP_FLAG_ENCRYPTED = 0x1; +const ZIP_FLAG_DATA_DESCRIPTOR = 0x8; export const MAX_ACTUAL_ARCHIVE_BYTES = 128 * 1024 * 1024; export const MAX_ACTUAL_ARCHIVE_ENTRY_BYTES = 256 * 1024 * 1024; @@ -10,6 +14,21 @@ const MAX_ACTUAL_ARCHIVE_EXPANDED_BYTES = 256 * 1024 * 1024; const MAX_ACTUAL_ARCHIVE_ENTRIES = 128; const SAFE_ACTUAL_BUDGET_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +interface ActualArchiveEntry { + name: string; + flags: number; + compressionMethod: number; + checksum: number; + compressedSize: number; + uncompressedSize: number; + compressedDataOffset: number; +} + +export interface ActualBudgetArchive { + database: Buffer; + metadata: Buffer; +} + function unsafeArchive(reason: string): Error { return Object.assign(new Error(`Actual Budget archive is unsafe: ${reason}`), { status: 502 }); } @@ -31,7 +50,7 @@ function findEndOfCentralDirectory(archive: Buffer): number { throw unsafeArchive("missing central directory"); } -export function assertSafeActualBudgetArchive(archive: Buffer): void { +function parseActualBudgetArchive(archive: Buffer): ActualArchiveEntry[] { if (archive.length > MAX_ACTUAL_ARCHIVE_BYTES) { throw unsafeArchive("download size limit exceeded"); } @@ -68,6 +87,7 @@ export function assertSafeActualBudgetArchive(archive: Buffer): void { throw unsafeArchive("invalid central directory bounds"); } + const entries: ActualArchiveEntry[] = []; let offset = centralDirectoryOffset; let expandedBytes = 0; for (let index = 0; index < entryCount; index += 1) { @@ -77,6 +97,7 @@ export function assertSafeActualBudgetArchive(archive: Buffer): void { const flags = archive.readUInt16LE(offset + 8); const compressionMethod = archive.readUInt16LE(offset + 10); + const checksum = archive.readUInt32LE(offset + 16); const compressedSize = archive.readUInt32LE(offset + 20); const uncompressedSize = archive.readUInt32LE(offset + 24); const fileNameLength = archive.readUInt16LE(offset + 28); @@ -91,7 +112,10 @@ export function assertSafeActualBudgetArchive(archive: Buffer): void { ) { throw unsafeArchive("ZIP64 entries are not supported"); } - if ((flags & 0x1) !== 0) throw unsafeArchive("encrypted entries are not supported"); + if ((flags & ZIP_FLAG_ENCRYPTED) !== 0) throw unsafeArchive("encrypted entries are not supported"); + if ((flags & ZIP_FLAG_DATA_DESCRIPTOR) !== 0) { + throw unsafeArchive("data descriptors are not supported"); + } if (compressionMethod !== 0 && compressionMethod !== 8) { throw unsafeArchive("unsupported compression method"); } @@ -113,18 +137,96 @@ export function assertSafeActualBudgetArchive(archive: Buffer): void { ) { throw unsafeArchive("invalid local file header"); } + + const localFlags = archive.readUInt16LE(localHeaderOffset + 6); + const localCompressionMethod = archive.readUInt16LE(localHeaderOffset + 8); + const localChecksum = archive.readUInt32LE(localHeaderOffset + 14); + const localCompressedSize = archive.readUInt32LE(localHeaderOffset + 18); + const localUncompressedSize = archive.readUInt32LE(localHeaderOffset + 22); const localFileNameLength = archive.readUInt16LE(localHeaderOffset + 26); const localExtraLength = archive.readUInt16LE(localHeaderOffset + 28); - const compressedDataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraLength; + const centralNameStart = offset + 46; + const centralName = archive.subarray(centralNameStart, centralNameStart + fileNameLength); + const localNameStart = localHeaderOffset + 30; + const localNameEnd = localNameStart + localFileNameLength; + if (localNameEnd > centralDirectoryOffset) { + throw unsafeArchive("invalid local file header bounds"); + } + const localName = archive.subarray(localNameStart, localNameEnd); + if ( + localFlags !== flags + || localCompressionMethod !== compressionMethod + || localChecksum !== checksum + || localCompressedSize !== compressedSize + || localUncompressedSize !== uncompressedSize + || !localName.equals(centralName) + ) { + throw unsafeArchive("local file header does not match central directory"); + } + + const compressedDataOffset = localNameEnd + localExtraLength; const compressedDataEnd = compressedDataOffset + compressedSize; if (compressedDataEnd > centralDirectoryOffset || compressedDataEnd < compressedDataOffset) { throw unsafeArchive("compressed entry exceeds archive bounds"); } + entries.push({ + name: centralName.toString("utf8"), + flags, + compressionMethod, + checksum, + compressedSize, + uncompressedSize, + compressedDataOffset, + }); offset = nextOffset; } if (offset !== centralDirectoryEnd) { throw unsafeArchive("central directory size does not match its entries"); } + return entries; +} + +function readEntry(archive: Buffer, entry: ActualArchiveEntry): Buffer { + const compressed = archive.subarray( + entry.compressedDataOffset, + entry.compressedDataOffset + entry.compressedSize, + ); + let expanded: Buffer; + try { + expanded = entry.compressionMethod === 0 + ? Buffer.from(compressed) + : inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize + 1 }); + } catch { + throw unsafeArchive("entry decompression failed or expanded size does not match"); + } + if (expanded.length !== entry.uncompressedSize) { + throw unsafeArchive("actual expanded size does not match entry header"); + } + if (crc32(expanded) !== entry.checksum) { + throw unsafeArchive("entry CRC does not match"); + } + return expanded; +} + +export function readActualBudgetArchive(archive: Buffer): ActualBudgetArchive { + const entries = parseActualBudgetArchive(archive); + const databaseEntries = entries.filter((entry) => entry.name.split(/[\\/]/).at(-1) === "db.sqlite"); + const metadataEntries = entries.filter((entry) => entry.name.split(/[\\/]/).at(-1) === "metadata.json"); + if (databaseEntries.length !== 1 || metadataEntries.length !== 1) { + throw unsafeArchive("archive must contain exactly one db.sqlite and metadata.json"); + } + + let database: Buffer | undefined; + let metadata: Buffer | undefined; + for (const entry of entries) { + const expanded = readEntry(archive, entry); + if (entry === databaseEntries[0]) database = expanded; + if (entry === metadataEntries[0]) metadata = expanded; + } + if (!database || !metadata) { + throw unsafeArchive("archive must contain exactly one db.sqlite and metadata.json"); + } + return { database, metadata }; } diff --git a/server/actual/actual-local-metadata.test.ts b/server/actual/actual-local-metadata.test.ts index d255d550..913b5277 100644 --- a/server/actual/actual-local-metadata.test.ts +++ b/server/actual/actual-local-metadata.test.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, readdir, utimes, writeFile } from "fs/promises"; +import { crc32 } from "node:zlib"; import { createTestTempDir, removeTempDir } from "../test-utils/temp-dir.ts"; import path from "path"; import { createClient } from "@libsql/client"; @@ -24,9 +25,53 @@ import { readLocalActualMetadata, } from "./actual-local-metadata.ts"; import { syncDownloadedBudget } from "./actualMetadataSync.ts"; +import { encrypt } from "../platform/encryption.ts"; +import { settingsCredentialContext } from "../platform/credential-encryption-context.ts"; let tempDir: string | null = null; const originalFetch = global.fetch; +const originalEncryptionKey = process.env.EA_ENCRYPTION_KEY; + +function storedZip(entries: Array<{ name: string; data: Buffer; checksum?: number }>): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let localOffset = 0; + for (const entry of entries) { + const name = Buffer.from(entry.name); + const checksum = entry.checksum ?? crc32(entry.data); + const local = Buffer.alloc(30 + name.length + entry.data.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(entry.data.length, 18); + local.writeUInt32LE(entry.data.length, 22); + local.writeUInt16LE(name.length, 26); + name.copy(local, 30); + entry.data.copy(local, 30 + name.length); + + const central = Buffer.alloc(46 + name.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(entry.data.length, 20); + central.writeUInt32LE(entry.data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(localOffset, 42); + name.copy(central, 46); + localParts.push(local); + centralParts.push(central); + localOffset += local.length; + } + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localOffset, 16); + return Buffer.concat([...localParts, centralDirectory, end]); +} function settingsDbClient({ encryptedPassword = null }: { encryptedPassword?: string | null } = {}) { return { @@ -203,6 +248,8 @@ beforeEach(() => { afterEach(async () => { vi.useRealTimers(); global.fetch = originalFetch; + if (originalEncryptionKey === undefined) delete process.env.EA_ENCRYPTION_KEY; + else process.env.EA_ENCRYPTION_KEY = originalEncryptionKey; if (tempDir) await removeTempDir(tempDir); tempDir = null; }); @@ -453,6 +500,39 @@ describe("readLocalActualMetadata", () => { expect(result.dbSizeBytes).toBeGreaterThan(0); }); + it("does not write hydration files when the downloaded archive fails validation", async () => { + tempDir = await createTestTempDir("actual-local-"); + process.env.EA_ENCRYPTION_KEY = "11".repeat(32); + const encryptedPassword = encrypt( + "password-1", + settingsCredentialContext("u1", "actual_budget_password_encrypted"), + ); + const archive = storedZip([ + { name: "db.sqlite", data: Buffer.from("corrupt"), checksum: 123 }, + { name: "metadata.json", data: Buffer.from('{"id":"Budget-Remote"}') }, + ]); + global.fetch = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/account/login")) return Response.json({ data: { token: "token-1" } }); + if (url.endsWith("/sync/list-user-files")) { + return Response.json({ data: [{ groupId: "sync-123", fileId: "file-1" }] }); + } + if (url.endsWith("/sync/get-user-file-info")) { + return Response.json({ status: "ok", data: { encryptMeta: false } }); + } + if (url.endsWith("/sync/download-user-file")) return new Response(archive); + throw new Error(`Unexpected Actual request: ${url}`); + }) as typeof fetch; + + await expect(hydrateLocalActualCache("u1", { + dbClient: settingsDbClient({ encryptedPassword }), + dataDir: tempDir, + forceDownload: true, + })).rejects.toThrow(/CRC/); + + await expect(readdir(tempDir)).resolves.toEqual([]); + }); + it("keeps only the newest local Actual zip backup for a budget", async () => { tempDir = await createTestTempDir("actual-local-"); const budgetDir = path.join(tempDir!, "My-Finances-d8e502a"); diff --git a/server/actual/actual-local-metadata.ts b/server/actual/actual-local-metadata.ts index ec057edb..a4123e9e 100644 --- a/server/actual/actual-local-metadata.ts +++ b/server/actual/actual-local-metadata.ts @@ -1,4 +1,3 @@ -import AdmZip from "adm-zip"; import { createClient } from "@libsql/client"; import type { Client } from "@libsql/client"; import type { InStatement } from "@libsql/client"; @@ -21,7 +20,7 @@ import { fetchActualBuffer, syncDownloadedBudget, } from "./actualMetadataSync.ts"; -import { assertSafeActualBudgetArchive, validateActualBudgetId } from "./actual-budget-archive.ts"; +import { readActualBudgetArchive, validateActualBudgetId } from "./actual-budget-archive.ts"; import { mkdir, writeFile } from "fs/promises"; import path from "path"; import db from "../db/connection.ts"; @@ -158,18 +157,8 @@ async function downloadBudgetZip(config: ActualConfig, { dataDir = actualDataDir token, fileId, }); - assertSafeActualBudgetArchive(buffer); - const zip = new AdmZip(buffer); - const entries = zip.getEntries(); - const dbEntries = entries.filter((entry) => entry.entryName.split(/[\\/]/).at(-1) === "db.sqlite"); - const metaEntries = entries.filter((entry) => entry.entryName.split(/[\\/]/).at(-1) === "metadata.json"); - const dbEntry = dbEntries.length === 1 ? dbEntries[0] : null; - const metaEntry = metaEntries.length === 1 ? metaEntries[0] : null; - if (!dbEntry || !metaEntry) { - throw Object.assign(new Error("Actual Budget download did not include db.sqlite and metadata.json"), { status: 502 }); - } - - const parsedMetadata = JSON.parse(zip.readAsText(metaEntry)) as BudgetMetadata; + const archive = readActualBudgetArchive(buffer); + const parsedMetadata = JSON.parse(archive.metadata.toString("utf8")) as BudgetMetadata; const budgetId = validateActualBudgetId(parsedMetadata.id); const metadata: LocalBudget["metadata"] = { ...parsedMetadata, @@ -181,9 +170,7 @@ async function downloadBudgetZip(config: ActualConfig, { dataDir = actualDataDir }; const budgetDir = path.join(dataDir, budgetId); await mkdir(budgetDir, { recursive: true }); - const databaseBuffer = zip.readFile(dbEntry); - if (!databaseBuffer) throw Object.assign(new Error("Actual Budget download did not include a readable db.sqlite"), { status: 502 }); - await writeFile(path.join(budgetDir, "db.sqlite"), databaseBuffer); + await writeFile(path.join(budgetDir, "db.sqlite"), archive.database); await writeFile(path.join(budgetDir, "metadata.json"), JSON.stringify(metadata)); const syncDeltas = await syncDownloadedBudget(config, token, { budgetDir, metadata }); let backupPrune = { removed: 0, kept: 0 }; From 97be8e205d7c963da8db83fb5c96f01f7068af63 Mon Sep 17 00:00:00 2001 From: ansidian Date: Mon, 20 Jul 2026 14:23:17 -0700 Subject: [PATCH 37/44] feat: add transactional root key rotation --- README.md | 23 +++ package.json | 1 + server/platform/root-key-rotation.test.ts | 149 +++++++++++++++++++ server/platform/root-key-rotation.ts | 129 ++++++++++++++++ server/scripts/rotate-encryption-key.test.ts | 17 +++ server/scripts/rotate-encryption-key.ts | 64 ++++++++ 6 files changed, 383 insertions(+) create mode 100644 server/platform/root-key-rotation.test.ts create mode 100644 server/platform/root-key-rotation.ts create mode 100644 server/scripts/rotate-encryption-key.test.ts create mode 100644 server/scripts/rotate-encryption-key.ts diff --git a/README.md b/README.md index 34962f6d..6208532c 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,29 @@ WebAuthn/redirect variables may stay in the host environment while migrating. Setpoint imports legacy owner auth once and keeps optional provider environment fallbacks active until they are explicitly migrated or disabled in Settings. +#### Rotating `EA_ENCRYPTION_KEY` + +Root-key rotation is an offline database operation. Back up Turso and both keys, +deploy this dual-read release first, and stop every Setpoint web/worker process +before applying it. Provide the current key as `EA_ENCRYPTION_KEY` and the new +key as `EA_ENCRYPTION_KEY_NEXT` through the private process environment—never +as command-line arguments. For Turso, also select the production database with +`NODE_ENV=production`, `TURSO_DATABASE_URL`, and `TURSO_AUTH_TOKEN`. + +1. Run `npm run security:rotate-encryption-key`. The default dry-run decrypts, + re-encrypts, and verifies the full inventory in memory without changing rows. +2. Review the redacted row counts and old/new key fingerprints. +3. With every Setpoint process still stopped, run + `npm run security:rotate-encryption-key -- --apply --confirm-offline`. +4. Replace the host's `EA_ENCRYPTION_KEY` with the new key, remove + `EA_ENCRYPTION_KEY_NEXT`, restart, and verify health and provider connections. + +Any pre-commit failure rolls the transaction back. After a successful commit, +the database requires the new key; if restart/deployment fails, keep the service +stopped and fix it with the new key. Returning to the old key requires restoring +the matching pre-rotation database backup. Never run rotation during a rolling +deployment, because an old process could write old-key ciphertext after rotation. + When adding or changing a custom domain, attach and verify it in Render first, then change the canonical URL under Settings → System using recent password confirmation. Review the displayed passkey and OAuth/webhook consequences and diff --git a/package.json b/package.json index d0dede3e..3f2b93fa 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start": "NODE_ENV=production node server/index.ts", "db:init": "node server/db/migrate.ts", "auth:reset-passkeys": "node server/scripts/reset-passkeys.ts", + "security:rotate-encryption-key": "node server/scripts/rotate-encryption-key.ts", "lint": "eslint .", "typecheck:client": "tsc -p tsconfig.client.json --pretty false", "typecheck:server": "tsc -p tsconfig.server.json --pretty false", diff --git a/server/platform/root-key-rotation.test.ts b/server/platform/root-key-rotation.test.ts new file mode 100644 index 00000000..87474dc7 --- /dev/null +++ b/server/platform/root-key-rotation.test.ts @@ -0,0 +1,149 @@ +import crypto from "crypto"; +import { createClient, type Client, type InStatement } from "@libsql/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import path from "node:path"; +import { createTestTempDir, removeTempDir } from "../test-utils/temp-dir.ts"; +import { + accountCredentialContext, + instanceCredentialContext, + settingsCredentialContext, +} from "./credential-encryption-context.ts"; +import { createEncryption } from "./encryption.ts"; +import { rotateRootEncryptionKey } from "./root-key-rotation.ts"; + +const OLD_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const NEW_KEY = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + +function legacyEncrypt(value: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(OLD_KEY, "hex"), iv); + const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `gcm:${iv.toString("hex")}:${encrypted.toString("hex")}:${cipher.getAuthTag().toString("hex")}`; +} + +async function ciphertexts(db: Client): Promise { + const rows = await Promise.all([ + db.execute("SELECT credentials_encrypted AS value FROM ea_accounts"), + db.execute("SELECT actual_budget_password_encrypted AS value FROM ea_settings"), + db.execute("SELECT todoist_api_token_encrypted AS value FROM ea_settings"), + db.execute("SELECT todoist_oauth_refresh_token_encrypted AS value FROM ea_settings"), + db.execute("SELECT discord_webhook_url_encrypted AS value FROM ea_settings"), + db.execute("SELECT active_value_encrypted AS value FROM ea_instance_credentials"), + db.execute("SELECT pending_value_encrypted AS value FROM ea_instance_credentials"), + ]); + return rows.flatMap((result) => result.rows.map((row) => String(row.value))); +} + +describe("root key rotation", () => { + let db: Client; + let tempDir: string; + + beforeEach(async () => { + tempDir = await createTestTempDir("root-key-rotation-"); + db = createClient({ url: `file:${path.join(tempDir, "rotation.db")}` }); + await db.executeMultiple(` + CREATE TABLE ea_accounts (id TEXT PRIMARY KEY, credentials_encrypted TEXT); + CREATE TABLE ea_settings ( + user_id TEXT PRIMARY KEY, + actual_budget_password_encrypted TEXT, + todoist_api_token_encrypted TEXT, + todoist_oauth_refresh_token_encrypted TEXT, + discord_webhook_url_encrypted TEXT + ); + CREATE TABLE ea_instance_credentials ( + credential_key TEXT PRIMARY KEY, + active_value_encrypted TEXT, + pending_value_encrypted TEXT + ); + `); + const oldEncryption = createEncryption(() => OLD_KEY); + await db.execute({ + sql: "INSERT INTO ea_accounts VALUES (?, ?)", + args: ["account-1", legacyEncrypt("account-secret")], + }); + await db.execute({ + sql: "INSERT INTO ea_settings VALUES (?, ?, ?, ?, ?)", + args: [ + "owner-1", + oldEncryption.encrypt("actual-secret", settingsCredentialContext("owner-1", "actual_budget_password_encrypted")), + legacyEncrypt("todoist-access"), + oldEncryption.encrypt("todoist-refresh", settingsCredentialContext("owner-1", "todoist_oauth_refresh_token_encrypted")), + oldEncryption.encrypt("discord-secret", settingsCredentialContext("owner-1", "discord_webhook_url_encrypted")), + ], + }); + await db.execute({ + sql: "INSERT INTO ea_instance_credentials VALUES (?, ?, ?)", + args: [ + "ai.openai_api_key", + oldEncryption.encrypt("active-secret", instanceCredentialContext("ai.openai_api_key")), + oldEncryption.encrypt("pending-secret", instanceCredentialContext("ai.openai_api_key")), + ], + }); + }); + + afterEach(async () => { + await db.close(); + await removeTempDir(tempDir); + }); + + it("preflights every ciphertext without writing by default", async () => { + const before = await ciphertexts(db); + const result = await rotateRootEncryptionKey({ dbClient: db, oldKey: OLD_KEY, newKey: NEW_KEY }); + + expect(result).toMatchObject({ applied: false, credentialCount: 7 }); + expect(await ciphertexts(db)).toEqual(before); + }); + + it("atomically rewrites legacy and v2 ciphertext under the new key and context", async () => { + const result = await rotateRootEncryptionKey({ + dbClient: db, + oldKey: OLD_KEY, + newKey: NEW_KEY, + apply: true, + }); + const values = await ciphertexts(db); + expect(result).toMatchObject({ applied: true, credentialCount: 7 }); + expect(values.every((value) => value.startsWith("gcm:v2:"))).toBe(true); + + const next = createEncryption(() => NEW_KEY); + expect(next.decrypt(values[0]!, accountCredentialContext("account-1"))).toBe("account-secret"); + expect(next.decrypt(values[1]!, settingsCredentialContext("owner-1", "actual_budget_password_encrypted"))).toBe("actual-secret"); + expect(next.decrypt(values[5]!, instanceCredentialContext("ai.openai_api_key"))).toBe("active-secret"); + expect(next.decrypt(values[6]!, instanceCredentialContext("ai.openai_api_key"))).toBe("pending-secret"); + }); + + it("rolls every update back when a mid-rotation write fails", async () => { + const before = await ciphertexts(db); + const failingDb = { + execute: db.execute.bind(db), + async transaction(mode: "write") { + const tx = await db.transaction(mode); + let updates = 0; + return { + execute(statement: InStatement | string) { + const sql = typeof statement === "string" ? statement : statement.sql; + if (/^UPDATE /i.test(sql) && ++updates === 3) throw new Error("injected write failure"); + return tx.execute(statement); + }, + commit: () => tx.commit(), + rollback: () => tx.rollback(), + }; + }, + }; + + await expect(rotateRootEncryptionKey({ + dbClient: failingDb as never, + oldKey: OLD_KEY, + newKey: NEW_KEY, + apply: true, + })).rejects.toThrow("injected write failure"); + expect(await ciphertexts(db)).toEqual(before); + }); + + it("rejects equal keys and a wrong old key before writing", async () => { + await expect(rotateRootEncryptionKey({ dbClient: db, oldKey: OLD_KEY, newKey: OLD_KEY })) + .rejects.toThrow("must be different"); + await expect(rotateRootEncryptionKey({ dbClient: db, oldKey: NEW_KEY, newKey: OLD_KEY, apply: true })) + .rejects.toThrow("cannot be decrypted"); + }); +}); diff --git a/server/platform/root-key-rotation.ts b/server/platform/root-key-rotation.ts new file mode 100644 index 00000000..73cba6bd --- /dev/null +++ b/server/platform/root-key-rotation.ts @@ -0,0 +1,129 @@ +import type { InStatement } from "@libsql/client"; +import { createEncryption, getRootKeyHealth } from "./encryption.ts"; +import { + readEncryptedCredentialInventory, + type EncryptedCredentialRecord, +} from "./encrypted-credential-inventory.ts"; + +type RotationExecuteResult = { + rows: Array>; + rowsAffected?: number; +}; + +type RotationExecutor = { + execute(statement: string | InStatement): Promise; +}; + +type RotationTransaction = RotationExecutor & { + commit(): Promise; + rollback(): Promise; +}; + +export type RootKeyRotationDb = RotationExecutor & { + transaction(mode: "write"): Promise; +}; + +export type RootKeyRotationResult = Readonly<{ + applied: boolean; + credentialCount: number; + targetCounts: Readonly>; + oldKeyFingerprint: string; + newKeyFingerprint: string; +}>; + +function fingerprint(key: string): string { + const health = getRootKeyHealth(key); + if (!health.valid || !health.fingerprint) { + throw new Error("Root encryption key is invalid"); + } + return health.fingerprint; +} + +function targetCounts(records: readonly EncryptedCredentialRecord[]): Record { + const counts: Record = {}; + for (const record of records) { + counts[record.target.name] = (counts[record.target.name] ?? 0) + 1; + } + return counts; +} + +async function prepareRotation( + executor: RotationExecutor, + oldKey: string, + newKey: string, +) { + const oldEncryption = createEncryption(() => oldKey); + const newEncryption = createEncryption(() => newKey); + const records = await readEncryptedCredentialInventory(executor as never); + const prepared = records.map((record) => { + const plaintext = oldEncryption.decrypt(record.ciphertext, record.context); + const ciphertext = newEncryption.encrypt(plaintext, record.context); + if (newEncryption.decrypt(ciphertext, record.context) !== plaintext) { + throw new Error("Rotated credential verification failed"); + } + return { record, ciphertext }; + }); + return { records, prepared, newEncryption }; +} + +export async function rotateRootEncryptionKey({ + dbClient, + oldKey, + newKey, + apply = false, +}: { + dbClient: RootKeyRotationDb; + oldKey: string; + newKey: string; + apply?: boolean; +}): Promise { + const oldKeyFingerprint = fingerprint(oldKey); + const newKeyFingerprint = fingerprint(newKey); + if (oldKeyFingerprint === newKeyFingerprint) { + throw new Error("Old and new root encryption keys must be different"); + } + + if (!apply) { + const { records } = await prepareRotation(dbClient, oldKey, newKey); + return { + applied: false, + credentialCount: records.length, + targetCounts: targetCounts(records), + oldKeyFingerprint, + newKeyFingerprint, + }; + } + + const tx = await dbClient.transaction("write"); + try { + const { records, prepared, newEncryption } = await prepareRotation(tx, oldKey, newKey); + for (const item of prepared) { + const result = await tx.execute({ + sql: item.record.target.updateSql, + args: [item.ciphertext, item.record.recordId, item.record.ciphertext], + }); + if (result.rowsAffected !== 1) { + throw new Error("Credential changed during root key rotation"); + } + } + + const verified = await readEncryptedCredentialInventory(tx as never); + if (verified.length !== records.length) { + throw new Error("Credential inventory changed during root key rotation"); + } + for (const record of verified) { + newEncryption.decrypt(record.ciphertext, record.context); + } + await tx.commit(); + return { + applied: true, + credentialCount: records.length, + targetCounts: targetCounts(records), + oldKeyFingerprint, + newKeyFingerprint, + }; + } catch (error) { + await tx.rollback().catch(() => {}); + throw error; + } +} diff --git a/server/scripts/rotate-encryption-key.test.ts b/server/scripts/rotate-encryption-key.test.ts new file mode 100644 index 00000000..2d28609d --- /dev/null +++ b/server/scripts/rotate-encryption-key.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { parseRootKeyRotationArgs } from "./rotate-encryption-key.ts"; + +describe("root key rotation CLI arguments", () => { + it("defaults to dry-run", () => { + expect(parseRootKeyRotationArgs([])).toEqual({ apply: false }); + }); + + it("requires an explicit offline confirmation for writes", () => { + expect(() => parseRootKeyRotationArgs(["--apply"])).toThrow("--confirm-offline"); + expect(parseRootKeyRotationArgs(["--apply", "--confirm-offline"])).toEqual({ apply: true }); + }); + + it("rejects unknown arguments", () => { + expect(() => parseRootKeyRotationArgs(["--old-key=secret"])).toThrow("Unknown option"); + }); +}); diff --git a/server/scripts/rotate-encryption-key.ts b/server/scripts/rotate-encryption-key.ts new file mode 100644 index 00000000..b6ea8daf --- /dev/null +++ b/server/scripts/rotate-encryption-key.ts @@ -0,0 +1,64 @@ +import db from "../db/connection.ts"; +import { pathToFileURL } from "node:url"; +import { rotateRootEncryptionKey } from "../platform/root-key-rotation.ts"; + +function usage(): string { + return [ + "Usage:", + " npm run security:rotate-encryption-key", + " npm run security:rotate-encryption-key -- --apply --confirm-offline", + "", + "EA_ENCRYPTION_KEY and EA_ENCRYPTION_KEY_NEXT must be set in the command environment.", + "Dry-run is the default. Stop every Setpoint process before using --apply.", + ].join("\n"); +} + +export function parseRootKeyRotationArgs(args: string[]): { apply: boolean } { + const unknown = args.filter((arg) => arg !== "--apply" && arg !== "--confirm-offline"); + if (unknown.length > 0) throw new Error(`Unknown option: ${unknown[0]}`); + const apply = args.includes("--apply"); + if (apply && !args.includes("--confirm-offline")) { + throw new Error("--apply requires --confirm-offline after every Setpoint process is stopped"); + } + if (!apply && args.includes("--confirm-offline")) { + throw new Error("--confirm-offline is only valid with --apply"); + } + return { apply }; +} + +async function main(): Promise { + try { + const { apply } = parseRootKeyRotationArgs(process.argv.slice(2)); + const oldKey = process.env.EA_ENCRYPTION_KEY; + const newKey = process.env.EA_ENCRYPTION_KEY_NEXT; + if (!oldKey || !newKey) { + throw new Error("EA_ENCRYPTION_KEY and EA_ENCRYPTION_KEY_NEXT are required"); + } + const result = await rotateRootEncryptionKey({ + dbClient: db, + oldKey, + newKey, + apply, + }); + console.log(JSON.stringify({ + mode: result.applied ? "applied" : "dry-run", + credentialCount: result.credentialCount, + targetCounts: result.targetCounts, + oldKeyFingerprint: result.oldKeyFingerprint, + newKeyFingerprint: result.newKeyFingerprint, + }, null, 2)); + if (!result.applied) { + console.log("Dry-run complete. No credential rows were changed."); + } + } catch (error) { + console.error(error instanceof Error ? error.message : "Root key rotation failed"); + console.error(usage()); + process.exitCode = 1; + } finally { + await db.close(); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main(); +} From 85e908d843d6b858029054d3db42d9646925604c Mon Sep 17 00:00:00 2001 From: ansidian Date: Mon, 20 Jul 2026 14:36:25 -0700 Subject: [PATCH 38/44] fix: expire and discard pending credentials --- server/capability-status-service.test.ts | 2 + server/db/migrations.test.ts | 21 ++ .../040_pending_credential_lifecycle.sql | 11 + server/google-oauth-credentials.test.ts | 24 +- server/google-oauth-credentials.ts | 9 + server/platform/capability-projection.test.ts | 2 + .../instance-credential-service.test.ts | 44 +++- .../platform/instance-credential-service.ts | 53 +++-- .../instance-credential-store.test.ts | 128 ++++++++++- server/platform/instance-credential-store.ts | 213 ++++++++++++++++-- server/routes/instance-credentials.test.ts | 69 ++++++ server/routes/instance-credentials.ts | 31 +++ .../tasks/todoist-oauth-credentials.test.ts | 22 +- server/tasks/todoist-oauth-credentials.ts | 9 + server/tasks/todoist-oauth.test.ts | 3 + server/tasks/todoist-oauth.ts | 15 ++ shared/types/instance-credentials.ts | 2 + shared/types/tasks.ts | 3 + src/api.ts | 11 + .../CoreProviderCredentialsCard.test.tsx | 27 +++ .../cards/CoreProviderCredentialsCard.tsx | 57 ++++- .../cards/GoogleOAuthCredentialsCard.test.tsx | 27 +++ .../cards/GoogleOAuthCredentialsCard.tsx | 33 ++- .../settings/cards/TodoistCard.test.tsx | 38 +++- src/components/settings/cards/TodoistCard.tsx | 54 ++++- .../cards/coreCredentialModel.test.ts | 13 ++ .../settings/cards/coreCredentialModel.ts | 5 + .../settings/connectionModel.test.ts | 2 + src/demo/capabilities.ts | 2 + src/demo/demoExhaustiveness.test.ts | 2 + src/lib/todoistSetupApi.ts | 6 + 31 files changed, 861 insertions(+), 77 deletions(-) create mode 100644 server/db/migrations/040_pending_credential_lifecycle.sql diff --git a/server/capability-status-service.test.ts b/server/capability-status-service.test.ts index 8c93165e..473c71f4 100644 --- a/server/capability-status-service.test.ts +++ b/server/capability-status-service.test.ts @@ -8,6 +8,8 @@ const metadata = [{ source: "stored" as const, activeConfigured: true, pendingConfigured: false, + pendingStagedAt: null, + pendingExpiresAt: null, validationState: "valid" as const, lastTestedAt: 100, lastSucceededAt: 100, diff --git a/server/db/migrations.test.ts b/server/db/migrations.test.ts index e3240524..31778eab 100644 --- a/server/db/migrations.test.ts +++ b/server/db/migrations.test.ts @@ -609,4 +609,25 @@ describe("database migrations", () => { const columns = await db.execute("PRAGMA table_info('ea_sessions')"); expect(columns.rows.map((row) => row.name)).toContain("step_up_window_started_at"); }); + + it("adds durable pending credential timestamps and backfills legacy candidates", async () => { + db = createClient({ url: "file::memory:" }); + await applyMigrations(db, ["033_instance_credentials.sql"]); + await db.execute({ + sql: `INSERT INTO ea_instance_credentials + (credential_key, pending_value_encrypted, validation_state, updated_at) + VALUES (?, ?, 'pending', ?)`, + args: ["ai.openai_api_key", "legacy-ciphertext", 1_000], + }); + + await applyMigrations(db, ["040_pending_credential_lifecycle.sql"]); + + const row = (await db.execute( + `SELECT pending_staged_at, pending_expires_at + FROM ea_instance_credentials WHERE credential_key = 'ai.openai_api_key'`, + )).rows[0]; + expect(row).toEqual({ pending_staged_at: 1_000, pending_expires_at: 86_401_000 }); + const columns = await db.execute("PRAGMA index_info('idx_instance_credentials_pending_expiry')"); + expect(columns.rows.map((entry) => entry.name)).toEqual(["pending_expires_at"]); + }); }); diff --git a/server/db/migrations/040_pending_credential_lifecycle.sql b/server/db/migrations/040_pending_credential_lifecycle.sql new file mode 100644 index 00000000..aa797b83 --- /dev/null +++ b/server/db/migrations/040_pending_credential_lifecycle.sql @@ -0,0 +1,11 @@ +ALTER TABLE ea_instance_credentials ADD COLUMN pending_staged_at INTEGER; +ALTER TABLE ea_instance_credentials ADD COLUMN pending_expires_at INTEGER; + +UPDATE ea_instance_credentials +SET pending_staged_at = updated_at, + pending_expires_at = updated_at + 86400000 +WHERE pending_value_encrypted IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_instance_credentials_pending_expiry + ON ea_instance_credentials(pending_expires_at) + WHERE pending_value_encrypted IS NOT NULL; diff --git a/server/google-oauth-credentials.test.ts b/server/google-oauth-credentials.test.ts index 33de8530..b2e0fd9d 100644 --- a/server/google-oauth-credentials.test.ts +++ b/server/google-oauth-credentials.test.ts @@ -9,10 +9,9 @@ import { createInstanceCredentialStore } from "./platform/instance-credential-st import { createGoogleOAuthCredentialManager } from "./google-oauth-credentials.ts"; const ROOT_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const migrationSql = readFileSync( - path.join(process.cwd(), "server/db/migrations/033_instance_credentials.sql"), - "utf8", -); +const migrationSql = ["033_instance_credentials.sql", "040_pending_credential_lifecycle.sql"] + .map((file) => readFileSync(path.join(process.cwd(), "server/db/migrations", file), "utf8")) + .join("\n"); describe("Google OAuth credential manager", () => { let db: Client; @@ -130,4 +129,21 @@ describe("Google OAuth credential manager", () => { await expect(service.getCredentialMetadata("google.oauth_client_id")).resolves.toMatchObject({ source: "disabled" }); await expect(service.getCredentialMetadata("google.oauth_client_secret")).resolves.toMatchObject({ source: "disabled" }); }); + + it("discards the Google candidate pair at matching versions", async () => { + const { manager: google, service } = manager({ + GOOGLE_CLIENT_ID: "env-client-id", + GOOGLE_CLIENT_SECRET: "env-client-secret", + }); + const staged = await google.stageCandidate({ clientId: "candidate-id", clientSecret: "candidate-secret" }); + + await google.discardCandidate(staged.candidateVersions); + + await expect(google.selectForAuthorization()).resolves.toMatchObject({ + credentials: { clientId: "env-client-id", clientSecret: "env-client-secret" }, + candidateVersions: null, + }); + await expect(service.getCredentialMetadata("google.oauth_client_id")) + .resolves.toMatchObject({ pendingConfigured: false }); + }); }); diff --git a/server/google-oauth-credentials.ts b/server/google-oauth-credentials.ts index 425d4c91..30a13853 100644 --- a/server/google-oauth-credentials.ts +++ b/server/google-oauth-credentials.ts @@ -108,6 +108,14 @@ export function createGoogleOAuthCredentialManager( ]); } + async function discardCandidate(candidateVersions: GoogleOAuthCandidateVersions) { + const credentials = await service(); + return credentials.discardPendingGroup([ + { key: CLIENT_ID_KEY, expectedVersion: candidateVersions.clientId }, + { key: CLIENT_SECRET_KEY, expectedVersion: candidateVersions.clientSecret }, + ]); + } + async function importEnvironment() { const credentials = await service(); return credentials.importEnvironmentGroup([CLIENT_ID_KEY, CLIENT_SECRET_KEY]); @@ -129,6 +137,7 @@ export function createGoogleOAuthCredentialManager( stageCandidate, resolveCandidate, promoteCandidate, + discardCandidate, importEnvironment, disable, useHostValues, diff --git a/server/platform/capability-projection.test.ts b/server/platform/capability-projection.test.ts index ea4aabe2..5ccff3a8 100644 --- a/server/platform/capability-projection.test.ts +++ b/server/platform/capability-projection.test.ts @@ -13,6 +13,8 @@ function credential( source: "absent", activeConfigured: false, pendingConfigured: false, + pendingStagedAt: null, + pendingExpiresAt: null, validationState: "untested", lastTestedAt: null, lastSucceededAt: null, diff --git a/server/platform/instance-credential-service.test.ts b/server/platform/instance-credential-service.test.ts index c7d983a4..7c76d758 100644 --- a/server/platform/instance-credential-service.test.ts +++ b/server/platform/instance-credential-service.test.ts @@ -8,10 +8,9 @@ import { createInstanceCredentialService } from "./instance-credential-service.t import { createInstanceCredentialStore } from "./instance-credential-store.ts"; const ROOT_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const migrationSql = readFileSync( - path.join(process.cwd(), "server/db/migrations/033_instance_credentials.sql"), - "utf8", -); +const migrationSql = ["033_instance_credentials.sql", "040_pending_credential_lifecycle.sql"] + .map((file) => readFileSync(path.join(process.cwd(), "server/db/migrations", file), "utf8")) + .join("\n"); describe("instance credential service", () => { let db: Client; @@ -28,11 +27,12 @@ describe("instance credential service", () => { await removeTempDir(tempDir); }); - function createService(environment: Record) { + function createService(environment: Record, now: () => number = Date.now) { return createInstanceCredentialService({ store: createInstanceCredentialStore(db), environment, encryption: createEncryption(() => ROOT_KEY), + now, }); } @@ -165,4 +165,38 @@ describe("instance credential service", () => { expect(metadata.capabilities).toEqual(["email_triage", "bill_extraction", "alfred"]); expect(JSON.stringify(metadata)).not.toContain("host-anthropic-secret"); }); + + it("never reads, promotes, or exposes metadata for an expired pending value", async () => { + let currentTime = 100; + const service = createService({ EA_ENCRYPTION_KEY: ROOT_KEY }, () => currentTime); + const staged = await service.stagePending("ai.openai_api_key", "candidate-secret"); + expect(staged).toMatchObject({ + pendingStagedAt: 100, + pendingExpiresAt: 100 + 86_400_000, + }); + expect(JSON.stringify(staged)).not.toContain("candidate-secret"); + + currentTime = 100 + 86_400_000; + await expect(service.readPending("ai.openai_api_key")).resolves.toBeNull(); + await expect(service.promotePending("ai.openai_api_key", staged.version!)) + .rejects.toMatchObject({ code: "INSTANCE_CREDENTIAL_CONFLICT" }); + expect(await service.getCredentialMetadata("ai.openai_api_key")).toMatchObject({ + pendingConfigured: false, + pendingStagedAt: null, + pendingExpiresAt: null, + }); + }); + + it("discards a candidate by version while preserving the active credential", async () => { + let currentTime = 10; + const service = createService({ EA_ENCRYPTION_KEY: ROOT_KEY }, () => currentTime); + const first = await service.stagePending("ai.openai_api_key", "active-value"); + await service.promotePending("ai.openai_api_key", first.version!); + currentTime = 20; + const pending = await service.stagePending("ai.openai_api_key", "discard-me"); + const metadata = await service.discardPending("ai.openai_api_key", pending.version!); + + expect(metadata).toMatchObject({ activeConfigured: true, pendingConfigured: false }); + expect(await service.resolve("ai.openai_api_key")).toMatchObject({ value: "active-value" }); + }); }); diff --git a/server/platform/instance-credential-service.ts b/server/platform/instance-credential-service.ts index 4962d79b..e84bf172 100644 --- a/server/platform/instance-credential-service.ts +++ b/server/platform/instance-credential-service.ts @@ -29,7 +29,7 @@ export type ResolvedInstanceCredential = { export type InstanceCredentialChangeEvent = { key: InstanceCredentialKey; - reason: "pending_staged" | "promoted" | "validation_failed" | "disabled" | "host_selected" | "environment_imported"; + reason: "pending_staged" | "pending_discarded" | "promoted" | "validation_failed" | "disabled" | "host_selected" | "environment_imported"; }; export class UnknownInstanceCredentialError extends Error { @@ -76,11 +76,13 @@ export function createInstanceCredentialService({ environment = process.env, encryption = createEncryption(), rootKeyHealthResolver, + now = Date.now, }: { store?: InstanceCredentialStore; environment?: NodeJS.ProcessEnv | Record; encryption?: ReturnType; rootKeyHealthResolver?: () => Promise; + now?: () => number; } = {}) { const listeners = new Set<(event: InstanceCredentialChangeEvent) => void>(); @@ -95,7 +97,7 @@ export function createInstanceCredentialService({ async function resolve(inputKey: string): Promise { const key = requireKey(inputKey); - const record = await store.get(key); + const record = await store.get(key, now()); if (record?.activeValueEncrypted) { return { key, source: "stored", value: encryption.decrypt(record.activeValueEncrypted, instanceCredentialContext(key)) }; } @@ -107,7 +109,7 @@ export function createInstanceCredentialService({ async function readPending(inputKey: string): Promise<{ value: string; version: number } | null> { const key = requireKey(inputKey); - const record = await store.get(key); + const record = await store.get(key, now()); if (!record?.pendingValueEncrypted) return null; return { value: encryption.decrypt(record.pendingValueEncrypted, instanceCredentialContext(key)), version: record.version }; } @@ -129,6 +131,8 @@ export function createInstanceCredentialService({ source, activeConfigured: Boolean(record?.activeValueEncrypted) || source === "environment", pendingConfigured: Boolean(record?.pendingValueEncrypted), + pendingStagedAt: record?.pendingValueEncrypted ? record.pendingStagedAt : null, + pendingExpiresAt: record?.pendingValueEncrypted ? record.pendingExpiresAt : null, validationState: record?.validationState ?? "untested", lastTestedAt: record?.lastTestedAt ?? null, lastSucceededAt: record?.lastSucceededAt ?? null, @@ -154,7 +158,7 @@ export function createInstanceCredentialService({ } async function getMetadata(): Promise { - const records = await store.list(); + const records = await store.list(now()); const byKey = new Map(records.map((record) => [record.key, record])); return { credentials: listInstanceCredentialDefinitions().map((definition) => @@ -168,12 +172,12 @@ export function createInstanceCredentialService({ async function getCredentialMetadata(inputKey: string): Promise { const key = requireKey(inputKey); - return metadataFor(key, await store.get(key)); + return metadataFor(key, await store.get(key, now())); } async function stagePending(inputKey: string, value: string): Promise { const key = requireKey(inputKey); - const record = await store.stagePending(key, encryption.encrypt(value, instanceCredentialContext(key))); + const record = await store.stagePending(key, encryption.encrypt(value, instanceCredentialContext(key)), now()); publish({ key, reason: "pending_staged" }); return metadataFor(key, record); } @@ -185,14 +189,14 @@ export function createInstanceCredentialService({ key: requireKey(entry.key), encryptedValue: encryption.encrypt(entry.value, instanceCredentialContext(requireKey(entry.key))), })); - const records = await store.stagePendingGroup(supported); + const records = await store.stagePendingGroup(supported, now()); for (const record of records) publish({ key: record.key, reason: "pending_staged" }); return records.map((record) => metadataFor(record.key, record)); } async function promotePending(inputKey: string, expectedVersion: number): Promise { const key = requireKey(inputKey); - const record = await store.promotePending(key, expectedVersion); + const record = await store.promotePending(key, expectedVersion, now()); publish({ key, reason: "promoted" }); return metadataFor(key, record); } @@ -204,7 +208,7 @@ export function createInstanceCredentialService({ key: requireKey(entry.key), expectedVersion: entry.expectedVersion, })); - const records = await store.promotePendingGroup(supported); + const records = await store.promotePendingGroup(supported, now()); for (const record of records) publish({ key: record.key, reason: "promoted" }); return records.map((record) => metadataFor(record.key, record)); } @@ -212,21 +216,40 @@ export function createInstanceCredentialService({ async function recordPendingFailure(inputKey: string, expectedVersion: number, errorCode: string): Promise { const key = requireKey(inputKey); const redactedCode = safeErrorCode(errorCode) ?? "VALIDATION_FAILED"; - const record = await store.recordPendingFailure(key, expectedVersion, redactedCode); + const record = await store.recordPendingFailure(key, expectedVersion, redactedCode, now()); publish({ key, reason: "validation_failed" }); return metadataFor(key, record); } + async function discardPending(inputKey: string, expectedVersion: number): Promise { + const key = requireKey(inputKey); + const record = await store.discardPending(key, expectedVersion, now()); + publish({ key, reason: "pending_discarded" }); + return metadataFor(key, record); + } + + async function discardPendingGroup( + entries: Array<{ key: string; expectedVersion: number }>, + ): Promise { + const supported = entries.map((entry) => ({ + key: requireKey(entry.key), + expectedVersion: entry.expectedVersion, + })); + const records = await store.discardPendingGroup(supported, now()); + for (const record of records) publish({ key: record.key, reason: "pending_discarded" }); + return records.map((record) => metadataFor(record.key, record)); + } + async function disable(inputKey: string): Promise { const key = requireKey(inputKey); - const record = await store.disable(key); + const record = await store.disable(key, now()); publish({ key, reason: "disabled" }); return metadataFor(key, record); } async function disableGroup(inputKeys: string[]): Promise { const keys = inputKeys.map(requireKey); - const records = await store.disableGroup(keys); + const records = await store.disableGroup(keys, now()); for (const record of records) publish({ key: record.key, reason: "disabled" }); return records.map((record) => metadataFor(record.key, record)); } @@ -254,7 +277,7 @@ export function createInstanceCredentialService({ const key = requireKey(inputKey); const value = environmentValue(key, environment); if (value === null) throw new HostCredentialUnavailableError(); - const record = await store.importActive(key, encryption.encrypt(value, instanceCredentialContext(key))); + const record = await store.importActive(key, encryption.encrypt(value, instanceCredentialContext(key)), now()); publish({ key, reason: "environment_imported" }); return metadataFor(key, record); } @@ -266,7 +289,7 @@ export function createInstanceCredentialService({ if (value === null) throw new HostCredentialUnavailableError(); return { key, encryptedValue: encryption.encrypt(value, instanceCredentialContext(key)) }; }); - const records = await store.importActiveGroup(entries); + const records = await store.importActiveGroup(entries, now()); for (const record of records) publish({ key: record.key, reason: "environment_imported" }); return records.map((record) => metadataFor(record.key, record)); } @@ -281,6 +304,8 @@ export function createInstanceCredentialService({ promotePending, promotePendingGroup, recordPendingFailure, + discardPending, + discardPendingGroup, disable, disableGroup, useHostValue, diff --git a/server/platform/instance-credential-store.test.ts b/server/platform/instance-credential-store.test.ts index ad53e7df..8fa5d4e1 100644 --- a/server/platform/instance-credential-store.test.ts +++ b/server/platform/instance-credential-store.test.ts @@ -8,10 +8,9 @@ import { InstanceCredentialConflictError, } from "./instance-credential-store.ts"; -const migrationSql = readFileSync( - path.join(process.cwd(), "server/db/migrations/033_instance_credentials.sql"), - "utf8", -); +const migrationSql = ["033_instance_credentials.sql", "040_pending_credential_lifecycle.sql"] + .map((file) => readFileSync(path.join(process.cwd(), "server/db/migrations", file), "utf8")) + .join("\n"); describe("instance credential store", () => { let db: Client; @@ -64,7 +63,7 @@ describe("instance credential store", () => { await expect( store.promotePending("ai.openai_api_key", pending.version, 40), ).rejects.toBeInstanceOf(InstanceCredentialConflictError); - expect((await store.get("ai.openai_api_key"))?.activeValueEncrypted).toBe("encrypted-candidate"); + expect((await store.get("ai.openai_api_key", 40))?.activeValueEncrypted).toBe("encrypted-candidate"); }); it("stages and promotes a credential group atomically", async () => { @@ -84,12 +83,12 @@ describe("instance credential store", () => { await store.stagePending("google.oauth_client_secret", "newer-secret", 25); await expect(store.promotePendingGroup(versions, 30)) .rejects.toBeInstanceOf(InstanceCredentialConflictError); - expect(await store.get("google.oauth_client_id")).toMatchObject({ + expect(await store.get("google.oauth_client_id", 30)).toMatchObject({ activeValueEncrypted: "old-id", pendingValueEncrypted: "new-id", }); - const currentSecret = await store.get("google.oauth_client_secret"); + const currentSecret = await store.get("google.oauth_client_secret", 30); const promoted = await store.promotePendingGroup([ versions[0]!, { key: "google.oauth_client_secret", expectedVersion: currentSecret!.version }, @@ -106,7 +105,7 @@ describe("instance credential store", () => { expect(disabled).toMatchObject({ disabled: true, activeValueEncrypted: null }); await store.useHostValue("weather.pirate_weather_api_key"); - expect(await store.get("weather.pirate_weather_api_key")).toBeNull(); + expect(await store.get("weather.pirate_weather_api_key", 10)).toBeNull(); }); it("disables and restores a provider-owned credential group in one transaction", async () => { @@ -129,8 +128,8 @@ describe("instance credential store", () => { "google.oauth_client_id", "google.oauth_client_secret", ]); - expect(await store.get("google.oauth_client_id")).toBeNull(); - expect(await store.get("google.oauth_client_secret")).toBeNull(); + expect(await store.get("google.oauth_client_id", 20)).toBeNull(); + expect(await store.get("google.oauth_client_secret", 20)).toBeNull(); }); it("rejects unknown keys at the persistence boundary", async () => { @@ -141,4 +140,113 @@ describe("instance credential store", () => { )).rejects.toMatchObject({ code: "UNKNOWN_INSTANCE_CREDENTIAL" }); expect((await store.list())).toEqual([]); }); + + it("expires candidates at the exact 24-hour boundary without replacing active values", async () => { + const store = createInstanceCredentialStore(db); + await store.importActive("ai.openai_api_key", "encrypted-active", 10); + const pending = await store.stagePending("ai.openai_api_key", "encrypted-candidate", 20); + + expect(await store.get("ai.openai_api_key", 20 + 86_400_000 - 1)).toMatchObject({ + pendingValueEncrypted: "encrypted-candidate", + pendingStagedAt: 20, + pendingExpiresAt: 20 + 86_400_000, + }); + expect(await store.get("ai.openai_api_key", 20 + 86_400_000)).toMatchObject({ + activeValueEncrypted: "encrypted-active", + pendingValueEncrypted: null, + pendingStagedAt: null, + pendingExpiresAt: null, + validationState: "untested", + errorCode: null, + version: pending.version + 1, + }); + }); + + it("does not extend candidate expiry when validation state changes", async () => { + const store = createInstanceCredentialStore(db); + const pending = await store.stagePending("ai.openai_api_key", "encrypted-candidate", 100); + const failed = await store.recordPendingFailure( + "ai.openai_api_key", + pending.version, + "PROVIDER_UNAUTHORIZED", + 1_000, + ); + + expect(failed).toMatchObject({ + pendingStagedAt: 100, + pendingExpiresAt: 100 + 86_400_000, + updatedAt: 1_000, + }); + await expect(store.promotePending( + "ai.openai_api_key", + failed.version, + 100 + 86_400_000, + )).rejects.toBeInstanceOf(InstanceCredentialConflictError); + expect((await store.get("ai.openai_api_key", 100 + 86_400_000))?.pendingValueEncrypted).toBeNull(); + }); + + it("lazily prunes every expired candidate and clears provider pairs atomically", async () => { + const store = createInstanceCredentialStore(db); + await store.stagePending("ai.openai_api_key", "expired-single", 1); + await store.stagePendingGroup([ + { key: "google.oauth_client_id", encryptedValue: "expired-id" }, + { key: "google.oauth_client_secret", encryptedValue: "expired-secret" }, + ], 2); + await store.stagePendingGroup([ + { key: "tasks.todoist_client_id", encryptedValue: "todoist-id" }, + { key: "tasks.todoist_client_secret", encryptedValue: "todoist-secret" }, + ], 86_400_010); + + await store.get("tasks.todoist_client_id", 86_400_002); + + expect((await store.get("ai.openai_api_key", 86_400_002))?.pendingValueEncrypted).toBeNull(); + expect((await store.get("google.oauth_client_id", 86_400_002))?.pendingValueEncrypted).toBeNull(); + expect((await store.get("google.oauth_client_secret", 86_400_002))?.pendingValueEncrypted).toBeNull(); + expect((await store.get("tasks.todoist_client_id", 86_400_002))?.pendingValueEncrypted).toBe("todoist-id"); + }); + + it.each([ + ["google.oauth_client_id", "google.oauth_client_secret"], + ["tasks.todoist_client_id", "tasks.todoist_client_secret"], + ] as const)("expires the %s pair atomically when only one member has reached expiry", async (firstKey, secondKey) => { + const store = createInstanceCredentialStore(db); + await store.stagePendingGroup([ + { key: firstKey, encryptedValue: "first" }, + { key: secondKey, encryptedValue: "second" }, + ], 100); + await db.execute({ + sql: "UPDATE ea_instance_credentials SET pending_expires_at = ? WHERE credential_key = ?", + args: [200, firstKey], + }); + + await store.get(firstKey, 200); + + expect((await store.get(firstKey, 200))?.pendingValueEncrypted).toBeNull(); + expect((await store.get(secondKey, 200))?.pendingValueEncrypted).toBeNull(); + }); + + it("discards single and grouped candidates only at their expected versions", async () => { + const store = createInstanceCredentialStore(db); + await store.importActive("ai.openai_api_key", "active", 1); + const single = await store.stagePending("ai.openai_api_key", "candidate", 2); + await expect(store.discardPending("ai.openai_api_key", single.version - 1, 3)) + .rejects.toBeInstanceOf(InstanceCredentialConflictError); + const discarded = await store.discardPending("ai.openai_api_key", single.version, 3); + expect(discarded).toMatchObject({ activeValueEncrypted: "active", pendingValueEncrypted: null }); + + const pair = await store.stagePendingGroup([ + { key: "google.oauth_client_id", encryptedValue: "id" }, + { key: "google.oauth_client_secret", encryptedValue: "secret" }, + ], 4); + await expect(store.discardPendingGroup([ + { key: pair[0]!.key, expectedVersion: pair[0]!.version }, + { key: pair[1]!.key, expectedVersion: pair[1]!.version - 1 }, + ], 5)).rejects.toBeInstanceOf(InstanceCredentialConflictError); + expect((await store.get("google.oauth_client_id", 5))?.pendingValueEncrypted).toBe("id"); + const discardedPair = await store.discardPendingGroup(pair.map((record) => ({ + key: record.key, + expectedVersion: record.version, + })), 5); + expect(discardedPair.every((record) => record.pendingValueEncrypted === null)).toBe(true); + }); }); diff --git a/server/platform/instance-credential-store.ts b/server/platform/instance-credential-store.ts index 471e48b5..4936db0e 100644 --- a/server/platform/instance-credential-store.ts +++ b/server/platform/instance-credential-store.ts @@ -7,11 +7,16 @@ import { } from "./instance-credential-registry.ts"; type InstanceCredentialDb = Pick; +type InstanceCredentialExecutor = Pick; + +export const INSTANCE_CREDENTIAL_PENDING_TTL_MS = 24 * 60 * 60 * 1000; export type InstanceCredentialRecord = { key: InstanceCredentialKey; activeValueEncrypted: string | null; pendingValueEncrypted: string | null; + pendingStagedAt: number | null; + pendingExpiresAt: number | null; disabled: boolean; validationState: InstanceCredentialValidationState; lastTestedAt: number | null; @@ -56,6 +61,8 @@ function recordFromRow(row: Row): InstanceCredentialRecord { key: String(row.credential_key) as InstanceCredentialKey, activeValueEncrypted: nullableString(row.active_value_encrypted), pendingValueEncrypted: nullableString(row.pending_value_encrypted), + pendingStagedAt: nullableNumber(row.pending_staged_at), + pendingExpiresAt: nullableNumber(row.pending_expires_at), disabled: Number(row.disabled) === 1, validationState: String(row.validation_state) as InstanceCredentialValidationState, lastTestedAt: nullableNumber(row.last_tested_at), @@ -68,23 +75,90 @@ function recordFromRow(row: Row): InstanceCredentialRecord { } const SELECT_COLUMNS = `credential_key, active_value_encrypted, pending_value_encrypted, + pending_staged_at, pending_expires_at, disabled, validation_state, last_tested_at, last_succeeded_at, last_failed_at, error_code, version, updated_at`; export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = db) { - async function get(key: InstanceCredentialKey): Promise { + async function pruneExpiredPendingWith(executor: InstanceCredentialExecutor, now: number): Promise { + await executor.execute({ + sql: `UPDATE ea_instance_credentials SET + pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, + validation_state = CASE + WHEN disabled = 1 THEN 'disabled' + WHEN active_value_encrypted IS NOT NULL AND last_succeeded_at IS NOT NULL THEN 'valid' + ELSE 'untested' + END, + error_code = NULL, + version = version + 1, + updated_at = ? + WHERE pending_value_encrypted IS NOT NULL AND ( + pending_expires_at IS NULL OR pending_expires_at <= ? + OR ( + credential_key IN ('google.oauth_client_id', 'google.oauth_client_secret') + AND EXISTS ( + SELECT 1 FROM ea_instance_credentials expired + WHERE expired.credential_key IN ('google.oauth_client_id', 'google.oauth_client_secret') + AND expired.pending_value_encrypted IS NOT NULL + AND (expired.pending_expires_at IS NULL OR expired.pending_expires_at <= ?) + ) + ) + OR ( + credential_key IN ('tasks.todoist_client_id', 'tasks.todoist_client_secret') + AND EXISTS ( + SELECT 1 FROM ea_instance_credentials expired + WHERE expired.credential_key IN ('tasks.todoist_client_id', 'tasks.todoist_client_secret') + AND expired.pending_value_encrypted IS NOT NULL + AND (expired.pending_expires_at IS NULL OR expired.pending_expires_at <= ?) + ) + ) + )`, + args: [now, now, now, now], + }); + } + + async function pruneExpiredPending(now = Date.now()): Promise { + const tx = await dbClient.transaction("write"); + try { + await pruneExpiredPendingWith(tx, now); + await tx.commit(); + } catch (error) { + await tx.rollback().catch(() => {}); + throw error; + } + } + + async function get(key: InstanceCredentialKey, now = Date.now()): Promise { assertSupportedKey(key); - const result = await dbClient.execute({ + let result = await dbClient.execute({ sql: `SELECT ${SELECT_COLUMNS} FROM ea_instance_credentials WHERE credential_key = ?`, args: [key], }); + const row = result.rows[0]; + if (row?.pending_value_encrypted + && (nullableNumber(row.pending_expires_at) === null || Number(row.pending_expires_at) <= now)) { + await pruneExpiredPending(now); + result = await dbClient.execute({ + sql: `SELECT ${SELECT_COLUMNS} FROM ea_instance_credentials WHERE credential_key = ?`, + args: [key], + }); + } return result.rows[0] ? recordFromRow(result.rows[0]) : null; } - async function list(): Promise { - const result = await dbClient.execute( + async function list(now = Date.now()): Promise { + let result = await dbClient.execute( `SELECT ${SELECT_COLUMNS} FROM ea_instance_credentials ORDER BY credential_key`, ); + if (result.rows.some((row) => row.pending_value_encrypted + && (nullableNumber(row.pending_expires_at) === null || Number(row.pending_expires_at) <= now))) { + await pruneExpiredPending(now); + result = await dbClient.execute( + `SELECT ${SELECT_COLUMNS} FROM ea_instance_credentials ORDER BY credential_key`, + ); + } return result.rows.map(recordFromRow); } @@ -92,17 +166,20 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d assertSupportedKey(key); await dbClient.execute({ sql: `INSERT INTO ea_instance_credentials - (credential_key, pending_value_encrypted, disabled, validation_state, version, updated_at) - VALUES (?, ?, 0, 'pending', 1, ?) + (credential_key, pending_value_encrypted, pending_staged_at, pending_expires_at, + disabled, validation_state, version, updated_at) + VALUES (?, ?, ?, ?, 0, 'pending', 1, ?) ON CONFLICT(credential_key) DO UPDATE SET pending_value_encrypted = excluded.pending_value_encrypted, + pending_staged_at = excluded.pending_staged_at, + pending_expires_at = excluded.pending_expires_at, validation_state = 'pending', error_code = NULL, version = ea_instance_credentials.version + 1, updated_at = excluded.updated_at`, - args: [key, encryptedValue, now], + args: [key, encryptedValue, now, now + INSTANCE_CREDENTIAL_PENDING_TTL_MS, now], }); - return (await get(key))!; + return (await get(key, now))!; } async function stagePendingGroup( @@ -115,15 +192,18 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d for (const entry of entries) { await tx.execute({ sql: `INSERT INTO ea_instance_credentials - (credential_key, pending_value_encrypted, disabled, validation_state, version, updated_at) - VALUES (?, ?, 0, 'pending', 1, ?) + (credential_key, pending_value_encrypted, pending_staged_at, pending_expires_at, + disabled, validation_state, version, updated_at) + VALUES (?, ?, ?, ?, 0, 'pending', 1, ?) ON CONFLICT(credential_key) DO UPDATE SET pending_value_encrypted = excluded.pending_value_encrypted, + pending_staged_at = excluded.pending_staged_at, + pending_expires_at = excluded.pending_expires_at, validation_state = 'pending', error_code = NULL, version = ea_instance_credentials.version + 1, updated_at = excluded.updated_at`, - args: [entry.key, entry.encryptedValue, now], + args: [entry.key, entry.encryptedValue, now, now + INSTANCE_CREDENTIAL_PENDING_TTL_MS, now], }); } const records: InstanceCredentialRecord[] = []; @@ -151,6 +231,8 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d ON CONFLICT(credential_key) DO UPDATE SET active_value_encrypted = excluded.active_value_encrypted, pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, disabled = 0, validation_state = 'untested', error_code = NULL, @@ -158,7 +240,7 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d updated_at = excluded.updated_at`, args: [key, encryptedValue, now], }); - return (await get(key))!; + return (await get(key, now))!; } async function importActiveGroup( @@ -176,6 +258,8 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d ON CONFLICT(credential_key) DO UPDATE SET active_value_encrypted = excluded.active_value_encrypted, pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, disabled = 0, validation_state = 'untested', error_code = NULL, @@ -204,10 +288,13 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d assertSupportedKey(key); const tx = await dbClient.transaction("write"); try { + await pruneExpiredPendingWith(tx, now); const result = await tx.execute({ sql: `UPDATE ea_instance_credentials SET active_value_encrypted = pending_value_encrypted, pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, disabled = 0, validation_state = 'valid', last_tested_at = ?, @@ -215,8 +302,9 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d error_code = NULL, version = version + 1, updated_at = ? - WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL`, - args: [now, now, now, key, expectedVersion], + WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL + AND pending_expires_at > ?`, + args: [now, now, now, key, expectedVersion, now], }); if (result.rowsAffected !== 1) throw new InstanceCredentialConflictError(); const selected = await tx.execute({ @@ -238,11 +326,14 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d for (const entry of entries) assertSupportedKey(entry.key); const tx = await dbClient.transaction("write"); try { + await pruneExpiredPendingWith(tx, now); for (const entry of entries) { const result = await tx.execute({ sql: `UPDATE ea_instance_credentials SET active_value_encrypted = pending_value_encrypted, pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, disabled = 0, validation_state = 'valid', last_tested_at = ?, @@ -250,8 +341,9 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d error_code = NULL, version = version + 1, updated_at = ? - WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL`, - args: [now, now, now, entry.key, entry.expectedVersion], + WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL + AND pending_expires_at > ?`, + args: [now, now, now, entry.key, entry.expectedVersion, now], }); if (result.rowsAffected !== 1) throw new InstanceCredentialConflictError(); } @@ -278,19 +370,85 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d now = Date.now(), ): Promise { assertSupportedKey(key); - const result = await dbClient.execute({ - sql: `UPDATE ea_instance_credentials SET + const tx = await dbClient.transaction("write"); + try { + await pruneExpiredPendingWith(tx, now); + const result = await tx.execute({ + sql: `UPDATE ea_instance_credentials SET validation_state = 'invalid', last_tested_at = ?, last_failed_at = ?, error_code = ?, version = version + 1, updated_at = ? - WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL`, - args: [now, now, errorCode, now, key, expectedVersion], - }); - if (result.rowsAffected !== 1) throw new InstanceCredentialConflictError(); - return (await get(key))!; + WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL + AND pending_expires_at > ?`, + args: [now, now, errorCode, now, key, expectedVersion, now], + }); + if (result.rowsAffected !== 1) throw new InstanceCredentialConflictError(); + const selected = await tx.execute({ + sql: `SELECT ${SELECT_COLUMNS} FROM ea_instance_credentials WHERE credential_key = ?`, + args: [key], + }); + await tx.commit(); + return recordFromRow(selected.rows[0]!); + } catch (error) { + await tx.rollback().catch(() => {}); + throw error; + } + } + + async function discardPending( + key: InstanceCredentialKey, + expectedVersion: number, + now = Date.now(), + ): Promise { + const records = await discardPendingGroup([{ key, expectedVersion }], now); + return records[0]!; + } + + async function discardPendingGroup( + entries: Array<{ key: InstanceCredentialKey; expectedVersion: number }>, + now = Date.now(), + ): Promise { + for (const entry of entries) assertSupportedKey(entry.key); + const tx = await dbClient.transaction("write"); + try { + await pruneExpiredPendingWith(tx, now); + for (const entry of entries) { + const result = await tx.execute({ + sql: `UPDATE ea_instance_credentials SET + pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, + validation_state = CASE + WHEN disabled = 1 THEN 'disabled' + WHEN active_value_encrypted IS NOT NULL AND last_succeeded_at IS NOT NULL THEN 'valid' + ELSE 'untested' + END, + error_code = NULL, + version = version + 1, + updated_at = ? + WHERE credential_key = ? AND version = ? AND pending_value_encrypted IS NOT NULL + AND pending_expires_at > ?`, + args: [now, entry.key, entry.expectedVersion, now], + }); + if (result.rowsAffected !== 1) throw new InstanceCredentialConflictError(); + } + const records: InstanceCredentialRecord[] = []; + for (const entry of entries) { + const selected = await tx.execute({ + sql: `SELECT ${SELECT_COLUMNS} FROM ea_instance_credentials WHERE credential_key = ?`, + args: [entry.key], + }); + records.push(recordFromRow(selected.rows[0]!)); + } + await tx.commit(); + return records; + } catch (error) { + await tx.rollback().catch(() => {}); + throw error; + } } async function disable(key: InstanceCredentialKey, now = Date.now()): Promise { @@ -302,6 +460,8 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d ON CONFLICT(credential_key) DO UPDATE SET active_value_encrypted = NULL, pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, disabled = 1, validation_state = 'disabled', error_code = NULL, @@ -309,7 +469,7 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d updated_at = excluded.updated_at`, args: [key, now], }); - return (await get(key))!; + return (await get(key, now))!; } async function disableGroup( @@ -327,6 +487,8 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d ON CONFLICT(credential_key) DO UPDATE SET active_value_encrypted = NULL, pending_value_encrypted = NULL, + pending_staged_at = NULL, + pending_expires_at = NULL, disabled = 1, validation_state = 'disabled', error_code = NULL, @@ -386,10 +548,13 @@ export function createInstanceCredentialStore(dbClient: InstanceCredentialDb = d promotePending, promotePendingGroup, recordPendingFailure, + discardPending, + discardPendingGroup, disable, disableGroup, useHostValue, useHostValueGroup, + pruneExpiredPending, }; } diff --git a/server/routes/instance-credentials.test.ts b/server/routes/instance-credentials.test.ts index 898dbf5f..32382a6a 100644 --- a/server/routes/instance-credentials.test.ts +++ b/server/routes/instance-credentials.test.ts @@ -41,6 +41,8 @@ function createApp( source: "stored" as const, activeConfigured: true, pendingConfigured: true, + pendingStagedAt: 100, + pendingExpiresAt: 86_400_100, validationState: "pending" as const, lastTestedAt: null, lastSucceededAt: null, @@ -54,6 +56,7 @@ function createApp( rootKey: { configured: true, valid: true, fingerprint: "sha256:abc", decryptability: "ok" as const }, })), stagePending: vi.fn(async () => metadata), + discardPending: vi.fn(async () => ({ ...metadata, pendingConfigured: false, pendingStagedAt: null, pendingExpiresAt: null })), importEnvironment: vi.fn(async () => metadata), disable: vi.fn(async () => ({ ...metadata, source: "disabled" as const })), useHostValue: vi.fn(async () => ({ ...metadata, source: "environment" as const })), @@ -85,6 +88,10 @@ function createApp( { ...metadata, key: "google.oauth_client_id", source: "environment" as const }, { ...metadata, key: "google.oauth_client_secret", source: "environment" as const }, ]), + discardCandidate: vi.fn(async () => [ + { ...metadata, key: "google.oauth_client_id", pendingConfigured: false }, + { ...metadata, key: "google.oauth_client_secret", pendingConfigured: false }, + ]), ...googleOAuthManagerOverrides, } as unknown as GoogleOAuthCredentialManager; const gmailPubSubManager = { @@ -109,6 +116,10 @@ function createApp( { ...metadata, key: "tasks.todoist_client_id" }, { ...metadata, key: "tasks.todoist_client_secret" }, ]), + discardCandidate: vi.fn(async () => [ + { ...metadata, key: "tasks.todoist_client_id", pendingConfigured: false }, + { ...metadata, key: "tasks.todoist_client_secret", pendingConfigured: false }, + ]), ...todoistOAuthManagerOverrides, } as unknown as TodoistOAuthCredentialManager; app.use(express.json()); @@ -173,6 +184,23 @@ describe("instance credential routes", () => { expect(JSON.stringify(response.body)).not.toContain("browser-secret"); }); + it("discards a generic candidate by expected version with recent password auth", async () => { + const { app, service } = createApp(); + const blocked = await request(app) + .delete("/api/instance-credentials/ai.openai_api_key/pending") + .set("Cookie", "ea_session=stale") + .send({ expectedVersion: 2 }); + const response = await request(app) + .delete("/api/instance-credentials/ai.openai_api_key/pending") + .set("Cookie", "ea_session=valid") + .send({ expectedVersion: 2 }); + + expect(blocked.status).toBe(403); + expect(response.status).toBe(200); + expect(service.discardPending).toHaveBeenCalledWith("ai.openai_api_key", 2); + expect(response.body).toMatchObject({ pendingConfigured: false }); + }); + it("stages the Google application pair through one write-only provider action", async () => { const { app, googleOAuthManager } = createApp(); const response = await request(app) @@ -205,6 +233,18 @@ describe("instance credential routes", () => { expect(JSON.stringify(response.body)).not.toContain("environment-secret-value"); }); + it("discards the Google pair through one version-bound action", async () => { + const { app, googleOAuthManager } = createApp(); + const response = await request(app) + .delete("/api/instance-credentials/google-oauth/pending") + .set("Cookie", "ea_session=valid") + .send({ candidateVersions: { clientId: 3, clientSecret: 4 } }); + + expect(response.status).toBe(200); + expect(googleOAuthManager.discardCandidate).toHaveBeenCalledWith({ clientId: 3, clientSecret: 4 }); + expect(response.body.credentials).toHaveLength(2); + }); + it("rejects generic single-key mutations for provider-owned credential pairs", async () => { const { app, service } = createApp(); const response = await request(app) @@ -235,6 +275,33 @@ describe("instance credential routes", () => { expect(JSON.stringify(response.body)).not.toContain("browser-client-secret"); }); + it("discards the Todoist pair through one version-bound action", async () => { + const { app, todoistOAuthManager } = createApp(); + const response = await request(app) + .delete("/api/instance-credentials/todoist-oauth/pending") + .set("Cookie", "ea_session=valid") + .send({ candidateVersions: { clientId: 5, clientSecret: 6 } }); + + expect(response.status).toBe(200); + expect(todoistOAuthManager.discardCandidate).toHaveBeenCalledWith({ clientId: 5, clientSecret: 6 }); + }); + + it("rejects generic pair-member discard and malformed expected versions", async () => { + const { app, service } = createApp(); + const pair = await request(app) + .delete("/api/instance-credentials/google.oauth_client_id/pending") + .set("Cookie", "ea_session=valid") + .send({ expectedVersion: 3 }); + const malformed = await request(app) + .delete("/api/instance-credentials/ai.openai_api_key/pending") + .set("Cookie", "ea_session=valid") + .send({ expectedVersion: "3" }); + + expect(pair.status).toBe(409); + expect(malformed.status).toBe(400); + expect(service.discardPending).not.toHaveBeenCalled(); + }); + it("migrates Todoist host credentials through an explicit redacted action", async () => { const { app, todoistOAuthManager } = createApp(); const response = await request(app) @@ -295,6 +362,8 @@ describe("instance credential routes", () => { source: "stored" as const, activeConfigured: true, pendingConfigured: true, + pendingStagedAt: 1, + pendingExpiresAt: 86_400_001, validationState: "invalid" as const, lastTestedAt: 1, lastSucceededAt: null, diff --git a/server/routes/instance-credentials.ts b/server/routes/instance-credentials.ts index e96358f3..cc063b6f 100644 --- a/server/routes/instance-credentials.ts +++ b/server/routes/instance-credentials.ts @@ -36,6 +36,18 @@ function candidateValue(value: unknown): string | null { return value; } +function expectedVersion(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function candidateVersions(value: unknown): { clientId: number; clientSecret: number } | null { + if (!value || typeof value !== "object") return null; + const input = value as Record; + const clientId = expectedVersion(input.clientId); + const clientSecret = expectedVersion(input.clientSecret); + return clientId !== null && clientSecret !== null ? { clientId, clientSecret } : null; +} + const PROVIDER_OWNED_GROUP_KEYS = new Set([ "google.oauth_client_id", "google.oauth_client_secret", @@ -76,6 +88,12 @@ export function createInstanceCredentialsRouter( return res.json(await googleOAuthManager.stageCandidate({ clientId, clientSecret })); }); + router.delete("/google-oauth/pending", requireRecentPasswordAuth, async (req, res) => { + const versions = candidateVersions(req.body?.candidateVersions); + if (!versions) return res.status(400).json({ message: "Expected Google candidate versions are required" }); + return res.json({ credentials: await googleOAuthManager.discardCandidate(versions) }); + }); + router.post("/google-oauth/import-environment", requireRecentPasswordAuth, async (_req, res) => { return res.json({ credentials: await googleOAuthManager.importEnvironment() }); }); @@ -97,6 +115,12 @@ export function createInstanceCredentialsRouter( return res.json(await todoistOAuthManager.stageCandidate({ clientId, clientSecret })); }); + router.delete("/todoist-oauth/pending", requireRecentPasswordAuth, async (req, res) => { + const versions = candidateVersions(req.body?.candidateVersions); + if (!versions) return res.status(400).json({ message: "Expected Todoist candidate versions are required" }); + return res.json({ credentials: await todoistOAuthManager.discardCandidate(versions) }); + }); + router.post("/todoist-oauth/import-environment", requireRecentPasswordAuth, async (_req, res) => { return res.json({ credentials: await todoistOAuthManager.importEnvironment() }); }); @@ -139,6 +163,13 @@ export function createInstanceCredentialsRouter( return res.json(await service.stagePending(req.params.key!, value)); }); + router.delete("/:key/pending", requireRecentPasswordAuth, async (req, res) => { + if (rejectGenericGroupMutation(req.params.key!, res)) return; + const version = expectedVersion(req.body?.expectedVersion); + if (version === null) return res.status(400).json({ message: "Expected credential version is required" }); + return res.json(await service.discardPending(req.params.key!, version)); + }); + router.post("/:key/test", requireRecentPasswordAuth, async (req, res) => { const key = req.params.key!; if (rejectGenericGroupMutation(key, res)) return; diff --git a/server/tasks/todoist-oauth-credentials.test.ts b/server/tasks/todoist-oauth-credentials.test.ts index 49dfe70b..b3f53c0a 100644 --- a/server/tasks/todoist-oauth-credentials.test.ts +++ b/server/tasks/todoist-oauth-credentials.test.ts @@ -9,10 +9,9 @@ import { createTestTempDir, removeTempDir } from "../test-utils/temp-dir.ts"; import { createTodoistOAuthCredentialManager } from "./todoist-oauth-credentials.ts"; const ROOT_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const migrationSql = readFileSync( - path.join(process.cwd(), "server/db/migrations/033_instance_credentials.sql"), - "utf8", -); +const migrationSql = ["033_instance_credentials.sql", "040_pending_credential_lifecycle.sql"] + .map((file) => readFileSync(path.join(process.cwd(), "server/db/migrations", file), "utf8")) + .join("\n"); describe("Todoist OAuth credential manager", () => { let db: Client; @@ -97,4 +96,19 @@ describe("Todoist OAuth credential manager", () => { value: "env-client-secret", }); }); + + it("discards the Todoist candidate pair at matching versions", async () => { + const { manager: todoist } = manager({ + TODOIST_CLIENT_ID: "env-client-id", + TODOIST_CLIENT_SECRET: "env-client-secret", + }); + const staged = await todoist.stageCandidate({ clientId: "candidate-id", clientSecret: "candidate-secret" }); + + await todoist.discardCandidate(staged.candidateVersions); + + await expect(todoist.selectForAuthorization()).resolves.toMatchObject({ + credentials: { clientId: "env-client-id", clientSecret: "env-client-secret" }, + candidateVersions: null, + }); + }); }); diff --git a/server/tasks/todoist-oauth-credentials.ts b/server/tasks/todoist-oauth-credentials.ts index 4b3a4f04..c0e649fc 100644 --- a/server/tasks/todoist-oauth-credentials.ts +++ b/server/tasks/todoist-oauth-credentials.ts @@ -99,6 +99,14 @@ export function createTodoistOAuthCredentialManager(injectedService?: InstanceCr ]); } + async function discardCandidate(candidateVersions: TodoistOAuthCandidateVersions) { + const credentials = await service(); + return credentials.discardPendingGroup([ + { key: CLIENT_ID_KEY, expectedVersion: candidateVersions.clientId }, + { key: CLIENT_SECRET_KEY, expectedVersion: candidateVersions.clientSecret }, + ]); + } + async function importEnvironment() { const credentials = await service(); return credentials.importEnvironmentGroup([CLIENT_ID_KEY, CLIENT_SECRET_KEY]); @@ -110,6 +118,7 @@ export function createTodoistOAuthCredentialManager(injectedService?: InstanceCr stageCandidate, resolveCandidate, promoteCandidate, + discardCandidate, importEnvironment, }; } diff --git a/server/tasks/todoist-oauth.test.ts b/server/tasks/todoist-oauth.test.ts index 104d6cd2..7d981262 100644 --- a/server/tasks/todoist-oauth.test.ts +++ b/server/tasks/todoist-oauth.test.ts @@ -166,6 +166,9 @@ describe("Todoist OAuth service", () => { configured: true, source: "environment", pendingConfigured: false, + pendingStagedAt: null, + pendingExpiresAt: null, + candidateVersions: null, }, callbackUrl: "https://setpoint.example.com/api/ea/accounts/todoist/callback", webhookUrl: "https://setpoint.example.com/api/todoist/webhook", diff --git a/server/tasks/todoist-oauth.ts b/server/tasks/todoist-oauth.ts index 8036e043..bf7a58a4 100644 --- a/server/tasks/todoist-oauth.ts +++ b/server/tasks/todoist-oauth.ts @@ -82,6 +82,9 @@ export function createTodoistOAuthService({ source: string; activeConfigured: boolean; pendingConfigured: boolean; + pendingStagedAt?: number | null; + pendingExpiresAt?: number | null; + version?: number | null; }>; fetchFn?: FetchFunction; storeTokenResponse?: (userId: string, response: TodoistTokenResponse) => Promise; @@ -208,6 +211,15 @@ export function createTodoistOAuthService({ : "disconnected"; const applicationConfigured = clientId.activeConfigured && clientSecret.activeConfigured; const source = clientId.source === clientSecret.source ? clientId.source : "mixed"; + const candidateVersions = clientId.pendingConfigured && clientSecret.pendingConfigured + && Number.isInteger(clientId.version) && Number.isInteger(clientSecret.version) + ? { clientId: clientId.version!, clientSecret: clientSecret.version! } + : null; + const pairTimestampsMatch = candidateVersions !== null + && typeof clientId.pendingStagedAt === "number" + && clientId.pendingStagedAt === clientSecret.pendingStagedAt + && typeof clientId.pendingExpiresAt === "number" + && clientId.pendingExpiresAt === clientSecret.pendingExpiresAt; return { mode, configured, @@ -217,6 +229,9 @@ export function createTodoistOAuthService({ configured: applicationConfigured, source, pendingConfigured: clientId.pendingConfigured || clientSecret.pendingConfigured, + pendingStagedAt: pairTimestampsMatch ? clientId.pendingStagedAt! : null, + pendingExpiresAt: pairTimestampsMatch ? clientId.pendingExpiresAt! : null, + candidateVersions, }, callbackUrl, webhookUrl, diff --git a/shared/types/instance-credentials.ts b/shared/types/instance-credentials.ts index 78aac957..5b4664be 100644 --- a/shared/types/instance-credentials.ts +++ b/shared/types/instance-credentials.ts @@ -8,6 +8,8 @@ export type InstanceCredentialMetadata = { source: InstanceCredentialSource; activeConfigured: boolean; pendingConfigured: boolean; + pendingStagedAt: number | null; + pendingExpiresAt: number | null; validationState: InstanceCredentialValidationState; lastTestedAt: number | null; lastSucceededAt: number | null; diff --git a/shared/types/tasks.ts b/shared/types/tasks.ts index 43aaf4be..345d4b54 100644 --- a/shared/types/tasks.ts +++ b/shared/types/tasks.ts @@ -129,6 +129,9 @@ export interface TodoistConnectionStatus { configured: boolean; source: "stored" | "environment" | "disabled" | "absent" | "mixed"; pendingConfigured: boolean; + pendingStagedAt: number | null; + pendingExpiresAt: number | null; + candidateVersions: { clientId: number; clientSecret: number } | null; }; callbackUrl: string; webhookUrl: string; diff --git a/src/api.ts b/src/api.ts index b8846f09..80ca1962 100644 --- a/src/api.ts +++ b/src/api.ts @@ -482,6 +482,11 @@ export const stageInstanceCredential = (key: string, value: string): Promise => + apiFetch(`/api/instance-credentials/${encodeURIComponent(key)}/pending`, { + method: "DELETE", + body: JSON.stringify({ expectedVersion }), + }); export const testInstanceCredential = (key: string): Promise<{ ok: boolean; code: string; @@ -500,6 +505,12 @@ export const stageGoogleOAuthApplication = (clientId: string, clientSecret: stri method: "PUT", body: JSON.stringify({ clientId, clientSecret }), }); +export const discardGoogleOAuthPending = (candidateVersions: { clientId: number; clientSecret: number }): Promise<{ + credentials: InstanceCredentialMetadata[]; +}> => apiFetch("/api/instance-credentials/google-oauth/pending", { + method: "DELETE", + body: JSON.stringify({ candidateVersions }), +}); export const importGoogleOAuthEnvironment = (): Promise<{ credentials: InstanceCredentialMetadata[] }> => apiFetch("/api/instance-credentials/google-oauth/import-environment", { method: "POST" }); export const disableGoogleOAuthApplication = (): Promise<{ credentials: InstanceCredentialMetadata[] }> => apiFetch("/api/instance-credentials/google-oauth/disable", { method: "POST" }); export const useHostGoogleOAuthApplication = (): Promise<{ credentials: InstanceCredentialMetadata[] }> => apiFetch("/api/instance-credentials/google-oauth/use-host", { method: "POST" }); diff --git a/src/components/settings/cards/CoreProviderCredentialsCard.test.tsx b/src/components/settings/cards/CoreProviderCredentialsCard.test.tsx index 64bb9678..92fe4e47 100644 --- a/src/components/settings/cards/CoreProviderCredentialsCard.test.tsx +++ b/src/components/settings/cards/CoreProviderCredentialsCard.test.tsx @@ -5,6 +5,7 @@ import type { InstanceCredentialMetadata } from "../../../../shared/types/instan const mockApi = vi.hoisted(() => ({ disableInstanceCredential: vi.fn(), + discardInstanceCredentialPending: vi.fn(), getInstanceCredentials: vi.fn(), importInstanceCredentialEnvironment: vi.fn(), stageInstanceCredential: vi.fn(), @@ -33,6 +34,8 @@ const metadata = (overrides: Partial = {}): Instance lastFailedAt: null, errorCode: null, version: null, + pendingStagedAt: null, + pendingExpiresAt: null, ...overrides, }); @@ -140,6 +143,30 @@ describe("CoreProviderCredentialsCard", () => { expect(input.value).toBe(""); }); + it("shows pending expiry and discards only the candidate after password step-up", async () => { + const expiresAt = Date.UTC(2026, 6, 21, 18); + const pending = metadata({ source: "stored", activeConfigured: true, pendingConfigured: true, pendingStagedAt: expiresAt - 86_400_000, pendingExpiresAt: expiresAt, validationState: "pending", version: 7 }); + const active = metadata({ source: "stored", activeConfigured: true, validationState: "valid", version: 8 }); + mockApi.discardInstanceCredentialPending + .mockRejectedValueOnce(Object.assign(new Error("Confirm your password"), { code: "PASSWORD_STEP_UP_REQUIRED", status: 403 })) + .mockResolvedValueOnce(active); + mockApi.getInstanceCredentials.mockResolvedValueOnce({ credentials: [active], rootKey: {} }); + + renderCard([pending]); + expect(await screen.findByText(/Pending candidate expires/)).toBeTruthy(); + expect(screen.getByText("Setpoint")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Discard pending" })); + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirm and retry" })); + + await waitFor(() => expect(mockApi.discardInstanceCredentialPending).toHaveBeenCalledTimes(2)); + expect(mockApi.discardInstanceCredentialPending).toHaveBeenLastCalledWith("ai.openai_api_key", 7); + await waitFor(() => expect(mockApi.getInstanceCredentials).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("button", { name: "Discard pending" })).toBeNull(); + expect(screen.getByText("Setpoint")).toBeTruthy(); + }); + it("copies an environment value server-side and explains the Render cleanup boundary", async () => { const environment = metadata({ source: "environment", activeConfigured: true }); const stored = metadata({ source: "stored", activeConfigured: true }); diff --git a/src/components/settings/cards/CoreProviderCredentialsCard.tsx b/src/components/settings/cards/CoreProviderCredentialsCard.tsx index ee0b9c0d..60057777 100644 --- a/src/components/settings/cards/CoreProviderCredentialsCard.tsx +++ b/src/components/settings/cards/CoreProviderCredentialsCard.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import type { FormEvent, ReactNode } from "react"; import { disableInstanceCredential, + discardInstanceCredentialPending, importInstanceCredentialEnvironment, stageInstanceCredential, testInstanceCredential, @@ -25,6 +26,7 @@ import { credentialErrorMessage, credentialStatusView, formatCredentialTimestamp, + pendingCredentialExpiryLabel, } from "./coreCredentialModel"; import type { InstanceCredentialMetadata } from "../../../../shared/types/instance-credentials"; import type { SettingsCredentialMetadataProps } from "../settingsTypes"; @@ -170,9 +172,31 @@ function CredentialRow({ : `restoring the host-managed ${definition.label} credential`); } + async function discardPending() { + if (metadata.version === null) return; + const expectedVersion = metadata.version; + await stepUp.run(async () => { + setBusy("discard"); + setMessage(null); + setError(null); + try { + await discardInstanceCredentialPending(definition.key, expectedVersion); + await onRefresh(); + setMessage("Pending candidate discarded. The active credential is unchanged."); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setError("The pending candidate could not be discarded. The active credential is unchanged."); + await onRefresh().catch(() => {}); + } finally { + setBusy(null); + } + }, `discarding the pending ${definition.label} credential`); + } + const lastTest = formatCredentialTimestamp(metadata.lastTestedAt); const lastSuccess = formatCredentialTimestamp(metadata.lastSucceededAt); const lastFailure = formatCredentialTimestamp(metadata.lastFailedAt); + const pendingExpiry = pendingCredentialExpiryLabel(metadata); return (
@@ -216,16 +240,28 @@ function CredentialRow({
{metadata.pendingConfigured ? ( - + <> + + + ) : null} {metadata.source === "environment" ? ( + {pending ? ( + + ) : null} {allEnvironment ? (
) : null} + {pendingExpiry ? {pendingExpiry} : null} {callbackUrl ? (
Authorized redirect URI
diff --git a/src/components/settings/cards/TodoistCard.test.tsx b/src/components/settings/cards/TodoistCard.test.tsx index bcfd03d6..55a55c09 100644 --- a/src/components/settings/cards/TodoistCard.test.tsx +++ b/src/components/settings/cards/TodoistCard.test.tsx @@ -8,6 +8,7 @@ const mockApi = vi.hoisted(() => ({ stageTodoistOAuthApplication: vi.fn(), importTodoistOAuthEnvironment: vi.fn(), beginTodoistOAuth: vi.fn(), + discardTodoistOAuthPending: vi.fn(), })); const mockSecurity = vi.hoisted(() => ({ stepUpWithPassword: vi.fn(), @@ -29,7 +30,7 @@ const disconnectedStatus = { configured: false, oauthRefreshable: false, needsReauth: false, - application: { configured: false, source: "absent", pendingConfigured: false }, + application: { configured: false, source: "absent", pendingConfigured: false, pendingStagedAt: null, pendingExpiresAt: null, candidateVersions: null }, callbackUrl: "https://setpoint.example.com/api/ea/accounts/todoist/callback", webhookUrl: "https://setpoint.example.com/api/todoist/webhook", deliveryMode: "periodic", @@ -161,6 +162,41 @@ describe("TodoistCard", () => { expect(clientSecret.value).toBe(""); }); + it("shows OAuth expiry and atomically discards the pair after password step-up", async () => { + const pendingStatus = { + ...disconnectedStatus, + application: { + configured: true, + source: "stored" as const, + pendingConfigured: true, + pendingStagedAt: Date.UTC(2026, 6, 20, 18), + pendingExpiresAt: Date.UTC(2026, 6, 21, 18), + candidateVersions: { clientId: 21, clientSecret: 22 }, + }, + }; + const activeStatus = { + ...pendingStatus, + application: { ...pendingStatus.application, pendingConfigured: false, pendingStagedAt: null, pendingExpiresAt: null, candidateVersions: null }, + }; + mockApi.getTodoistConnectionStatus.mockResolvedValueOnce(pendingStatus).mockResolvedValueOnce(activeStatus); + mockApi.discardTodoistOAuthPending + .mockRejectedValueOnce(Object.assign(new Error("Confirm your password"), { code: "PASSWORD_STEP_UP_REQUIRED", status: 403 })) + .mockResolvedValueOnce({ credentials: [] }); + + render(); + expect(await screen.findByText(/Pending candidate expires/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Discard pending" })); + expect(await screen.findByLabelText("Current password")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("Current password"), { target: { value: "owner-password" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirm and retry" })); + + await waitFor(() => expect(mockApi.discardTodoistOAuthPending).toHaveBeenCalledTimes(2)); + expect(mockApi.discardTodoistOAuthPending).toHaveBeenLastCalledWith({ clientId: 21, clientSecret: 22 }); + await waitFor(() => expect(mockApi.getTodoistConnectionStatus).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole("button", { name: "Discard pending" })).toBeNull(); + expect(screen.getByText(/App credentials: stored/)).toBeTruthy(); + }); + it("copies the OAuth pair and explains the Render cleanup boundary", async () => { const environmentStatus = { ...disconnectedStatus, diff --git a/src/components/settings/cards/TodoistCard.tsx b/src/components/settings/cards/TodoistCard.tsx index 46b77d59..4c03b9c7 100644 --- a/src/components/settings/cards/TodoistCard.tsx +++ b/src/components/settings/cards/TodoistCard.tsx @@ -3,6 +3,7 @@ import { SiTodoist } from "@icons-pack/react-simple-icons"; import { disconnectTodoistConnection, saveTodoistPersonalToken } from "@/api"; import { beginTodoistOAuth, + discardTodoistOAuthPending, getTodoistConnectionStatus, importTodoistOAuthEnvironment, stageTodoistOAuthApplication, @@ -29,6 +30,7 @@ import { isPasswordStepUpRequired, useSensitiveActionStepUp, } from "../sensitiveActionStepUpModel"; +import { formatCredentialTimestamp } from "./coreCredentialModel"; const BUTTON_MOTION_CLASS = "min-h-11 motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:translate-y-0 sm:min-h-8"; @@ -52,6 +54,7 @@ export default function TodoistCard({ const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [oauthBusy, setOauthBusy] = useState(false); + const [oauthDiscarding, setOauthDiscarding] = useState(false); const [oauthMessage, setOauthMessage] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(openAdvancedSetup); const stepUp = useSensitiveActionStepUp(); @@ -182,6 +185,32 @@ export default function TodoistCard({ }, "copying the Todoist OAuth credentials into Setpoint"); } + async function handleDiscardOAuthApplication() { + const candidateVersions = oauthStatus?.application.candidateVersions; + if (!candidateVersions) return; + await stepUp.run(async () => { + setOauthBusy(true); + setOauthDiscarding(true); + setOauthMessage(null); + try { + await discardTodoistOAuthPending(candidateVersions); + setOauthStatus(await getTodoistConnectionStatus()); + setOauthMessage("Pending application discarded. The active Todoist connection is unchanged."); + } catch (caught) { + if (isPasswordStepUpRequired(caught)) throw caught; + setOauthMessage("The pending Todoist application could not be discarded. The active connection is unchanged."); + try { + setOauthStatus(await getTodoistConnectionStatus()); + } catch { + // Preserve the last redacted status when the refresh is also unavailable. + } + } finally { + setOauthDiscarding(false); + setOauthBusy(false); + } + }, "discarding the pending Todoist application"); + } + async function handleBeginOAuth() { await stepUp.run(async () => { setOauthBusy(true); @@ -320,10 +349,15 @@ export default function TodoistCard({ delivery to periodic sync.

{oauthStatus ? ( - - Mode: {oauthStatus.mode.replace("_", " ")} · App credentials: {oauthStatus.application.source} - {oauthStatus.application.pendingConfigured ? " (pending validation)" : ""} · Delivery: {oauthStatus.deliveryMode.replace("_", " ")} - +
+ + Mode: {oauthStatus.mode.replace("_", " ")} · App credentials: {oauthStatus.application.source} + {oauthStatus.application.pendingConfigured ? " (pending validation)" : ""} · Delivery: {oauthStatus.deliveryMode.replace("_", " ")} + + {oauthStatus.application.pendingConfigured && oauthStatus.application.pendingExpiresAt !== null ? ( + Pending candidate expires {formatCredentialTimestamp(oauthStatus.application.pendingExpiresAt)} + ) : null} +
) : null}
@@ -369,6 +403,18 @@ export default function TodoistCard({ > Connect with OAuth + {oauthStatus?.application.pendingConfigured ? ( + + ) : null} {oauthStatus?.application.source === "environment" ? (