Merge Fitbit classic Web API transport into release/v1.20.0

Retarget the Fitbit integration from the Google Health API (which needs
annual CASA review — a wall for self-hosters) to the classic Fitbit Web
API with PKCE and rotating refresh tokens, so a self-hoster can connect
with an instant developer app (migration 0191 adds the PKCE verifier).
Transport-only fork; the ingest, dedup, source-priority, backfill and
poll paths are reused. Intraday HR and skin temperature are not mapped
(no honest slot); the integration stays experimental.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:22:31 +02:00
commit 9021b3e0ee
25 files changed
+1666 -1949

No files matched your search

+27 -13
View File
@@ -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(
+13 -5
View File
@@ -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, {
+7 -7
View File
@@ -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();
@@ -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/);
});
});
@@ -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 },
);