Files
HealthLog/tests/integration/whoop-connect-ticket.test.ts
T
Marc-André Bombeck 1a478d3028 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 <scheme>://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=<opaque>
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) <noreply@anthropic.com>
(cherry picked from commit 221b362011d52dda314139e0be7cc195c47d016e)
2026-06-05 08:10:13 +02:00

246 lines
8.4 KiB
TypeScript

/**
* 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=<raw> 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<string> {
// 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<Response>)();
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=<raw> 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();
});
});