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

+64 -7
View File
@@ -5,6 +5,7 @@ import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
import { NextRequest, NextResponse } from "next/server";
@@ -12,6 +13,20 @@ import { timingSafeEqual } from "node:crypto";
/**
* OAuth callback from Withings. Exchanges code for tokens and stores them.
*
* v1.4.47 W6 — `state` is now a fully-random 22-character base64url
* nonce keyed against the `WithingsOAuthState` ledger, rather than the
* legacy `${user.id}:${random16}` shape. The user identity bound to
* the in-flight handshake is resolved via the ledger's `userId`
* column (no longer parsed from the cookie value) and cross-checked
* against the session — both must agree before the token exchange
* runs. The row is consumed (deleted) on every exit branch so a
* replay of the same nonce fails the second time.
*
* Closes the v1.4.43 security audit L-1 finding: the cookie now
* carries only the opaque nonce, so a future refactor flipping it to
* non-httpOnly cannot leak the user id into request logs / network
* captures.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -20,9 +35,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const storedState = request.cookies.get("withings_state")?.value;
const storedState = request.cookies.get(WITHINGS_OAUTH_STATE_COOKIE)?.value;
// CSRF check (timing-safe comparison to prevent timing attacks)
// CSRF check, leg 1: URL state must match the cookie state byte-for-byte.
// Timing-safe comparison so a probe can't recover the cookie bytes via
// response-time analysis.
if (
!state ||
!storedState ||
@@ -37,18 +54,42 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// Verify state contains current user's ID
const [stateUserId] = state.split(":");
if (stateUserId !== user.id) {
// CSRF check, leg 2: the nonce must resolve to a live, unexpired
// ledger row whose `userId` matches the authenticated session.
// Resolving the user from the row (rather than parsing the cookie
// value) is the v1.4.43 audit recommendation — a future refactor
// flipping the cookie to non-httpOnly cannot leak the user id this
// way.
const stateRow = await prisma.withingsOAuthState.findUnique({
where: { nonce: state },
});
if (
!stateRow ||
stateRow.expiresAt <= new Date() ||
stateRow.userId !== user.id
) {
// Single-use: stamp out the row whether the expiry tripped, the
// user mismatched, or we found nothing. The user will retry from
// `withings/connect` with a fresh nonce.
if (stateRow) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
}
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=user",
"/settings/integrations?withings=error&reason=state",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (!code) {
// Consume the row even on the no-code path so the nonce can't
// be replayed by an attacker who scraped the redirect URL.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocode",
@@ -60,6 +101,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
try {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocreds",
@@ -112,16 +156,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
// Consume the ledger row — single-use semantics. From this point
// on a replay of the same `state` lands on the not-found branch
// above and redirects to the error page.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
const response = NextResponse.redirect(
new URL(
"/settings/integrations?withings=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete("withings_state");
response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
// Consume the row on the failure path too — the user is going to
// retry from `withings/connect` which mints a fresh nonce, and a
// stranded row buys us nothing.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=token",
+25 -7
View File
@@ -1,14 +1,26 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { randomBytes } from "node:crypto";
import {
WITHINGS_OAUTH_STATE_COOKIE,
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextResponse } from "next/server";
/**
* Redirects the user to Withings OAuth authorization page.
* State param = userId:random for CSRF protection.
*
* v1.4.47 W6 — state is now a fully-random 22-character base64url
* nonce backed by a 10-minute `WithingsOAuthState` row, rather than
* the legacy `${user.id}:${random16}` shape. The cookie carries JUST
* the nonce (no user id), the OAuth state URL param carries JUST the
* nonce, and the callback resolves the user via the row's `userId`
* column. Closes the v1.4.43 security audit L-1 finding where the
* user id was recoverable from log entries / network captures.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -22,17 +34,23 @@ export const GET = apiHandler(async () => {
);
}
const stateNonce = randomBytes(16).toString("hex");
const state = `${user.id}:${stateNonce}`;
const nonce = mintWithingsOAuthStateNonce();
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
const url = getAuthorizationUrl(state, creds);
const url = getAuthorizationUrl(nonce, creds);
const response = NextResponse.redirect(url);
response.cookies.set("withings_state", state, {
response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 600,
maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
@@ -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");
}