Files
HealthLog/src/lib/fitbit/oauth.ts
T
Marc-André Bombeck b6f45c0d88 refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.

Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
2026-10-23 12:57:47 +02:00

79 lines
2.4 KiB
TypeScript

/**
* The Fitbit Web API OAuth client: consent URL, code exchange and refresh,
* over the shared factory (`integrations/oauth-kit/client.ts`).
*
* S256 PKCE with the confidential client's secret by HTTP Basic beside it.
* Refresh tokens rotate (one-time use): every answer carries a new refresh
* token that replaces the stored one. A refusal names itself in
* `errors[0].errorType`, or in an RFC 6749 `error`.
*/
import {
defineOAuthClient,
oauthErrorCode,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { checkedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import { FitbitApiError } from "./response-classifier";
export type FitbitCredentials = OAuthClientCredentials;
/**
* The classic scopes HealthLog requests, space-separated on the wire. Each is
* self-serve grantable; `temperature` is left out on purpose (the classic
* skin-temperature reading is a delta, not an absolute value).
*/
export const FITBIT_OAUTH_SCOPE = [
"activity",
"cardio_fitness",
"heartrate",
"oxygen_saturation",
"profile",
"respiratory_rate",
"sleep",
"weight",
].join(" ");
export interface FitbitTokenResponse {
access_token: string;
/**
* Rotates on every answer and must replace the stored one. Optional only to
* guard a malformed answer.
*/
refresh_token?: string;
expires_in: number;
scope?: string;
token_type?: string;
user_id?: string;
}
function fitbitErrorType(body: unknown): string | undefined {
if (body !== null && typeof body === "object") {
const first = (body as { errors?: Array<{ errorType?: unknown }> })
.errors?.[0];
if (typeof first?.errorType === "string") return first.errorType;
}
return oauthErrorCode(body);
}
export const fitbitOAuth = defineOAuthClient<FitbitTokenResponse>({
service: "fitbit",
authorizeUrl: "https://www.fitbit.com/oauth2/authorize",
tokenUrl: "https://api.fitbit.com/oauth2/token",
redirectUri: () =>
checkedRedirectUri("Fitbit", "FITBIT_REDIRECT_URI", "/api/fitbit/callback"),
authorizeParams: () => ({ scope: FITBIT_OAUTH_SCOPE }),
pkce: true,
clientAuth: { kind: "basic", idInForm: { exchange: true, refresh: false } },
refresh: true,
upstreamError: fitbitErrorType,
error: (failure) =>
new FitbitApiError({
verb: failure.verb,
classification: failure.classification,
httpStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
});