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

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 117,
"total": 138503,
"count": 116,
"total": 136645,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -69,11 +69,11 @@
"lib/export/backup-plan.ts": 853,
"lib/export/coach-backup.ts": 1070,
"lib/export/profile-backup.ts": 1019,
"lib/fitbit/client.ts": 1529,
"lib/fitbit/client.ts": 1262,
"lib/fitbit/sync-core.ts": 968,
"lib/gamification/achievements-result.ts": 1094,
"lib/gamification/achievements.ts": 913,
"lib/google-health/client.ts": 1875,
"lib/google-health/client.ts": 1589,
"lib/google-health/mappers.ts": 1295,
"lib/google-health/sync-core.ts": 1634,
"lib/idempotency.ts": 879,
@@ -107,16 +107,15 @@
"lib/medications/scheduling/recurrence.ts": 1108,
"lib/medications/scheduling/slot-upsert.ts": 926,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/client.ts": 1360,
"lib/oura/client.ts": 1089,
"lib/oura/sync.ts": 902,
"lib/polar/client.ts": 1010,
"lib/polar/client.ts": 893,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 1046,
"lib/support/report.ts": 903,
"lib/telegram-webhook-handlers.ts": 1341,
"lib/validations/inbound-documents.ts": 1130,
"lib/whoop/client.ts": 917,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 978,
"proxy.ts": 870
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2924,
"total": 10177,
"count": 2921,
"total": 10172,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2686,7 +2686,6 @@
"lib/openapi/routes/workouts.ts": 4,
"lib/optimistic-lock.ts": 1,
"lib/oura/__tests__/sync.test.ts": 1,
"lib/oura/client.ts": 1,
"lib/oura/credentials.ts": 3,
"lib/oura/cycle-sync.ts": 1,
"lib/oura/response-classifier.ts": 1,
@@ -2698,7 +2697,6 @@
"lib/personal-records/pr-detection-worker.ts": 6,
"lib/personal-records/pr-direction.ts": 26,
"lib/polar/__tests__/dedup.test.ts": 1,
"lib/polar/client.ts": 1,
"lib/polar/credentials.ts": 3,
"lib/polar/response-classifier.ts": 1,
"lib/polar/sync-workouts.ts": 1,
@@ -2800,7 +2798,6 @@
"lib/sse/__tests__/create-stream.test.ts": 1,
"lib/sse/create-stream.ts": 3,
"lib/strava/__tests__/dedup.test.ts": 1,
"lib/strava/client.ts": 1,
"lib/strava/credentials.ts": 1,
"lib/strava/response-classifier.ts": 1,
"lib/strava/sync.ts": 1,
@@ -2915,7 +2912,7 @@
"lib/withings/__tests__/sync-sleep.test.ts": 4,
"lib/withings/__tests__/sync-typed-classification.test.ts": 1,
"lib/withings/__tests__/sync.test.ts": 1,
"lib/withings/client.ts": 5,
"lib/withings/client.ts": 3,
"lib/withings/oauth-state.ts": 1,
"lib/withings/response-classifier.ts": 1,
"lib/withings/sync-activity.ts": 7,
@@ -88,15 +88,18 @@ describe("an empty environment variable counts as unset", () => {
it("T3 — the sweep read the tree and the matcher fires on real source", () => {
// An empty offender list proves nothing if the narrowed walk found no
// files, or if the matcher no longer fires on what the tree actually
// holds. Floor the narrowed set, then put the pre-fix read back into a
// real client and require the matcher to find it there.
// holds. Floor the narrowed set, then put the pre-fix read back into the
// redirect-URI helper every client uses and require the matcher to find it there.
const files = sourceFiles();
expect(files.length).toBeGreaterThan(2400);
expect(files).toContain("lib/withings/client.ts");
const real = readFileSync(join(SRC, "lib/withings/client.ts"), "utf8");
expect(files).toContain("lib/integrations/oauth-kit/redirect-uri.ts");
const real = readFileSync(
join(SRC, "lib/integrations/oauth-kit/redirect-uri.ts"),
"utf8",
);
expect([...real.matchAll(NULLISH_ENV_FALLBACK)]).toEqual([]);
const reverted = real.replace(
/envOr\(\s*"WITHINGS_REDIRECT_URI",/,
/envOr\(envVar,/,
"process.env.WITHINGS_REDIRECT_URI ?? (",
);
expect(reverted).not.toBe(real);
+31 -14
View File
@@ -10,17 +10,23 @@
*
* A. Each kit provider has `src/lib/<provider>/oauth-descriptor.ts`, and the
* descriptor names that provider.
* B. A provider client's `refreshAccessToken` is imported by that
* provider's descriptor and by nothing else outside tests.
* B. A provider's refresh (`<provider>OAuth.refresh(…)`, from the shared
* OAuth client factory) is called by that provider's descriptor and by
* nothing else outside tests.
* C. The refresh lock is taken by the kit and by the AI provider store
* (`src/lib/ai/provider.ts`, which is not an integration) only.
* D. A token is sealed into a connection row only by a callback route
* (the connect-time write). The kit writes ciphertext the descriptor is
* handed and never encrypts on its own.
* E. A provider integration speaks to a token endpoint only through the
* factory (`oauth-kit/client.ts`): no other integration module builds a
* `grant_type` form. Sign-in (OIDC), the MCP authorization server and
* the Codex device login are not provider integrations.
*
* Fitbit is the one listed exception: the classic Fitbit Web API is retired
* on 2026-10-30 and its integration rides out on its own refresh until then,
* rather than being migrated onto a lifecycle it will never use.
* Fitbit is the one listed exception to the lifecycle: the classic Fitbit Web
* API is retired on 2026-10-30 and its integration rides out on its own
* refresh until then, rather than being migrated onto a lifecycle it will
* never use. Its wire goes through the factory like every other provider's.
*
* Every leg asserts a non-empty match set before it compares, so a matcher
* that stops matching fails instead of passing.
@@ -68,21 +74,18 @@ describe("OAuth token lifecycle guard", () => {
}
});
it("B: a provider's refreshAccessToken is imported only by its descriptor", () => {
const importers = files.filter((f) => {
const src = read(f);
return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test(
src,
);
});
expect(importers.length).toBeGreaterThan(0);
it("B: a provider's refresh is called only by its descriptor", () => {
const callers = files.filter((f) =>
/\b[a-z][A-Za-z]*OAuth\.refresh\s*\(/.test(read(f)),
);
expect(callers.length).toBeGreaterThan(0);
const allowed = new Set([
...KIT_PROVIDERS.filter((p) => p !== "polar").map(
(p) => `src/lib/${p}/oauth-descriptor.ts`,
),
...RETIRED,
]);
expect(importers.filter((f) => !allowed.has(f))).toEqual([]);
expect(callers.filter((f) => !allowed.has(f))).toEqual([]);
});
it("C: only the kit and the AI provider store take the refresh lock", () => {
@@ -111,4 +114,18 @@ describe("OAuth token lifecycle guard", () => {
);
expect(offenders).toEqual([]);
});
it("E: provider integrations reach a token endpoint only through the factory", () => {
const forms = files.filter((f) => /\bgrant_type\b/.test(read(f)));
expect(forms.length).toBeGreaterThan(0);
const NOT_INTEGRATIONS = new Set([
"src/lib/integrations/oauth-kit/client.ts",
"src/lib/auth/oidc.ts",
"src/lib/ai/codex-oauth.ts",
"src/lib/mcp/oauth/metadata.ts",
"src/app/api/mcp/oauth/token/route.ts",
"src/app/api/mcp/oauth/register/route.ts",
]);
expect(forms.filter((f) => !NOT_INTEGRATIONS.has(f)).sort()).toEqual([]);
});
});
+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";
+21 -15
View File
@@ -4,13 +4,9 @@ import {
FITBIT_API_BASE,
FITBIT_RETRY_AFTER_WAIT_CAP_S,
FITBIT_FIELD_MAP,
FITBIT_OAUTH_SCOPE,
exchangeCode,
fetchSleepRange,
fetchSpo2Range,
fetchWeightRange,
generatePkcePair,
getAuthorizationUrl,
mapActiveCalories,
mapBodyFat,
mapDistance,
@@ -29,9 +25,11 @@ import {
parseVo2Max,
readActivityList,
readSleepSessions,
refreshAccessToken,
resolveFitbitUserId,
} from "../client";
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
import { fitbitOAuth } from "../oauth";
import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
@@ -89,7 +87,7 @@ describe("generatePkcePair", () => {
describe("getAuthorizationUrl", () => {
it("builds the classic Fitbit authorize URL with PKCE S256", () => {
const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz");
const url = fitbitOAuth.authorizationUrl("nonce123", CREDS, "chal-xyz");
expect(url).toContain("www.fitbit.com/oauth2/authorize");
expect(url).toContain("response_type=code");
expect(url).toContain("client_id=cid");
@@ -118,7 +116,7 @@ describe("getAuthorizationUrl", () => {
});
it("carries the configured app origin in the redirect_uri", () => {
const url = getAuthorizationUrl("nonce123", CREDS, "c");
const url = fitbitOAuth.authorizationUrl("nonce123", CREDS, "c");
const redirect = new URL(url).searchParams.get("redirect_uri");
expect(redirect).toBe("https://app.example.test/api/fitbit/callback");
});
@@ -133,12 +131,14 @@ describe("redirect_uri allowlist", () => {
it("rejects a non-https app origin", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test";
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/);
expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow(
/must be https/,
);
});
it("allows http on localhost (dev)", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
const url = getAuthorizationUrl("n", CREDS, "c");
const url = fitbitOAuth.authorizationUrl("n", CREDS, "c");
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
"http://localhost:3000/api/fitbit/callback",
);
@@ -146,7 +146,7 @@ describe("redirect_uri allowlist", () => {
it("rejects an explicit redirect that targets the wrong path", () => {
process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil";
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow(
/must target \/api\/fitbit\/callback/,
);
});
@@ -155,7 +155,7 @@ describe("redirect_uri allowlist", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
process.env.FITBIT_REDIRECT_URI =
"https://attacker.example.test/api/fitbit/callback";
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow(
/does not match/,
);
});
@@ -175,7 +175,9 @@ describe("token exchange + refresh", () => {
},
},
]);
const tok = await exchangeCode("code", "verifier-123", CREDS);
const tok = await fitbitOAuth.exchange("code", CREDS, {
codeVerifier: "verifier-123",
});
expect(tok.access_token).toBe("at");
expect(tok.refresh_token).toBe("rt");
expect(tok.expires_in).toBe(28800);
@@ -202,7 +204,7 @@ describe("token exchange + refresh", () => {
},
},
]);
const tok = await refreshAccessToken("rt1", CREDS);
const tok = await fitbitOAuth.refresh("rt1", CREDS);
expect(tok.access_token).toBe("at2");
// Classic Fitbit rotates: a fresh refresh token is returned every time.
expect(tok.refresh_token).toBe("rt2");
@@ -220,7 +222,9 @@ describe("token exchange + refresh", () => {
installFetchMock([
{ status: 401, body: { errors: [{ errorType: "invalid_grant" }] } },
]);
await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({
await expect(
fitbitOAuth.exchange("bad", CREDS, { codeVerifier: "v" }),
).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
});
@@ -231,7 +235,9 @@ describe("token exchange + refresh", () => {
// TypeError. It must surface as a classified transient integration error.
it("classifies a 2xx with an empty body instead of casting null", async () => {
installFetchMock([{ status: 200, body: null }]);
await expect(exchangeCode("code", "v", CREDS)).rejects.toMatchObject({
await expect(
fitbitOAuth.exchange("code", CREDS, { codeVerifier: "v" }),
).rejects.toMatchObject({
name: "FitbitApiError",
classification: "transient",
reason: "empty_token_body",
@@ -3,9 +3,9 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/client";
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
import { FITBIT_OAUTH_SCOPE } from "../client";
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
/**
* Guard against the class of bug reported in #396: the setup docs drifting away
@@ -70,7 +70,9 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("../credentials", () => ({
getUserFitbitCredentials: getUserFitbitCredentialsMock,
}));
vi.mock("../client", () => ({ refreshAccessToken: refreshAccessTokenMock }));
vi.mock("../oauth", () => ({
fitbitOAuth: { refresh: refreshAccessTokenMock },
}));
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
vi.mock("../sync-activity", () => ({ syncUserActivity: resourceFake }));
@@ -44,7 +44,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({
vi.mock("../credentials", () => ({
getUserFitbitCredentials: vi.fn(async () => null),
}));
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() },
};
});
import { eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
@@ -57,7 +57,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({
vi.mock("../credentials", () => ({
getUserFitbitCredentials: vi.fn(async () => null),
}));
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() },
};
});
import { upsertFitbitMeasurements } from "../sync-core";
+6 -3
View File
@@ -55,11 +55,14 @@ vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
}));
vi.mock("../client", async (orig) => {
const actual = await orig<typeof import("../client")>();
vi.mock("../oauth", async (orig) => {
const actual = await orig<typeof import("../oauth")>();
return {
...actual,
refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a),
fitbitOAuth: {
...actual.fitbitOAuth,
refresh: (...a: unknown[]) => refreshAccessTokenMock(...a),
},
};
});
-267
View File
@@ -36,19 +36,15 @@
* self-serve Google path exists, the classic API is the only viable transport
* for self-hosters; the card keeps its experimental badge.
*/
import { createHash, randomBytes } from "node:crypto";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { FitbitApiError, classifyFitbitResponse } from "./response-classifier";
import { envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
/** Classic Fitbit Web API base. */
export const FITBIT_API_BASE = "https://api.fitbit.com";
const FITBIT_OAUTH_AUTH_URL = "https://www.fitbit.com/oauth2/authorize";
const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token";
/**
* Fitbit returns body weight / distance in metric (kg / km) only when an
@@ -58,269 +54,6 @@ const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token";
*/
const FITBIT_METRIC_LOCALE = "en_GB";
export interface FitbitCredentials {
clientId: string;
clientSecret: string;
}
/**
* Resolve the OAuth `redirect_uri`, then assert it against an allowlist
* (defence-in-depth; carried over from the prior transport).
*
* The value is operator-controlled config (`FITBIT_REDIRECT_URI`, else derived
* from `NEXT_PUBLIC_APP_URL`), not user input, and Fitbit's registered-redirect
* check is the real backstop. But a misconfigured or `Host`-coerced
* `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting a forwarded
* Host) would otherwise send the authorization code's landing URL off-origin.
* Pin the target so a malformed origin fails fast at the handshake:
* - must be an absolute, parseable URL,
* - must be https (the one exception is a localhost/loopback dev host),
* - must land on the fixed `/api/fitbit/callback` path,
* - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it.
*/
export function getFitbitRedirectUri(): string {
// An empty or blank value counts as unset: the compose whitelist
// materialises an unset var as an empty string, which must fall through to
// the derived URI rather than read as "not configured".
const explicit = envValue("FITBIT_REDIRECT_URI");
const appUrl = envValue("NEXT_PUBLIC_APP_URL");
const raw =
explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined);
if (!raw) {
throw new Error(
"Fitbit redirect_uri is not configured — set FITBIT_REDIRECT_URI or NEXT_PUBLIC_APP_URL",
);
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Fitbit redirect_uri is not an absolute URL: ${raw}`);
}
const isLoopback =
parsed.hostname === "localhost" ||
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "[::1]" ||
parsed.hostname === "::1";
if (
parsed.protocol !== "https:" &&
!(parsed.protocol === "http:" && isLoopback)
) {
throw new Error(
`Fitbit redirect_uri must be https (or http on localhost): ${parsed.origin}`,
);
}
if (parsed.pathname !== "/api/fitbit/callback") {
throw new Error(
`Fitbit redirect_uri must target /api/fitbit/callback, got ${parsed.pathname}`,
);
}
// When an explicit FITBIT_REDIRECT_URI is set alongside NEXT_PUBLIC_APP_URL,
// require them to share an origin so the pinned value can't drift to an
// unexpected host relative to the app's own base URL.
if (explicit && appUrl) {
let appOrigin: string;
try {
appOrigin = new URL(appUrl).origin;
} catch {
throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`);
}
if (parsed.origin !== appOrigin) {
throw new Error(
`Fitbit redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`,
);
}
}
return parsed.toString();
}
/**
* Classic Fitbit OAuth scopes HealthLog requests (space-separated on the wire).
* Each is independently self-serve grantable at the consent screen and the
* `temperature` set is omitted on purpose (see the skin-temperature note in
* `mapping.md` — the classic skin-temp reading is a baseline DELTA, not an
* absolute reading, so it has no honest canonical slot here). The launch bundle
* covers every metric the v1.20.0 mappers extract.
*/
export const FITBIT_OAUTH_SCOPE = [
"activity",
"cardio_fitness",
"heartrate",
"oxygen_saturation",
"profile",
"respiratory_rate",
"sleep",
"weight",
].join(" ");
// ─── PKCE ──────────────────────────────────────────────────────
//
// Fitbit recommends PKCE (S256). The verifier is a high-entropy random string;
// the challenge is BASE64URL(SHA256(verifier)). The verifier is stashed on the
// OAuth-state row at connect and presented on the token exchange at callback.
export interface FitbitPkcePair {
verifier: string;
challenge: string;
}
/**
* Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars,
* comfortably inside Fitbit's 43–128 char verifier range and well past the
* 256-bit entropy floor.
*/
export function generatePkcePair(): FitbitPkcePair {
const verifier = randomBytes(64).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Build the Fitbit authorization URL (a browser redirect, not a fetch). `state`
* is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the
* S256 PKCE challenge whose verifier the callback presents on token exchange.
*/
export function getAuthorizationUrl(
state: string,
creds: FitbitCredentials,
codeChallenge: string,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getFitbitRedirectUri(),
scope: FITBIT_OAUTH_SCOPE,
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
return `${FITBIT_OAUTH_AUTH_URL}?${params}`;
}
export interface FitbitTokenResponse {
access_token: string;
/**
* Classic Fitbit refresh tokens ROTATE (one-time use) — every token response
* (initial exchange AND every refresh) carries a fresh `refresh_token` that
* MUST replace the stored one. Typed optional only to guard a malformed
* response defensively; the callers treat an absent value as a hard error /
* keep-existing fallback.
*/
refresh_token?: string;
expires_in: number;
scope?: string;
token_type?: string;
user_id?: string;
}
/** Basic-auth header carrying the confidential client credentials. */
function basicAuthHeader(creds: FitbitCredentials): string {
const raw = `${creds.clientId}:${creds.clientSecret}`;
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
}
async function postToken(
params: URLSearchParams,
creds: FitbitCredentials,
verb: string,
): Promise<FitbitTokenResponse> {
const start = performance.now();
const res = await safeFetch(FITBIT_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: basicAuthHeader(creds),
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyFitbitResponse(res.status);
getEvent()?.addExternalCall({
service: "fitbit",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new FitbitApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.errors?.[0]?.errorType === "string"
? json.errors[0].errorType
: typeof json?.error === "string"
? json.error
: undefined,
});
}
// A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204)
// leaves `json` null. Casting it straight to the token type handed callers a
// null they then dereferenced, surfacing an unclassified TypeError instead of
// a handled integration error. Narrow explicitly and classify as transient.
if (json === null || typeof json !== "object") {
throw new FitbitApiError({
verb,
classification: "transient",
httpStatus: res.status,
reason: "empty_token_body",
});
}
return json as FitbitTokenResponse;
}
/**
* Exchange an authorization code for the initial token pair, presenting the PKCE
* verifier. `redirect_uri` must exactly match the one sent to the authorize
* endpoint.
*/
export async function exchangeCode(
code: string,
codeVerifier: string,
creds: FitbitCredentials,
): Promise<FitbitTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: creds.clientId,
redirect_uri: getFitbitRedirectUri(),
code_verifier: codeVerifier,
}),
creds,
"exchangeCode",
);
}
/**
* Refresh an expired access token. Classic Fitbit ROTATES the refresh token:
* the response carries a fresh `access_token` AND a fresh `refresh_token`; the
* caller MUST persist the new refresh token (the old one is now invalid). The
* grant's scope is preserved, so no `scope` param is re-sent.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: FitbitCredentials,
): Promise<FitbitTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
creds,
"refreshAccessToken",
);
}
/**
* The Fitbit profile carries the stable external user id (`encodedId`) used as
* the connection's `fitbitUserId`. A single GET, not a paginated collection.
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { FitbitCredentials } from "./client";
import type { FitbitCredentials } from "./oauth";
/**
* Fetch and decrypt the user's Fitbit API credentials.
+78
View File
@@ -0,0 +1,78 @@
/**
* 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,
}),
});
+2 -2
View File
@@ -20,7 +20,7 @@ import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { refreshAccessToken } from "./client";
import { fitbitOAuth } from "./oauth";
import { isFitbitRetired } from "./retirement";
import { getUserFitbitCredentials } from "./credentials";
import { FitbitApiError, classifyFitbitError } from "./response-classifier";
@@ -115,7 +115,7 @@ export async function getValidToken(
return null;
}
const newTokens = await refreshAccessToken(refreshToken, creds);
const newTokens = await fitbitOAuth.refresh(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// Classic Fitbit ROTATES refresh tokens: the refresh response carries a
@@ -4,7 +4,7 @@ import {
GOOGLE_HEALTH_CORE_SCOPES,
getGoogleHealthScopeString,
resolveGoogleHealthScopes,
} from "../client";
} from "@/lib/google-health/oauth";
describe("Google Health scope resolver", () => {
it("ships exactly the four core Restricted read scopes", () => {
@@ -18,7 +18,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { GOOGLE_HEALTH_CORE_SCOPES } from "../client";
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
import {
INTEGRATION_DOCS_BASE,
integrationDocsHref,
@@ -85,9 +85,15 @@ vi.mock("@/lib/cache/invalidate", () => ({
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock,
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return { ...actual, refreshAccessToken: refreshAccessTokenMock };
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
googleHealthOAuth: {
...actual.googleHealthOAuth,
refresh: refreshAccessTokenMock,
},
};
});
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
@@ -45,12 +45,18 @@ vi.mock("../client", async () => {
const actual = await vi.importActual<typeof import("../client")>("../client");
return {
...actual,
refreshAccessToken: vi.fn(),
noteGoogleHealthMapped: vi.fn(),
noteGoogleHealthWritten: vi.fn(),
noteGoogleHealthOutcomeFailure: vi.fn(),
};
});
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
};
});
import { eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
@@ -32,7 +32,13 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(async () => null),
}));
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
};
});
import { replaceStaleGoogleHealthSleep } from "../sync-core";
@@ -45,9 +45,12 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(async () => null),
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return { ...actual, refreshAccessToken: vi.fn() };
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
};
});
import { upsertGoogleHealthMeasurements } from "../sync-core";
-286
View File
@@ -34,14 +34,12 @@
* module graph acyclic (`client` → `mappers`, never back).
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash, randomBytes } from "node:crypto";
import { annotate, getEvent } from "@/lib/logging/context";
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
GoogleHealthApiError,
classifyGoogleHealthResponse,
type GoogleHealthClassification,
} from "./response-classifier";
import {
googleHealthSampleInstant,
@@ -49,7 +47,6 @@ import {
type GoogleHealthDataType,
type GoogleHealthRollupPoint,
} from "./mappers";
import { envValue } from "@/lib/env";
import {
GOOGLE_HEALTH_NO_PROGRESS_PAGES,
googleHealthWalkProgress,
@@ -327,289 +324,6 @@ export function noteGoogleHealthDeferred(): void {
}
export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4";
const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
export interface GoogleHealthCredentials {
clientId: string;
clientSecret: string;
}
/**
* Resolve the OAuth `redirect_uri`, then assert it against an allowlist
* (defence-in-depth).
*
* The value is operator-controlled config (`GOOGLE_HEALTH_REDIRECT_URI`, else
* derived from `NEXT_PUBLIC_APP_URL`), not user input, so Google's
* registered-redirect check is the real backstop. But a misconfigured or
* `Host`-coerced `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting
* a forwarded Host) would otherwise send the authorization code's landing URL
* off-origin. Pin the target here so a malformed origin fails fast at the
* handshake rather than silently redirecting elsewhere:
* - must be an absolute, parseable URL,
* - must be https (the one exception is a localhost/loopback dev host, which
* Google itself permits over http),
* - must land on the fixed `/api/google-health/callback` path,
* - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it.
*/
export function getGoogleHealthRedirectUri(): string {
// An empty or blank value counts as unset: the compose whitelist
// materialises an unset var as an empty string, which must fall through to
// the derived URI rather than read as "not configured".
const explicit = envValue("GOOGLE_HEALTH_REDIRECT_URI");
const appUrl = envValue("NEXT_PUBLIC_APP_URL");
const raw =
explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined);
if (!raw) {
throw new Error(
"Google Health redirect_uri is not configured — set GOOGLE_HEALTH_REDIRECT_URI or NEXT_PUBLIC_APP_URL",
);
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(
`Google Health redirect_uri is not an absolute URL: ${raw}`,
);
}
const isLoopback =
parsed.hostname === "localhost" ||
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "[::1]" ||
parsed.hostname === "::1";
if (
parsed.protocol !== "https:" &&
!(parsed.protocol === "http:" && isLoopback)
) {
throw new Error(
`Google Health redirect_uri must be https (or http on localhost): ${parsed.origin}`,
);
}
if (parsed.pathname !== "/api/google-health/callback") {
throw new Error(
`Google Health redirect_uri must target /api/google-health/callback, got ${parsed.pathname}`,
);
}
// When an explicit GOOGLE_HEALTH_REDIRECT_URI is set alongside
// NEXT_PUBLIC_APP_URL, require them to share an origin so the pinned value
// can't drift to an unexpected host relative to the app's own base URL.
if (explicit && appUrl) {
let appOrigin: string;
try {
appOrigin = new URL(appUrl).origin;
} catch {
throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`);
}
if (parsed.origin !== appOrigin) {
throw new Error(
`Google Health redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`,
);
}
}
return parsed.toString();
}
/**
* The four core Restricted read scopes HealthLog requests for v1. Every scope
* is Restricted → the operator's OAuth client needs Google verification + an
* annual CASA assessment before it leaves "Testing" publishing mode (staying in
* Testing with ≤100 test users avoids CASA at the cost of a 7-day refresh-token
* expiry).
*/
export const GOOGLE_HEALTH_CORE_SCOPES = [
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
"https://www.googleapis.com/auth/googlehealth.profile.readonly",
] as const;
/**
* Resolve the scope list HealthLog requests: the four core Restricted bundles.
* ECG/IRN are a future enhancement — add those two Restricted read scopes only
* together with an ECG/IRN reader, never before (Google penalizes requesting a
* Restricted scope the app never consumes).
*/
export function resolveGoogleHealthScopes(): string[] {
return [...GOOGLE_HEALTH_CORE_SCOPES];
}
/** The space-separated scope string sent on the authorize request. */
export function getGoogleHealthScopeString(): string {
return resolveGoogleHealthScopes().join(" ");
}
// ─── PKCE ──────────────────────────────────────────────────────
//
// Google's authorization-code flow accepts S256 PKCE. The verifier is a
// high-entropy random string; the challenge is BASE64URL(SHA256(verifier)). The
// verifier is stashed on the OAuth-state row at connect and presented on the
// token exchange at callback — never in the cookie or the URL.
export interface GoogleHealthPkcePair {
verifier: string;
challenge: string;
}
/**
* Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars,
* comfortably inside the RFC 7636 43–128 char verifier range and well past the
* 256-bit entropy floor.
*/
export function generatePkcePair(): GoogleHealthPkcePair {
const verifier = randomBytes(64).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Build the Google authorization URL (a browser redirect, not a fetch). `state`
* is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the
* S256 PKCE challenge whose verifier the callback presents on token exchange.
*
* `access_type=offline` + `prompt=consent` are Google's requirement to receive
* a refresh token; `prompt=consent` forces the consent screen so a re-connect
* always returns a fresh refresh token even if the user previously granted.
*/
export function getAuthorizationUrl(
state: string,
creds: GoogleHealthCredentials,
codeChallenge: string,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getGoogleHealthRedirectUri(),
scope: getGoogleHealthScopeString(),
access_type: "offline",
prompt: "consent",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
return `${GOOGLE_OAUTH_AUTH_URL}?${params}`;
}
export interface GoogleHealthTokenResponse {
access_token: string;
/**
* Present on the initial code exchange and whenever Google issues a new
* refresh token; ABSENT on a routine refresh because Google does not rotate.
* The sync layer keeps the stored token when this is missing.
*/
refresh_token?: string;
expires_in: number;
scope?: string;
token_type?: string;
}
/** Basic-auth header carrying the confidential client credentials. */
function basicAuthHeader(creds: GoogleHealthCredentials): string {
const raw = `${creds.clientId}:${creds.clientSecret}`;
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
}
async function postToken(
params: URLSearchParams,
creds: GoogleHealthCredentials,
verb: string,
): Promise<GoogleHealthTokenResponse> {
const start = performance.now();
const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: basicAuthHeader(creds),
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyGoogleHealthResponse(res.status);
const upstreamError =
typeof json?.error === "string" ? (json.error as string) : undefined;
// Google signals a revoked / expired refresh token — the user disconnected
// the app upstream, OR the 7-day "Testing"-mode refresh window lapsed — via
// `invalid_grant` on the token endpoint (sometimes a 400, sometimes a 401). A
// bare status classification buckets a 400 as `persistent` (never prompts a
// reconnect); lift an `invalid_grant` onto `reauth_required` so the connection
// surfaces the reconnect CTA. A 401 already classifies as `reauth_required`.
const classification: GoogleHealthClassification =
upstreamError === "invalid_grant"
? "reauth_required"
: verdict.classification;
getEvent()?.addExternalCall({
service: "google-health",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (classification !== "success") {
throw new GoogleHealthApiError({
verb,
classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError,
});
}
return json as GoogleHealthTokenResponse;
}
/**
* Exchange an authorization code for the initial token pair, presenting the
* PKCE verifier. `redirect_uri` must exactly match the one sent to the
* authorize endpoint.
*/
export async function exchangeCode(
code: string,
codeVerifier: string,
creds: GoogleHealthCredentials,
): Promise<GoogleHealthTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: creds.clientId,
redirect_uri: getGoogleHealthRedirectUri(),
code_verifier: codeVerifier,
}),
creds,
"exchangeCode",
);
}
/**
* Refresh an expired access token. Google does NOT rotate refresh tokens — the
* response carries a fresh `access_token` + `expires_in` but usually omits
* `refresh_token`. The caller persists the new access token + expiry and keeps
* the stored refresh token unless a new one is returned. The original scope is
* preserved by Google, so no `scope` param is re-sent. A revoked / expired
* refresh token throws a `GoogleHealthApiError` classified `reauth_required`.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: GoogleHealthCredentials,
): Promise<GoogleHealthTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: creds.clientId,
}),
creds,
"refreshAccessToken",
);
}
/**
* The Google Health profile carries the external user id used as the
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { GoogleHealthCredentials } from "./client";
import type { GoogleHealthCredentials } from "./oauth";
/**
* Fetch and decrypt the user's Google Health API credentials.
+2 -2
View File
@@ -7,7 +7,7 @@
* back without one, and the kit then keeps the stored refresh token.
*/
import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type GoogleHealthCredentials } from "./client";
import { type GoogleHealthCredentials, googleHealthOAuth } from "./oauth";
import { getUserGoogleHealthCredentials } from "./credentials";
/** Refresh this long before the stored expiry. */
@@ -43,5 +43,5 @@ export const googleHealthTokenDescriptor: OAuthTokenDescriptor<GoogleHealthCrede
},
loadCredentials: (userId) => getUserGoogleHealthCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
googleHealthOAuth.refresh(refreshToken, credentials),
};
+105
View File
@@ -0,0 +1,105 @@
/**
* The Google Health OAuth client: consent URL, code exchange and refresh,
* over the shared factory (`integrations/oauth-kit/client.ts`).
*
* - Access tokens live an hour. Refresh tokens do not rotate: a routine
* refresh returns a new access token without a refresh token, and the
* token lifecycle keeps the stored one. They expire after six months of
* disuse, or after seven days while the consent screen is in "Testing".
* - A revoked or expired refresh token answers `invalid_grant` (a 400 or a
* 401); it is lifted onto `reauth_required` so the connection asks for a
* reconnect instead of failing hard.
* - S256 PKCE, and the confidential client's secret by HTTP Basic beside it
* (RFC 6749 2.3.1), with the client id in the form as well.
* - `access_type=offline` and `prompt=consent` are what reliably return a
* refresh token, a new one on every re-consent.
*/
import {
defineOAuthClient,
oauthErrorCode,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { checkedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import { classifyHttpStatus } from "@/lib/integrations/http-status-classifier";
import { GoogleHealthApiError } from "./response-classifier";
export type GoogleHealthCredentials = OAuthClientCredentials;
/**
* The four core Restricted read scopes. Every one is Restricted, so the
* operator's OAuth client needs Google verification and a yearly CASA
* assessment before it leaves "Testing" (staying in Testing with at most 100
* test users avoids CASA at the cost of a 7-day refresh-token expiry).
*/
export const GOOGLE_HEALTH_CORE_SCOPES = [
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
"https://www.googleapis.com/auth/googlehealth.profile.readonly",
] as const;
/**
* The scope list HealthLog requests: the four core bundles. ECG and IRN come
* with their reader, never before (Google penalises requesting a Restricted
* scope the app never consumes).
*/
export function resolveGoogleHealthScopes(): string[] {
return [...GOOGLE_HEALTH_CORE_SCOPES];
}
/** The space-separated scope string sent on the authorize request. */
export function getGoogleHealthScopeString(): string {
return resolveGoogleHealthScopes().join(" ");
}
export interface GoogleHealthTokenResponse {
access_token: string;
/**
* Present on the code exchange and whenever Google issues a new refresh
* token; absent on a routine refresh, because Google does not rotate.
*/
refresh_token?: string;
expires_in: number;
scope?: string;
token_type?: string;
}
export const googleHealthOAuth = defineOAuthClient<GoogleHealthTokenResponse>({
service: "google-health",
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
redirectUri: () =>
checkedRedirectUri(
"Google Health",
"GOOGLE_HEALTH_REDIRECT_URI",
"/api/google-health/callback",
),
authorizeParams: () => ({
scope: getGoogleHealthScopeString(),
access_type: "offline",
prompt: "consent",
}),
pkce: true,
clientAuth: { kind: "basic", idInForm: { exchange: true, refresh: true } },
refresh: true,
classify: (httpStatus, body) => {
const verdict = classifyHttpStatus(httpStatus);
return {
classification:
oauthErrorCode(body) === "invalid_grant"
? "reauth_required"
: verdict.classification,
status: verdict.httpStatus,
reason: verdict.reason,
};
},
error: (failure) =>
new GoogleHealthApiError({
verb: failure.verb,
classification: failure.classification,
httpStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
});
@@ -7,29 +7,29 @@
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { getWithingsRedirectUri } from "@/lib/withings/client";
import { getFitbitRedirectUri } from "@/lib/fitbit/client";
import { getGoogleHealthRedirectUri } from "@/lib/google-health/client";
import { getPolarRedirectUri } from "@/lib/polar/client";
import { getWhoopRedirectUri } from "@/lib/whoop/client";
import { getStravaRedirectUri } from "@/lib/strava/client";
import { getOuraRedirectUri } from "@/lib/oura/client";
import { withingsOAuth } from "@/lib/withings/oauth";
import { fitbitOAuth } from "@/lib/fitbit/oauth";
import { googleHealthOAuth } from "@/lib/google-health/oauth";
import { polarOAuth } from "@/lib/polar/oauth";
import { whoopOAuth } from "@/lib/whoop/oauth";
import { stravaOAuth } from "@/lib/strava/oauth";
import { ouraOAuth } from "@/lib/oura/oauth";
const APP = "https://health.example.test";
const CASES: Array<[string, string, () => string, string]> = [
["Withings", "WITHINGS_REDIRECT_URI", getWithingsRedirectUri, "withings"],
["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri, "fitbit"],
["Withings", "WITHINGS_REDIRECT_URI", withingsOAuth.redirectUri, "withings"],
["Fitbit", "FITBIT_REDIRECT_URI", fitbitOAuth.redirectUri, "fitbit"],
[
"Google Health",
"GOOGLE_HEALTH_REDIRECT_URI",
getGoogleHealthRedirectUri,
googleHealthOAuth.redirectUri,
"google-health",
],
["Polar", "POLAR_REDIRECT_URI", getPolarRedirectUri, "polar"],
["WHOOP", "WHOOP_REDIRECT_URI", getWhoopRedirectUri, "whoop"],
["Strava", "STRAVA_REDIRECT_URI", getStravaRedirectUri, "strava"],
["Oura", "OURA_REDIRECT_URI", getOuraRedirectUri, "oura"],
["Polar", "POLAR_REDIRECT_URI", polarOAuth.redirectUri, "polar"],
["WHOOP", "WHOOP_REDIRECT_URI", whoopOAuth.redirectUri, "whoop"],
["Strava", "STRAVA_REDIRECT_URI", stravaOAuth.redirectUri, "strava"],
["Oura", "OURA_REDIRECT_URI", ouraOAuth.redirectUri, "oura"],
];
afterEach(() => {
@@ -56,8 +56,12 @@ describe.each(CASES)("%s redirect URI", (_name, variable, resolve, path) => {
describe("with neither variable set", () => {
it.each([
["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri],
["Google Health", "GOOGLE_HEALTH_REDIRECT_URI", getGoogleHealthRedirectUri],
["Fitbit", "FITBIT_REDIRECT_URI", fitbitOAuth.redirectUri],
[
"Google Health",
"GOOGLE_HEALTH_REDIRECT_URI",
googleHealthOAuth.redirectUri,
],
] as const)("%s still says it is not configured", (_n, variable, resolve) => {
vi.stubEnv(variable, "");
vi.stubEnv("NEXT_PUBLIC_APP_URL", " ");
+14 -14
View File
@@ -20,13 +20,13 @@
* path for the guide and skip the origin comparison.
*/
import { getFitbitRedirectUri } from "@/lib/fitbit/client";
import { getGoogleHealthRedirectUri } from "@/lib/google-health/client";
import { getOuraRedirectUri } from "@/lib/oura/client";
import { getPolarRedirectUri } from "@/lib/polar/client";
import { getStravaRedirectUri } from "@/lib/strava/client";
import { getWhoopRedirectUri } from "@/lib/whoop/client";
import { getWithingsRedirectUri } from "@/lib/withings/client";
import { fitbitOAuth } from "@/lib/fitbit/oauth";
import { googleHealthOAuth } from "@/lib/google-health/oauth";
import { ouraOAuth } from "@/lib/oura/oauth";
import { polarOAuth } from "@/lib/polar/oauth";
import { stravaOAuth } from "@/lib/strava/oauth";
import { whoopOAuth } from "@/lib/whoop/oauth";
import { withingsOAuth } from "@/lib/withings/oauth";
export const INTEGRATION_CALLBACK_PROVIDERS = [
"withings",
@@ -48,13 +48,13 @@ export type IntegrationCallbackUrls = Record<
>;
const RESOLVERS: Record<IntegrationCallbackProvider, () => string> = {
withings: getWithingsRedirectUri,
whoop: getWhoopRedirectUri,
fitbit: getFitbitRedirectUri,
"google-health": getGoogleHealthRedirectUri,
polar: getPolarRedirectUri,
oura: getOuraRedirectUri,
strava: getStravaRedirectUri,
withings: withingsOAuth.redirectUri,
whoop: whoopOAuth.redirectUri,
fitbit: fitbitOAuth.redirectUri,
"google-health": googleHealthOAuth.redirectUri,
polar: polarOAuth.redirectUri,
oura: ouraOAuth.redirectUri,
strava: stravaOAuth.redirectUri,
};
function resolve(provider: IntegrationCallbackProvider): string | null {
@@ -41,7 +41,7 @@ import {
} from "./metric-freshness";
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features";
import { hasActivityScope } from "@/lib/withings/client";
import { hasActivityScope } from "@/lib/withings/oauth";
import { readOuraScopeDenied } from "@/lib/oura/credentials";
import { ouraScopeReconnectList } from "@/lib/oura/scope";
import { localDayWindow } from "@/lib/tz/local-day";
@@ -57,8 +57,11 @@
},
"empty_body": {
"error": {
"name": "SyntaxError",
"message": "Unexpected end of JSON input"
"name": "WithingsApiError",
"message": "Withings token error: ?",
"classification": "transient",
"reason": "no_status_field",
"verb": "token"
},
"requests": [
{
@@ -79,7 +82,20 @@
"body": null
}
],
"events": [],
"events": [
{
"method": "addExternalCall",
"args": [
{
"service": "withings",
"method": "exchangeCode",
"duration_ms": 0,
"status": 200,
"error": "no_status_field"
}
]
}
],
"unansweredScript": 0
},
"invalid_grant": {
@@ -260,8 +276,11 @@
},
"outage": {
"error": {
"name": "SyntaxError",
"message": "Unexpected token '<', \"<html>unav\"... is not valid JSON"
"name": "WithingsApiError",
"message": "Withings token error: ?",
"classification": "transient",
"reason": "http_503",
"verb": "token"
},
"requests": [
{
@@ -282,7 +301,20 @@
"body": null
}
],
"events": [],
"events": [
{
"method": "addExternalCall",
"args": [
{
"service": "withings",
"method": "exchangeCode",
"duration_ms": 0,
"status": 503,
"error": "http_503"
}
]
}
],
"unansweredScript": 0
}
},
@@ -331,8 +363,11 @@
},
"empty_body": {
"error": {
"name": "SyntaxError",
"message": "Unexpected end of JSON input"
"name": "WithingsApiError",
"message": "Withings refresh error: ?",
"classification": "transient",
"reason": "no_status_field",
"verb": "refresh"
},
"requests": [
{
@@ -352,7 +387,20 @@
"body": null
}
],
"events": [],
"events": [
{
"method": "addExternalCall",
"args": [
{
"service": "withings",
"method": "refreshAccessToken",
"duration_ms": 0,
"status": 200,
"error": "no_status_field"
}
]
}
],
"unansweredScript": 0
},
"invalid_grant": {
@@ -529,8 +577,11 @@
},
"outage": {
"error": {
"name": "SyntaxError",
"message": "Unexpected token '<', \"<html>unav\"... is not valid JSON"
"name": "WithingsApiError",
"message": "Withings refresh error: ?",
"classification": "transient",
"reason": "http_503",
"verb": "refresh"
},
"requests": [
{
@@ -550,7 +601,20 @@
"body": null
}
],
"events": [],
"events": [
{
"method": "addExternalCall",
"args": [
{
"service": "withings",
"method": "refreshAccessToken",
"duration_ms": 0,
"status": 503,
"error": "http_503"
}
]
}
],
"unansweredScript": 0
}
}
@@ -610,7 +674,15 @@
"unansweredScript": 0
},
"empty_body": {
"result": null,
"error": {
"name": "WhoopApiError",
"message": "WHOOP exchangeCode error: 200",
"classification": "transient",
"httpStatus": 200,
"reason": "empty_token_body",
"verb": "exchangeCode",
"vendor": "WHOOP"
},
"requests": [
{
"method": "POST",
@@ -911,7 +983,15 @@
"unansweredScript": 0
},
"empty_body": {
"result": null,
"error": {
"name": "WhoopApiError",
"message": "WHOOP refreshAccessToken error: 200",
"classification": "transient",
"httpStatus": 200,
"reason": "empty_token_body",
"verb": "refreshAccessToken",
"vendor": "WHOOP"
},
"requests": [
{
"method": "POST",
@@ -1226,7 +1306,15 @@
"unansweredScript": 0
},
"empty_body": {
"result": null,
"error": {
"name": "StravaApiError",
"message": "Strava exchangeCode error: 200",
"classification": "transient",
"httpStatus": 200,
"reason": "empty_token_body",
"verb": "exchangeCode",
"vendor": "Strava"
},
"requests": [
{
"method": "POST",
@@ -1528,7 +1616,15 @@
"unansweredScript": 0
},
"empty_body": {
"result": null,
"error": {
"name": "StravaApiError",
"message": "Strava refreshAccessToken error: 200",
"classification": "transient",
"httpStatus": 200,
"reason": "empty_token_body",
"verb": "refreshAccessToken",
"vendor": "Strava"
},
"requests": [
{
"method": "POST",
@@ -3345,7 +3441,15 @@
"unansweredScript": 0
},
"empty_body": {
"result": null,
"error": {
"name": "GoogleHealthApiError",
"message": "GoogleHealth exchangeCode error: 200",
"classification": "transient",
"httpStatus": 200,
"reason": "empty_token_body",
"verb": "exchangeCode",
"vendor": "GoogleHealth"
},
"requests": [
{
"method": "POST",
@@ -3651,7 +3755,15 @@
"unansweredScript": 0
},
"empty_body": {
"result": null,
"error": {
"name": "GoogleHealthApiError",
"message": "GoogleHealth refreshAccessToken error: 200",
"classification": "transient",
"httpStatus": 200,
"reason": "empty_token_body",
"verb": "refreshAccessToken",
"vendor": "GoogleHealth"
},
"requests": [
{
"method": "POST",
@@ -32,7 +32,9 @@ vi.mock("@/lib/withings/sync", () => ({
WITHINGS_NOTIFY_APPLIS: [1, 44],
getWithingsWebhookCallbackUrl: () => "https://app.example/hook",
}));
vi.mock("@/lib/strava/client", () => ({ deauthorize: deauthorizeMock }));
vi.mock("@/lib/strava/oauth", () => ({
stravaOAuth: { deauthorize: deauthorizeMock },
}));
vi.mock("@/lib/strava/credentials", () => ({
STRAVA_DISCONNECTED: { stravaAccessTokenEncrypted: null },
getStravaConnection: vi.fn(async () => ({ accessToken: "at" })),
@@ -167,90 +167,92 @@ interface ProviderUnderTest {
* file that follows the clients' API; the golden does not.
*/
async function providers(): Promise<Record<string, ProviderUnderTest>> {
const withings = await import("@/lib/withings/client");
const whoop = await import("@/lib/whoop/client");
const strava = await import("@/lib/strava/client");
const oura = await import("@/lib/oura/client");
const polar = await import("@/lib/polar/client");
const google = await import("@/lib/google-health/client");
const fitbit = await import("@/lib/fitbit/client");
const { withingsOAuth } = await import("@/lib/withings/oauth");
const { whoopOAuth } = await import("@/lib/whoop/oauth");
const { stravaOAuth } = await import("@/lib/strava/oauth");
const { ouraOAuth, exchangeOuraCode } = await import("@/lib/oura/oauth");
const { polarOAuth } = await import("@/lib/polar/oauth");
const { googleHealthOAuth } = await import("@/lib/google-health/oauth");
const { fitbitOAuth } = await import("@/lib/fitbit/oauth");
const MOI = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous";
const pkce = { codeVerifier: VERIFIER };
return {
withings: {
answers: WITHINGS_ANSWERS,
redirectUri: withings.getWithingsRedirectUri,
authorize: () => withings.getAuthorizationUrl(STATE, CREDS),
exchange: () => withings.exchangeCode("code-1", CREDS),
refresh: () => withings.refreshAccessToken("refresh-0", CREDS),
redirectUri: withingsOAuth.redirectUri,
authorize: () => withingsOAuth.authorizationUrl(STATE, CREDS),
exchange: () => withingsOAuth.exchange("code-1", CREDS),
refresh: () => withingsOAuth.refresh("refresh-0", CREDS),
},
whoop: {
answers: STANDARD_ANSWERS,
redirectUri: whoop.getWhoopRedirectUri,
authorize: () => whoop.getAuthorizationUrl(STATE, CREDS),
exchange: () => whoop.exchangeCode("code-1", CREDS),
refresh: () => whoop.refreshAccessToken("refresh-0", CREDS),
redirectUri: whoopOAuth.redirectUri,
authorize: () => whoopOAuth.authorizationUrl(STATE, CREDS),
exchange: () => whoopOAuth.exchange("code-1", CREDS),
refresh: () => whoopOAuth.refresh("refresh-0", CREDS),
},
strava: {
answers: STANDARD_ANSWERS,
redirectUri: strava.getStravaRedirectUri,
authorize: () => strava.getAuthorizationUrl(STATE, CREDS),
exchange: () => strava.exchangeCode("code-1", CREDS),
refresh: () => strava.refreshAccessToken("refresh-0", CREDS),
redirectUri: stravaOAuth.redirectUri,
authorize: () => stravaOAuth.authorizationUrl(STATE, CREDS),
exchange: () => stravaOAuth.exchange("code-1", CREDS),
refresh: () => stravaOAuth.refresh("refresh-0", CREDS),
extra: {
deauthorize_ok: {
answers: [json(200, { access_token: "access-1" })],
call: () => strava.deauthorize("access-1"),
call: () => stravaOAuth.deauthorize("access-1"),
},
deauthorize_refused: {
answers: [json(401, { message: "Authorization Error" })],
call: () => strava.deauthorize("access-1"),
call: () => stravaOAuth.deauthorize("access-1"),
},
},
},
oura: {
answers: STANDARD_ANSWERS,
redirectUri: oura.getOuraRedirectUri,
authorize: () => oura.getAuthorizationUrl(STATE, CREDS),
exchange: () => oura.exchangeCode("code-1", CREDS),
refresh: () => oura.refreshAccessToken("refresh-0", CREDS),
redirectUri: ouraOAuth.redirectUri,
authorize: () => ouraOAuth.authorizationUrl(STATE, CREDS),
exchange: () => exchangeOuraCode("code-1", CREDS),
refresh: () => ouraOAuth.refresh("refresh-0", CREDS),
extra: {
exchange_from_moi_refused: {
answers: [json(400, { error: "invalid_grant" })],
call: () => oura.exchangeCode("code-1", CREDS, MOI),
call: () => exchangeOuraCode("code-1", CREDS, MOI),
},
exchange_legacy_fallback: {
answers: [json(400, { error: "invalid_request" }), json(200, TOKEN)],
call: () => oura.exchangeCode("code-1", CREDS, null),
call: () => exchangeOuraCode("code-1", CREDS, null),
},
exchange_both_refused: {
answers: [
json(401, { error: "invalid_client" }),
json(400, { error: "invalid_grant" }),
],
call: () => oura.exchangeCode("code-1", CREDS),
call: () => exchangeOuraCode("code-1", CREDS),
},
refresh_legacy_fallback: {
answers: [json(404, { error: "not_found" }), json(200, TOKEN)],
call: () => oura.refreshAccessToken("refresh-0", CREDS),
call: () => ouraOAuth.refresh("refresh-0", CREDS),
},
refresh_outage_no_fallback: {
answers: [{ status: 502, body: "" }],
call: () => oura.refreshAccessToken("refresh-0", CREDS),
call: () => ouraOAuth.refresh("refresh-0", CREDS),
},
},
},
polar: {
answers: STANDARD_ANSWERS,
redirectUri: polar.getPolarRedirectUri,
authorize: () => polar.getAuthorizationUrl(STATE, CREDS),
exchange: () => polar.exchangeCode("code-1", CREDS),
redirectUri: polarOAuth.redirectUri,
authorize: () => polarOAuth.authorizationUrl(STATE, CREDS),
exchange: () => polarOAuth.exchange("code-1", CREDS),
},
"google-health": {
answers: STANDARD_ANSWERS,
redirectUri: google.getGoogleHealthRedirectUri,
authorize: () => google.getAuthorizationUrl(STATE, CREDS, CHALLENGE),
exchange: () => google.exchangeCode("code-1", VERIFIER, CREDS),
refresh: () => google.refreshAccessToken("refresh-0", CREDS),
redirectUri: googleHealthOAuth.redirectUri,
authorize: () =>
googleHealthOAuth.authorizationUrl(STATE, CREDS, CHALLENGE),
exchange: () => googleHealthOAuth.exchange("code-1", CREDS, pkce),
refresh: () => googleHealthOAuth.refresh("refresh-0", CREDS),
},
fitbit: {
answers: {
@@ -264,10 +266,10 @@ async function providers(): Promise<Record<string, ProviderUnderTest>> {
}),
],
},
redirectUri: fitbit.getFitbitRedirectUri,
authorize: () => fitbit.getAuthorizationUrl(STATE, CREDS, CHALLENGE),
exchange: () => fitbit.exchangeCode("code-1", VERIFIER, CREDS),
refresh: () => fitbit.refreshAccessToken("refresh-0", CREDS),
redirectUri: fitbitOAuth.redirectUri,
authorize: () => fitbitOAuth.authorizationUrl(STATE, CREDS, CHALLENGE),
exchange: () => fitbitOAuth.exchange("code-1", CREDS, pkce),
refresh: () => fitbitOAuth.refresh("refresh-0", CREDS),
},
};
}
+348
View File
@@ -0,0 +1,348 @@
/**
* 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}`,
});
},
};
}
+15 -15
View File
@@ -20,35 +20,33 @@ import {
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { getAuthorizationUrl as withingsAuthorizationUrl } from "@/lib/withings/client";
import { withingsOAuth } from "@/lib/withings/oauth";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
WITHINGS_OAUTH_STATE_COOKIE,
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { getAuthorizationUrl as whoopAuthorizationUrl } from "@/lib/whoop/client";
import { whoopOAuth } from "@/lib/whoop/oauth";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import {
WHOOP_OAUTH_STATE_COOKIE,
WHOOP_OAUTH_STATE_TTL_MS,
mintWhoopOAuthStateNonce,
} from "@/lib/whoop/oauth-state";
import {
generatePkcePair,
getAuthorizationUrl as googleHealthAuthorizationUrl,
} from "@/lib/google-health/client";
import { googleHealthOAuth } from "@/lib/google-health/oauth";
import { generatePkcePair } from "./pkce";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
import {
GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
GOOGLE_HEALTH_OAUTH_STATE_TTL_MS,
mintGoogleHealthOAuthStateNonce,
} from "@/lib/google-health/oauth-state";
import { getAuthorizationUrl as ouraAuthorizationUrl } from "@/lib/oura/client";
import { ouraOAuth } from "@/lib/oura/oauth";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getAuthorizationUrl as polarAuthorizationUrl } from "@/lib/polar/client";
import { polarOAuth } from "@/lib/polar/oauth";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getAuthorizationUrl as stravaAuthorizationUrl } from "@/lib/strava/client";
import { stravaOAuth } from "@/lib/strava/oauth";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import type { OAuthKitProvider } from "./token";
@@ -152,7 +150,7 @@ export async function startOAuthConnect(
value: nonce,
maxAgeSeconds: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
},
() => withingsAuthorizationUrl(nonce, creds),
() => withingsOAuth.authorizationUrl(nonce, creds),
);
}
case "whoop": {
@@ -174,7 +172,7 @@ export async function startOAuthConnect(
value: nonce,
maxAgeSeconds: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000),
},
() => whoopAuthorizationUrl(nonce, creds),
() => whoopOAuth.authorizationUrl(nonce, creds),
);
}
case "google-health": {
@@ -199,26 +197,28 @@ export async function startOAuthConnect(
value: nonce,
maxAgeSeconds: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000),
},
() => googleHealthAuthorizationUrl(nonce, creds, challenge),
() => googleHealthOAuth.authorizationUrl(nonce, creds, challenge),
);
}
case "oura": {
const creds = await getOuraClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("oura", userId, (s) => ouraAuthorizationUrl(s, creds));
return signedStart("oura", userId, (s) =>
ouraOAuth.authorizationUrl(s, creds),
);
}
case "polar": {
const creds = await getPolarClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("polar", userId, (s) =>
polarAuthorizationUrl(s, creds),
polarOAuth.authorizationUrl(s, creds),
);
}
case "strava": {
const creds = await getStravaClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("strava", userId, (s) =>
stravaAuthorizationUrl(s, creds),
stravaOAuth.authorizationUrl(s, creds),
);
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ import {
WITHINGS_NOTIFY_APPLIS,
getWithingsWebhookCallbackUrl,
} from "@/lib/withings/sync";
import { deauthorize } from "@/lib/strava/client";
import { stravaOAuth } from "@/lib/strava/oauth";
import {
STRAVA_DISCONNECTED,
getStravaConnection,
@@ -144,7 +144,7 @@ async function dropGrant(
: [];
if (sharedWith.length === 0) {
try {
await deauthorize(connection.accessToken);
await stravaOAuth.deauthorize(connection.accessToken);
} catch (err) {
getEvent()?.addWarning(
`strava: deauthorize failed on disconnect: ${err}`,
+20
View File
@@ -0,0 +1,20 @@
/**
* PKCE (RFC 7636, S256) for the providers that take it.
*
* The verifier is stashed on the OAuth-state row at connect and presented on
* the token exchange at callback, never in a cookie or a URL. 64 random bytes
* give an 86-character base64url verifier, inside the 43 to 128 the RFC
* allows and well past its 256-bit entropy floor.
*/
import { createHash, randomBytes } from "node:crypto";
export interface PkcePair {
verifier: string;
challenge: string;
}
export function generatePkcePair(): PkcePair {
const verifier = randomBytes(64).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
@@ -0,0 +1,88 @@
/**
* The OAuth `redirect_uri` of a provider integration.
*
* An explicit `<PROVIDER>_REDIRECT_URI` wins; otherwise the callback route
* under `NEXT_PUBLIC_APP_URL`. An empty or blank value counts as unset: the
* compose whitelist materialises an unset variable as an empty string, which
* must fall through to the derived URI rather than go out as `redirect_uri=`.
*/
import { envOr, envValue } from "@/lib/env";
/** The configured or derived redirect URI, unchecked. */
export function derivedRedirectUri(
envVar: string,
callbackPath: string,
): string {
return envOr(envVar, `${envValue("NEXT_PUBLIC_APP_URL")}${callbackPath}`);
}
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
/**
* The redirect URI, checked against an allowlist before it is used
* (defence in depth for the providers that need it).
*
* The value is operator configuration, not user input, and the provider's
* registered-redirect check is the real backstop. But a misconfigured or
* `Host`-coerced `NEXT_PUBLIC_APP_URL` (a reverse proxy reflecting a forwarded
* Host) would otherwise send the authorization code's landing URL off-origin,
* so the handshake fails fast instead:
* - the URI must be absolute and parseable,
* - https, or http on a loopback development host,
* - on the fixed callback path,
* - and an explicit URI must share its origin with `NEXT_PUBLIC_APP_URL`.
*/
export function checkedRedirectUri(
label: string,
envVar: string,
callbackPath: string,
): string {
const explicit = envValue(envVar);
const appUrl = envValue("NEXT_PUBLIC_APP_URL");
const raw = explicit ?? (appUrl ? `${appUrl}${callbackPath}` : undefined);
if (!raw) {
throw new Error(
`${label} redirect_uri is not configured — set ${envVar} or NEXT_PUBLIC_APP_URL`,
);
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`${label} redirect_uri is not an absolute URL: ${raw}`);
}
const isLoopback = LOOPBACK_HOSTS.has(parsed.hostname);
if (
parsed.protocol !== "https:" &&
!(parsed.protocol === "http:" && isLoopback)
) {
throw new Error(
`${label} redirect_uri must be https (or http on localhost): ${parsed.origin}`,
);
}
if (parsed.pathname !== callbackPath) {
throw new Error(
`${label} redirect_uri must target ${callbackPath}, got ${parsed.pathname}`,
);
}
if (explicit && appUrl) {
let appOrigin: string;
try {
appOrigin = new URL(appUrl).origin;
} catch {
throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`);
}
if (parsed.origin !== appOrigin) {
throw new Error(
`${label} redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`,
);
}
}
return parsed.toString();
}
@@ -59,7 +59,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({
vi.mock("@/lib/fitbit/credentials", () => ({
getUserFitbitCredentials: vi.fn(async () => null),
}));
vi.mock("@/lib/fitbit/client", () => ({ refreshAccessToken: vi.fn() }));
vi.mock("@/lib/fitbit/oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fitbit/oauth")>();
return {
...actual,
fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() },
};
});
import {
FITBIT_BACKFILL_DAYS,
@@ -70,10 +70,13 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("@/lib/google-health/credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(async () => null),
}));
vi.mock("@/lib/google-health/client", async (importOriginal) => {
vi.mock("@/lib/google-health/oauth", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/google-health/client")>();
return { ...actual, refreshAccessToken: vi.fn() };
await importOriginal<typeof import("@/lib/google-health/oauth")>();
return {
...actual,
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
};
});
import {
+23 -21
View File
@@ -1,19 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OURA_OAUTH_LEGACY_TOKEN_URL,
OURA_OAUTH_TOKEN_URL,
RESILIENCE_LEVELS,
derivePeriodDaysFromCyclePhases,
exchangeCode,
fetchCardiovascularAge,
fetchDailyCyclePhases,
fetchReadiness,
fetchResilience,
fetchVo2Max,
getAuthorizationUrl,
getOuraCredentials,
isAcceptedOuraIssuer,
mapCardiovascularAge,
mapDailyActivity,
mapDailySleep,
@@ -22,7 +16,6 @@ import {
mapResilience,
mapSleep,
mapVo2Max,
refreshAccessToken,
type OuraCardiovascularAge,
type OuraDailyActivity,
type OuraDailySleep,
@@ -32,7 +25,14 @@ import {
type OuraSleep,
type OuraVo2Max,
} from "../client";
import {
OURA_OAUTH_LEGACY_TOKEN_URL,
OURA_OAUTH_TOKEN_URL,
getOuraCredentials,
isAcceptedOuraIssuer,
} from "@/lib/oura/oauth";
import { OuraApiError } from "../response-classifier";
import { ouraOAuth, exchangeOuraCode } from "../oauth";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
@@ -75,7 +75,7 @@ describe("getOuraCredentials", () => {
describe("getAuthorizationUrl", () => {
it("builds the Oura authorize URL", () => {
const url = getAuthorizationUrl("signed", CREDS);
const url = ouraOAuth.authorizationUrl("signed", CREDS);
expect(url).toContain("cloud.ouraring.com/oauth/authorize");
expect(url).toContain(
"scope=daily+spo2+heart_health+stress+workout+heartrate+personal",
@@ -92,7 +92,7 @@ describe("exchangeCode / refreshAccessToken", () => {
body: { access_token: "a", refresh_token: "r", expires_in: 86400 },
},
]);
const t = await exchangeCode("code", CREDS);
const t = await exchangeOuraCode("code", CREDS);
expect(t.access_token).toBe("a");
expect(t.refresh_token).toBe("r");
});
@@ -104,7 +104,7 @@ describe("exchangeCode / refreshAccessToken", () => {
body: { access_token: "a2", refresh_token: "r2", expires_in: 86400 },
},
]);
const t = await refreshAccessToken("r1", CREDS);
const t = await ouraOAuth.refresh("r1", CREDS);
expect(t.refresh_token).toBe("r2");
const [, init] = fetchMock.mock.calls[0]!;
expect(init!.body).toContain("grant_type=refresh_token");
@@ -112,7 +112,7 @@ describe("exchangeCode / refreshAccessToken", () => {
it("throws OuraApiError on a 400 invalid_grant", async () => {
installFetchMock([{ status: 400, body: { error: "invalid_grant" } }]);
await expect(refreshAccessToken("dead", CREDS)).rejects.toBeInstanceOf(
await expect(ouraOAuth.refresh("dead", CREDS)).rejects.toBeInstanceOf(
OuraApiError,
);
});
@@ -122,7 +122,7 @@ describe("exchangeCode / refreshAccessToken", () => {
// TypeError. It must surface as a classified transient integration error.
it("classifies a 2xx with an empty body instead of casting null", async () => {
installFetchMock([{ status: 200, body: null }]);
await expect(exchangeCode("code", CREDS)).rejects.toMatchObject({
await expect(exchangeOuraCode("code", CREDS)).rejects.toMatchObject({
classification: "transient",
reason: "empty_token_body",
});
@@ -130,7 +130,7 @@ describe("exchangeCode / refreshAccessToken", () => {
it("classifies a 2xx with a non-object body on refresh", async () => {
installFetchMock([{ status: 200, body: "not-json" }]);
await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf(
await expect(ouraOAuth.refresh("r1", CREDS)).rejects.toBeInstanceOf(
OuraApiError,
);
});
@@ -153,7 +153,7 @@ describe("token endpoint order", () => {
it("exchanges a code at the live endpoint, sending the secret in the body", async () => {
const fetchMock = installFetchMock([OK]);
await exchangeCode("code", CREDS, MOI_ISS);
await exchangeOuraCode("code", CREDS, MOI_ISS);
expect(OURA_OAUTH_TOKEN_URL).toBe(
"https://moi.ouraring.com/oauth/v2/ext/oauth-token",
);
@@ -169,7 +169,9 @@ describe("token endpoint order", () => {
{ status: 400, body: { error: "invalid_request" } },
OK,
]);
await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({
await expect(
exchangeOuraCode("code", CREDS, MOI_ISS),
).rejects.toMatchObject({
upstreamError: "invalid_request",
});
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
@@ -181,7 +183,7 @@ describe("token endpoint order", () => {
{ status: 401, body: { error: "invalid_client" } },
OK,
]);
const t = await exchangeCode("code", CREDS);
const t = await exchangeOuraCode("code", CREDS);
expect(t.access_token).toBe("a");
expect(urls(fetchMock)).toEqual([
OURA_OAUTH_TOKEN_URL,
@@ -191,13 +193,13 @@ describe("token endpoint order", () => {
it("does not try the legacy endpoint once the live one accepted the code", async () => {
const fetchMock = installFetchMock([OK]);
await exchangeCode("code", CREDS);
await exchangeOuraCode("code", CREDS);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
});
it("refreshes at the live endpoint first", async () => {
const fetchMock = installFetchMock([OK]);
await refreshAccessToken("r1", CREDS);
await ouraOAuth.refresh("r1", CREDS);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
});
@@ -208,7 +210,7 @@ describe("token endpoint order", () => {
"falls back to the legacy endpoint on a %i from the live one",
async (status, body) => {
const fetchMock = installFetchMock([{ status, body }, OK]);
const t = await refreshAccessToken("r1", CREDS);
const t = await ouraOAuth.refresh("r1", CREDS);
expect(t.refresh_token).toBe("r");
expect(urls(fetchMock)).toEqual([
OURA_OAUTH_TOKEN_URL,
@@ -222,7 +224,7 @@ describe("token endpoint order", () => {
{ status: 400, body: { error: "invalid_grant" } },
{ status: 400, body: { error: "invalid_request" } },
]);
await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({
await expect(ouraOAuth.refresh("dead", CREDS)).rejects.toMatchObject({
upstreamError: "invalid_grant",
});
});
@@ -230,7 +232,7 @@ describe("token endpoint order", () => {
it("does not fall back on a server error or a rate limit", async () => {
for (const status of [429, 503]) {
const fetchMock = installFetchMock([{ status }, OK]);
await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf(
await expect(ouraOAuth.refresh("r1", CREDS)).rejects.toBeInstanceOf(
OuraApiError,
);
expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]);
+1 -1
View File
@@ -13,7 +13,7 @@ vi.mock("@/lib/crypto", () => ({
}));
// The env fallback lives in ./client; stub it so the precedence is observable.
vi.mock("../client", () => ({
vi.mock("../oauth", () => ({
getOuraCredentials: () => getOuraCredentials(),
}));
+7 -1
View File
@@ -148,7 +148,13 @@ vi.mock("../client", async (importOriginal) => {
fetchDailyCyclePhases: fetchCyclePhasesMock,
fetchWorkouts: fetchWorkoutsMock,
forEachHeartRatePage: heartRatePagesMock,
refreshAccessToken: refreshMock,
};
});
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
ouraOAuth: { ...actual.ouraOAuth, refresh: refreshMock },
};
});
+6 -277
View File
@@ -1,294 +1,23 @@
/**
* v1.17.0 — Oura Cloud API v2 client for OAuth + data fetching.
* Oura Cloud API v2 client: collection reads and mappers.
* Docs: https://cloud.ouraring.com/v2/docs (re-verify at build).
*
* Mirrors the WHOOP / Polar client structure: hand-rolled fetch over
* `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` /
* `exchangeCode` / `refreshAccessToken`), typed v2 collection fetchers with
* `next_token` pagination, and a single source-of-truth field→Measurement
* mapping.
*
* Oura specifics: standard authorization-code OAuth WITH refresh tokens (the
* sync layer refreshes on 401, persisting BOTH rotated tokens). The token
* endpoint takes client_id/secret in the body (client_secret_post) and lives
* on moi.ouraring.com, with the documented api.ouraring.com endpoint as a
* fallback for older apps. Collections are read by
* `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`.
* Hand-rolled fetch over `safeFetch` (no SDK): typed v2 collection fetchers
* with `next_token` pagination and a single source-of-truth field→Measurement
* mapping. Collections are read by `start_date` / `end_date` (YYYY-MM-DD).
* The OAuth handshake is `./oauth.ts`.
*/
import { getEvent } from "@/lib/logging/context";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { dayDiff } from "@/lib/cycle/day-math";
import { safeFetch } from "@/lib/safe-fetch";
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
import { walkPages } from "@/lib/integrations/page-walk";
import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after";
import { OURA_OAUTH_SCOPE, parseOuraMissingScope } from "./scope";
import { parseOuraMissingScope } from "./scope";
const OURA_API_BASE = "https://api.ouraring.com";
const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize";
/**
* Oura's live token endpoint. Apps created in the current developer portal
* get their codes from this authorization server (the callback carries
* `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the
* documented `api.ouraring.com/oauth/token` answers those codes with
* `400 invalid_request`. Older apps are served here too.
*/
export const OURA_OAUTH_TOKEN_URL =
"https://moi.ouraring.com/oauth/v2/ext/oauth-token";
/**
* The documented endpoint, kept only as a fallback for the older apps the
* live endpoint turns away. Never tried first for a code: an authorization
* code works once (RFC 6749 4.1.2), so a rejected first attempt would burn
* it before the right endpoint saw it.
*/
export const OURA_OAUTH_LEGACY_TOKEN_URL =
"https://api.ouraring.com/oauth/token";
/** The origin of Oura's current authorization server. */
const OURA_MOI_ORIGIN = "https://moi.ouraring.com";
/**
* Authorization-server origins an `iss` callback parameter may name
* (RFC 9207). Anything else is a mix-up and refused.
*/
const OURA_ISSUER_ORIGINS = new Set([
OURA_MOI_ORIGIN,
"https://cloud.ouraring.com",
"https://api.ouraring.com",
]);
/**
* Check the `iss` the authorization server sent with the callback. Absent is
* fine (older apps never send one); present, it must name one of Oura's own
* authorization servers.
*/
export function isAcceptedOuraIssuer(iss: string | null): boolean {
if (iss === null) return true;
try {
const url = new URL(iss);
return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin);
} catch {
return false;
}
}
/** True when the code was issued by the current authorization server. */
function issuedByMoi(iss: string | null | undefined): boolean {
if (!iss) return false;
try {
return new URL(iss).origin === OURA_MOI_ORIGIN;
} catch {
return false;
}
}
export interface OuraCredentials {
clientId: string;
clientSecret: string;
}
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Oura client id/secret. As of v1.17.1 the
* per-user resolver `getOuraClientCredentials` (DB-first) is the primary path
* and calls this only when no per-user pair is stored.
*/
export function getOuraCredentials(): OuraCredentials | null {
const clientId = process.env.OURA_CLIENT_ID;
const clientSecret = process.env.OURA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getOuraRedirectUri(): string {
// An empty or blank value counts as unset (the compose whitelist
// materialises an unset var as an empty string).
return envOr(
"OURA_REDIRECT_URI",
`${envValue("NEXT_PUBLIC_APP_URL")}/api/oura/callback`,
);
}
/**
* The consent-screen URL. The scopes come from `./scope`, which maps every
* collection HealthLog reads to the scope Oura enforces for it. A grant issued
* before a scope was added keeps the old set until the person reconnects once;
* the sync records what it lacks (`syncOuraScopeDenied`).
*/
export function getAuthorizationUrl(
state: string,
creds: OuraCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getOuraRedirectUri(),
scope: OURA_OAUTH_SCOPE,
state,
});
return `${OURA_OAUTH_AUTH_URL}?${params}`;
}
export interface OuraTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type?: string;
scope?: string;
}
interface TokenAttempt {
status: number;
json: unknown;
}
async function requestToken(
url: string,
params: URLSearchParams,
verb: string,
): Promise<TokenAttempt> {
const start = performance.now();
const res = await safeFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
const json: unknown = await res.json().catch(() => null);
const verdict = classifyOuraResponse(res.status);
getEvent()?.addExternalCall({
service: "oura",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
return { status: res.status, json };
}
function upstreamError(json: unknown): string | undefined {
if (json === null || typeof json !== "object") return undefined;
const error = (json as { error?: unknown }).error;
return typeof error === "string" ? error : undefined;
}
/**
* Whether the legacy endpoint is worth asking after the live one refused:
* a 400, 401 or 404. The live endpoint may not know an older app's client,
* code or refresh token at all, and its refusal does not tell "unknown here"
* apart from "bad grant" reliably, so every such refusal gets the one retry.
* Server errors and rate limits do not: they say nothing about the grant.
*/
const FALLBACK_STATUSES = new Set([400, 401, 404]);
function belongsElsewhere(attempt: TokenAttempt): boolean {
return FALLBACK_STATUSES.has(attempt.status);
}
function tokenResult(attempt: TokenAttempt, verb: string): OuraTokenResponse {
const verdict = classifyOuraResponse(attempt.status);
if (verdict.classification !== "success") {
throw new OuraApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: upstreamError(attempt.json),
});
}
// A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204)
// leaves `json` null. Casting it straight to the token type handed callers a
// null they then dereferenced, surfacing an unclassified TypeError instead of
// a handled integration error. Narrow explicitly and classify as transient.
if (attempt.json === null || typeof attempt.json !== "object") {
throw new OuraApiError({
verb,
classification: "transient",
httpStatus: attempt.status,
reason: "empty_token_body",
});
}
return attempt.json as OuraTokenResponse;
}
/**
* Ask the live endpoint, then the legacy one when `mayFallBack` allows it and
* the live endpoint refused with 400, 401 or 404. The legacy endpoint is
* never asked first.
*/
async function postToken(
params: URLSearchParams,
verb: string,
mayFallBack: boolean,
): Promise<OuraTokenResponse> {
const live = await requestToken(OURA_OAUTH_TOKEN_URL, params, verb);
if (mayFallBack && belongsElsewhere(live)) {
const legacy = await requestToken(
OURA_OAUTH_LEGACY_TOKEN_URL,
params,
`${verb}.legacy`,
);
if (legacy.status >= 200 && legacy.status < 300) {
return tokenResult(legacy, verb);
}
// Neither endpoint took it. The live endpoint's answer is the one that
// describes the grant for every current app, so report that one.
}
return tokenResult(live, verb);
}
/**
* Exchange an authorization code. `iss` is the callback's issuer parameter:
* a code from the current authorization server goes to the live endpoint
* only, since a second attempt elsewhere could never succeed. Without an
* issuer (an older app) the legacy endpoint is the fallback after the live
* one has refused the code; the live endpoint is always asked first.
*/
export async function exchangeCode(
code: string,
creds: OuraCredentials,
iss?: string | null,
): Promise<OuraTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: getOuraRedirectUri(),
client_id: creds.clientId,
client_secret: creds.clientSecret,
}),
"exchangeCode",
!issuedByMoi(iss),
);
}
/**
* Refresh an access token: live endpoint first, the legacy endpoint when the
* live one refuses it (a refresh token of an older app).
* A refused refresh does not consume the token, so the retry is safe.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: OuraCredentials,
): Promise<OuraTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: creds.clientId,
client_secret: creds.clientSecret,
}),
"refreshAccessToken",
true,
);
}
// ─── Collection reads (v2) ─────────────────────────────────────
/** v2 paginated envelope: `{ data, next_token }`. */
+1 -1
View File
@@ -13,7 +13,7 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { decrypt, encrypt } from "@/lib/crypto";
import { getOuraCredentials, type OuraCredentials } from "./client";
import { getOuraCredentials, type OuraCredentials } from "./oauth";
/**
* Resolve the user's Oura OAuth client id/secret, DB-first then env.
+2 -2
View File
@@ -9,7 +9,7 @@ import {
obtainAccessToken,
type OAuthTokenDescriptor,
} from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type OuraCredentials } from "./client";
import { type OuraCredentials, ouraOAuth } from "./oauth";
import { getOuraClientCredentials } from "./credentials";
export const ouraTokenDescriptor: OAuthTokenDescriptor<OuraCredentials> = {
@@ -45,7 +45,7 @@ export const ouraTokenDescriptor: OAuthTokenDescriptor<OuraCredentials> = {
},
loadCredentials: (userId) => getOuraClientCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
ouraOAuth.refresh(refreshToken, credentials),
};
/**
+137
View File
@@ -0,0 +1,137 @@
/**
* The Oura OAuth client: consent URL, code exchange and refresh, over the
* shared factory (`integrations/oauth-kit/client.ts`).
*
* Standard authorization code with refresh tokens; Oura rotates the refresh
* token on every use, and the sync refreshes after a refused read. The client
* id and secret ride in the form (client_secret_post).
*
* Two token endpoints. Apps created in the current developer portal get their
* codes from the authorization server on moi.ouraring.com (the callback
* carries `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the
* documented `api.ouraring.com/oauth/token` answers those codes with
* `400 invalid_request`. The live endpoint is always asked first; the legacy
* one only after the live one refused with 400, 401 or 404, which may mean
* "unknown here" for an older app. A code from the current server goes to the
* live endpoint only: an authorization code works once (RFC 6749 4.1.2), so a
* second attempt elsewhere could never succeed. A refused refresh does not
* consume the token, so its retry is safe. Server errors and rate limits are
* never retried elsewhere: they say nothing about the grant.
*/
import {
defineOAuthClient,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import { OuraApiError } from "./response-classifier";
import { OURA_OAUTH_SCOPE } from "./scope";
export type OuraCredentials = OAuthClientCredentials;
/** Oura's live token endpoint. */
export const OURA_OAUTH_TOKEN_URL =
"https://moi.ouraring.com/oauth/v2/ext/oauth-token";
/** The documented endpoint, kept only as the fallback for older apps. */
export const OURA_OAUTH_LEGACY_TOKEN_URL =
"https://api.ouraring.com/oauth/token";
/** The origin of Oura's current authorization server. */
const OURA_MOI_ORIGIN = "https://moi.ouraring.com";
/**
* Authorization-server origins an `iss` callback parameter may name
* (RFC 9207). Anything else is a mix-up and refused.
*/
const OURA_ISSUER_ORIGINS = new Set([
OURA_MOI_ORIGIN,
"https://cloud.ouraring.com",
"https://api.ouraring.com",
]);
/**
* Check the `iss` the authorization server sent with the callback. Absent is
* fine (older apps never send one); present, it must name one of Oura's own
* authorization servers.
*/
export function isAcceptedOuraIssuer(iss: string | null): boolean {
if (iss === null) return true;
try {
const url = new URL(iss);
return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin);
} catch {
return false;
}
}
/** True when the code was issued by the current authorization server. */
function issuedByMoi(iss: string | null | undefined): boolean {
if (!iss) return false;
try {
return new URL(iss).origin === OURA_MOI_ORIGIN;
} catch {
return false;
}
}
/**
* Env-configured shared OAuth credentials: the fallback app used when a user
* has not registered their own client id and secret. The per-user resolver
* `getOuraClientCredentials` (database first) is the primary path.
*/
export function getOuraCredentials(): OuraCredentials | null {
const clientId = process.env.OURA_CLIENT_ID;
const clientSecret = process.env.OURA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export interface OuraTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type?: string;
scope?: string;
}
export const ouraOAuth = defineOAuthClient<OuraTokenResponse>({
service: "oura",
authorizeUrl: "https://cloud.ouraring.com/oauth/authorize",
tokenUrl: OURA_OAUTH_TOKEN_URL,
legacyTokenUrl: {
url: OURA_OAUTH_LEGACY_TOKEN_URL,
retryOn: new Set([400, 401, 404]),
},
redirectUri: () =>
derivedRedirectUri("OURA_REDIRECT_URI", "/api/oura/callback"),
// The scopes come from `./scope`, which maps every collection HealthLog
// reads to the scope Oura enforces for it. A grant from before a scope was
// added keeps the old set until the person reconnects once.
authorizeParams: () => ({ scope: OURA_OAUTH_SCOPE }),
clientAuth: { kind: "form" },
acceptJson: true,
refresh: true,
error: (failure) =>
new OuraApiError({
verb: failure.verb,
classification: failure.classification,
httpStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
});
/**
* Exchange an authorization code. `iss` is the callback's issuer parameter: a
* code from the current authorization server goes to the live endpoint only.
*/
export function exchangeOuraCode(
code: string,
credentials: OuraCredentials,
iss?: string | null,
): Promise<OuraTokenResponse> {
return ouraOAuth.exchange(code, credentials, {
useLegacyEndpoint: !issuedByMoi(iss),
});
}
+8 -11
View File
@@ -7,16 +7,11 @@ vi.mock("@/lib/logging/context", () => ({
}));
import {
POLAR_OAUTH_SCOPE,
exchangeCode,
fetchActivities,
fetchCardioLoads,
fetchNightlyRecharges,
fetchSleeps,
fetchSpo2,
getAuthorizationUrl,
getPolarCredentials,
getPolarRedirectUri,
mapActivity,
mapCardioLoad,
mapNightlyRecharge,
@@ -29,7 +24,9 @@ import {
type PolarSleep,
type PolarSpo2,
} from "../client";
import { POLAR_OAUTH_SCOPE, getPolarCredentials } from "@/lib/polar/oauth";
import { PolarApiError } from "../response-classifier";
import { polarOAuth } from "../oauth";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
@@ -82,7 +79,7 @@ describe("getPolarCredentials", () => {
describe("getAuthorizationUrl", () => {
it("builds the Polar authorize URL with scope + state", () => {
const url = getAuthorizationUrl("signed-state", CREDS);
const url = polarOAuth.authorizationUrl("signed-state", CREDS);
expect(url).toContain("flow.polar.com/oauth2/authorization");
expect(url).toContain("response_type=code");
expect(url).toContain("scope=accesslink.read_all");
@@ -93,13 +90,13 @@ describe("getAuthorizationUrl", () => {
describe("getPolarRedirectUri", () => {
it("derives from app URL when override absent", () => {
expect(getPolarRedirectUri()).toBe(
expect(polarOAuth.redirectUri()).toBe(
"https://app.example/api/polar/callback",
);
});
it("honours the explicit override", () => {
process.env.POLAR_REDIRECT_URI = "https://custom/cb";
expect(getPolarRedirectUri()).toBe("https://custom/cb");
expect(polarOAuth.redirectUri()).toBe("https://custom/cb");
});
});
@@ -111,7 +108,7 @@ describe("exchangeCode", () => {
body: { access_token: "tok", token_type: "bearer", x_user_id: 42 },
},
]);
const tok = await exchangeCode("code123", CREDS);
const tok = await polarOAuth.exchange("code123", CREDS);
expect(tok.access_token).toBe("tok");
expect(tok.x_user_id).toBe(42);
expect(fetchMock.mock.calls[0]![0]).toBe(
@@ -130,7 +127,7 @@ describe("exchangeCode", () => {
it("throws a classified PolarApiError on a 401", async () => {
installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]);
await expect(exchangeCode("bad", CREDS)).rejects.toBeInstanceOf(
await expect(polarOAuth.exchange("bad", CREDS)).rejects.toBeInstanceOf(
PolarApiError,
);
});
@@ -140,7 +137,7 @@ describe("exchangeCode", () => {
// TypeError. It must surface as a classified transient integration error.
it("classifies a 2xx with an empty body instead of casting null", async () => {
installFetchMock([{ status: 200, body: null }]);
await expect(exchangeCode("code", CREDS)).rejects.toMatchObject({
await expect(polarOAuth.exchange("code", CREDS)).rejects.toMatchObject({
classification: "transient",
reason: "empty_token_body",
});
+1 -1
View File
@@ -15,7 +15,7 @@ vi.mock("@/lib/crypto", () => ({
}));
// The env fallback lives in ./client; stub it so the precedence is observable.
vi.mock("../client", () => ({
vi.mock("../oauth", () => ({
getPolarCredentials: () => getPolarCredentials(),
}));
+3 -120
View File
@@ -1,14 +1,12 @@
/**
* v1.17.0 — Polar AccessLink v3 client for OAuth + data fetching.
* Polar AccessLink v3 client: user registration, collection reads, mappers.
* Docs: https://www.polar.com/accesslink-api/ (re-verify at build).
* Envelope shapes + windows below verified against the live AccessLink swagger
* (`accesslink-api/swagger.yaml`) on 2026-07-24; a body that no longer matches
* emits a `polar.envelope.drift` breadcrumb rather than silently reading empty.
*
* Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled
* fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection
* fetchers, and per-resource field→Measurement mappers (self-documenting; there
* is no separate reference constant).
* Hand-rolled fetch over `safeFetch` (no SDK): typed collection fetchers and
* per-resource field→Measurement mappers. The OAuth handshake is `./oauth.ts`.
*
* Polar specifics that differ from WHOOP:
* - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`)
@@ -31,124 +29,9 @@ import type { Prisma } from "@/generated/prisma/client";
import type { WorkoutSportType } from "@/lib/validations/workout";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
import { mapPolarSportType } from "./sport-map";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
const POLAR_API_BASE = "https://www.polaraccesslink.com";
const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization";
const POLAR_OAUTH_TOKEN_URL = "https://polarremote.com/v2/oauth2/token";
export interface PolarCredentials {
clientId: string;
clientSecret: string;
}
/** Resolve the env-configured shared OAuth credentials. This is the fallback
* the per-user BYO-key resolver (`getPolarClientCredentials`) drops to when a
* user has not stored their own AccessLink app id/secret. Returns null when
* unconfigured so the connect route can surface a clean "integration disabled"
* message. */
export function getPolarCredentials(): PolarCredentials | null {
const clientId = process.env.POLAR_CLIENT_ID;
const clientSecret = process.env.POLAR_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getPolarRedirectUri(): string {
// An empty or blank value counts as unset: the compose whitelist
// materialises an unset var as an empty string, which must still fall
// through to the derived URI.
return envOr(
"POLAR_REDIRECT_URI",
`${envValue("NEXT_PUBLIC_APP_URL")}/api/polar/callback`,
);
}
/**
* Polar OAuth scope. `accesslink.read_all` grants every read collection so the
* user consents once. Space-separated on the wire.
*/
export const POLAR_OAUTH_SCOPE = "accesslink.read_all" as const;
/** Build the browser-redirect authorization URL. `state` is the signed nonce. */
export function getAuthorizationUrl(
state: string,
creds: PolarCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getPolarRedirectUri(),
scope: POLAR_OAUTH_SCOPE,
state,
});
return `${POLAR_OAUTH_AUTH_URL}?${params}`;
}
export interface PolarTokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
x_user_id: number;
}
function basicAuthHeader(creds: PolarCredentials): string {
const raw = `${creds.clientId}:${creds.clientSecret}`;
return `Basic ${Buffer.from(raw).toString("base64")}`;
}
/** Exchange an authorization code for the access token + `x_user_id`. */
export async function exchangeCode(
code: string,
creds: PolarCredentials,
): Promise<PolarTokenResponse> {
const start = performance.now();
const res = await safeFetch(POLAR_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
Authorization: basicAuthHeader(creds),
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: getPolarRedirectUri(),
}).toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyPolarResponse(res.status);
getEvent()?.addExternalCall({
service: "polar",
method: "exchangeCode",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new PolarApiError({
verb: "exchangeCode",
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
// A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204)
// leaves `json` null. Casting it straight to the token type handed callers a
// null they then dereferenced, surfacing an unclassified TypeError instead of
// a handled integration error. Narrow explicitly and classify as transient.
if (json === null || typeof json !== "object") {
throw new PolarApiError({
verb: "exchangeCode",
classification: "transient",
httpStatus: res.status,
reason: "empty_token_body",
});
}
return json as PolarTokenResponse;
}
/**
* Register the user with the Polar app (`POST /v3/users`). Required once before
+1 -1
View File
@@ -12,7 +12,7 @@
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
import { getPolarCredentials, type PolarCredentials } from "./client";
import { getPolarCredentials, type PolarCredentials } from "./oauth";
import { polarTokenDescriptor } from "./oauth-descriptor";
/**
+64
View File
@@ -0,0 +1,64 @@
/**
* The Polar AccessLink OAuth client: consent URL and code exchange, over the
* shared factory (`integrations/oauth-kit/client.ts`).
*
* The token endpoint takes HTTP Basic client authentication. Polar access
* tokens do not expire and there is no refresh token; a revoked grant shows
* as a refused read. The token answer carries `x_user_id`, Polar's member id,
* which user registration and direct user lookups still need.
*/
import {
defineOAuthClient,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import { PolarApiError } from "./response-classifier";
export type PolarCredentials = OAuthClientCredentials;
/**
* `accesslink.read_all` grants every read collection so the person consents
* once. Space-separated on the wire.
*/
export const POLAR_OAUTH_SCOPE = "accesslink.read_all" as const;
/**
* The env-configured shared OAuth credentials: the fallback the per-user
* resolver (`getPolarClientCredentials`) drops to when a user has not stored
* their own AccessLink app. Null when unconfigured, so the connect route can
* say the integration is not set up.
*/
export function getPolarCredentials(): PolarCredentials | null {
const clientId = process.env.POLAR_CLIENT_ID;
const clientSecret = process.env.POLAR_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export interface PolarTokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
x_user_id: number;
}
export const polarOAuth = defineOAuthClient<PolarTokenResponse>({
service: "polar",
authorizeUrl: "https://flow.polar.com/oauth2/authorization",
tokenUrl: "https://polarremote.com/v2/oauth2/token",
redirectUri: () =>
derivedRedirectUri("POLAR_REDIRECT_URI", "/api/polar/callback"),
authorizeParams: () => ({ scope: POLAR_OAUTH_SCOPE }),
clientAuth: { kind: "basic", idInForm: { exchange: false, refresh: false } },
acceptJson: true,
refresh: false,
error: (failure) =>
new PolarApiError({
verb: failure.verb,
classification: failure.classification,
httpStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
});
@@ -15,13 +15,13 @@ vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() }));
import { safeFetch } from "@/lib/safe-fetch";
import { exchangeCode, refreshAccessToken } from "../client";
import {
classifyStravaError,
stravaFaultSummary,
StravaApiError,
} from "../response-classifier";
import { classifyStravaFailure } from "../sync";
import { stravaOAuth } from "../oauth";
const creds = { clientId: "12345", clientSecret: "not-a-real-secret" };
@@ -65,7 +65,7 @@ beforeEach(() => {
describe("a refused Strava token request", () => {
it("asks for a reconnect when the refresh token is dead", async () => {
answer(400, DEAD_REFRESH_TOKEN);
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
const err = await refusal(() => stravaOAuth.refresh("rt-dead", creds));
expect(err.httpStatus).toBe(400);
expect(err.upstreamError).toBe("RefreshToken.refresh_token:invalid");
@@ -75,7 +75,7 @@ describe("a refused Strava token request", () => {
it("still asks for a reconnect when the error lost its prototype on a retry", async () => {
answer(400, DEAD_REFRESH_TOKEN);
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
const err = await refusal(() => stravaOAuth.refresh("rt-dead", creds));
const replayed = new Error(err.message);
expect(replayed.message).toBe(
@@ -89,13 +89,13 @@ describe("a refused Strava token request", () => {
// answers a per-person condition this way is not documented, so the
// status decides, as it always has: 400 persistent, 401 reconnect.
answer(400, BAD_CLIENT_ID);
const bad = await refusal(() => refreshAccessToken("rt-live", creds));
const bad = await refusal(() => stravaOAuth.refresh("rt-live", creds));
expect(bad.upstreamError).toBe("Application.client_id:invalid");
expect(classifyStravaError(bad)).toBe("persistent");
answer(401, BAD_CLIENT);
const unauthorised = await refusal(() =>
refreshAccessToken("rt-live", creds),
stravaOAuth.refresh("rt-live", creds),
);
expect(unauthorised.upstreamError).toBe("Application:invalid");
expect(classifyStravaError(unauthorised)).toBe("reauth_required");
@@ -103,18 +103,18 @@ describe("a refused Strava token request", () => {
it("leaves a 400 that names neither as persistent and a 5xx as transient", async () => {
answer(400, { message: "Bad Request", errors: [] });
const plain = await refusal(() => exchangeCode("code", creds));
const plain = await refusal(() => stravaOAuth.exchange("code", creds));
expect(plain.upstreamError).toBe("Bad Request");
expect(classifyStravaError(plain)).toBe("persistent");
answer(503, { message: "Service Unavailable" });
const outage = await refusal(() => refreshAccessToken("rt-live", creds));
const outage = await refusal(() => stravaOAuth.refresh("rt-live", creds));
expect(classifyStravaError(outage)).toBe("transient");
});
it("still honours a standards-shaped invalid_grant", async () => {
answer(400, { error: "invalid_grant" });
const err = await refusal(() => refreshAccessToken("rt-dead", creds));
const err = await refusal(() => stravaOAuth.refresh("rt-dead", creds));
expect(err.upstreamError).toBe("invalid_grant");
expect(classifyStravaError(err)).toBe("reauth_required");
});
+5 -168
View File
@@ -1,11 +1,10 @@
/**
* v1.28.x — Strava API v3 client for OAuth + activity fetching.
* Strava API v3 client: activity reads.
* Docs: https://developers.strava.com/docs/reference/ (re-verify at build).
*
* Mirrors the WHOOP / Polar / Oura client structure: hand-rolled fetch over
* `safeFetch` (no vendor SDK), an OAuth handshake (`getAuthorizationUrl` /
* `exchangeCode` / `refreshAccessToken`), typed collection fetchers, and a
* single field → `Workout` mapper.
* Hand-rolled fetch over `safeFetch` (no vendor SDK): typed collection
* fetchers and a single field → `Workout` mapper. The OAuth handshake is
* `./oauth.ts`.
*
* Strava specifics:
* - It is a WORKOUT source only — the API is activity-centric and exposes no
@@ -25,173 +24,11 @@ import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after";
import type { Prisma } from "@/generated/prisma/client";
import {
StravaApiError,
classifyStravaResponse,
stravaFaultSummary,
} from "./response-classifier";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
import { mapStravaSportType } from "./sport-map";
import type { WorkoutSportType } from "@/lib/validations/workout";
import { envOr, envValue } from "@/lib/env";
const STRAVA_API_BASE = "https://www.strava.com/api/v3";
const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize";
const STRAVA_OAUTH_TOKEN_URL = "https://www.strava.com/oauth/token";
const STRAVA_DEAUTHORIZE_URL = "https://www.strava.com/oauth/deauthorize";
export interface StravaCredentials {
clientId: string;
clientSecret: string;
}
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Strava client id/secret. The per-user
* resolver `getStravaClientCredentials` (DB-first) is the primary path and
* calls this only when no per-user pair is stored.
*/
export function getStravaCredentials(): StravaCredentials | null {
const clientId = process.env.STRAVA_CLIENT_ID;
const clientSecret = process.env.STRAVA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getStravaRedirectUri(): string {
// An empty or blank value counts as unset: the compose whitelist
// materialises an unset var as an empty string, which must still fall
// through to the derived URI.
return envOr(
"STRAVA_REDIRECT_URI",
`${envValue("NEXT_PUBLIC_APP_URL")}/api/strava/callback`,
);
}
/**
* Strava OAuth scope. `activity:read_all` reads every activity including those
* marked "only me" (`activity:read` omits private ones). Comma-separated on the
* wire per Strava's authorization endpoint.
*/
export const STRAVA_OAUTH_SCOPE = "activity:read_all" as const;
export function getAuthorizationUrl(
state: string,
creds: StravaCredentials,
): string {
const params = new URLSearchParams({
client_id: creds.clientId,
response_type: "code",
redirect_uri: getStravaRedirectUri(),
approval_prompt: "auto",
scope: STRAVA_OAUTH_SCOPE,
state,
});
return `${STRAVA_OAUTH_AUTH_URL}?${params}`;
}
/** Strava's `SummaryAthlete` — only the numeric id is retained (the granted
* account pin + webhook fan-out key). */
export interface StravaAthlete {
id?: number | null;
}
export interface StravaTokenResponse {
access_token: string;
refresh_token: string;
expires_at?: number;
expires_in?: number;
token_type?: string;
athlete?: StravaAthlete | null;
}
async function postToken(
params: URLSearchParams,
verb: string,
): Promise<StravaTokenResponse> {
const start = performance.now();
const res = await safeFetch(STRAVA_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyStravaResponse(res.status);
getEvent()?.addExternalCall({
service: "strava",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new StravaApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
// Strava's `Fault`, reduced to the resource it names; the classifier
// tells a dead refresh token from a misconfigured app by it.
upstreamError: stravaFaultSummary(json),
});
}
return json as StravaTokenResponse;
}
export async function exchangeCode(
code: string,
creds: StravaCredentials,
): Promise<StravaTokenResponse> {
return postToken(
new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
code,
grant_type: "authorization_code",
}),
"exchangeCode",
);
}
export async function refreshAccessToken(
refreshToken: string,
creds: StravaCredentials,
): Promise<StravaTokenResponse> {
return postToken(
new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
"refreshAccessToken",
);
}
/**
* Best-effort deauthorize at Strava on disconnect. Failure is swallowed by the
* caller — the local grant is cleared regardless, so a Strava-side outage never
* strands the user with a card that refuses to disconnect.
*/
export async function deauthorize(accessToken: string): Promise<void> {
const start = performance.now();
const res = await safeFetch(STRAVA_DEAUTHORIZE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
getEvent()?.addExternalCall({
service: "strava",
method: "deauthorize",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: res.ok ? undefined : `http_${res.status}`,
});
}
// ─── Activity reads (v3) ───────────────────────────────────────
+1 -1
View File
@@ -12,7 +12,7 @@
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { getStravaCredentials, type StravaCredentials } from "./client";
import { getStravaCredentials, type StravaCredentials } from "./oauth";
/**
* Resolve the user's Strava OAuth client id/secret, DB-first then env. A
+2 -2
View File
@@ -9,7 +9,7 @@ import {
obtainAccessToken,
type OAuthTokenDescriptor,
} from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type StravaCredentials } from "./client";
import { type StravaCredentials, stravaOAuth } from "./oauth";
import { getStravaClientCredentials } from "./credentials";
export const stravaTokenDescriptor: OAuthTokenDescriptor<StravaCredentials> = {
@@ -45,7 +45,7 @@ export const stravaTokenDescriptor: OAuthTokenDescriptor<StravaCredentials> = {
},
loadCredentials: (userId) => getStravaClientCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
stravaOAuth.refresh(refreshToken, credentials),
};
/**
+79
View File
@@ -0,0 +1,79 @@
/**
* The Strava OAuth client: consent URL, code exchange, refresh and
* deauthorization, over the shared factory
* (`integrations/oauth-kit/client.ts`).
*
* Strava rotates its refresh token on every refresh; the token lifecycle
* persists both. Its token endpoint does not answer in RFC 6749 form: a
* refusal is the API's generic `Fault`, reduced by `stravaFaultSummary` so the
* classifier can tell a dead refresh token (`RefreshToken.refresh_token:…`)
* from a misconfigured app. The exchange carries no `redirect_uri`.
*/
import {
defineOAuthClient,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import { StravaApiError, stravaFaultSummary } from "./response-classifier";
export type StravaCredentials = OAuthClientCredentials;
/**
* `activity:read_all` reads every activity including those marked "only me"
* (`activity:read` omits private ones). Comma-separated on the wire.
*/
export const STRAVA_OAUTH_SCOPE = "activity:read_all" as const;
/** Strava's `SummaryAthlete`: only the numeric id is kept (the account pin). */
export interface StravaAthlete {
id?: number | null;
}
export interface StravaTokenResponse {
access_token: string;
refresh_token: string;
expires_at?: number;
expires_in?: number;
token_type?: string;
athlete?: StravaAthlete | null;
}
export const stravaOAuth = defineOAuthClient<StravaTokenResponse>({
service: "strava",
authorizeUrl: "https://www.strava.com/oauth/authorize",
tokenUrl: "https://www.strava.com/oauth/token",
deauthorizeUrl: "https://www.strava.com/oauth/deauthorize",
redirectUri: () =>
derivedRedirectUri("STRAVA_REDIRECT_URI", "/api/strava/callback"),
authorizeParams: () => ({
approval_prompt: "auto",
scope: STRAVA_OAUTH_SCOPE,
}),
clientAuth: { kind: "form" },
acceptJson: true,
exchangeRedirectUri: false,
refresh: true,
upstreamError: stravaFaultSummary,
error: (failure) =>
new StravaApiError({
verb: failure.verb,
classification: failure.classification,
httpStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
});
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Strava client id/secret. The per-user
* resolver `getStravaClientCredentials` (DB-first) is the primary path and
* calls this only when no per-user pair is stored.
*/
export function getStravaCredentials(): StravaCredentials | null {
const clientId = process.env.STRAVA_CLIENT_ID;
const clientSecret = process.env.STRAVA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
+6 -8
View File
@@ -2,9 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
KJ_TO_KCAL,
WHOOP_FIELD_MAP,
WHOOP_OAUTH_SCOPE,
WHOOP_PAGE_LIMIT,
exchangeCode,
fetchBodyMeasurement,
fetchRecoveryByCycleId,
fetchSleepById,
@@ -15,16 +13,16 @@ import {
fetchRecoveries,
fetchSleeps,
fetchWorkouts,
getAuthorizationUrl,
mapCycle,
mapRecovery,
mapSleep,
refreshAccessToken,
type WhoopCycle,
type WhoopRecovery,
type WhoopSleep,
} from "../client";
import { WHOOP_OAUTH_SCOPE } from "@/lib/whoop/oauth";
import { WhoopApiError } from "../response-classifier";
import { whoopOAuth } from "../oauth";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
@@ -50,7 +48,7 @@ afterEach(() => {
describe("getAuthorizationUrl", () => {
it("builds the v2 authorize URL with the offline scope and state", () => {
const url = getAuthorizationUrl("nonce123", CREDS);
const url = whoopOAuth.authorizationUrl("nonce123", CREDS);
expect(url).toContain("api.prod.whoop.com/oauth/oauth2/auth");
expect(url).toContain("response_type=code");
expect(url).toContain("client_id=cid");
@@ -76,7 +74,7 @@ describe("token exchange + refresh", () => {
},
},
]);
const tok = await exchangeCode("code", CREDS);
const tok = await whoopOAuth.exchange("code", CREDS);
expect(tok.access_token).toBe("at");
expect(tok.refresh_token).toBe("rt");
expect(tok.expires_in).toBe(3600);
@@ -89,7 +87,7 @@ describe("token exchange + refresh", () => {
body: { access_token: "at2", refresh_token: "rt2", expires_in: 3600 },
},
]);
const tok = await refreshAccessToken("rt1", CREDS);
const tok = await whoopOAuth.refresh("rt1", CREDS);
expect(tok.refresh_token).toBe("rt2");
const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
@@ -101,7 +99,7 @@ describe("token exchange + refresh", () => {
it("throws a classified WhoopApiError on a 401 token response", async () => {
installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]);
await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({
await expect(whoopOAuth.exchange("bad", CREDS)).rejects.toMatchObject({
name: "WhoopApiError",
classification: "reauth_required",
});
@@ -84,9 +84,12 @@ vi.mock("@/lib/logging/context", () => ({
vi.mock("../credentials", () => ({
getUserWhoopCredentials: getUserWhoopCredentialsMock,
}));
vi.mock("../client", async (orig) => {
const actual = await orig<typeof import("../client")>();
return { ...actual, refreshAccessToken: refreshAccessTokenMock };
vi.mock("../oauth", async (orig) => {
const actual = await orig<typeof import("../oauth")>();
return {
...actual,
whoopOAuth: { ...actual.whoopOAuth, refresh: refreshAccessTokenMock },
};
});
// Each resource resolves the token first (the ledger-registration seam).
+10 -1
View File
@@ -60,11 +60,20 @@ vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
}));
vi.mock("../oauth", async (orig) => {
const actual = await orig<typeof import("../oauth")>();
return {
...actual,
whoopOAuth: {
...actual.whoopOAuth,
refresh: (...a: unknown[]) => refreshAccessTokenMock(...a),
},
};
});
vi.mock("../client", async (orig) => {
const actual = await orig<typeof import("../client")>();
return {
...actual,
refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a),
fetchRecoveries: (...a: unknown[]) => fetchRecoveriesMock(...a),
};
});
+4 -130
View File
@@ -2,12 +2,10 @@
* WHOOP API v2 client for OAuth and data fetching.
* Docs: https://developer.whoop.com (re-verify at build — the space moves).
*
* Mirrors the Withings client structure (`src/lib/withings/client.ts`):
* hand-rolled fetch over `safeFetch` (no SDK), an OAuth handshake
* (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), typed
* collection fetchers with `next_token` pagination, and a single
* source-of-truth field→Measurement mapping (`WHOOP_FIELD_MAP`, kept in sync
* with `src/lib/whoop/mapping.md`).
* Hand-rolled fetch over `safeFetch` (no SDK): typed collection fetchers with
* `next_token` pagination, and a single source-of-truth field→Measurement
* mapping (`WHOOP_FIELD_MAP`, kept in sync with `src/lib/whoop/mapping.md`).
* The OAuth handshake is `./oauth.ts`.
*
* v2 only — v1 was removed 2025-10-01. Sleep / workout / recovery ids are
* UUID strings; cycle id is int64. Token TTL 3600 s; refresh tokens ROTATE
@@ -19,138 +17,14 @@ import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
import { walkPages } from "@/lib/integrations/page-walk";
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
const WHOOP_OAUTH_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth";
const WHOOP_OAUTH_TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token";
/** WHOOP collection reads cap `limit` at 25. */
export const WHOOP_PAGE_LIMIT = 25;
export interface WhoopCredentials {
clientId: string;
clientSecret: string;
}
export function getWhoopRedirectUri(): string {
// An empty or blank value counts as unset: the compose whitelist
// materialises the var as an empty string when unset, which must still fall
// through to the derived URI.
return envOr(
"WHOOP_REDIRECT_URI",
`${envValue("NEXT_PUBLIC_APP_URL")}/api/whoop/callback`,
);
}
/**
* OAuth scopes HealthLog requests (space-separated on the wire). `offline` is
* mandatory to receive a refresh token; the rest are read-only collection
* scopes. Request all so the user grants once and every sync resource is
* covered.
*/
export const WHOOP_OAUTH_SCOPE =
"offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const;
/**
* Generate the WHOOP OAuth authorization URL (a browser redirect, not a
* fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`.
*/
export function getAuthorizationUrl(
state: string,
creds: WhoopCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getWhoopRedirectUri(),
scope: WHOOP_OAUTH_SCOPE,
state,
});
return `${WHOOP_OAUTH_AUTH_URL}?${params}`;
}
export interface WhoopTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
token_type?: string;
}
async function postToken(
params: URLSearchParams,
verb: string,
): Promise<WhoopTokenResponse> {
const start = performance.now();
const res = await safeFetch(WHOOP_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyWhoopResponse(res.status);
getEvent()?.addExternalCall({
service: "whoop",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WhoopApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
return json as WhoopTokenResponse;
}
/** Exchange an authorization code for the initial token pair. */
export async function exchangeCode(
code: string,
creds: WhoopCredentials,
): Promise<WhoopTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: creds.clientId,
client_secret: creds.clientSecret,
redirect_uri: getWhoopRedirectUri(),
}),
"exchangeCode",
);
}
/**
* Refresh an expired access token. WHOOP rotates the refresh token on every
* use — the caller MUST persist the returned `refresh_token`, not just the
* access token. `scope` must include `offline` (re-sent here per the WHOOP
* spec) for the rotation to return a fresh refresh token.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: WhoopCredentials,
): Promise<WhoopTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: creds.clientId,
client_secret: creds.clientSecret,
scope: "offline",
}),
"refreshAccessToken",
);
}
// ─── Collection envelopes (v2) ─────────────────────────────────
/** WHOOP paginated collection envelope: `{ records, next_token }`. */
+1 -1
View File
@@ -9,7 +9,7 @@
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { WhoopCredentials } from "./client";
import type { WhoopCredentials } from "./oauth";
/**
* Fetch and decrypt the user's WHOOP API credentials.
+2 -2
View File
@@ -7,7 +7,7 @@
* WHOOP user id is a connect still in flight and has no usable grant yet.
*/
import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type WhoopCredentials } from "./client";
import { type WhoopCredentials, whoopOAuth } from "./oauth";
import { getUserWhoopCredentials } from "./credentials";
/** Refresh this long before the stored expiry. */
@@ -47,5 +47,5 @@ export const whoopTokenDescriptor: OAuthTokenDescriptor<WhoopCredentials> = {
},
loadCredentials: (userId) => getUserWhoopCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
whoopOAuth.refresh(refreshToken, credentials),
};
+54
View File
@@ -0,0 +1,54 @@
/**
* The WHOOP OAuth client: consent URL, code exchange and refresh, over the
* shared factory (`integrations/oauth-kit/client.ts`).
*
* Token TTL is 3600 s and refresh tokens rotate: each refresh invalidates the
* prior access and refresh token, so the token lifecycle persists both. The
* `offline` scope is what returns a refresh token at all, and WHOOP wants it
* re-sent on the refresh.
*/
import {
defineOAuthClient,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import { WhoopApiError } from "./response-classifier";
export type WhoopCredentials = OAuthClientCredentials;
/**
* Space-separated on the wire. `offline` is mandatory for a refresh token;
* the rest are the read-only collection scopes, all requested so the person
* grants once.
*/
export const WHOOP_OAUTH_SCOPE =
"offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const;
export interface WhoopTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
token_type?: string;
}
export const whoopOAuth = defineOAuthClient<WhoopTokenResponse>({
service: "whoop",
authorizeUrl: "https://api.prod.whoop.com/oauth/oauth2/auth",
tokenUrl: "https://api.prod.whoop.com/oauth/oauth2/token",
redirectUri: () =>
derivedRedirectUri("WHOOP_REDIRECT_URI", "/api/whoop/callback"),
authorizeParams: () => ({ scope: WHOOP_OAUTH_SCOPE }),
clientAuth: { kind: "form" },
extraFields: { refresh: { scope: "offline" } },
refresh: true,
error: (failure) =>
new WhoopApiError({
verb: failure.verb,
classification: failure.classification,
httpStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
});
+11 -8
View File
@@ -1,16 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MEASURE_TYPE_MAP,
WITHINGS_OAUTH_SCOPE,
exchangeCode,
fetchMeasurements,
hasActivityScope,
parseWithingsScope,
refreshAccessToken,
subscribeWebhook,
} from "../client";
import {
WITHINGS_OAUTH_SCOPE,
hasActivityScope,
parseWithingsScope,
} from "@/lib/withings/oauth";
import { WithingsApiError } from "../response-classifier";
import { WITHINGS_NOTIFY_APPLIS } from "../sync";
import { withingsOAuth } from "../oauth";
/**
* Withings client — meastype mapping + payload-parsing unit tests.
@@ -400,7 +401,7 @@ describe("client off-response handling — refreshAccessToken", () => {
})),
);
await expect(refreshAccessToken("rt", creds)).rejects.toMatchObject({
await expect(withingsOAuth.refresh("rt", creds)).rejects.toMatchObject({
name: "WithingsApiError",
classification: "reauth_required",
withingsStatus: 101,
@@ -416,7 +417,7 @@ describe("client off-response handling — refreshAccessToken", () => {
})),
);
const err = await refreshAccessToken("rt", creds).catch((e) => e);
const err = await withingsOAuth.refresh("rt", creds).catch((e) => e);
expect(err).toBeInstanceOf(WithingsApiError);
expect((err as WithingsApiError).classification).toBe("transient");
expect((err as WithingsApiError).reason).toBe("http_503");
@@ -434,7 +435,9 @@ describe("client off-response handling — exchangeCode", () => {
json: async () => ({ status: 100, error: "Authentication failed" }),
})),
);
const err = await exchangeCode("auth-code", creds).catch((e) => e);
const err = await withingsOAuth
.exchange("auth-code", creds)
.catch((e) => e);
expect(err).toBeInstanceOf(WithingsApiError);
expect((err as WithingsApiError).classification).toBe("reauth_required");
});
@@ -31,9 +31,15 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("../client", () => ({
fetchMeasurements: vi.fn(),
refreshAccessToken: vi.fn(),
subscribeWebhook: vi.fn(),
}));
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
withingsOAuth: { ...actual.withingsOAuth, refresh: vi.fn() },
};
});
vi.mock("../credentials", () => ({
getUserWithingsCredentials: vi.fn(async () => ({
@@ -37,10 +37,14 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("../client", () => ({
WITHINGS_MEASTYPES: [1, 9, 10, 11],
fetchMeasurements: vi.fn(),
refreshAccessToken: vi.fn(),
subscribeWebhook: vi.fn(),
}));
vi.mock("../oauth", async (orig) => {
const actual = await orig<typeof import("../oauth")>();
return { ...actual, withingsOAuth: { refresh: vi.fn() } };
});
vi.mock("../credentials", () => ({
getUserWithingsCredentials: vi.fn(async () => ({
clientId: "cid",
@@ -88,7 +92,8 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { prisma } from "@/lib/db";
import { fetchMeasurements, refreshAccessToken } from "../client";
import { fetchMeasurements } from "../client";
import { withingsOAuth } from "../oauth";
import {
recordSyncFailure,
recordSyncSuccess,
@@ -149,7 +154,7 @@ describe("getValidToken — serialized rotating refresh", () => {
expect(result?.accessToken).toBe("winner-access");
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(refreshAccessToken).not.toHaveBeenCalled();
expect(withingsOAuth.refresh).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
@@ -40,10 +40,16 @@ vi.mock("../client", async () => {
return {
...actual,
fetchMeasurements: vi.fn(),
refreshAccessToken: vi.fn(),
subscribeWebhook: vi.fn(),
};
});
vi.mock("../oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../oauth")>();
return {
...actual,
withingsOAuth: { ...actual.withingsOAuth, refresh: vi.fn() },
};
});
vi.mock("../credentials", () => ({
getUserWithingsCredentials: vi.fn(async () => ({
+2 -173
View File
@@ -1,5 +1,6 @@
/**
* Withings API client for OAuth and data fetching.
* Withings API client: measurement reads and webhook subscriptions. The OAuth
* handshake is `./oauth.ts`.
* Docs: https://developer.withings.com/api-reference
*/
import { getEvent } from "@/lib/logging/context";
@@ -8,184 +9,12 @@ import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
import { walkPages } from "@/lib/integrations/page-walk";
const WITHINGS_OAUTH_URL =
"https://account.withings.com/oauth2_user/authorize2";
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
export interface WithingsCredentials {
clientId: string;
clientSecret: string;
}
export function getWithingsRedirectUri(): string {
// An empty or blank value counts as unset: the compose whitelist
// materialises an unset var as an empty string, which must fall through to
// the derived URI rather than go out as `redirect_uri=`.
return envOr(
"WITHINGS_REDIRECT_URI",
`${envValue("NEXT_PUBLIC_APP_URL")}/api/withings/callback`,
);
}
/**
* v1.4.25 — Withings OAuth scope set HealthLog requests.
*
* - `user.metrics` : every meastype the Measure / Heart-list /
* Sleep endpoints expose (weight, BP, pulse,
* SpO2, body comp, temperature, VO2 max, …)
* - `user.activity` : steps / active energy / distance / floors —
* served by `POST /v2/measure?action=getactivity`.
* The sync routine itself lands in v1.4.26; the
* scope is requested now so existing users
* reconnect once instead of twice.
*
* v1.4.24-and-earlier connections requested `user.metrics` only — the
* Settings → Integrations card surfaces a reconnect banner for those
* users (see `WithingsConnection.scope IS NULL` or scope without
* `user.activity`).
*/
export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const;
/**
* Parse the persisted scope string into a Set for membership checks.
* Legacy NULL → empty set (treated as "no scopes yet") so the
* reconnect-banner conditional reads as truthy.
*/
export function parseWithingsScope(scope: string | null): Set<string> {
if (!scope) return new Set();
return new Set(
scope
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
/**
* Returns true when the connection holds `user.activity` — the
* minimum scope required by the Activity / Sleep endpoints.
*/
export function hasActivityScope(scope: string | null): boolean {
return parseWithingsScope(scope).has("user.activity");
}
/**
* Generate Withings OAuth authorization URL.
*/
export function getAuthorizationUrl(
state: string,
creds: WithingsCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getWithingsRedirectUri(),
scope: WITHINGS_OAUTH_SCOPE,
state,
});
return `${WITHINGS_OAUTH_URL}?${params}`;
}
export interface WithingsTokenResponse {
userid: string;
access_token: string;
refresh_token: string;
expires_in: number;
}
/**
* Exchange authorization code for tokens.
*/
export async function exchangeCode(
code: string,
creds: WithingsCredentials,
): Promise<WithingsTokenResponse> {
const params = new URLSearchParams({
action: "requesttoken",
grant_type: "authorization_code",
client_id: creds.clientId,
client_secret: creds.clientSecret,
redirect_uri: getWithingsRedirectUri(),
code,
});
const start = performance.now();
const res = await safeFetch(WITHINGS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "exchangeCode",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "token",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
return json.body as WithingsTokenResponse;
}
/**
* Refresh an expired access token.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: WithingsCredentials,
): Promise<WithingsTokenResponse> {
const params = new URLSearchParams({
action: "requesttoken",
grant_type: "refresh_token",
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: refreshToken,
});
const start = performance.now();
const res = await safeFetch(WITHINGS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "refreshAccessToken",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "refresh",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
return json.body as WithingsTokenResponse;
}
// Withings measure type mapping. The single source of truth is
// `src/lib/withings/mapping.md` — keep both in sync when adding entries.
// https://developer.withings.com/api-reference#tag/measure/operation/measure-getmeas
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import type { WithingsCredentials } from "./client";
import type { WithingsCredentials } from "./oauth";
/**
* Fetch and decrypt the user's Withings API credentials.
+2 -2
View File
@@ -6,7 +6,7 @@
* ahead of it, and rotates the refresh token on every use.
*/
import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type WithingsCredentials } from "./client";
import { type WithingsCredentials, withingsOAuth } from "./oauth";
import { getUserWithingsCredentials } from "./credentials";
/** Refresh this long before the stored expiry. */
@@ -42,5 +42,5 @@ export const withingsTokenDescriptor: OAuthTokenDescriptor<WithingsCredentials>
},
loadCredentials: (userId) => getUserWithingsCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
withingsOAuth.refresh(refreshToken, credentials),
};
+108
View File
@@ -0,0 +1,108 @@
/**
* The Withings OAuth client: consent URL, code exchange and refresh, over the
* shared factory (`integrations/oauth-kit/client.ts`).
*
* Withings answers HTTP 200 with the real status inside the body
* (`{ status, body, error }`), so its answers are classified by
* `classifyWithingsResponse`, the token sits under `body`, and every token
* request names `action=requesttoken`. A failure is filed under the verbs
* `token` and `refresh`.
*/
import {
defineOAuthClient,
type OAuthClientCredentials,
} from "@/lib/integrations/oauth-kit/client";
import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
import {
WithingsApiError,
classifyWithingsResponse,
type WithingsBodyLike,
} from "./response-classifier";
export type WithingsCredentials = OAuthClientCredentials;
/**
* The scope set HealthLog requests:
*
* - `user.metrics` : every meastype the Measure, Heart-list and Sleep
* endpoints expose (weight, BP, pulse, SpO2, body
* composition, temperature, VO2 max, ...)
* - `user.activity` : steps, active energy, distance, floors.
*
* A connection granted `user.metrics` only (or with no stored scope) is shown
* a reconnect banner on the integrations card.
*/
export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const;
/**
* The persisted scope string as a set. A legacy NULL is the empty set, so the
* reconnect-banner condition reads true.
*/
export function parseWithingsScope(scope: string | null): Set<string> {
if (!scope) return new Set();
return new Set(
scope
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
/** Whether the grant holds `user.activity`, which Activity and Sleep need. */
export function hasActivityScope(scope: string | null): boolean {
return parseWithingsScope(scope).has("user.activity");
}
export interface WithingsTokenResponse {
userid: string;
access_token: string;
refresh_token: string;
expires_in: number;
}
function withingsError(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;
}
export const withingsOAuth = defineOAuthClient<WithingsTokenResponse>({
service: "withings",
authorizeUrl: "https://account.withings.com/oauth2_user/authorize2",
tokenUrl: "https://wbsapi.withings.net/v2/oauth2",
redirectUri: () =>
derivedRedirectUri("WITHINGS_REDIRECT_URI", "/api/withings/callback"),
authorizeParams: () => ({ scope: WITHINGS_OAUTH_SCOPE }),
clientAuth: { kind: "form" },
extraFields: {
exchange: { action: "requesttoken" },
refresh: { action: "requesttoken" },
},
refresh: true,
classify: (httpStatus, body) => {
const verdict = classifyWithingsResponse(
httpStatus,
body as WithingsBodyLike | null,
);
return {
classification: verdict.classification,
status: verdict.withingsStatus,
reason: verdict.reason,
};
},
upstreamError: withingsError,
failureVerb: { exchange: "token", refresh: "refresh" },
error: (failure) =>
new WithingsApiError({
verb: failure.verb,
classification: failure.classification,
withingsStatus: failure.status,
reason: failure.reason,
upstreamError: failure.upstreamError,
}),
tokenOf: (body) =>
body !== null && typeof body === "object"
? (body as { body?: unknown }).body
: null,
});
+2 -5
View File
@@ -46,11 +46,8 @@ import { getUnitForType } from "@/lib/validations/measurement";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import {
hasActivityScope,
WITHINGS_MAX_PAGES,
withingsNextOffset,
} from "./client";
import { WITHINGS_MAX_PAGES, withingsNextOffset } from "./client";
import { hasActivityScope } from "./oauth";
import {
WithingsApiError,
classifyWithingsResponse,
+1 -1
View File
@@ -55,7 +55,7 @@ import {
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import { hasActivityScope } from "./client";
import { hasActivityScope } from "./oauth";
import {
nextSleepStageReads,
parseSleepStageReads,
@@ -26,7 +26,6 @@ vi.mock("@/lib/oura/client", async (importOriginal) => {
const actual = await importOriginal<typeof OuraClientModule>();
return {
...actual,
refreshAccessToken: ouraRefresh,
fetchReadiness: collection,
fetchSleep: collection,
fetchDailyActivity: collection,
@@ -38,6 +37,13 @@ vi.mock("@/lib/oura/client", async (importOriginal) => {
fetchDailyCyclePhases: collection,
};
});
vi.mock("@/lib/oura/oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/oura/oauth")>();
return {
...actual,
ouraOAuth: { ...actual.ouraOAuth, refresh: ouraRefresh },
};
});
import { decrypt, encrypt } from "@/lib/crypto";
import { OuraApiError } from "@/lib/oura/response-classifier";
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as WhoopClientModule from "@/lib/whoop/client";
import type * as WithingsClientModule from "@/lib/withings/client";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -17,14 +16,24 @@ const { whoopRefresh, withingsRefresh, whoopRecoveries } = vi.hoisted(() => ({
vi.mock("@/lib/whoop/client", async (importOriginal) => ({
...(await importOriginal<typeof WhoopClientModule>()),
refreshAccessToken: whoopRefresh,
fetchRecoveries: whoopRecoveries,
}));
vi.mock("@/lib/withings/client", async (importOriginal) => ({
...(await importOriginal<typeof WithingsClientModule>()),
refreshAccessToken: withingsRefresh,
}));
vi.mock("@/lib/whoop/oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/whoop/oauth")>();
return {
...actual,
whoopOAuth: { ...actual.whoopOAuth, refresh: whoopRefresh },
};
});
vi.mock("@/lib/withings/oauth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/withings/oauth")>();
return {
...actual,
withingsOAuth: { ...actual.withingsOAuth, refresh: withingsRefresh },
};
});
import { encrypt, decrypt } from "@/lib/crypto";
import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync-core";