mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
`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.
79 lines
2.4 KiB
TypeScript
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,
|
|
}),
|
|
});
|