mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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>
27 lines
781 B
TypeScript
27 lines
781 B
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
FITBIT_OAUTH_STATE_COOKIE,
|
|
FITBIT_OAUTH_STATE_TTL_MS,
|
|
mintFitbitOAuthStateNonce,
|
|
} from "../oauth-state";
|
|
|
|
describe("Fitbit OAuth state", () => {
|
|
it("pins the cookie name and 10-minute TTL", () => {
|
|
expect(FITBIT_OAUTH_STATE_COOKIE).toBe("fitbit_state");
|
|
expect(FITBIT_OAUTH_STATE_TTL_MS).toBe(10 * 60 * 1000);
|
|
});
|
|
|
|
it("mints a 22-char base64url nonce (128 bits, no padding)", () => {
|
|
const nonce = mintFitbitOAuthStateNonce();
|
|
expect(nonce).toHaveLength(22);
|
|
expect(nonce).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
|
});
|
|
|
|
it("mints unique nonces", () => {
|
|
const set = new Set(
|
|
Array.from({ length: 100 }, () => mintFitbitOAuthStateNonce()),
|
|
);
|
|
expect(set.size).toBe(100);
|
|
});
|
|
});
|