mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
1a84cf27a8
commit
b6f45c0d88
89 files changed
+1690
-1764
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user