From b42b0d5fee48cc0f1ceccf600b22f3f82f3d98b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 31 Oct 2026 09:34:25 +0100 Subject: [PATCH] fix(whoop): refuse a profile without a user id instead of storing "undefined" String(profile.user_id) turned a missing id into the literal "undefined", so every such connection would have looked like one WHOOP account and the unique account id would have refused the second record. The callback now treats it like a failed exchange (reason=token), as the Polar callback already does for a token body without x_user_id. --- .../api/whoop/callback/__tests__/route.test.ts | 18 ++++++++++++++++++ src/app/api/whoop/callback/route.ts | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index 58aed091c..251b5298b 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -175,6 +175,24 @@ describe("GET /api/whoop/callback custom-scheme redirect", () => { expect(connUpsert).not.toHaveBeenCalled(); }); + it("refuses a profile without a user id instead of storing a shared placeholder", async () => { + stateDelete.mockResolvedValue({ + userId: "u1", + expiresAt: new Date(Date.now() + 60_000), + returnScheme: null, + }); + const { fetchProfile } = await import("@/lib/whoop/client"); + vi.mocked(fetchProfile).mockResolvedValueOnce({} as never); + + const res = await GET(makeReq("nonce-noid")); + + expect(res.headers.get("location")).toBe( + "https://app.example/settings/integrations?whoop=error&reason=token", + ); + expect(connFindUnique).not.toHaveBeenCalled(); + expect(connUpsert).not.toHaveBeenCalled(); + }); + it("maps a concurrent WHOOP identity P2002 to the safe owner-conflict redirect", async () => { stateDelete.mockResolvedValue({ userId: "local-user", diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 36da98e8b..e1a511857 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -204,6 +204,15 @@ export const GET = apiHandler(async (request: NextRequest) => { const tokens = await whoopOAuth.exchange(code, creds); const profile = await fetchProfile(tokens.access_token); + // An off-spec profile without `user_id` would store the string + // "undefined" as the account id: every such connection would then look + // like one WHOOP account, and the check below would refuse the second. + if ( + typeof profile.user_id !== "number" || + !Number.isFinite(profile.user_id) + ) { + return outcomeRedirect(returnScheme, "error", "token"); + } const whoopUserId = String(profile.user_id); const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const owner = await prisma.whoopConnection.findUnique({