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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 12:57:47 +02:00
1 parent 1a84cf27a8
commit b6f45c0d88
89 files changed
+1690 -1764

No files matched your search

+6 -7
View File
@@ -7,12 +7,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
fetchProfile,
resolveFitbitUserId,
FITBIT_OAUTH_SCOPE,
} from "@/lib/fitbit/client";
import { fetchProfile, resolveFitbitUserId } from "@/lib/fitbit/client";
import { fitbitOAuth } from "@/lib/fitbit/oauth";
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state";
import { FITBIT_BACKFILL_QUEUE } from "@/lib/fitbit/jobs/queues";
@@ -150,7 +147,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
const tokens = await fitbitOAuth.exchange(code, creds, {
codeVerifier: consumed.codeVerifier,
});
// The initial code exchange MUST return a refresh token. If Fitbit ever
// omits it, refuse to persist — a stored empty refresh token silently bricks
@@ -27,10 +27,15 @@ vi.mock("@/lib/fitbit/credentials", () => ({
getUserFitbitCredentials: vi.fn(),
}));
vi.mock("@/lib/fitbit/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
),
vi.mock("@/lib/fitbit/oauth", () => ({
fitbitOAuth: {
authorizationUrl: vi.fn(
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
),
},
}));
vi.mock("@/lib/integrations/oauth-kit/pkce", () => ({
generatePkcePair: vi.fn(() => ({
verifier: "verifier-stub",
challenge: "challenge-stub",
+3 -2
View File
@@ -2,7 +2,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
import { fitbitOAuth } from "@/lib/fitbit/oauth";
import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import {
FITBIT_OAUTH_STATE_COOKIE,
@@ -94,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
const url = fitbitOAuth.authorizationUrl(nonce, creds, pkce.challenge);
const response = NextResponse.redirect(url);
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
+9 -9
View File
@@ -10,11 +10,11 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
fetchProfile,
resolveGoogleHealthUserId,
getGoogleHealthScopeString,
} from "@/lib/google-health/client";
import { googleHealthOAuth } from "@/lib/google-health/oauth";
import { getGoogleHealthScopeString } from "@/lib/google-health/oauth";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state";
import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill-queue";
@@ -162,14 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
const tokens = await googleHealthOAuth.exchange(code, creds, {
codeVerifier: consumed.codeVerifier,
});
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits
// it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the
// connection dies the moment the access token expires). Error out cleanly
// so the user re-consents rather than landing a dead connection.
// The exchange must return a refresh token (the connect route forces
// `prompt=consent` and `access_type=offline`). A stored empty one would
// fail every later refresh, so the person re-consents instead of landing
// a connection that dies with its first access token.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`google-health callback: token response carried no refresh_token for user ${recordUserId}`,
+1 -1
View File
@@ -50,7 +50,7 @@ import {
} from "@/lib/withings/plan-features";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { hasActivityScope } from "@/lib/withings/oauth";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import {
googleHealthBackfillEndedList,
@@ -74,13 +74,15 @@ const {
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock }));
vi.mock("@/lib/oura/client", async (importOriginal) => ({
isAcceptedOuraIssuer: (
await importOriginal<typeof import("@/lib/oura/client")>()
).isAcceptedOuraIssuer,
exchangeCode: exchangeMock,
vi.mock("@/lib/oura/client", () => ({
fetchOuraAccountId: vi.fn(async () => "oura-account-1"),
}));
vi.mock("@/lib/oura/oauth", async (importOriginal) => ({
isAcceptedOuraIssuer: (
await importOriginal<typeof import("@/lib/oura/oauth")>()
).isAcceptedOuraIssuer,
exchangeOuraCode: exchangeMock,
}));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: getCredsMock,
}));
+3 -6
View File
@@ -6,11 +6,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
fetchOuraAccountId,
isAcceptedOuraIssuer,
} from "@/lib/oura/client";
import { fetchOuraAccountId } from "@/lib/oura/client";
import { exchangeOuraCode, isAcceptedOuraIssuer } from "@/lib/oura/oauth";
import {
ALREADY_LINKED_REASON,
accountLinkedElsewhere,
@@ -137,7 +134,7 @@ async function completeCallback(
const creds = await getOuraClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds, iss);
const tokens = await exchangeOuraCode(code, creds, iss);
// One Oura account, one record: a guardian signed in to their own Oura
// account must not attach it to the profile they manage.
@@ -76,9 +76,11 @@ vi.mock("@/lib/crypto", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
vi.mock("@/lib/polar/client", () => ({
exchangeCode: exchangeCodeMock,
registerUser: registerUserMock,
}));
vi.mock("@/lib/polar/oauth", () => ({
polarOAuth: { exchange: exchangeCodeMock },
}));
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: getCredsMock,
}));
+3 -2
View File
@@ -9,7 +9,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode, registerUser } from "@/lib/polar/client";
import { registerUser } from "@/lib/polar/client";
import { polarOAuth } from "@/lib/polar/oauth";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
oauthStateCookieName,
@@ -115,7 +116,7 @@ async function completeCallback(
const creds = await getPolarClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
const tokens = await polarOAuth.exchange(code, creds);
// Guard against an off-spec token body without `x_user_id`: persisting the
// string "undefined" as the member id would break every later data read
// (`/v3/users/undefined/...`) with no clear cause.
@@ -83,7 +83,9 @@ vi.mock("@/lib/logging/context", () => ({
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => {}) }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc(${s})` }));
vi.mock("@/lib/strava/client", () => ({ exchangeCode: exchangeCodeMock }));
vi.mock("@/lib/strava/oauth", () => ({
stravaOAuth: { exchange: exchangeCodeMock },
}));
vi.mock("@/lib/strava/credentials", () => ({
getStravaClientCredentials: getCredsMock,
}));
+2 -2
View File
@@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode } from "@/lib/strava/client";
import { stravaOAuth } from "@/lib/strava/oauth";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import {
oauthStateCookieName,
@@ -114,7 +114,7 @@ async function completeCallback(
const creds = await getStravaClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
const tokens = await stravaOAuth.exchange(code, creds);
const athleteId =
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
@@ -52,15 +52,19 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/whoop/client", () => ({
WHOOP_OAUTH_SCOPE: "read:recovery",
exchangeCode: vi.fn(async () => ({
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
scope: "read:recovery",
})),
fetchProfile: vi.fn(async () => ({ user_id: 42 })),
}));
vi.mock("@/lib/whoop/oauth", () => ({
WHOOP_OAUTH_SCOPE: "read:recovery",
whoopOAuth: {
exchange: vi.fn(async () => ({
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
scope: "read:recovery",
})),
},
}));
vi.mock("@/lib/whoop/credentials", () => ({
getUserWhoopCredentials: vi.fn(async () => ({
+3 -6
View File
@@ -6,11 +6,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
fetchProfile,
WHOOP_OAUTH_SCOPE,
} from "@/lib/whoop/client";
import { fetchProfile } from "@/lib/whoop/client";
import { WHOOP_OAUTH_SCOPE, whoopOAuth } from "@/lib/whoop/oauth";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state";
import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme";
@@ -205,7 +202,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return outcomeRedirect(returnScheme, "error", "nocreds");
}
const tokens = await exchangeCode(code, creds);
const tokens = await whoopOAuth.exchange(code, creds);
const profile = await fetchProfile(tokens.access_token);
const whoopUserId = String(profile.user_id);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
@@ -81,8 +81,8 @@ vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/withings/client", () => ({
exchangeCode: vi.fn(),
vi.mock("@/lib/withings/oauth", () => ({
withingsOAuth: { exchange: vi.fn() },
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
}));
@@ -157,8 +157,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
clientId: "cid",
clientSecret: "csec",
} as never);
const { exchangeCode } = await import("@/lib/withings/client");
vi.mocked(exchangeCode).mockResolvedValue({
const { withingsOAuth } = await import("@/lib/withings/oauth");
vi.mocked(withingsOAuth.exchange).mockResolvedValue({
userid: "wuid",
access_token: "atok",
refresh_token: "rtok",
@@ -221,8 +221,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
clientId: "cid",
clientSecret: "csec",
} as never);
const { exchangeCode } = await import("@/lib/withings/client");
vi.mocked(exchangeCode).mockResolvedValue({
const { withingsOAuth } = await import("@/lib/withings/oauth");
vi.mocked(withingsOAuth.exchange).mockResolvedValue({
userid: "wuid",
access_token: "atok",
refresh_token: "rtok",
@@ -300,7 +300,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
expiredRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const { withingsOAuth } = await import("@/lib/withings/oauth");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
@@ -310,7 +310,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
"withings=error&reason=expired",
);
// Single atomic round-trip consumed the row; no token exchange.
expect(exchangeCode).not.toHaveBeenCalled();
expect(withingsOAuth.exchange).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
});
@@ -325,7 +325,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
crossUserRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const { withingsOAuth } = await import("@/lib/withings/oauth");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
@@ -334,7 +334,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
expect(res.headers.get("location")).toContain(
"withings=error&reason=cross_user",
);
expect(exchangeCode).not.toHaveBeenCalled();
expect(withingsOAuth.exchange).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({
meta: { reason: "cross_user" },
});
+2 -2
View File
@@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
import { WITHINGS_OAUTH_SCOPE, withingsOAuth } from "@/lib/withings/oauth";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
import { setupWebhook } from "@/lib/withings/sync";
@@ -209,7 +209,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
const tokens = await exchangeCode(code, creds);
const tokens = await withingsOAuth.exchange(code, creds);
// One Withings account, one record. A guardian signed in to their own
// Health Mate account must not attach it to the profile they manage:
+1 -1
View File
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { hasActivityScope } from "@/lib/withings/client";
import { hasActivityScope } from "@/lib/withings/oauth";
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor";
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";