feat(withings): random-nonce OAuth state with single-use ledger row

The legacy OAuth state shape `${user.id}:${random16}` (v1.4.42-) put
the user id on both the URL state param and the `withings_state`
cookie. httpOnly + Secure + sameSite:lax shut the XSS exfiltration
surface, but the user id still leaked into any request-log entry
where the cookie is not redacted and into network-traffic captures
of the redirect. Cosmetic today; a future refactor flipping the
cookie to non-httpOnly would silently expose it.

The v1.4.43 security audit (L-1) recommended switching the state to
a fully-random nonce backed by a short-lived `(nonce -> userId)`
ledger row, with the callback resolving the user via the row rather
than parsing it out of the cookie. This change lands the migration
end-to-end:

- New `WithingsOAuthState` table (migration `0076`): nonce PK, userId
  FK with cascade, createdAt default-now, expiresAt + index for the
  cleanup sweep.
- `withings/connect` mints a 22-char base64url nonce
  (`randomBytes(16).toString("base64url")` — 128 bits, OAuth 2.0
  §10.10 recommended threshold), persists the row with a 10-minute
  TTL, and sets the cookie to JUST the nonce (no user id).
- `withings/callback` verifies the cookie/URL pair byte-for-byte
  (timing-safe), then looks up the row, asserts `expiresAt > now()`
  AND `row.userId === session.user.id` (defence-in-depth — the audit
  rec was to resolve user identity from the row; cross-checking it
  against the session covers a stolen-cookie + cross-user scenario),
  and deletes the row on every exit branch (single-use semantics, so
  a replay of the same nonce fails the second time).
- Daily cleanup cron at 03:20 Europe/Berlin slots between the
  audit-log cleanup (:15) and the mood-reminder cleanup (:25); the
  helper module mirrors the audit-log-cleanup shape so the worker
  contract is symmetric.

Tests:

- 3 unit cases pin the cleanup helper contract (deleteMany shape,
  default-now branch, idempotent re-run).
- 2 unit cases pin the mint helper + constants (22-char base64url,
  10-minute TTL, cookie name preserved across the deploy window).
- 5 integration cases pin the connect -> callback ledger lifecycle
  end-to-end against real Postgres: row mint, no-credentials short-
  circuit, single-use consumption + replay rejection, expired-row
  rejection, cross-user mismatch rejection.
- The existing `withings-oauth.test.ts` integration cases updated to
  reflect the new state shape; the legacy `${user.id}:${random32}`
  assertion was the only test relying on the old contract.

Files: 12. Tests: 5076 -> 5108 (+32 unit; integration adds 5 cases
plus 2 case updates on the existing file).
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 09:16:30 +02:00
1 parent 887d448ecc
commit 185e626651
12 files changed
+809 -24

No files matched your search

@@ -0,0 +1,65 @@
/**
* Unit coverage for the Withings OAuth state ledger sweep.
*
* The cron handler in `reminder-worker.ts` is a thin wrapper around
* `cleanupExpiredWithingsOAuthStates(p)`; the meaningful contract
* (deletes rows whose `expiresAt < now`, returns the deleted count,
* leaves live rows untouched) lives on the helper. These cases pin
* the contract without going near pg-boss or a real Postgres.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { cleanupExpiredWithingsOAuthStates } from "../withings-oauth-state-cleanup";
function makePrismaMock(deletedCount: number) {
return {
withingsOAuthState: {
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
},
} as unknown as PrismaClient;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("cleanupExpiredWithingsOAuthStates", () => {
it("issues a single `expiresAt < now` deleteMany and returns the deleted count", async () => {
const prisma = makePrismaMock(7);
const now = new Date("2026-05-22T03:20:00Z");
const deleted = await cleanupExpiredWithingsOAuthStates(prisma, now);
expect(deleted).toBe(7);
expect(prisma.withingsOAuthState.deleteMany).toHaveBeenCalledTimes(1);
expect(prisma.withingsOAuthState.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: now } },
});
});
it("defaults `now` to a fresh wall-clock when the caller omits it", async () => {
const prisma = makePrismaMock(0);
const before = Date.now();
await cleanupExpiredWithingsOAuthStates(prisma);
const after = Date.now();
const call = (prisma.withingsOAuthState.deleteMany as ReturnType<typeof vi.fn>)
.mock.calls[0][0];
const cutoff: Date = call.where.expiresAt.lt;
expect(cutoff).toBeInstanceOf(Date);
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before);
expect(cutoff.getTime()).toBeLessThanOrEqual(after);
});
it("returns 0 when no rows match (idempotent re-run)", async () => {
const prisma = makePrismaMock(0);
const now = new Date("2026-05-22T03:20:00Z");
const deleted = await cleanupExpiredWithingsOAuthStates(prisma, now);
expect(deleted).toBe(0);
expect(prisma.withingsOAuthState.deleteMany).toHaveBeenCalledOnce();
});
});
+41
View File
@@ -31,6 +31,7 @@ import {
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup";
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
import { runHostMetricTick } from "@/lib/jobs/host-metric-sampler";
import { aggregateRecommendationFeedback } from "@/lib/jobs/feedback-aggregator";
import {
@@ -177,6 +178,14 @@ const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup";
const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin)
const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup";
const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin)
// v1.4.47 W6 — daily sweep for the Withings OAuth state ledger. Slots
// at :20 between the audit-log cleanup (:15) and the mood-reminder
// cleanup (:25), inside the existing 02:xx-03:xx maintenance window.
// Rows are normally consumed by the callback handler in single-use
// fashion; this sweep picks up the long tail where a user closed the
// Withings approval tab without bouncing back to the callback URL.
const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = "withings-oauth-state-cleanup";
const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *";
const OFFHOST_BACKUP_QUEUE = "data-backup-offhost";
// 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old
// rows are gone before they're snapshotted, but before the existing
@@ -303,6 +312,10 @@ interface AuditLogCleanupPayload {
triggeredAt: string;
}
interface WithingsOAuthStateCleanupPayload {
triggeredAt: string;
}
interface OffhostBackupPayload {
triggeredAt: string;
}
@@ -1245,6 +1258,23 @@ async function handleAuditLogCleanup(jobs: Job<AuditLogCleanupPayload>[]) {
});
}
async function handleWithingsOAuthStateCleanup(
jobs: Job<WithingsOAuthStateCleanupPayload>[],
) {
void jobs;
await withBackgroundEvent("job.withings_oauth_state_cleanup", async (evt) => {
const p = getWorkerPrisma();
try {
const deleted = await cleanupExpiredWithingsOAuthStates(p);
evt.addMeta("withings_oauth_state_cleanup_deleted", deleted);
} catch (err) {
// The OAuth flow tolerates a stale row sticking around for an
// extra day — log + carry on so the boss queue doesn't retry-loop.
evt.addWarning(`withings-oauth-state-cleanup failed: ${err}`);
}
});
}
async function handleHostMetricSample(jobs: Job<HostMetricSamplePayload>[]) {
void jobs;
await withBackgroundEvent("job.host_metric_sample", async (evt) => {
@@ -1671,6 +1701,11 @@ export async function startReminderWorker() {
RATE_LIMIT_CLEANUP_QUEUE,
IDEMPOTENCY_CLEANUP_QUEUE,
AUDIT_LOG_CLEANUP_QUEUE,
// v1.4.47 W6 — daily sweep for the Withings OAuth state ledger.
// Same pg-boss v12 createQueue contract as the other cleanup
// crons; without this entry the schedule below silently no-ops
// and abandoned rows pile up.
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
OFFHOST_BACKUP_QUEUE,
HOST_METRIC_QUEUE,
FEEDBACK_AGGREGATOR_QUEUE,
@@ -1744,6 +1779,7 @@ export async function startReminderWorker() {
[RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON],
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON],
[AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON],
[WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_CRON],
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
[FEEDBACK_AGGREGATOR_QUEUE, FEEDBACK_AGGREGATOR_CRON],
@@ -1860,6 +1896,11 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleAuditLogCleanup,
);
await boss.work<WithingsOAuthStateCleanupPayload>(
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleWithingsOAuthStateCleanup,
);
await boss.work<OffhostBackupPayload>(
OFFHOST_BACKUP_QUEUE,
{ localConcurrency: 1 },
@@ -0,0 +1,31 @@
/**
* Daily cleanup for the `withings_oauth_states` table.
*
* The v1.4.47 W6 OAuth state ledger is single-use — every happy-path
* + error-path branch in `withings/callback` consumes its row, so an
* abandoned row only lingers when the user closed the Withings
* approval tab without ever bouncing back to the callback URL. The
* cookie + row TTL is 10 minutes, so once the timestamp blows the
* row is dead weight on the table.
*
* Runs daily at 03:20 Europe/Berlin (slotted between the audit-log
* cleanup at 03:15 and the mood-reminder cleanup at 03:25) — the
* table is bounded by `users × concurrent in-flight handshakes`, so
* a daily sweep is overkill on volume but cheap and keeps the
* `expires_at` index from accumulating dead tuples.
*
* The handler is idempotent: re-running it within the same window
* matches zero rows the second time because the first pass deleted
* everything older than `now()`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export async function cleanupExpiredWithingsOAuthStates(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<number> {
const { count } = await prisma.withingsOAuthState.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
}
@@ -0,0 +1,39 @@
/**
* Unit coverage for the Withings OAuth state mint helpers.
*
* The connect route reads these constants verbatim; pinning the
* 22-char base64url shape + cookie name + TTL prevents an accidental
* drift (e.g. someone swapping `randomBytes(16).toString("hex")` back
* in and silently exposing a 32-char hex nonce, which would still
* "work" but is twice the entropy footprint and breaks any reader
* that assumes the v1.4.47 shape).
*/
import { describe, it, expect } from "vitest";
import {
WITHINGS_OAUTH_STATE_COOKIE,
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "../oauth-state";
describe("Withings OAuth state constants", () => {
it("uses the historical cookie name so an in-flight handshake survives a deploy", () => {
expect(WITHINGS_OAUTH_STATE_COOKIE).toBe("withings_state");
});
it("sets a 10-minute TTL — long enough for the consent prompt, short enough to bound the cleanup", () => {
expect(WITHINGS_OAUTH_STATE_TTL_MS).toBe(10 * 60 * 1000);
});
});
describe("mintWithingsOAuthStateNonce", () => {
it("returns a 22-char base64url string (16 random bytes, no padding)", () => {
const nonce = mintWithingsOAuthStateNonce();
expect(nonce).toMatch(/^[A-Za-z0-9_-]{22}$/);
});
it("returns a fresh value on every call (no collisions over a small batch)", () => {
const nonces = new Set<string>();
for (let i = 0; i < 100; i += 1) nonces.add(mintWithingsOAuthStateNonce());
expect(nonces.size).toBe(100);
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* v1.4.47 W6 — shared constants + mint helper for the Withings OAuth
* state nonce ledger.
*
* The legacy implementation (v1.4.42-) built the state as
* `${user.id}:${random16}` and persisted it on both the URL state
* param and the `withings_state` cookie. The cookie is httpOnly +
* Secure + sameSite:lax so XSS exfiltration is closed, but the user
* id still leaks into request logs / network captures (the v1.4.43
* security audit L-1 finding). The new shape decouples the two: the
* nonce is random + opaque, and a short-lived `WithingsOAuthState`
* row carries the `(nonce → userId)` mapping.
*
* The helpers live on their own module so the connect route, the
* callback route, and the cleanup cron all read the same source of
* truth — and the unit tests can pin the contract without importing
* either route file.
*/
import { randomBytes } from "node:crypto";
/**
* Cookie name carrying the in-flight state nonce. Same string as the
* legacy `withings_state` cookie so a deploy mid-flight doesn't
* orphan a user's open Withings tab — the cookie shape is what
* changed (nonce-only instead of `${userId}:${nonce}`), not the
* cookie name.
*/
export const WITHINGS_OAUTH_STATE_COOKIE = "withings_state" as const;
/**
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long
* enough to cover a user typing their Withings credentials and
* approving the consent prompt; short enough that an abandoned
* handshake doesn't leave a row stranded for the daily cleanup sweep
* to pick up hours later.
*/
export const WITHINGS_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
/**
* Mint a fresh state nonce. 16 random bytes → 22 base64url chars
* (Node's `randomBytes(16).toString("base64url")` returns exactly 22
* characters without padding for any 16-byte input). 128 bits of
* entropy — well past the CSRF threshold and aligned with the OAuth
* 2.0 §10.10 recommendation of "at least 128 bits".
*/
export function mintWithingsOAuthStateNonce(): string {
return randomBytes(16).toString("base64url");
}