mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
60 lines
2.6 KiB
SQL
60 lines
2.6 KiB
SQL
-- v1.4.47 W6 — Withings OAuth state nonce ledger.
|
||
--
|
||
-- The v1.4.43 security audit (L-1) flagged the legacy state shape
|
||
-- `${user.id}:${random16}` as cosmetic-but-fragile: the user id is
|
||
-- recoverable from any request log entry where the cookie isn't
|
||
-- redacted, and from a network-traffic capture of the redirect. The
|
||
-- cookie is `httpOnly` + `Secure` + `sameSite:lax` today so the XSS
|
||
-- exfiltration surface is closed — but a future refactor that flips
|
||
-- the cookie to a non-httpOnly variant would silently expose the user
|
||
-- id. Audit recommendation was to switch the state to a fully-random
|
||
-- 16-byte nonce backed by a short-lived `(nonce → userId)` row.
|
||
--
|
||
-- This table is that ledger. Each `withings/connect` mint persists one
|
||
-- row; `withings/callback` looks it up, asserts `expiresAt > now()`,
|
||
-- and DELETEs the row in a single transaction so a replay of the same
|
||
-- state nonce against a fresh user session fails the CSRF check (the
|
||
-- row is gone after first consumption). A daily cron sweeps any
|
||
-- abandoned rows whose `expiresAt` has passed (user closed the
|
||
-- Withings tab without completing the handshake).
|
||
--
|
||
-- Schema choices:
|
||
--
|
||
-- * `nonce` is the primary key — it's already random and
|
||
-- 22-character base64url, so a dedicated `id` column would just
|
||
-- duplicate the key without buying anything.
|
||
-- * `userId` carries `ON DELETE CASCADE` so removing a user cleans
|
||
-- up their in-flight nonces atomically.
|
||
-- * `expiresAt` is indexed for the cleanup sweep — without it the
|
||
-- daily `DELETE … WHERE expiresAt < now()` would seq-scan the
|
||
-- entire table once a day even though the typical row count is
|
||
-- bounded by `users × concurrent in-flight handshakes`.
|
||
--
|
||
-- Idempotent guards (`IF NOT EXISTS`) match the 0067 / 0070 / 0071 /
|
||
-- 0074 / 0075 pattern so reruns are safe.
|
||
--
|
||
-- Reversibility: down migration is `DROP TABLE IF EXISTS
|
||
-- "withings_oauth_states"`. Additive-only; no existing row is
|
||
-- touched.
|
||
|
||
CREATE TABLE IF NOT EXISTS "withings_oauth_states" (
|
||
"nonce" TEXT NOT NULL,
|
||
"user_id" TEXT NOT NULL,
|
||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||
|
||
CONSTRAINT "withings_oauth_states_pkey" PRIMARY KEY ("nonce")
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS "withings_oauth_states_expires_at_idx"
|
||
ON "withings_oauth_states" ("expires_at");
|
||
|
||
DO $$ BEGIN
|
||
ALTER TABLE "withings_oauth_states"
|
||
ADD CONSTRAINT "withings_oauth_states_user_id_fkey"
|
||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||
EXCEPTION
|
||
WHEN duplicate_object THEN NULL;
|
||
END $$;
|