Files
HealthLog/src/lib/integrations/oauth-kit/client.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

349 lines
12 KiB
TypeScript

/**
* The one OAuth client every provider integration speaks through: the
* authorization URL, the code exchange, the refresh, and (Strava) the
* deauthorization, with the token endpoint's answer classified the same way
* for every provider.
*
* A provider declares what differs (`OAuthClientConfig`): its endpoints and
* scope, how its client authenticates (credentials in the form, or HTTP Basic
* with or without the client id beside it), the fields its grants add, how
* it classifies an answer and names the upstream refusal, the error type it
* throws, and where the token sits in the body. Everything else is here once:
* the form post, the external-call line on the wide event, the refusal
* becoming the provider's own classified error, and a 2xx without a token
* object (a captive portal, a gateway 200, a 204) becoming a transient
* `empty_token_body` failure instead of a null the caller dereferences.
*
* The token lifecycle (when to refresh, under which lock, writing the grant
* back) is `token.ts`; this module is the wire.
*
* `__tests__/oauth-wire-parity.test.ts` pins what every provider puts on the
* wire and makes of each answer against the hand-written clients this
* replaced.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import {
classifyHttpStatus,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export interface OAuthClientCredentials {
clientId: string;
clientSecret: string;
}
/** A provider's verdict on one token-endpoint answer. */
export interface TokenVerdict {
classification: IntegrationClassification;
/** The status that drove it: HTTP, or a provider's in-body status. */
status: number | undefined;
reason: string;
}
/** What the provider's error type is built from. */
export interface TokenFailure extends TokenVerdict {
verb: string;
upstreamError: string | undefined;
}
type FormFields = Readonly<Record<string, string>>;
export interface OAuthClientConfig {
/** The service name on the wide event's external-call line. */
service: string;
authorizeUrl: string;
tokenUrl: string;
/**
* A second token endpoint asked after the first refused (Oura's legacy
* one). Never asked first: an authorization code works once.
*/
legacyTokenUrl?: {
url: string;
/** HTTP statuses of the first answer that send the request on. */
retryOn: ReadonlySet<number>;
};
redirectUri: () => string;
/** Query parameters of the authorization URL after the client and redirect. */
authorizeParams: () => FormFields;
/** S256 PKCE: the authorization URL carries the challenge, the exchange the verifier. */
pkce?: boolean;
/**
* How the client authenticates at the token endpoint: its id and secret in
* the form, or HTTP Basic, with the id also in the form for the grants
* named.
*/
clientAuth:
| { kind: "form" }
| { kind: "basic"; idInForm: { exchange: boolean; refresh: boolean } };
/** Send `Accept: application/json` with the token request. */
acceptJson?: boolean;
/** Whether the exchange repeats the redirect URI (RFC 6749 4.1.3). Default true. */
exchangeRedirectUri?: boolean;
/** Fields a grant adds to the form (Withings' `action`, WHOOP's `scope`). */
extraFields?: { exchange?: FormFields; refresh?: FormFields };
/** Whether the provider issues refresh tokens at all. */
refresh: boolean;
/** Classify one answer. Default: the HTTP status alone. */
classify?: (httpStatus: number, body: unknown) => TokenVerdict;
/** The provider's own name for a refusal, read from the body. */
upstreamError?: (body: unknown) => string | undefined;
/** The verb a failure is filed under, when it differs from the call's. */
failureVerb?: { exchange?: string; refresh?: string };
/** Build the provider's error type. */
error: (failure: TokenFailure) => Error;
/** The token object inside the answer. Default: the body itself. */
tokenOf?: (body: unknown) => unknown;
/** Best-effort deauthorization at the provider (Strava). */
deauthorizeUrl?: string;
}
export interface OAuthClient<Token> {
readonly redirectUri: () => string;
/** The browser redirect to the consent screen; `state` is the CSRF nonce. */
authorizationUrl(
state: string,
credentials: OAuthClientCredentials,
codeChallenge?: string,
): string;
/**
* Exchange an authorization code. `codeVerifier` is the PKCE verifier;
* `useLegacyEndpoint` is false when the code is known to come from the
* current authorization server, so a refusal is final.
*/
exchange(
code: string,
credentials: OAuthClientCredentials,
options?: { codeVerifier?: string; useLegacyEndpoint?: boolean },
): Promise<Token>;
/** Refresh an access token. Throws for a provider without refresh tokens. */
refresh(
refreshToken: string,
credentials: OAuthClientCredentials,
): Promise<Token>;
/** Tell the provider the grant is gone. Never throws on a refusal. */
deauthorize(accessToken: string): Promise<void>;
}
const EXCHANGE_VERB = "exchangeCode";
const REFRESH_VERB = "refreshAccessToken";
/** HTTP Basic credentials for the confidential client (RFC 6749 2.3.1). */
export function basicAuthorization(
credentials: OAuthClientCredentials,
): string {
const raw = `${credentials.clientId}:${credentials.clientSecret}`;
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
}
/** The RFC 6749 `error` code of a refusal body, when it has one. */
export function oauthErrorCode(body: unknown): string | undefined {
if (body === null || typeof body !== "object") return undefined;
const error = (body as { error?: unknown }).error;
return typeof error === "string" ? error : undefined;
}
function defaultVerdict(httpStatus: number): TokenVerdict {
const verdict = classifyHttpStatus(httpStatus);
return {
classification: verdict.classification,
status: verdict.httpStatus,
reason: verdict.reason,
};
}
interface Answer {
httpStatus: number;
body: unknown;
verdict: TokenVerdict;
}
export function defineOAuthClient<Token>(
config: OAuthClientConfig,
): OAuthClient<Token> {
const classify =
config.classify ?? ((httpStatus: number) => defaultVerdict(httpStatus));
const upstreamError = config.upstreamError ?? oauthErrorCode;
const tokenOf = config.tokenOf ?? ((body: unknown) => body);
async function post(
url: string,
form: Record<string, string>,
credentials: OAuthClientCredentials,
eventMethod: string,
): Promise<Answer> {
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
};
if (config.acceptJson) headers.Accept = "application/json";
if (config.clientAuth.kind === "basic") {
headers.Authorization = basicAuthorization(credentials);
}
const start = performance.now();
const res = await safeFetch(url, {
method: "POST",
headers,
body: new URLSearchParams(form).toString(),
});
const body: unknown = await res.json().catch(() => null);
const verdict = classify(res.status, body);
getEvent()?.addExternalCall({
service: config.service,
method: eventMethod,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
return { httpStatus: res.status, body, verdict };
}
function settle(answer: Answer, verb: string): Token {
const { verdict, body } = answer;
if (verdict.classification !== "success") {
throw config.error({
...verdict,
verb,
upstreamError: upstreamError(body),
});
}
const token = tokenOf(body);
if (token === null || typeof token !== "object") {
throw config.error({
classification: "transient",
status: answer.httpStatus,
reason: "empty_token_body",
verb,
upstreamError: undefined,
});
}
return token as Token;
}
async function request(
grant: "exchange" | "refresh",
form: Record<string, string>,
credentials: OAuthClientCredentials,
mayUseLegacy: boolean,
): Promise<Token> {
const eventMethod = grant === "exchange" ? EXCHANGE_VERB : REFRESH_VERB;
const verb = config.failureVerb?.[grant] ?? eventMethod;
const first = await post(config.tokenUrl, form, credentials, eventMethod);
const legacy = config.legacyTokenUrl;
if (legacy && mayUseLegacy && legacy.retryOn.has(first.httpStatus)) {
const second = await post(
legacy.url,
form,
credentials,
`${eventMethod}.legacy`,
);
if (second.httpStatus >= 200 && second.httpStatus < 300) {
return settle(second, verb);
}
// Neither endpoint took it; the first answer describes the grant for
// every current client, so that is the one reported.
}
return settle(first, verb);
}
function clientFields(
grant: "exchange" | "refresh",
credentials: OAuthClientCredentials,
): Record<string, string> {
if (config.clientAuth.kind === "form") {
return {
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
};
}
return config.clientAuth.idInForm[grant]
? { client_id: credentials.clientId }
: {};
}
return {
redirectUri: config.redirectUri,
authorizationUrl(state, credentials, codeChallenge) {
if (config.pkce && !codeChallenge) {
throw new Error(
`${config.service}: the authorization needs a PKCE challenge`,
);
}
const params = new URLSearchParams({
response_type: "code",
client_id: credentials.clientId,
redirect_uri: config.redirectUri(),
...config.authorizeParams(),
...(config.pkce && codeChallenge
? { code_challenge: codeChallenge, code_challenge_method: "S256" }
: {}),
state,
});
return `${config.authorizeUrl}?${params}`;
},
exchange(code, credentials, options = {}) {
if (config.pkce && !options.codeVerifier) {
throw new Error(
`${config.service}: the exchange needs the PKCE verifier`,
);
}
return request(
"exchange",
{
...config.extraFields?.exchange,
grant_type: "authorization_code",
code,
...clientFields("exchange", credentials),
...(config.exchangeRedirectUri === false
? {}
: { redirect_uri: config.redirectUri() }),
...(options.codeVerifier
? { code_verifier: options.codeVerifier }
: {}),
},
credentials,
options.useLegacyEndpoint ?? true,
);
},
refresh(refreshToken, credentials) {
if (!config.refresh) {
return Promise.reject(
new Error(`${config.service} issues no refresh tokens`),
);
}
return request(
"refresh",
{
...config.extraFields?.refresh,
grant_type: "refresh_token",
refresh_token: refreshToken,
...clientFields("refresh", credentials),
},
credentials,
true,
);
},
async deauthorize(accessToken) {
if (!config.deauthorizeUrl) return;
const start = performance.now();
const res = await safeFetch(config.deauthorizeUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
getEvent()?.addExternalCall({
service: config.service,
method: "deauthorize",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: res.ok ? undefined : `http_${res.status}`,
});
},
};
}