Files
HealthLog/src/lib/fitbit/oauth-state.ts
T
Marc-André Bombeck 4ff0e4ca0c feat(fitbit): retarget the client to the classic Fitbit Web API
The shipped Fitbit integration spoke the Google Health API, whose Restricted
scopes need brand verification plus an annual CASA assessment before leaving
testing mode — a wall most self-hosters cannot clear. Retarget the transport to
the classic api.fitbit.com Web API: a self-hoster registers an app at
dev.fitbit.com in minutes, no CASA.

Forks only the transport; the upsert/dedup/rollup tail, source-priority,
backfill, poll worker, and status ledger are reused unchanged.

- OAuth: www.fitbit.com/oauth2/authorize + api.fitbit.com/oauth2/token, PKCE
  (S256), classic self-serve scopes.
- Refresh tokens rotate (one-time use): persist the new token every refresh,
  with a defensive keep-existing guard on a malformed reply.
- Per-endpoint date-range fetchers chunked to the API range caps; the backfill
  horizon and poll concurrency are tuned for the 150 req/h-per-user budget.
- Verified classic-shape extractors for weight, body fat, SpO2, HRV (dailyRmssd),
  resting HR, respiratory rate, steps, distance, active calories, floors, VO2 max
  (range midpoint), per-segment sleep stages, and workouts.
- Skips intraday HR (needs a per-app grant; daily resting HR covers it) and skin
  temperature (a baseline delta with no honest absolute slot).
2026-06-22 07:20:58 +02:00

37 lines
1.5 KiB
TypeScript

/**
* Shared constants + mint helper for the Fitbit OAuth state nonce ledger.
* Mirrors `src/lib/whoop/oauth-state.ts`. (v1.20.0 — the PKCE `code_verifier`
* lives on the `FitbitOAuthState` row; the connect route mints it via the
* client's `generatePkcePair`.)
*
* The nonce is random + opaque; a short-lived `FitbitOAuthState` row carries
* the `(nonce → userId)` mapping so the user id never leaks into request logs
* or network captures via the OAuth `state` param. The connect route, the
* callback route, and the cleanup cron all read these constants so the
* contract has one source of truth and the unit tests can pin it without
* importing either route file.
*/
import { randomBytes } from "node:crypto";
/**
* Cookie name carrying the in-flight state nonce. httpOnly + Secure +
* sameSite:lax at the connect route (mirrors the WHOOP cookie shape).
*/
export const FITBIT_OAUTH_STATE_COOKIE = "fitbit_state" as const;
/**
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to
* cover a user approving the Fitbit consent prompt; short enough that an
* abandoned handshake doesn't strand a row for hours.
*/
export const FITBIT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
/**
* Mint a fresh state nonce. 16 random bytes → 22 base64url chars without
* padding — 128 bits of entropy, aligned with the OAuth 2.0 §10.10
* "at least 128 bits" recommendation.
*/
export function mintFitbitOAuthStateNonce(): string {
return randomBytes(16).toString("base64url");
}