Files
HealthLog/src/lib/fitbit/credentials.ts
T
Marc-André BombeckandClaude Opus 4.8 7ab2cbaa56 feat(fitbit): add Google OAuth, credentials, and token refresh
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>
2026-06-04 21:49:39 +02:00

39 lines
1.2 KiB
TypeScript

/**
* Helper to retrieve per-user Fitbit / Google Health OAuth credentials from the
* database. Mirrors `src/lib/whoop/credentials.ts`.
*
* Fitbit ships per-user BYO-keys: each self-hoster registers their own Google
* Cloud OAuth client and pastes the client id/secret into Settings, stored
* encrypted on `User` (the Restricted-scope brand verification + annual CASA
* assessment is per-OAuth-client, so a single shared app is unworkable for a
* multi-operator product).
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { FitbitCredentials } from "./client";
/**
* Fetch and decrypt the user's Fitbit API credentials.
* Returns null if the user has not configured credentials.
*/
export async function getUserFitbitCredentials(
userId: string,
): Promise<FitbitCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
if (!user?.fitbitClientIdEncrypted || !user?.fitbitClientSecretEncrypted) {
return null;
}
return {
clientId: decrypt(user.fitbitClientIdEncrypted),
clientSecret: decrypt(user.fitbitClientSecretEncrypted),
};
}