mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
commit
9021b3e0ee
25 files changed
+1666
-1949
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user