mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -69,6 +69,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
"user_achievements",
|
||||
"users",
|
||||
"withings_connections",
|
||||
"withings_oauth_states",
|
||||
"workout_routes",
|
||||
"workouts",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* v1.4.47 W6 — end-to-end coverage for the new Withings OAuth state
|
||||
* ledger.
|
||||
*
|
||||
* The audit's L-1 recommendation was a fully-random nonce backed by a
|
||||
* `(nonce → userId)` ledger row, single-use semantics, with the
|
||||
* cookie carrying only the opaque nonce. This file pins the
|
||||
* connect → callback handshake end-to-end against real Postgres:
|
||||
*
|
||||
* 1. `withings/connect` mints a row and stamps the cookie.
|
||||
* 2. `withings/callback` resolves the user via the row's `userId`,
|
||||
* verifies it matches the session, and deletes the row.
|
||||
* 3. A second callback with the same nonce hits the "row not
|
||||
* found" branch and redirects to the error page (replay
|
||||
* protection).
|
||||
* 4. A callback whose row has expired hits the same branch.
|
||||
* 5. The connect handler refuses to start when the user has no
|
||||
* Withings credentials — no ledger row is minted.
|
||||
*
|
||||
* Sibling assertions in `withings-oauth.test.ts` already cover the
|
||||
* token-exchange happy path + the `disconnect` route; this file
|
||||
* focuses on the ledger lifecycle the audit flagged.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TEST_USER_ID = "user-withings-oauth-flow";
|
||||
|
||||
const WITHINGS_OAUTH_URL =
|
||||
"https://account.withings.com/oauth2_user/authorize2";
|
||||
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
|
||||
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "withings-flow-user",
|
||||
email: "withings-flow@example.test",
|
||||
role: "USER",
|
||||
withingsClientIdEncrypted: encrypt("client-id-flow"),
|
||||
withingsClientSecretEncrypted: encrypt("client-secret-flow"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Withings OAuth nonce ledger (real Postgres)", () => {
|
||||
it("connect mints exactly one row keyed on the URL state, with `userId` + 10-min TTL", async () => {
|
||||
const { GET } = await import("@/app/api/withings/connect/route");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/withings/connect"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain(WITHINGS_OAUTH_URL);
|
||||
const target = new URL(location);
|
||||
const state = target.searchParams.get("state")!;
|
||||
|
||||
// 22-char base64url. The legacy `${userId}:${random16}` shape no
|
||||
// longer reaches the URL — the audit L-1 fix.
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
expect(state).not.toContain(":");
|
||||
expect(state).not.toContain(TEST_USER_ID);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const rows = await prisma.withingsOAuthState.findMany();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].nonce).toBe(state);
|
||||
expect(rows[0].userId).toBe(TEST_USER_ID);
|
||||
// TTL is 10 minutes; allow a generous floor so a slow CI box
|
||||
// doesn't flake on the upper bound.
|
||||
const ttlMs = rows[0].expiresAt.getTime() - Date.now();
|
||||
expect(ttlMs).toBeGreaterThan(9 * 60 * 1000);
|
||||
expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 5_000);
|
||||
|
||||
// Cookie carries the same opaque nonce — no user id.
|
||||
const setCookie = res.headers.get("set-cookie")!;
|
||||
expect(setCookie).toContain(`withings_state=${state}`);
|
||||
expect(setCookie).not.toContain(TEST_USER_ID);
|
||||
});
|
||||
|
||||
it("connect refuses (400) when the user has no Withings credentials — no ledger row minted", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.update({
|
||||
where: { id: TEST_USER_ID },
|
||||
data: {
|
||||
withingsClientIdEncrypted: null,
|
||||
withingsClientSecretEncrypted: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/withings/connect/route");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/withings/connect"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const rows = await prisma.withingsOAuthState.findMany();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("connect → callback consumes the row (single-use); replay of the same nonce hits the error page", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
if (url === WITHINGS_TOKEN_URL) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: 0,
|
||||
body: {
|
||||
userid: "withings-userid-flow",
|
||||
access_token: "access-flow",
|
||||
refresh_token: "refresh-flow",
|
||||
expires_in: 3600,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === WITHINGS_NOTIFY_URL) {
|
||||
return new Response(JSON.stringify({ status: 0 }), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch in flow test: ${url}`);
|
||||
});
|
||||
|
||||
// Leg 1 — connect
|
||||
const { GET: connect } = await import("@/app/api/withings/connect/route");
|
||||
const connectRes = await (
|
||||
connect as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/withings/connect"));
|
||||
const location = connectRes.headers.get("location")!;
|
||||
const nonce = new URL(location).searchParams.get("state")!;
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const beforeCallback = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(beforeCallback).not.toBeNull();
|
||||
|
||||
// Leg 2 — callback (happy path)
|
||||
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
||||
const callbackReq = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const callbackRes = await callback(callbackReq);
|
||||
expect(callbackRes.status).toBe(307);
|
||||
expect(callbackRes.headers.get("location")).toContain(
|
||||
"withings=connected",
|
||||
);
|
||||
|
||||
const afterCallback = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(afterCallback).toBeNull();
|
||||
|
||||
// Leg 3 — replay the same nonce. The cookie compare still passes
|
||||
// (matching strings) but the row lookup misses, so the handler
|
||||
// bounces to the error page without hitting the token endpoint
|
||||
// again.
|
||||
const fetchSpy = vi.mocked(globalThis.fetch);
|
||||
fetchSpy.mockClear();
|
||||
const replayReq = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const replayRes = await callback(replayReq);
|
||||
expect(replayRes.status).toBe(307);
|
||||
expect(replayRes.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
// No token-endpoint call on the replay leg.
|
||||
const replayTokenCalls = fetchSpy.mock.calls.filter(
|
||||
([input]) =>
|
||||
(typeof input === "string" ? input : (input as Request).url) ===
|
||||
WITHINGS_TOKEN_URL,
|
||||
);
|
||||
expect(replayTokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("callback rejects an expired row, deletes it, and never reaches the token exchange", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
||||
|
||||
const nonce = "expired-nonce-22charXX";
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() - 60_000), // already expired
|
||||
},
|
||||
});
|
||||
|
||||
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const res = await callback(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
|
||||
// Token endpoint never hit.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The handler stamps out the expired row so a clock-skewed replay
|
||||
// can't slip through.
|
||||
const lingering = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(lingering).toBeNull();
|
||||
});
|
||||
|
||||
it("callback rejects a row whose userId does not match the session, deletes it, and writes no connection", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
||||
|
||||
// Seed a second user + a ledger row that points to *them*. The
|
||||
// callback runs under TEST_USER_ID's session, so the row's userId
|
||||
// mismatch must trip the rejection branch.
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "user-other-flow",
|
||||
username: "other-user",
|
||||
email: "other@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const nonce = "cross-user-noncexxxxxx";
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: "user-other-flow",
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const res = await callback(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The row is stamped out — single-use + cross-user mismatch is a
|
||||
// CSRF signal, not a recoverable state, so we don't leave the
|
||||
// row around for a retry.
|
||||
const lingering = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(lingering).toBeNull();
|
||||
|
||||
// No connection landed under EITHER user.
|
||||
expect(
|
||||
await prisma.withingsConnection.findUnique({
|
||||
where: { userId: TEST_USER_ID },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await prisma.withingsConnection.findUnique({
|
||||
where: { userId: "user-other-flow" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -118,15 +118,29 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
);
|
||||
expect(target.searchParams.get("scope")).toBe("user.metrics,user.activity");
|
||||
const state = target.searchParams.get("state");
|
||||
expect(state).toMatch(/^user-withings-oauth:[a-f0-9]{32}$/);
|
||||
// v1.4.47 W6 — state is a fully-random 22-char base64url nonce.
|
||||
// The legacy `${user.id}:${random16}` shape leaked the user id to
|
||||
// request logs / network captures (audit L-1); the new shape is
|
||||
// opaque and resolves the user via the `WithingsOAuthState` row.
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
|
||||
// The Set-Cookie header carries the same state nonce — that cookie
|
||||
// is what `callback/route.ts` compares against on the second leg.
|
||||
// Cookie value is URL-encoded for the `:` separator; compare on the
|
||||
// encoded form so the assertion is byte-stable.
|
||||
// The Set-Cookie header carries the same opaque nonce — that
|
||||
// cookie is what `callback/route.ts` compares against on the
|
||||
// second leg before resolving the user via the ledger row.
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toContain("withings_state=");
|
||||
expect(setCookie).toContain(encodeURIComponent(state!));
|
||||
expect(setCookie).toContain(state!);
|
||||
|
||||
// v1.4.47 W6 — the connect handler also persists a ledger row.
|
||||
// Asserting on it here closes the contract loop: the row is what
|
||||
// the callback handler will look up.
|
||||
const prisma = getPrismaClient();
|
||||
const row = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce: state! },
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.userId).toBe(TEST_USER_ID);
|
||||
expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("callback exchanges code → encrypted tokens on the WithingsConnection row", async () => {
|
||||
@@ -156,7 +170,19 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
throw new Error(`Unexpected fetch in withings-oauth test: ${url}`);
|
||||
});
|
||||
|
||||
const state = `${TEST_USER_ID}:${"a".repeat(32)}`;
|
||||
// v1.4.47 W6 — seed a live ledger row so the callback can resolve
|
||||
// the user via the row's `userId`. The state on the URL + cookie
|
||||
// must equal the row's `nonce`.
|
||||
const state = "abcdefghijklmnopqrstuv";
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce: state,
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(state)}`,
|
||||
{
|
||||
@@ -175,7 +201,14 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
"/settings/integrations?withings=connected",
|
||||
);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
// v1.4.47 W6 — the ledger row is consumed on the happy path
|
||||
// (single-use semantics). A replay of the same nonce would fail
|
||||
// the state-not-found branch and bounce to the error page.
|
||||
const consumed = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce: state },
|
||||
});
|
||||
expect(consumed).toBeNull();
|
||||
|
||||
const conn = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
@@ -210,8 +243,12 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
||||
|
||||
const validState = `${TEST_USER_ID}:${"a".repeat(32)}`;
|
||||
const replayedState = `${TEST_USER_ID}:${"b".repeat(32)}`;
|
||||
// v1.4.47 W6 — 22-char base64url nonces; the cookie value and the
|
||||
// URL state diverge so the constant-time check short-circuits on
|
||||
// the first byte mismatch. No ledger row needs to exist because
|
||||
// the request never reaches the row lookup.
|
||||
const validState = "aaaaaaaaaaaaaaaaaaaaaa";
|
||||
const replayedState = "bbbbbbbbbbbbbbbbbbbbbb";
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(replayedState)}`,
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user