From 1a478d3028ebdcca708849cd7374d1efc8809721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 08:08:51 +0200 Subject: [PATCH] feat(whoop): native-scheme connect redirect and Bearer connect ticket Two connect-in-app enhancements for the native client. return_scheme: GET /api/whoop/connect accepts an optional custom scheme, threaded through the server-side OAuth-state row so it survives the round trip without ever riding in the URL or cookie. When a valid scheme is present, the callback's final redirect targets ://whoop?whoop= connected|error&reason=... so an in-app web session auto-completes on its custom-scheme match; absent or invalid, the web settings redirect is unchanged. Schemes validate against a strict allowlist (custom app scheme only, ^[a-z][a-z0-9.+-]*$, http/https/javascript/data/file/vbscript rejected, pinned to dev.healthlog.app). A rejected scheme falls back to the web redirect and never reflects an arbitrary value. Bearer connect ticket: a purely Bearer-authenticated client holds no web session cookie, so it cannot start the handshake through the cookie-gated connect route. POST /api/whoop/connect/ticket (Bearer) mints a one-time, ~60s, opaque ticket bound to the user; only its HMAC-SHA256 hash is stored (the raw value never persists). GET /api/whoop/connect?ticket= accepts the ticket in lieu of a cookie, consumes it atomically (single-use, reuse rejected), sets the nonce cookie, and 302s to WHOOP exactly as the cookie path. Expired, consumed, or invalid tickets return a typed 401. The two combine. The daily WHOOP OAuth-state cleanup also sweeps expired ticket rows. The ticket query param is added to the egress redaction denylist so the opaque value never reaches logs or error reports. Migration 0124 adds whoop_connect_tickets (id, user_id FK cascade, token_hash unique, expires_at, consumed_at, created_at) and a nullable return_scheme column on whoop_oauth_states. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 221b362011d52dda314139e0be7cc195c47d016e) --- .../migration.sql | 55 ++++ prisma/schema.prisma | 39 +++ .../whoop/callback/__tests__/route.test.ts | 128 +++++++++ src/app/api/whoop/callback/route.ts | 132 +++++++--- .../api/whoop/connect/__tests__/route.test.ts | 149 +++++++++++ src/app/api/whoop/connect/route.ts | 55 +++- .../connect/ticket/__tests__/route.test.ts | 69 +++++ src/app/api/whoop/connect/ticket/route.ts | 49 ++++ src/lib/jobs/reminder-worker.ts | 7 +- src/lib/jobs/whoop-oauth-state-cleanup.ts | 17 ++ src/lib/logging/__tests__/redact.test.ts | 4 + src/lib/logging/redact.ts | 5 +- .../whoop/__tests__/connect-ticket.test.ts | 93 +++++++ src/lib/whoop/__tests__/return-scheme.test.ts | 61 +++++ src/lib/whoop/connect-ticket.ts | 88 +++++++ src/lib/whoop/return-scheme.ts | 87 +++++++ .../integration/whoop-connect-ticket.test.ts | 245 ++++++++++++++++++ 17 files changed, 1239 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql create mode 100644 src/app/api/whoop/callback/__tests__/route.test.ts create mode 100644 src/app/api/whoop/connect/__tests__/route.test.ts create mode 100644 src/app/api/whoop/connect/ticket/__tests__/route.test.ts create mode 100644 src/app/api/whoop/connect/ticket/route.ts create mode 100644 src/lib/whoop/__tests__/connect-ticket.test.ts create mode 100644 src/lib/whoop/__tests__/return-scheme.test.ts create mode 100644 src/lib/whoop/connect-ticket.ts create mode 100644 src/lib/whoop/return-scheme.ts create mode 100644 tests/integration/whoop-connect-ticket.test.ts diff --git a/prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql b/prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql new file mode 100644 index 000000000..6ac99d75d --- /dev/null +++ b/prisma/migrations/0124_v1122_whoop_connect_ticket/migration.sql @@ -0,0 +1,55 @@ +-- v1.12.2 — WHOOP connect-in-app enhancements (two iOS-requested asks). +-- +-- 1. `whoop_oauth_states` += `return_scheme` (nullable). The native client may +-- pass `?return_scheme=` to `GET /api/whoop/connect`; the +-- connect route validates it against a strict allowlist and stores it on the +-- in-flight state row so it survives the OAuth round-trip server-side (never +-- in the URL or cookie). The callback reads it off the consumed row to send +-- its FINAL redirect to `://whoop?whoop=connected|error&reason=…` +-- instead of the web settings URL. Null = unchanged web redirect. +-- +-- 2. `whoop_connect_tickets` — a one-time, Bearer-mintable connect ticket so a +-- purely Bearer-authenticated native client (no web-session cookie) can +-- start the WHOOP handshake. The client mints a ticket via an authenticated +-- `POST /api/whoop/connect/ticket`, then opens +-- `GET /api/whoop/connect?ticket=` in an in-app web session. Only +-- the HMAC-SHA256 hash of the opaque ticket is stored (`token_hash`, keyed +-- by `API_TOKEN_HMAC_KEY`); the raw value never persists. The ticket is +-- single-use (`consumed_at` stamped atomically on first use) and short-lived +-- (~60s `expires_at`). Mirrors `whoop_oauth_states` in shape + lifecycle. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: the column drops with `DROP COLUMN IF EXISTS "return_scheme"`, +-- the table with `DROP TABLE IF EXISTS "whoop_connect_tickets"`. + +-- ── 1. whoop_oauth_states — native return-scheme carrier ────────────── +ALTER TABLE "whoop_oauth_states" + ADD COLUMN IF NOT EXISTS "return_scheme" TEXT; + +-- ── 2. whoop_connect_tickets — one-time Bearer-startable connect ticket ─ +CREATE TABLE IF NOT EXISTS "whoop_connect_tickets" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + + CONSTRAINT "whoop_connect_tickets_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "whoop_connect_tickets_token_hash_key" + ON "whoop_connect_tickets" ("token_hash"); + +CREATE INDEX IF NOT EXISTS "whoop_connect_tickets_expires_at_idx" + ON "whoop_connect_tickets" ("expires_at"); + +DO $$ BEGIN + ALTER TABLE "whoop_connect_tickets" + ADD CONSTRAINT "whoop_connect_tickets_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 250120304..e07cb488e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -372,6 +372,7 @@ model User { // OAuth-state ledger (1:N) relations. whoopConnection WhoopConnection? whoopOAuthStates WhoopOAuthState[] + whoopConnectTickets WhoopConnectTicket[] // v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) + // OAuth-state ledger (1:N) relations. fitbitConnection FitbitConnection? @@ -1892,6 +1893,12 @@ model WhoopOAuthState { userId String @map("user_id") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") + /// v1.12.2 — optional native custom-scheme (e.g. `dev.healthlog.app`) the + /// connect route validated against the strict allowlist. Carried here so it + /// survives the OAuth round-trip server-side (never in the URL/cookie); the + /// callback reads it off the consumed row to drive its FINAL redirect to + /// `://whoop?…` instead of the web settings URL. Null = web redirect. + returnScheme String? @map("return_scheme") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -1899,6 +1906,38 @@ model WhoopOAuthState { @@map("whoop_oauth_states") } +/// v1.12.2 — one-time, Bearer-mintable WHOOP connect ticket. Lets a purely +/// Bearer-authenticated native client (no web-session cookie) start the WHOOP +/// OAuth handshake: it mints a ticket via `POST /api/whoop/connect/ticket`, +/// then opens `GET /api/whoop/connect?ticket=` in an in-app web +/// session. The connect route resolves the user from the unconsumed/unexpired +/// ticket IN LIEU of a session cookie. +/// +/// Security shape (mirrors the Bearer-token storage pattern): +/// * The raw ticket is returned to the client exactly once and NEVER stored; +/// only its HMAC-SHA256 hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`) +/// lands in the table — a DB read cannot recover a usable ticket. +/// * Single-use: `consumedAt` is stamped by an atomic conditional update at +/// the connect route; a second presentation finds the row already consumed +/// and is rejected. +/// * Short-lived (~60s `expiresAt`); the daily WHOOP OAuth-state cleanup +/// cron also sweeps expired/consumed ticket rows. +model WhoopConnectTicket { + id String @id @default(cuid()) + userId String @map("user_id") + /// HMAC-SHA256 of the opaque raw ticket. The raw value never persists. + tokenHash String @unique @map("token_hash") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + /// Stamped on first (and only) successful consumption. Null = still usable. + consumedAt DateTime? @map("consumed_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([expiresAt]) + @@map("whoop_connect_tickets") +} + /// v1.12.0 — Fitbit/Pixel connection over the Google Health API. Mirrors /// `WhoopConnection` 1:1 per user. Tokens are encrypted at the app level /// before persistence. Unlike WHOOP, Google refresh tokens do not rotate per diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts new file mode 100644 index 000000000..ca630c84a --- /dev/null +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + whoopOAuthState: { delete: vi.fn() }, + whoopConnection: { upsert: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + // Native ticket path → no web session present. + getSession: vi.fn(async () => null), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ + setError: vi.fn(), + addWarning: vi.fn(), + setAuth: vi.fn(), + }), +})); + +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); + +vi.mock("@/lib/whoop/client", () => ({ + WHOOP_OAUTH_SCOPE: "read:recovery", + exchangeCode: vi.fn(async () => ({ + access_token: "at", + refresh_token: "rt", + expires_in: 3600, + scope: "read:recovery", + })), + fetchProfile: vi.fn(async () => ({ user_id: 42 })), +})); + +vi.mock("@/lib/whoop/credentials", () => ({ + getUserWhoopCredentials: vi.fn(async () => ({ + clientId: "cid", + clientSecret: "secret", + })), +})); + +vi.mock("@/lib/jobs/whoop-backfill", () => ({ WHOOP_BACKFILL_QUEUE: "q" })); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); +vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() })); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import type { NextRequest } from "next/server"; + +const stateDelete = prisma.whoopOAuthState.delete as ReturnType; +const connUpsert = prisma.whoopConnection.upsert as ReturnType; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +function makeReq(nonce: string): NextRequest { + return { + url: `https://app.example/api/whoop/callback?code=auth-code&state=${nonce}`, + cookies: { get: (name: string) => (name === "whoop_state" ? { value: nonce } : undefined) }, + } as unknown as NextRequest; +} + +describe("GET /api/whoop/callback custom-scheme redirect", () => { + beforeEach(() => { + vi.clearAllMocks(); + connUpsert.mockResolvedValue({}); + }); + + it("redirects to the native custom scheme on success when returnScheme is set", async () => { + stateDelete.mockResolvedValue({ + userId: "ticket-user", + expiresAt: new Date(Date.now() + 60_000), + returnScheme: "dev.healthlog.app", + }); + + const res = await GET(makeReq("nonce-abc")); + expect(res.headers.get("location")).toBe( + "dev.healthlog.app://whoop?whoop=connected", + ); + expect(connUpsert).toHaveBeenCalled(); + }); + + it("uses the web redirect on success when no returnScheme", async () => { + stateDelete.mockResolvedValue({ + userId: "u1", + expiresAt: new Date(Date.now() + 60_000), + returnScheme: null, + }); + + const res = await GET(makeReq("nonce-web")); + expect(res.headers.get("location")).toBe( + "https://app.example/settings/integrations?whoop=connected", + ); + }); + + it("routes an error to the native scheme when returnScheme is set (expired row)", async () => { + stateDelete.mockResolvedValue({ + userId: "u1", + expiresAt: new Date(Date.now() - 1000), + returnScheme: "dev.healthlog.app", + }); + + const res = await GET(makeReq("nonce-exp")); + expect(res.headers.get("location")).toBe( + "dev.healthlog.app://whoop?whoop=error&reason=expired", + ); + expect(connUpsert).not.toHaveBeenCalled(); + }); + + it("pre-resolution CSRF rejection always uses the web redirect", async () => { + // Cookie/url state mismatch → csrf1, no row consumed. + const req = { + url: "https://app.example/api/whoop/callback?code=c&state=URLSTATE", + cookies: { get: () => ({ value: "DIFFERENT" }) }, + } as unknown as NextRequest; + const res = await GET(req); + expect(res.headers.get("location")).toBe( + "https://app.example/settings/integrations?whoop=error&reason=csrf1", + ); + expect(stateDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index a9e065f33..cff863413 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -10,6 +11,7 @@ import { } from "@/lib/whoop/client"; import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state"; +import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme"; import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { markReconnected } from "@/lib/integrations/status"; @@ -18,23 +20,40 @@ import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; /** - * OAuth callback from WHOOP (v1.11.0). Mirrors the Withings callback: the - * `state` param is a random base64url nonce keyed against the - * `WhoopOAuthState` ledger. The in-flight user is resolved via the row's - * `userId` (never parsed from the cookie value) and cross-checked against the - * session. The row is consumed (deleted) atomically on every exit branch so a - * replay of the same nonce fails the second time. + * OAuth callback from WHOOP (v1.11.0; native enhancements v1.12.2). Mirrors the + * Withings callback: the `state` param is a random base64url nonce keyed + * against the `WhoopOAuthState` ledger. The in-flight user is resolved via the + * row's `userId` (never parsed from the cookie value) — this is the + * authoritative identity, bound at connect time from either the session or the + * one-time Bearer connect ticket. The row is consumed (deleted) atomically on + * every exit branch so a replay of the same nonce fails the second time. * - * Reason tags distinguish the four post-delete branches for the audit trail: + * Auth model. The state row's `userId` is the source of truth. When a web + * session cookie is ALSO present (the browser/web-login path) we additionally + * cross-check it matches the row, so a logged-in user can't complete another + * user's in-flight handshake. The cookie is OPTIONAL: the native ticket path + * (v1.12.2) carries no web session, and the nonce-cookie CSRF check + atomic + * single-use delete already pin the row to the caller who started the flow. + * + * Reason tags distinguish the post-delete branches for the audit trail: * `csrf1` (URL/cookie mismatch, short-circuit before delete), `replay` * (P2025 — nonce already consumed), `expired` (valid row, TTL elapsed), and * `cross_user` (valid row, session userId mismatch). * + * v1.12.2 — when the state row carries a validated `returnScheme` (set by the + * connect route from a native `?return_scheme=`), the FINAL redirect targets + * `://whoop?whoop=connected|error&reason=…` instead of the web settings + * URL, so `ASWebAuthenticationSession` auto-completes on its custom-scheme + * match. The pre-resolution CSRF rejections (`csrf1`/`replay`/`state`) have no + * row yet, so they always use the web redirect. + * * On success: exchange the code, fetch the WHOOP profile for `whoopUserId`, * persist the encrypted `WhoopConnection`, clear any prior reauth state, and * enqueue the self-converging history backfill. */ -const ERR = (reason: string) => + +/** Web-URL redirect (default + every pre-row-resolution path). */ +const WEB_ERR = (reason: string) => NextResponse.redirect( new URL( `/settings/integrations?whoop=error&reason=${reason}`, @@ -42,8 +61,31 @@ const ERR = (reason: string) => ), ); +/** + * Outcome redirect honouring an optional validated native return scheme. + * `scheme` null → web URL (unchanged behaviour). + */ +const outcomeRedirect = ( + scheme: string | null, + outcome: "connected" | "error", + reason?: string, +) => { + if (scheme) { + return NextResponse.redirect( + buildReturnSchemeRedirect(scheme, outcome, reason), + ); + } + return outcome === "error" + ? WEB_ERR(reason ?? "unknown") + : NextResponse.redirect( + new URL( + "/settings/integrations?whoop=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); +}; + export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); annotate({ action: { name: "whoop.callback" } }); const { searchParams } = new URL(request.url); @@ -61,15 +103,20 @@ export const GET = apiHandler(async (request: NextRequest) => { !timingSafeEqual(Buffer.from(state), Buffer.from(storedState)) ) { annotate({ meta: { reason: "csrf1" } }); - return ERR("csrf1"); + return WEB_ERR("csrf1"); } // CSRF leg 2: atomically consume the ledger row. `delete` returns the row - // on success so the `expiresAt` + `userId` checks run against the consumed - // payload. P2025 means the nonce was already consumed (replay) or never - // existed. Atomic at the Postgres row level: two concurrent callbacks with - // the same nonce can't both pass before either deletes. - let stateRow: { userId: string; expiresAt: Date } | null = null; + // on success so the `expiresAt` + `userId` + `returnScheme` checks run + // against the consumed payload. P2025 means the nonce was already consumed + // (replay) or never existed. Atomic at the Postgres row level: two + // concurrent callbacks with the same nonce can't both pass before either + // deletes. + let stateRow: { + userId: string; + expiresAt: Date; + returnScheme: string | null; + } | null = null; try { stateRow = await prisma.whoopOAuthState.delete({ where: { nonce: state } }); } catch (err) { @@ -78,31 +125,49 @@ export const GET = apiHandler(async (request: NextRequest) => { err.code === "P2025" ) { annotate({ meta: { reason: "replay" } }); - return ERR("replay"); + return WEB_ERR("replay"); } const errName = err instanceof Error ? err.name : "unknown"; getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`); annotate({ meta: { reason: "state" } }); - return ERR("state"); + return WEB_ERR("state"); + } + + // The validated native return scheme (if any) is now known; every redirect + // below honours it. Identity is the row's userId (bound at connect time from + // the session or the one-time ticket). + const returnScheme = stateRow.returnScheme; + const userId = stateRow.userId; + + const evt = getEvent(); + if (evt) { + // Identity resolved from the consumed state row (bound at connect time + // from the session or the one-time ticket); no role is asserted here. + evt.setAuth({ user_id: userId, auth_method: "session" }); } if (stateRow.expiresAt <= new Date()) { annotate({ meta: { reason: "expired" } }); - return ERR("expired"); + return outcomeRedirect(returnScheme, "error", "expired"); } - if (stateRow.userId !== user.id) { + + // Optional session cross-check: only when a web session cookie is present. + // The native ticket path carries no session — the nonce cookie + atomic + // single-use delete already bind the row to its originator. + const sessionData = await getSession(); + if (sessionData && sessionData.user.id !== userId) { annotate({ meta: { reason: "cross_user" } }); - return ERR("cross_user"); + return outcomeRedirect(returnScheme, "error", "cross_user"); } if (!code) { - return ERR("nocode"); + return outcomeRedirect(returnScheme, "error", "nocode"); } try { - const creds = await getUserWhoopCredentials(user.id); + const creds = await getUserWhoopCredentials(userId); if (!creds) { - return ERR("nocreds"); + return outcomeRedirect(returnScheme, "error", "nocreds"); } const tokens = await exchangeCode(code, creds); @@ -111,7 +176,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); await prisma.whoopConnection.upsert({ - where: { userId: user.id }, + where: { userId }, update: { whoopUserId, accessToken: encrypt(tokens.access_token), @@ -121,7 +186,7 @@ export const GET = apiHandler(async (request: NextRequest) => { backfillCompletedAt: null, }, create: { - userId: user.id, + userId, whoopUserId, accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), @@ -131,12 +196,12 @@ export const GET = apiHandler(async (request: NextRequest) => { }); await auditLog("whoop.connect", { - userId: user.id, + userId, details: { whoopUserId }, }); // Re-completing OAuth clears any prior reauth-required state. - await markReconnected(user.id, "whoop"); + await markReconnected(userId, "whoop"); // Enqueue the self-converging history backfill. Best-effort: the boot-time // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a @@ -145,7 +210,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (boss) { await boss .send(WHOOP_BACKFILL_QUEUE, { - userId: user.id, + userId, enqueuedAt: new Date().toISOString(), }) .catch((err) => @@ -153,16 +218,11 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } - const response = NextResponse.redirect( - new URL( - "/settings/integrations?whoop=connected", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); + const response = outcomeRedirect(returnScheme, "connected"); response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE); return response; } catch (err) { getEvent()?.setError(err); - return ERR("token"); + return outcomeRedirect(returnScheme, "error", "token"); } }); diff --git a/src/app/api/whoop/connect/__tests__/route.test.ts b/src/app/api/whoop/connect/__tests__/route.test.ts new file mode 100644 index 000000000..219c0fa70 --- /dev/null +++ b/src/app/api/whoop/connect/__tests__/route.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "cookie-user" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { whoopOAuthState: { create: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ setError: vi.fn() }), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/whoop/client", () => ({ + getAuthorizationUrl: vi.fn( + (nonce: string) => `https://api.prod.whoop.com/oauth/oauth2/auth?state=${nonce}`, + ), +})); + +vi.mock("@/lib/whoop/credentials", () => ({ + getUserWhoopCredentials: vi.fn(async () => ({ + clientId: "cid", + clientSecret: "secret", + })), +})); + +vi.mock("@/lib/whoop/connect-ticket", () => ({ + consumeWhoopConnectTicket: vi.fn(), +})); + +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number) => ({ + __apiError: true, + error, + status, + }), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAuth } from "@/lib/api-handler"; +import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; +import { checkRateLimit } from "@/lib/rate-limit"; +import type { NextRequest } from "next/server"; + +const create = prisma.whoopOAuthState.create as ReturnType; +const consume = consumeWhoopConnectTicket as unknown as ReturnType; +const reqAuth = requireAuth as unknown as ReturnType; + +function makeReq(url: string): NextRequest { + return { url } as NextRequest; +} + +describe("GET /api/whoop/connect", () => { + beforeEach(() => { + vi.clearAllMocks(); + create.mockResolvedValue({}); + (checkRateLimit as ReturnType).mockResolvedValue({ + allowed: true, + }); + }); + + it("cookie path: 302s to WHOOP and stores the state row (no scheme)", async () => { + const res = await GET( + makeReq("https://app.example/api/whoop/connect"), + ); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain( + "api.prod.whoop.com/oauth/oauth2/auth", + ); + expect(reqAuth).toHaveBeenCalled(); + expect(consume).not.toHaveBeenCalled(); + const data = create.mock.calls[0][0].data; + expect(data.userId).toBe("cookie-user"); + expect(data.returnScheme).toBeNull(); + // Nonce cookie is set. + expect(res.cookies.get("whoop_state")?.value).toBeTruthy(); + }); + + it("ticket path: resolves user from the ticket, NOT requireAuth, and 302s to WHOOP", async () => { + consume.mockResolvedValue({ userId: "ticket-user" }); + const res = await GET( + makeReq("https://app.example/api/whoop/connect?ticket=opaque123"), + ); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("api.prod.whoop.com"); + expect(consume).toHaveBeenCalledWith("opaque123"); + expect(reqAuth).not.toHaveBeenCalled(); + expect(create.mock.calls[0][0].data.userId).toBe("ticket-user"); + }); + + it("ticket path: invalid/expired/used ticket returns typed 401", async () => { + consume.mockResolvedValue(null); + const res = (await GET( + makeReq("https://app.example/api/whoop/connect?ticket=bad"), + )) as unknown as { __apiError: boolean; status: number }; + expect(res.__apiError).toBe(true); + expect(res.status).toBe(401); + expect(create).not.toHaveBeenCalled(); + }); + + it("stores a valid return_scheme on the state row", async () => { + const res = await GET( + makeReq( + "https://app.example/api/whoop/connect?return_scheme=dev.healthlog.app", + ), + ); + expect(res.status).toBe(307); + expect(create.mock.calls[0][0].data.returnScheme).toBe("dev.healthlog.app"); + }); + + it("rejects http/javascript return_scheme → null (web fallback)", async () => { + await GET( + makeReq("https://app.example/api/whoop/connect?return_scheme=http"), + ); + expect(create.mock.calls[0][0].data.returnScheme).toBeNull(); + create.mockClear(); + await GET( + makeReq( + "https://app.example/api/whoop/connect?return_scheme=javascript", + ), + ); + expect(create.mock.calls[0][0].data.returnScheme).toBeNull(); + }); + + it("ticket + return_scheme combine", async () => { + consume.mockResolvedValue({ userId: "ticket-user" }); + const res = await GET( + makeReq( + "https://app.example/api/whoop/connect?ticket=opaque&return_scheme=dev.healthlog.app", + ), + ); + expect(res.status).toBe(307); + const data = create.mock.calls[0][0].data; + expect(data.userId).toBe("ticket-user"); + expect(data.returnScheme).toBe("dev.healthlog.app"); + }); +}); diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts index fdf1283be..8f5bf8226 100644 --- a/src/app/api/whoop/connect/route.ts +++ b/src/app/api/whoop/connect/route.ts @@ -4,17 +4,20 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAuthorizationUrl } from "@/lib/whoop/client"; +import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { WHOOP_OAUTH_STATE_COOKIE, WHOOP_OAUTH_STATE_TTL_MS, mintWhoopOAuthStateNonce, } from "@/lib/whoop/oauth-state"; +import { validateReturnScheme } from "@/lib/whoop/return-scheme"; import { NextRequest, NextResponse } from "next/server"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** - * Redirect the user to the WHOOP OAuth authorization page (v1.11.0). + * Redirect the user to the WHOOP OAuth authorization page (v1.11.0; native + * enhancements v1.12.2). * * Mirrors the Withings connect route: a fully-random base64url state nonce * backed by a 10-minute `WhoopOAuthState` ledger row carries the @@ -23,20 +26,54 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * httpOnly + Secure cookie carries JUST the nonce; the callback resolves the * user via the row's `userId`. * + * v1.12.2 — two native-client enhancements: + * - `?ticket=` lets a purely Bearer-authenticated native client (no + * web-session cookie) start the handshake. The connect route resolves the + * user from a one-time, unconsumed/unexpired ticket IN LIEU of a cookie, + * consumes it, and proceeds exactly as the cookie path. Expired / consumed + * / invalid → typed 401. The ticket is minted via the Bearer + * `POST /api/whoop/connect/ticket` route. + * - `?return_scheme=` (validated against a strict allowlist) + * is stored on the state row so it survives the OAuth round-trip; the + * callback uses it to send its FINAL redirect to a native custom scheme. + * * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and - * create-failure paths redirect (not JSON) because the entry point is a + * create-failure paths redirect (not JSON) because the cookie entry point is a * browser navigation — a 429 envelope would surface as a blank page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; export const GET = apiHandler(async (req: NextRequest) => { - const { user } = await requireAuth(); + const { searchParams } = new URL(req.url); + const ticket = searchParams.get("ticket"); + + // Resolve the in-flight user. The ticket path is for Bearer-only native + // clients whose in-app web session carries no cookie; it must NOT call + // requireAuth (which would 401 the cookieless session). The cookie path is + // unchanged. The ticket is single-use + consumed atomically here. + let userId: string; + if (ticket) { + annotate({ action: { name: "whoop.connect.ticket.consume" } }); + const resolved = await consumeWhoopConnectTicket(ticket); + if (!resolved) { + annotate({ action: { name: "whoop.connect.ticket.invalid" } }); + // Typed 401 — the in-app session surfaces this; the client re-mints. + return apiError( + "Connect ticket is invalid, expired, or already used.", + 401, + ); + } + userId = resolved.userId; + } else { + const { user } = await requireAuth(); + userId = user.id; + } annotate({ action: { name: "whoop.connect" } }); const rl = await checkRateLimit( - `whoop:connect:${user.id}`, + `whoop:connect:${userId}`, CONNECT_RATE_LIMIT, CONNECT_WINDOW_MS, ); @@ -50,7 +87,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const creds = await getUserWhoopCredentials(user.id); + const creds = await getUserWhoopCredentials(userId); if (!creds) { return apiError( "Please configure your WHOOP Client ID and Client Secret in Settings first.", @@ -58,13 +95,19 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } + // Validate the optional native return scheme against the strict allowlist. + // An invalid/absent value resolves to null → the callback uses the web + // redirect (never an arbitrary reflected scheme). + const returnScheme = validateReturnScheme(searchParams.get("return_scheme")); + const nonce = mintWhoopOAuthStateNonce(); try { await prisma.whoopOAuthState.create({ data: { nonce, - userId: user.id, + userId, expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS), + returnScheme, }, }); } catch (err) { diff --git a/src/app/api/whoop/connect/ticket/__tests__/route.test.ts b/src/app/api/whoop/connect/ticket/__tests__/route.test.ts new file mode 100644 index 000000000..3c56362bd --- /dev/null +++ b/src/app/api/whoop/connect/ticket/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/whoop/connect-ticket", () => ({ + mintWhoopConnectTicket: vi.fn(async () => "opaque-raw-ticket"), +})); + +vi.mock("@/lib/whoop/credentials", () => ({ + getUserWhoopCredentials: vi.fn(async () => ({ + clientId: "cid", + clientSecret: "s", + })), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), +})); + +import { POST } from "../route"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; +import { mintWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; + +const rl = checkRateLimit as ReturnType; +const creds = getUserWhoopCredentials as unknown as ReturnType; +const mint = mintWhoopConnectTicket as unknown as ReturnType; + +const call = () => + (POST as unknown as () => Promise<{ data: unknown; status: number }>)(); + +describe("POST /api/whoop/connect/ticket", () => { + beforeEach(() => { + vi.clearAllMocks(); + rl.mockResolvedValue({ allowed: true }); + creds.mockResolvedValue({ clientId: "cid", clientSecret: "s" }); + }); + + it("mints and returns a ticket once", async () => { + const res = await call(); + expect(res.status).toBe(200); + expect((res.data as { ticket: string }).ticket).toBe("opaque-raw-ticket"); + expect(mint).toHaveBeenCalledWith("u1"); + }); + + it("429s when rate-limited", async () => { + rl.mockResolvedValue({ allowed: false }); + const res = await call(); + expect(res.status).toBe(429); + expect(mint).not.toHaveBeenCalled(); + }); + + it("400s when WHOOP credentials are not configured", async () => { + creds.mockResolvedValue(null); + const res = await call(); + expect(res.status).toBe(400); + expect(mint).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/whoop/connect/ticket/route.ts b/src/app/api/whoop/connect/ticket/route.ts new file mode 100644 index 000000000..459921481 --- /dev/null +++ b/src/app/api/whoop/connect/ticket/route.ts @@ -0,0 +1,49 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { mintWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; +import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; + +/** + * Mint a one-time, short-lived WHOOP connect ticket (v1.12.2). + * + * Bearer-capable: a purely Bearer-authenticated native client (no web-session + * cookie) mints a ticket here, then opens + * `GET /api/whoop/connect?ticket=` in an in-app web session to start + * the WHOOP OAuth handshake. The raw ticket is returned exactly once; only its + * hash is stored (see `src/lib/whoop/connect-ticket.ts`). + * + * Rate-limited per user so a token can't mint an unbounded backlog of tickets. + */ +const TICKET_RATE_LIMIT = 10; +const TICKET_WINDOW_MS = 60_000; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.connect.ticket.mint" } }); + + const rl = await checkRateLimit( + `whoop:connect:ticket:${user.id}`, + TICKET_RATE_LIMIT, + TICKET_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ action: { name: "whoop.connect.ticket.rate_limited" } }); + return apiError("Too many connect-ticket requests. Try again shortly.", 429); + } + + // BYO-key gate: a ticket is only useful if the user has WHOOP credentials + // configured (the connect route would 400 otherwise). Fail fast with a clear + // error rather than minting a ticket that can't complete. + const creds = await getUserWhoopCredentials(user.id); + if (!creds) { + return apiError( + "Please configure your WHOOP Client ID and Client Secret in Settings first.", + 400, + ); + } + + const ticket = await mintWhoopConnectTicket(user.id); + return apiSuccess({ ticket }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 92b46ad36..af50e25d6 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -31,7 +31,10 @@ import { enqueueBootTimeWhoopBackfill, type WhoopBackfillPayload, } from "@/lib/jobs/whoop-backfill"; -import { cleanupExpiredWhoopOAuthStates } from "@/lib/jobs/whoop-oauth-state-cleanup"; +import { + cleanupExpiredWhoopConnectTickets, + cleanupExpiredWhoopOAuthStates, +} from "@/lib/jobs/whoop-oauth-state-cleanup"; import { runFitbitPollCohort } from "@/lib/fitbit/sync"; import { FITBIT_BACKFILL_QUEUE, @@ -1729,6 +1732,8 @@ async function handleWhoopOAuthStateCleanup( try { const deleted = await cleanupExpiredWhoopOAuthStates(p); evt.addMeta("whoop_oauth_state_cleanup_deleted", deleted); + const ticketsDeleted = await cleanupExpiredWhoopConnectTickets(p); + evt.addMeta("whoop_connect_ticket_cleanup_deleted", ticketsDeleted); } catch (err) { evt.addWarning(`whoop-oauth-state-cleanup failed: ${err}`); } diff --git a/src/lib/jobs/whoop-oauth-state-cleanup.ts b/src/lib/jobs/whoop-oauth-state-cleanup.ts index 62d2e2f0d..59ca863d1 100644 --- a/src/lib/jobs/whoop-oauth-state-cleanup.ts +++ b/src/lib/jobs/whoop-oauth-state-cleanup.ts @@ -21,3 +21,20 @@ export async function cleanupExpiredWhoopOAuthStates( }); return count; } + +/** + * v1.12.2 — sweep expired (or already-consumed-and-now-stale) + * `whoop_connect_tickets`. The tickets are single-use + ~60s-lived, so the + * connect route consumes the live ones; this only reaps rows whose `expiresAt` + * has passed (covers both never-used and consumed-then-expired). Same daily + * cadence as the OAuth-state sweep above. + */ +export async function cleanupExpiredWhoopConnectTickets( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const { count } = await prisma.whoopConnectTicket.deleteMany({ + where: { expiresAt: { lt: now } }, + }); + return count; +} diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index 6ee7c294e..afbfa3ac7 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -34,6 +34,10 @@ describe("redactSecrets", () => { expect(redactSecrets("export ?insuranceNumber=A123456780&days=30")).toBe( "export ?insuranceNumber=[REDACTED]&days=30", ); + // v1.12.2 — one-time WHOOP connect ticket in the connect URL. + expect( + redactSecrets("GET /api/whoop/connect?ticket=abc_DEF-123&return_scheme=x"), + ).toBe("GET /api/whoop/connect?ticket=[REDACTED]&return_scheme=x"); }); it("redacts OpenAI and Anthropic API keys", () => { diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index ec35045ba..6aaa70f0b 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -93,8 +93,11 @@ export function redactSecrets(input: string): string { // insurance number) added as defence-in-depth: the value is // encrypted at rest and never deliberately logged, but a stray // query-string leak (`?kvnr=…`) is scrubbed at the egress boundary. + // v1.12.2 — `ticket` (the one-time WHOOP connect ticket rides in the + // `GET /api/whoop/connect?ticket=…` URL): scrub it so the opaque value + // never lands in `http.path`/error strings reaching Loki/Glitchtip. .replace( - /([?&])(secret|code|token|api[_-]?key|insurance(?:number)?|kvnr)=[^&\s]+/gi, + /([?&])(secret|code|token|ticket|api[_-]?key|insurance(?:number)?|kvnr)=[^&\s]+/gi, "$1$2=[REDACTED]", ) ); diff --git a/src/lib/whoop/__tests__/connect-ticket.test.ts b/src/lib/whoop/__tests__/connect-ticket.test.ts new file mode 100644 index 000000000..cb021d893 --- /dev/null +++ b/src/lib/whoop/__tests__/connect-ticket.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/hmac", () => ({ + // Deterministic stand-in so the test can assert hash binding without the + // real HMAC key. `hash()` => `h:`. + hashToken: (raw: string) => `h:${raw}`, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + whoopConnectTicket: { + create: vi.fn(), + updateMany: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +import { + WHOOP_CONNECT_TICKET_TTL_MS, + mintWhoopConnectTicket, + consumeWhoopConnectTicket, +} from "../connect-ticket"; +import { prisma } from "@/lib/db"; + +const create = prisma.whoopConnectTicket.create as ReturnType; +const updateMany = prisma.whoopConnectTicket.updateMany as ReturnType< + typeof vi.fn +>; +const findUnique = prisma.whoopConnectTicket.findUnique as ReturnType< + typeof vi.fn +>; + +describe("WHOOP connect ticket", () => { + beforeEach(() => vi.clearAllMocks()); + + it("pins a ~60s TTL", () => { + expect(WHOOP_CONNECT_TICKET_TTL_MS).toBe(60 * 1000); + }); + + it("mints an opaque ticket and stores ONLY its hash, never the raw value", async () => { + create.mockResolvedValue({}); + const before = Date.now(); + const raw = await mintWhoopConnectTicket("u1"); + + // Opaque, high-entropy, URL-safe. + expect(raw).toMatch(/^[A-Za-z0-9_-]{43}$/); + + expect(create).toHaveBeenCalledTimes(1); + const arg = create.mock.calls[0][0].data; + expect(arg.userId).toBe("u1"); + expect(arg.tokenHash).toBe(`h:${raw}`); + // The raw ticket must NOT be persisted in any plaintext column — only the + // (here-stubbed) hash carries it. The row has exactly userId/tokenHash/ + // expiresAt; no field equals the raw value. + expect(Object.keys(arg).sort()).toEqual([ + "expiresAt", + "tokenHash", + "userId", + ]); + for (const [key, value] of Object.entries(arg)) { + if (key === "tokenHash") continue; + expect(value).not.toBe(raw); + } + expect(arg.expiresAt.getTime()).toBeGreaterThanOrEqual( + before + WHOOP_CONNECT_TICKET_TTL_MS - 50, + ); + }); + + it("consumes a valid ticket atomically and resolves the user", async () => { + updateMany.mockResolvedValue({ count: 1 }); + findUnique.mockResolvedValue({ userId: "u1" }); + + const res = await consumeWhoopConnectTicket("raw-ticket"); + expect(res).toEqual({ userId: "u1" }); + + // Atomic single-use: the WHERE pins unconsumed + unexpired, the data + // stamps consumedAt in the same statement. + const where = updateMany.mock.calls[0][0].where; + expect(where.tokenHash).toBe("h:raw-ticket"); + expect(where.consumedAt).toBeNull(); + expect(where.expiresAt.gt).toBeInstanceOf(Date); + expect(updateMany.mock.calls[0][0].data.consumedAt).toBeInstanceOf(Date); + }); + + it("rejects reuse / expired / unknown (updateMany matches 0 rows)", async () => { + updateMany.mockResolvedValue({ count: 0 }); + const res = await consumeWhoopConnectTicket("already-used"); + expect(res).toBeNull(); + // Never re-reads the user when nothing was consumed. + expect(findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/whoop/__tests__/return-scheme.test.ts b/src/lib/whoop/__tests__/return-scheme.test.ts new file mode 100644 index 000000000..65ea904cf --- /dev/null +++ b/src/lib/whoop/__tests__/return-scheme.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + buildReturnSchemeRedirect, + validateReturnScheme, +} from "../return-scheme"; + +describe("validateReturnScheme", () => { + it("accepts the allowlisted native scheme (case-insensitive)", () => { + expect(validateReturnScheme("dev.healthlog.app")).toBe("dev.healthlog.app"); + expect(validateReturnScheme("DEV.HealthLog.App")).toBe("dev.healthlog.app"); + }); + + it("returns null for absent / empty input", () => { + expect(validateReturnScheme(null)).toBeNull(); + expect(validateReturnScheme(undefined)).toBeNull(); + expect(validateReturnScheme("")).toBeNull(); + }); + + it("rejects forbidden web/script schemes even if shaped like a scheme", () => { + for (const s of ["http", "https", "javascript", "data", "file", "vbscript"]) { + expect(validateReturnScheme(s)).toBeNull(); + } + }); + + it("rejects a syntactically valid but non-allowlisted custom scheme", () => { + expect(validateReturnScheme("com.evil.app")).toBeNull(); + expect(validateReturnScheme("myapp")).toBeNull(); + }); + + it("rejects malformed schemes (bad first char, spaces, slashes)", () => { + expect(validateReturnScheme("1app")).toBeNull(); + expect(validateReturnScheme("dev healthlog")).toBeNull(); + expect(validateReturnScheme("dev/healthlog")).toBeNull(); + expect(validateReturnScheme("dev:healthlog")).toBeNull(); + expect(validateReturnScheme("dev.healthlog.app://whoop")).toBeNull(); + }); + + it("rejects an over-long value", () => { + expect(validateReturnScheme("a".repeat(65))).toBeNull(); + }); +}); + +describe("buildReturnSchemeRedirect", () => { + it("builds the connected target", () => { + expect(buildReturnSchemeRedirect("dev.healthlog.app", "connected")).toBe( + "dev.healthlog.app://whoop?whoop=connected", + ); + }); + + it("builds the error target with an encoded reason", () => { + expect( + buildReturnSchemeRedirect("dev.healthlog.app", "error", "expired"), + ).toBe("dev.healthlog.app://whoop?whoop=error&reason=expired"); + }); + + it("defaults a missing reason to 'unknown'", () => { + expect(buildReturnSchemeRedirect("dev.healthlog.app", "error")).toBe( + "dev.healthlog.app://whoop?whoop=error&reason=unknown", + ); + }); +}); diff --git a/src/lib/whoop/connect-ticket.ts b/src/lib/whoop/connect-ticket.ts new file mode 100644 index 000000000..48548306e --- /dev/null +++ b/src/lib/whoop/connect-ticket.ts @@ -0,0 +1,88 @@ +/** + * v1.12.2 — one-time, Bearer-mintable WHOOP connect ticket. + * + * A purely Bearer-authenticated native client (no web-session cookie) cannot + * start the WHOOP OAuth handshake through `GET /api/whoop/connect` directly, + * because that route resolves the user from the session cookie. The ticket + * bridges that gap: the client mints one via an authenticated Bearer + * `POST /api/whoop/connect/ticket`, then opens + * `GET /api/whoop/connect?ticket=` in an in-app web session. The + * connect route resolves the user from the unconsumed/unexpired ticket IN LIEU + * of a cookie, marks it consumed, sets the nonce cookie, and 302s to WHOOP. + * + * Security shape (mirrors the Bearer-token storage pattern in + * `src/lib/auth/hmac.ts`): + * - The raw ticket is 32 random bytes → base64url; opaque, 256 bits. + * - Only its HMAC-SHA256 hash (keyed by `API_TOKEN_HMAC_KEY`) is persisted — + * a DB read cannot recover a usable ticket. + * - Single-use: consumption is an atomic conditional `updateMany` predicated + * on `consumedAt IS NULL` + `expiresAt > now()`; a `count` of 0 means the + * ticket was already consumed, expired, or never existed (all rejected). + * - Short-lived (~60s TTL). + */ +import { randomBytes } from "node:crypto"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; + +/** 60-second TTL: long enough to hand off into the in-app web session, short + * enough that an intercepted ticket is near-useless. */ +export const WHOOP_CONNECT_TICKET_TTL_MS = 60 * 1000; + +/** Opaque raw ticket: 32 random bytes → 43 base64url chars (256 bits). */ +function mintRawTicket(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Mint and persist a single-use connect ticket for `userId`. Returns the raw + * opaque ticket exactly once; only its hash is stored. Caller is responsible + * for having authenticated the user (Bearer `requireAuth`). + */ +export async function mintWhoopConnectTicket(userId: string): Promise { + const raw = mintRawTicket(); + await prisma.whoopConnectTicket.create({ + data: { + userId, + tokenHash: hashToken(raw), + expiresAt: new Date(Date.now() + WHOOP_CONNECT_TICKET_TTL_MS), + }, + }); + return raw; +} + +/** + * Atomically consume a connect ticket. Resolves the owning `userId` only when + * the ticket exists, is unexpired, AND was not previously consumed — and in + * that same operation stamps `consumedAt`, so a concurrent or later second + * presentation of the same raw ticket finds zero matching rows and is + * rejected. Returns `null` for any invalid / expired / already-consumed + * ticket (the caller maps `null` to a typed 401). + */ +export async function consumeWhoopConnectTicket( + rawTicket: string, +): Promise<{ userId: string } | null> { + const tokenHash = hashToken(rawTicket); + const now = new Date(); + + // Single-statement atomic consume: the WHERE pins the still-usable + // predicate, so two racers can't both flip the same row. `updateMany` + // returns the affected count; 1 = we won, 0 = unusable. + const consumed = await prisma.whoopConnectTicket.updateMany({ + where: { + tokenHash, + consumedAt: null, + expiresAt: { gt: now }, + }, + data: { consumedAt: now }, + }); + + if (consumed.count !== 1) return null; + + // Re-read to recover the owning user. The row is now consumed; this read + // only resolves the userId for the connect handshake. + const row = await prisma.whoopConnectTicket.findUnique({ + where: { tokenHash }, + select: { userId: true }, + }); + return row ? { userId: row.userId } : null; +} diff --git a/src/lib/whoop/return-scheme.ts b/src/lib/whoop/return-scheme.ts new file mode 100644 index 000000000..95882652e --- /dev/null +++ b/src/lib/whoop/return-scheme.ts @@ -0,0 +1,87 @@ +/** + * v1.12.2 — `return_scheme` validation for the WHOOP connect-in-app flow. + * + * The native client may pass `?return_scheme=` to + * `GET /api/whoop/connect`. When a VALID scheme is supplied, the callback's + * FINAL redirect targets `://whoop?whoop=connected|error&reason=…` + * instead of the web settings URL, so `ASWebAuthenticationSession` + * auto-completes on its `callbackURLScheme` match. An invalid/absent scheme + * falls back to the existing web redirect — this helper NEVER throws and never + * reflects an arbitrary attacker-supplied scheme. + * + * Security: a custom app scheme only. We pin to a small allowlist (easy to + * extend) AND require the strict RFC-3986 scheme shape, with the dangerous + * web/script schemes explicitly rejected as defence-in-depth so a future + * allowlist edit can't accidentally admit `javascript:`/`data:`/etc. + */ + +/** + * RFC 3986 scheme grammar: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). + * Lower-cased before the test so the comparison is case-insensitive in effect. + */ +const SCHEME_PATTERN = /^[a-z][a-z0-9.+-]*$/; + +/** + * Schemes that must never be honoured even if some future edit widens the + * allowlist. `http`/`https` would turn the redirect into an open-redirect to + * an arbitrary web origin; the rest are classic XSS / local-resource vectors. + */ +const FORBIDDEN_SCHEMES: ReadonlySet = new Set([ + "http", + "https", + "javascript", + "data", + "file", + "vbscript", +]); + +/** + * Allowlist of custom app schemes we honour. Keep this small and explicit; + * add the exact scheme string a self-host / TestFlight build advertises as its + * `ASWebAuthenticationSession` `callbackURLScheme`. + */ +const ALLOWED_SCHEMES: ReadonlySet = new Set([ + // The native HealthLog client's advertised custom scheme. + "dev.healthlog.app", +]); + +/** + * Validate a raw `return_scheme` query value. Returns the normalised + * (lower-cased) scheme when it passes every gate, or `null` for absent / + * malformed / forbidden / non-allowlisted input. Callers treat `null` as + * "use the web redirect". + */ +export function validateReturnScheme( + raw: string | null | undefined, +): string | null { + if (!raw) return null; + // A leading-byte length cap keeps a pathological value out of the DB column + // and any downstream URL; real custom schemes are short. + if (raw.length > 64) return null; + + const scheme = raw.toLowerCase(); + if (!SCHEME_PATTERN.test(scheme)) return null; + if (FORBIDDEN_SCHEMES.has(scheme)) return null; + if (!ALLOWED_SCHEMES.has(scheme)) return null; + + return scheme; +} + +/** + * Build the native custom-scheme redirect target for the callback's FINAL + * redirect. `scheme` MUST already be a validated allowlisted scheme (caller + * passes the result of {@link validateReturnScheme}). Shape: + * `://whoop?whoop=connected` + * `://whoop?whoop=error&reason=` + */ +export function buildReturnSchemeRedirect( + scheme: string, + outcome: "connected" | "error", + reason?: string, +): string { + const query = + outcome === "error" + ? `whoop=error&reason=${encodeURIComponent(reason ?? "unknown")}` + : "whoop=connected"; + return `${scheme}://whoop?${query}`; +} diff --git a/tests/integration/whoop-connect-ticket.test.ts b/tests/integration/whoop-connect-ticket.test.ts new file mode 100644 index 000000000..915e88348 --- /dev/null +++ b/tests/integration/whoop-connect-ticket.test.ts @@ -0,0 +1,245 @@ +/** + * v1.12.2 — integration coverage for the WHOOP connect-in-app enhancements + * against real Postgres: + * + * - POST /api/whoop/connect/ticket (Bearer-auth) mints a single-use ticket; + * only its HMAC hash lands in `whoop_connect_tickets`, the raw value never. + * - GET /api/whoop/connect?ticket= resolves the user from the ticket IN + * LIEU of a session cookie, consumes it (consumedAt stamped), and 302s to + * WHOOP, stamping the `whoop_state` ledger row + nonce cookie. + * - A SECOND presentation of the same ticket is rejected (typed 401). + * - An expired ticket is rejected (typed 401). + * - `?return_scheme=dev.healthlog.app` is validated + persisted on the state + * row so the callback can drive the native custom-scheme redirect. + * + * The whole point of the ticket is the native-only user with NO web session + * cookie, so the connect call below carries no cookie at all. + */ +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"; + +// Seed deterministic keys before any module that reads them lazily. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-whoop-connect-ticket-integration-32-bytes-12345678"; + +const { hashToken } = await import("@/lib/auth/hmac"); + +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-whoop-connect-ticket"; +const RAW_BEARER = "hlk_whoopconnectticketintegrationrawtokenvalue00000001"; +const WHOOP_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"; + +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: "whoop-ticket-user", + email: "whoop-ticket@example.test", + role: "USER", + whoopClientIdEncrypted: encrypt("whoop-client-id"), + whoopClientSecretEncrypted: encrypt("whoop-client-secret"), + }, + }); + // Bearer token for the native client minting the ticket. + await prisma.apiToken.create({ + data: { + userId: TEST_USER_ID, + name: "native", + tokenHash: hashToken(RAW_BEARER), + permissions: ["*"], + }, + }); + + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function mintTicketViaBearer(): Promise { + // Bearer only — no session cookie. + headerJar.set("authorization", `Bearer ${RAW_BEARER}`); + const { POST } = await import("@/app/api/whoop/connect/ticket/route"); + const res = await (POST as unknown as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { ticket: string } }; + headerJar.delete("authorization"); + return body.data.ticket; +} + +describe("WHOOP connect ticket lifecycle (real Postgres)", () => { + it("mints a ticket storing only its hash, never the raw value", async () => { + const ticket = await mintTicketViaBearer(); + expect(ticket).toMatch(/^[A-Za-z0-9_-]{43}$/); + + const prisma = getPrismaClient(); + const rows = await prisma.whoopConnectTicket.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].tokenHash).toBe(hashToken(ticket)); + expect(rows[0].consumedAt).toBeNull(); + // The raw ticket is not stored in any column. + expect(JSON.stringify(rows[0])).not.toContain(ticket); + }); + + it("connect?ticket= consumes the ticket (no cookie) and 302s to WHOOP", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); // native-only: NO session cookie on the connect call + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}`, + ), + ); + + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain(WHOOP_AUTH_URL); + expect(res.headers.get("set-cookie")).toContain("whoop_state="); + + const prisma = getPrismaClient(); + // Ticket consumed. + const consumed = await prisma.whoopConnectTicket.findUnique({ + where: { tokenHash: hashToken(ticket) }, + }); + expect(consumed?.consumedAt).not.toBeNull(); + // State ledger row was minted for this user. + const states = await prisma.whoopOAuthState.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(states).toHaveLength(1); + expect(states[0].returnScheme).toBeNull(); + }); + + it("rejects a second use of the same ticket with a typed 401", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const first = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}`, + ), + ); + expect(first.status).toBe(307); + + const second = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}`, + ), + ); + expect(second.status).toBe(401); + const body = (await second.json()) as { error: string | null }; + expect(body.error).toMatch(/invalid, expired, or already used/i); + + // No second state row was minted. + const prisma = getPrismaClient(); + const states = await prisma.whoopOAuthState.count({ + where: { userId: TEST_USER_ID }, + }); + expect(states).toBe(1); + }); + + it("rejects an expired ticket with a typed 401", async () => { + const prisma = getPrismaClient(); + // Hand-seed an already-expired ticket row. + const raw = "expired-raw-ticket-value-deadbeef"; + await prisma.whoopConnectTicket.create({ + data: { + userId: TEST_USER_ID, + tokenHash: hashToken(raw), + expiresAt: new Date(Date.now() - 1000), + }, + }); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(raw)}`, + ), + ); + expect(res.status).toBe(401); + // The expired row was not consumed (consumedAt stays null). + const row = await prisma.whoopConnectTicket.findUnique({ + where: { tokenHash: hashToken(raw) }, + }); + expect(row?.consumedAt).toBeNull(); + }); + + it("persists a valid return_scheme alongside a ticket connect", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}&return_scheme=dev.healthlog.app`, + ), + ); + expect(res.status).toBe(307); + + const prisma = getPrismaClient(); + const states = await prisma.whoopOAuthState.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(states).toHaveLength(1); + expect(states[0].returnScheme).toBe("dev.healthlog.app"); + }); + + it("drops a forbidden return_scheme (http) to null", async () => { + const ticket = await mintTicketViaBearer(); + cookieJar.clear(); + + const { GET } = await import("@/app/api/whoop/connect/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/whoop/connect?ticket=${encodeURIComponent(ticket)}&return_scheme=http`, + ), + ); + expect(res.status).toBe(307); + + const prisma = getPrismaClient(); + const states = await prisma.whoopOAuthState.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(states[0].returnScheme).toBeNull(); + }); +});