diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 95b83ce63..984698e3c 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -19,9 +19,9 @@ import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; /** - * OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the - * WHOOP callback: the `state` param is a random base64url nonce keyed against - * the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's + * OAuth callback from Fitbit (classic Web API, v1.20.0). Mirrors the WHOOP + * callback: the `state` param is a random base64url nonce keyed against the + * `FitbitOAuthState` 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. @@ -31,9 +31,12 @@ import { timingSafeEqual } from "node:crypto"; * nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user` * (valid row, session userId mismatch). * - * On success: exchange the code, fetch the Google Health profile for the - * external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any - * prior reauth state, and enqueue the self-converging history backfill. + * v1.20.0 — the deleted ledger row carries the PKCE `code_verifier` minted at + * connect; it is presented on the token exchange. + * + * On success: exchange the code, fetch the Fitbit profile for the external + * `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior + * reauth state, and enqueue the self-converging history backfill. */ const ERR = (reason: string) => NextResponse.redirect( @@ -70,7 +73,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // 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; + let stateRow: { + userId: string; + expiresAt: Date; + codeVerifier: string | null; + } | null = null; try { stateRow = await prisma.fitbitOAuthState.delete({ where: { nonce: state }, @@ -102,19 +109,26 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("nocode"); } + // The PKCE verifier minted at connect rides on the ledger row. Its absence + // means a malformed / legacy handshake — refuse rather than send a code + // exchange Fitbit will reject for a missing verifier. + if (!stateRow.codeVerifier) { + annotate({ meta: { reason: "no_verifier" } }); + return ERR("no_verifier"); + } + try { const creds = await getUserFitbitCredentials(user.id); if (!creds) { return ERR("nocreds"); } - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, stateRow.codeVerifier, creds); - // The initial code exchange MUST return a refresh token (the connect route - // forces `prompt=consent` + `access_type=offline`). If Google ever omits it, - // refuse to persist — a stored empty refresh token silently bricks every - // future token refresh (the refresh POST then 400s and the connection dies - // the moment the access token expires). Error out cleanly so the user + // The initial code exchange MUST return a refresh token. If Fitbit ever + // omits it, refuse to persist — a stored empty refresh token silently bricks + // every future token refresh (the refresh POST then 400s and the connection + // dies the moment the access token expires). Error out cleanly so the user // re-consents rather than landing a dead connection. if (!tokens.refresh_token) { getEvent()?.addWarning( diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index 93a30fb5a..cf1e33ae6 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -3,7 +3,7 @@ import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/fitbit/client"; +import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client"; import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; import { FITBIT_OAUTH_STATE_COOKIE, @@ -14,8 +14,8 @@ import { NextRequest, NextResponse } from "next/server"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** - * Redirect the user to the Google OAuth consent page for Fitbit / Google Health - * (v1.12.0). + * Redirect the user to the Fitbit OAuth consent page (classic + * `www.fitbit.com/oauth2/authorize`, v1.20.0). * * Mirrors the WHOOP connect route: a fully-random base64url state nonce backed * by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)` @@ -23,6 +23,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * land in request logs / network captures). The httpOnly + Secure cookie * carries JUST the nonce; the callback resolves the user via the row's `userId`. * + * v1.20.0 — the classic Fitbit Web API uses PKCE (S256). The route mints a + * `code_verifier`/`code_challenge` pair, stashes the verifier on the ledger row + * (server-side, never exposed to the browser), and sends the challenge to the + * authorize endpoint. The callback reads the verifier back to present on the + * token exchange. + * * 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 browser @@ -53,17 +59,19 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserFitbitCredentials(user.id); if (!creds) { return apiError( - "Please configure your Google OAuth Client ID and Client Secret in Settings first.", + "Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.", 400, ); } const nonce = mintFitbitOAuthStateNonce(); + const pkce = generatePkcePair(); try { await prisma.fitbitOAuthState.create({ data: { nonce, userId: user.id, + codeVerifier: pkce.verifier, expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS), }, }); @@ -75,7 +83,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const url = getAuthorizationUrl(nonce, creds); + const url = getAuthorizationUrl(nonce, creds, pkce.challenge); const response = NextResponse.redirect(url); response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, { diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index 02c1b1b2d..5197ef8e8 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -6,17 +6,17 @@ import { syncUserFitbit } from "@/lib/fitbit/sync"; import { NextRequest } from "next/server"; /** - * Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0). + * Manually trigger a Fitbit sync for the current user. * * Mirrors the WHOOP manual-sync route: incremental by default, full history when * `{ fullSync: true }` is posted. * - * v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test / - * resume) is limited; this one was the outlier, while `{ fullSync: true }` - * drives four paginated Google walkers (each capped at 1000 pages). A tight - * loop or a stolen native token could pin Prisma + the Google Health quota. - * A baseline 5/60s bucket (matching the test route) gates the route, and the - * expensive `fullSync` path carries a tighter 1/hour bucket of its own. + * Rate-limited: every sibling Fitbit route (connect / test / resume) is limited; + * this one was the outlier, while `{ fullSync: true }` drives four resource syncs + * over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a + * stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s + * bucket (matching the test route) gates the route, and the expensive `fullSync` + * path carries a tighter 1/hour bucket of its own. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts index bef0ba2ba..eade1195d 100644 --- a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -206,7 +206,7 @@ describe("POST /api/integrations/fitbit/test", () => { new Response( JSON.stringify({ error: "Authorization: Bearer sk-secret echoed", - url: "https://health.googleapis.com/v4/users/me/profile", + url: "https://api.fitbit.com/1/user/-/profile.json", }), { status: 401 }, ), @@ -215,6 +215,5 @@ describe("POST /api/integrations/fitbit/test", () => { const text = await response.text(); expect(response.status).toBe(502); expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/health\.googleapis\.com/); }); }); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts index 899576742..b13e7dfbf 100644 --- a/src/app/api/integrations/fitbit/test/route.ts +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -13,7 +13,7 @@ export const dynamic = "force-dynamic"; // Cheap authenticated probe — the single (non-paginated) profile endpoint the // Fitbit client already calls via `fetchProfile`. // Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`). -const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`; +const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/1/user/-/profile.json`; const TIMEOUT_MS = 5_000; type CategorisedError = { code: string; message: string }; @@ -22,24 +22,24 @@ function categoriseHttpStatus(status: number): CategorisedError { if (status === 401 || status === 403) { return { code: "credentials_rejected", - message: "Google Health rejected the credentials", + message: "Fitbit rejected the credentials", }; } if (status === 429) { return { code: "rate_limited", - message: "Google Health rate-limited the request", + message: "Fitbit rate-limited the request", }; } if (status >= 500) { return { code: "upstream_error", - message: "Google Health returned a server error", + message: "Fitbit returned a server error", }; } return { code: "connection_failed", - message: "Google Health connection failed", + message: "Fitbit connection failed", }; } @@ -57,7 +57,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const tokenInfo = await getValidToken(user.id); if (!tokenInfo) { - return apiError("Google Health not connected", 422, { + return apiError("Fitbit not connected", 422, { errorCode: "not_configured", }); } @@ -115,9 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); return apiError( - isAbort - ? "Google Health request timed out" - : "Google Health connection failed", + isAbort ? "Fitbit request timed out" : "Fitbit connection failed", 502, { errorCode: code }, );