mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Land the OAuth + credentials half of the Fitbit / Google Health provider, cloning the WHOOP provider structure and adapting it to the Google identity flow. - Client OAuth half (src/lib/fitbit/client.ts): Google authorize-URL builder (access_type=offline + prompt=consent), authorization-code exchange, token refresh, and a profile fetch for the connection's external user id. Client credentials ride in an HTTP Basic-auth header on the token endpoint; the four launch Restricted read scopes are pinned in FITBIT_OAUTH_SCOPE. All egress goes through safeFetch. - Non-rotating refresh tokens: getValidToken refreshes at expiry-minus-five- minutes, persists the new access token + expiry, and overwrites the stored refresh token ONLY when the response carries a fresh one — Google keeps the refresh token stable, unlike WHOOP. - BYO credentials route (PUT/GET/DELETE /api/fitbit/credentials): per-user client id/secret stored encrypted on the User columns, built field-by-field under requireAuth. - OAuth state + connect + callback routes: a random base64url nonce backed by a 10-minute FitbitOAuthState ledger row carries the (nonce -> userId) mapping; the callback validates the cookie/URL state with timingSafeEqual, consumes the row atomically (replay/expired/cross-user reason tags), exchanges the code, upserts the encrypted FitbitConnection, clears prior reauth state, and enqueues the self-converging history backfill. - Response classifier mirrors WHOOP's HTTP-status buckets; Google signals a revoked grant with a 401, so there is no invalid_grant body special-case. Tests cover the authorize URL, Basic-auth token exchange, the no-rotation refresh branch (token preserved when omitted, overwritten when present), the classifier status table, and the OAuth-state nonce mint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
35 lines
1.4 KiB
TypeScript
35 lines
1.4 KiB
TypeScript
/**
|
|
* v1.12.0 — shared constants + mint helper for the Fitbit / Google Health OAuth
|
|
* state nonce ledger. Mirrors `src/lib/whoop/oauth-state.ts`.
|
|
*
|
|
* 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 Google 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");
|
|
}
|