mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
887d448ecc
commit
185e626651
12 files changed
+809
-24
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user