mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Connect mints the state nonce into the WhoopOAuthState ledger and redirects to the WHOOP authorize page; callback verifies the nonce (atomic delete-first consume, four distinguished reason tags), exchanges the code, fetches the WHOOP profile for the user id, persists the encrypted WhoopConnection, and enqueues the self-converging history backfill. The webhook handler rate-limits per source first, verifies the WHOOP_WEBHOOK_SECRET path segment, then verifies the HMAC-SHA256 body signature over the raw bytes (timestamp + body, timingSafeEqual, stale timestamp reject) before any work. Valid *.updated enqueues the matching per-resource sync; *.deleted soft-deletes (workouts hard-delete, no soft-delete column). Unknown users return 200 to stop retries. CSP gates api.prod.whoop.com to the WHOOP settings surface + /api/whoop/*; the webhook path joins the public-path allowlist and the path-secret redact registry. Env wiring adds WHOOP_WEBHOOK_SECRET + WHOOP_REDIRECT_URI to the compose whitelist and .env.production.example.
220 lines
8.0 KiB
TypeScript
220 lines
8.0 KiB
TypeScript
/**
|
|
* Shared body processing + signature verification for the WHOOP webhook
|
|
* endpoint (`POST /api/whoop/webhook/[token]`). Mirrors the Withings
|
|
* `webhook-handler.ts` shape, with one genuine improvement: WHOOP signs the
|
|
* body, so we verify an HMAC-SHA256 signature in addition to the path-segment
|
|
* secret (Withings can't sign, so it only has the path secret).
|
|
*
|
|
* WHOOP webhook contract (API v2):
|
|
* - headers `X-WHOOP-Signature` (base64) + `X-WHOOP-Signature-Timestamp`
|
|
* (ms epoch);
|
|
* - signature = `base64(HMAC-SHA256(timestamp + rawBody, secret))`;
|
|
* - body `{ user_id: number, id: string|number, type: string, trace_id }`
|
|
* where `type` is e.g. `recovery.updated` / `sleep.updated` /
|
|
* `workout.updated` (+ the matching `*.deleted`). Creates arrive as
|
|
* `*.updated`. The payload carries NO resource data — the per-resource
|
|
* sync job re-fetches by id.
|
|
*
|
|
* Auth layering, in order (every leg short-circuits before any work):
|
|
* 1. per-source rate limit (BEFORE secret verify — DoS floor);
|
|
* 2. path-segment secret `timingSafeStringEqual`;
|
|
* 3. HMAC body signature `timingSafeEqual` over the raw bytes + a stale
|
|
* timestamp reject.
|
|
*
|
|
* Always returns 200 (even for an unknown WHOOP user) so WHOOP doesn't queue
|
|
* retries forever for a disconnected account — same contract as Withings.
|
|
*/
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
import { prisma } from "@/lib/db";
|
|
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
|
import { getClientIp } from "@/lib/api-response";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
|
|
|
/**
|
|
* Maximum age of a webhook timestamp before it is treated as a replay. Five
|
|
* minutes comfortably covers clock skew + delivery latency while bounding the
|
|
* window an attacker has to replay a captured (signed) body.
|
|
*/
|
|
const WHOOP_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Resource-`type` prefix → pg-boss queue name. The webhook only enqueues a
|
|
* per-user job; the worker re-fetches the resource by id. Cycle has no
|
|
* webhook (poll-only) so it is intentionally absent.
|
|
*/
|
|
const WHOOP_RESOURCE_QUEUE: Record<string, string> = {
|
|
recovery: "whoop-recovery-sync",
|
|
sleep: "whoop-sleep-sync",
|
|
workout: "whoop-workout-sync",
|
|
};
|
|
|
|
/**
|
|
* Constant-time string comparison. Returns false unless both inputs have the
|
|
* same byte length AND match exactly.
|
|
*/
|
|
export async function timingSafeStringEqual(
|
|
expected: string,
|
|
received: string,
|
|
): Promise<boolean> {
|
|
const a = Buffer.from(expected, "utf8");
|
|
const b = Buffer.from(received, "utf8");
|
|
if (a.length !== b.length) return false;
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
/**
|
|
* Apply the per-source rate limit every WHOOP webhook delivery shares.
|
|
* Returns a `NextResponse` when the request must be rejected, `null` when it
|
|
* should continue. Runs BEFORE secret/signature verification so a flood of
|
|
* forged deliveries can't drive unbounded crypto work.
|
|
*/
|
|
export async function applyWebhookRateLimit(
|
|
request: NextRequest,
|
|
): Promise<NextResponse | null> {
|
|
const ip = getClientIp(request);
|
|
const rl = await checkRateLimit(`whoop-webhook:${ip}`, 60, 60 * 1000);
|
|
if (!rl.allowed) {
|
|
return NextResponse.json(
|
|
{ status: "rate_limited" },
|
|
{ status: 429, headers: rateLimitHeaders(rl) },
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Verify the WHOOP HMAC body signature against the raw request bytes.
|
|
*
|
|
* `secret` is the per-instance `WHOOP_WEBHOOK_SECRET`. The base string is
|
|
* `timestamp + rawBody` (timestamp first, per the WHOOP v2 spec). Returns
|
|
* true only when the timestamp is fresh AND the recomputed signature matches
|
|
* the `X-WHOOP-Signature` header byte-for-byte under a constant-time compare.
|
|
*/
|
|
export function verifyWhoopSignature(args: {
|
|
rawBody: string;
|
|
signature: string | null;
|
|
timestamp: string | null;
|
|
secret: string;
|
|
now?: number;
|
|
}): boolean {
|
|
const { rawBody, signature, timestamp, secret } = args;
|
|
if (!signature || !timestamp) return false;
|
|
|
|
const ts = Number.parseInt(timestamp, 10);
|
|
if (!Number.isFinite(ts)) return false;
|
|
const now = args.now ?? Date.now();
|
|
if (Math.abs(now - ts) > WHOOP_SIGNATURE_MAX_AGE_MS) return false;
|
|
|
|
const expected = createHmac("sha256", secret)
|
|
.update(timestamp + rawBody, "utf8")
|
|
.digest("base64");
|
|
|
|
const a = Buffer.from(expected, "utf8");
|
|
const b = Buffer.from(signature, "utf8");
|
|
if (a.length !== b.length) return false;
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
interface WhoopWebhookBody {
|
|
user_id?: number | string;
|
|
id?: number | string;
|
|
type?: string;
|
|
}
|
|
|
|
/**
|
|
* Process an authenticated + signature-verified WHOOP notification. Resolves
|
|
* the `WhoopConnection` by `whoopUserId`, then either enqueues the matching
|
|
* per-resource sync job (`*.updated`) or soft-deletes the matching rows
|
|
* (`*.deleted`). The caller has already verified rate limit + path secret +
|
|
* HMAC signature and parsed the body.
|
|
*/
|
|
export async function processWhoopNotification(
|
|
body: WhoopWebhookBody,
|
|
): Promise<Response> {
|
|
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
|
|
|
const whoopUserId = body.user_id != null ? String(body.user_id) : null;
|
|
const type = typeof body.type === "string" ? body.type : null;
|
|
const resourceId = body.id != null ? String(body.id) : null;
|
|
|
|
if (!whoopUserId || !type) {
|
|
annotate({ action: { name: "whoop.webhook.ignored" } });
|
|
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
|
}
|
|
|
|
const connection = await prisma.whoopConnection.findFirst({
|
|
where: { whoopUserId },
|
|
select: { userId: true },
|
|
});
|
|
if (!connection) {
|
|
// Unknown / disconnected user. Return 200 so WHOOP stops retrying.
|
|
annotate({ action: { name: "whoop.webhook.unknown_user" } });
|
|
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
|
|
}
|
|
|
|
const [resource, verb] = type.split(".");
|
|
const queue = WHOOP_RESOURCE_QUEUE[resource ?? ""];
|
|
|
|
if (verb === "deleted") {
|
|
// Soft-delete every matching row for this user + WHOOP resource id.
|
|
// `externalId` is `<whoop-resource-uuid>:<field-tag>` so a `startsWith`
|
|
// match catches every measurement derived from the deleted resource.
|
|
if (resourceId) {
|
|
const now = new Date();
|
|
await prisma.measurement.updateMany({
|
|
where: {
|
|
userId: connection.userId,
|
|
source: "WHOOP",
|
|
externalId: { startsWith: `${resourceId}:` },
|
|
deletedAt: null,
|
|
},
|
|
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
|
});
|
|
if (resource === "workout") {
|
|
// The Workout model carries no soft-delete column, so a deleted
|
|
// WHOOP workout is removed outright (keyed by the canonical
|
|
// `(userId, source, externalId)` unique).
|
|
await prisma.workout.deleteMany({
|
|
where: {
|
|
userId: connection.userId,
|
|
source: "WHOOP",
|
|
externalId: resourceId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
annotate({
|
|
action: { name: "whoop.webhook.deleted" },
|
|
meta: { resource: resource ?? "unknown" },
|
|
});
|
|
return NextResponse.json({ status: "ok" }, { status: 200 });
|
|
}
|
|
|
|
if (!queue) {
|
|
// A resource type with no webhook-driven sync queue (e.g. cycle is
|
|
// poll-only). Acknowledge so WHOOP doesn't retry.
|
|
annotate({
|
|
action: { name: "whoop.webhook.no_queue" },
|
|
meta: { resource: resource ?? "unknown" },
|
|
});
|
|
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
|
}
|
|
|
|
// `*.updated` (creates arrive as updates too). Enqueue the per-user
|
|
// resource sync; the worker re-fetches by id (payload carries no data).
|
|
const boss = getGlobalBoss();
|
|
if (!boss) {
|
|
getEvent()?.addWarning("whoop-webhook: pg-boss not initialised");
|
|
return NextResponse.json({ status: "ok" }, { status: 200 });
|
|
}
|
|
await boss.send(queue, { userId: connection.userId });
|
|
|
|
annotate({
|
|
action: { name: "whoop.webhook.enqueued" },
|
|
meta: { resource: resource ?? "unknown" },
|
|
});
|
|
return NextResponse.json({ status: "ok" }, { status: 200 });
|
|
}
|