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(); + }); +});