mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange, refresh and deauthorization from a declaration of what differs: endpoints and scope, client authentication (form or HTTP Basic, with or without the client id beside it), the fields a grant adds, how an answer is classified and the refusal named, the error type, and where the token sits in the body. The seven hand-written copies of the redirect URI, authorization URL, token post, Basic header and PKCE pair are gone; each provider keeps an `oauth.ts` with its declaration. Shared behaviour is now uniform: a 2xx without a token object (an empty or non-JSON body) is a transient `empty_token_body` failure for every provider. WHOOP, Strava and Google Health used to return null, and Withings threw an unclassified SyntaxError; the OAuth wire golden records exactly these lines changing and nothing else. Oura's live/legacy endpoint fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's errorType are declared per provider.
This commit is contained in:
1 parent
1a84cf27a8
commit
b6f45c0d88
89 files changed
+1690
-1764
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchProfile,
|
||||
resolveFitbitUserId,
|
||||
FITBIT_OAUTH_SCOPE,
|
||||
} from "@/lib/fitbit/client";
|
||||
import { fetchProfile, resolveFitbitUserId } from "@/lib/fitbit/client";
|
||||
import { fitbitOAuth } from "@/lib/fitbit/oauth";
|
||||
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
|
||||
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
||||
import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state";
|
||||
import { FITBIT_BACKFILL_QUEUE } from "@/lib/fitbit/jobs/queues";
|
||||
@@ -150,7 +147,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return ERR("nocreds");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
|
||||
const tokens = await fitbitOAuth.exchange(code, creds, {
|
||||
codeVerifier: consumed.codeVerifier,
|
||||
});
|
||||
|
||||
// The initial code exchange MUST return a refresh token. If Fitbit ever
|
||||
// omits it, refuse to persist — a stored empty refresh token silently bricks
|
||||
|
||||
@@ -27,10 +27,15 @@ vi.mock("@/lib/fitbit/credentials", () => ({
|
||||
getUserFitbitCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fitbit/client", () => ({
|
||||
getAuthorizationUrl: vi.fn(
|
||||
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
|
||||
),
|
||||
vi.mock("@/lib/fitbit/oauth", () => ({
|
||||
fitbitOAuth: {
|
||||
authorizationUrl: vi.fn(
|
||||
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/oauth-kit/pkce", () => ({
|
||||
generatePkcePair: vi.fn(() => ({
|
||||
verifier: "verifier-stub",
|
||||
challenge: "challenge-stub",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
|
||||
import { fitbitOAuth } from "@/lib/fitbit/oauth";
|
||||
import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce";
|
||||
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
||||
import {
|
||||
FITBIT_OAUTH_STATE_COOKIE,
|
||||
@@ -94,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
|
||||
const url = fitbitOAuth.authorizationUrl(nonce, creds, pkce.challenge);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
|
||||
|
||||
@@ -10,11 +10,11 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchProfile,
|
||||
resolveGoogleHealthUserId,
|
||||
getGoogleHealthScopeString,
|
||||
} from "@/lib/google-health/client";
|
||||
import { googleHealthOAuth } from "@/lib/google-health/oauth";
|
||||
import { getGoogleHealthScopeString } from "@/lib/google-health/oauth";
|
||||
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
|
||||
import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state";
|
||||
import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill-queue";
|
||||
@@ -162,14 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return ERR("nocreds");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
|
||||
const tokens = await googleHealthOAuth.exchange(code, creds, {
|
||||
codeVerifier: consumed.codeVerifier,
|
||||
});
|
||||
|
||||
// The initial code exchange MUST return a refresh token (the connect route
|
||||
// forces `prompt=consent` + `access_type=offline`). If Google ever omits
|
||||
// it, refuse to persist — a stored empty refresh token silently bricks
|
||||
// every future token refresh (the refresh POST then 400s and the
|
||||
// connection dies the moment the access token expires). Error out cleanly
|
||||
// so the user re-consents rather than landing a dead connection.
|
||||
// The exchange must return a refresh token (the connect route forces
|
||||
// `prompt=consent` and `access_type=offline`). A stored empty one would
|
||||
// fail every later refresh, so the person re-consents instead of landing
|
||||
// a connection that dies with its first access token.
|
||||
if (!tokens.refresh_token) {
|
||||
getEvent()?.addWarning(
|
||||
`google-health callback: token response carried no refresh_token for user ${recordUserId}`,
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from "@/lib/withings/plan-features";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { hasActivityScope } from "@/lib/withings/oauth";
|
||||
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import {
|
||||
googleHealthBackfillEndedList,
|
||||
|
||||
@@ -74,13 +74,15 @@ const {
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock }));
|
||||
|
||||
vi.mock("@/lib/oura/client", async (importOriginal) => ({
|
||||
isAcceptedOuraIssuer: (
|
||||
await importOriginal<typeof import("@/lib/oura/client")>()
|
||||
).isAcceptedOuraIssuer,
|
||||
exchangeCode: exchangeMock,
|
||||
vi.mock("@/lib/oura/client", () => ({
|
||||
fetchOuraAccountId: vi.fn(async () => "oura-account-1"),
|
||||
}));
|
||||
vi.mock("@/lib/oura/oauth", async (importOriginal) => ({
|
||||
isAcceptedOuraIssuer: (
|
||||
await importOriginal<typeof import("@/lib/oura/oauth")>()
|
||||
).isAcceptedOuraIssuer,
|
||||
exchangeOuraCode: exchangeMock,
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
@@ -6,11 +6,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchOuraAccountId,
|
||||
isAcceptedOuraIssuer,
|
||||
} from "@/lib/oura/client";
|
||||
import { fetchOuraAccountId } from "@/lib/oura/client";
|
||||
import { exchangeOuraCode, isAcceptedOuraIssuer } from "@/lib/oura/oauth";
|
||||
import {
|
||||
ALREADY_LINKED_REASON,
|
||||
accountLinkedElsewhere,
|
||||
@@ -137,7 +134,7 @@ async function completeCallback(
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds, iss);
|
||||
const tokens = await exchangeOuraCode(code, creds, iss);
|
||||
|
||||
// One Oura account, one record: a guardian signed in to their own Oura
|
||||
// account must not attach it to the profile they manage.
|
||||
|
||||
@@ -76,9 +76,11 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
|
||||
vi.mock("@/lib/polar/client", () => ({
|
||||
exchangeCode: exchangeCodeMock,
|
||||
registerUser: registerUserMock,
|
||||
}));
|
||||
vi.mock("@/lib/polar/oauth", () => ({
|
||||
polarOAuth: { exchange: exchangeCodeMock },
|
||||
}));
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, registerUser } from "@/lib/polar/client";
|
||||
import { registerUser } from "@/lib/polar/client";
|
||||
import { polarOAuth } from "@/lib/polar/oauth";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
@@ -115,7 +116,7 @@ async function completeCallback(
|
||||
const creds = await getPolarClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await polarOAuth.exchange(code, creds);
|
||||
// Guard against an off-spec token body without `x_user_id`: persisting the
|
||||
// string "undefined" as the member id would break every later data read
|
||||
// (`/v3/users/undefined/...`) with no clear cause.
|
||||
|
||||
@@ -83,7 +83,9 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc(${s})` }));
|
||||
vi.mock("@/lib/strava/client", () => ({ exchangeCode: exchangeCodeMock }));
|
||||
vi.mock("@/lib/strava/oauth", () => ({
|
||||
stravaOAuth: { exchange: exchangeCodeMock },
|
||||
}));
|
||||
vi.mock("@/lib/strava/credentials", () => ({
|
||||
getStravaClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode } from "@/lib/strava/client";
|
||||
import { stravaOAuth } from "@/lib/strava/oauth";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
@@ -114,7 +114,7 @@ async function completeCallback(
|
||||
const creds = await getStravaClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await stravaOAuth.exchange(code, creds);
|
||||
|
||||
const athleteId =
|
||||
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
|
||||
|
||||
@@ -52,15 +52,19 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
|
||||
|
||||
vi.mock("@/lib/whoop/client", () => ({
|
||||
WHOOP_OAUTH_SCOPE: "read:recovery",
|
||||
exchangeCode: vi.fn(async () => ({
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
expires_in: 3600,
|
||||
scope: "read:recovery",
|
||||
})),
|
||||
fetchProfile: vi.fn(async () => ({ user_id: 42 })),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/oauth", () => ({
|
||||
WHOOP_OAUTH_SCOPE: "read:recovery",
|
||||
whoopOAuth: {
|
||||
exchange: vi.fn(async () => ({
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
expires_in: 3600,
|
||||
scope: "read:recovery",
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/whoop/credentials", () => ({
|
||||
getUserWhoopCredentials: vi.fn(async () => ({
|
||||
|
||||
@@ -6,11 +6,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchProfile,
|
||||
WHOOP_OAUTH_SCOPE,
|
||||
} from "@/lib/whoop/client";
|
||||
import { fetchProfile } from "@/lib/whoop/client";
|
||||
import { WHOOP_OAUTH_SCOPE, whoopOAuth } from "@/lib/whoop/oauth";
|
||||
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
|
||||
import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state";
|
||||
import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme";
|
||||
@@ -205,7 +202,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return outcomeRedirect(returnScheme, "error", "nocreds");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await whoopOAuth.exchange(code, creds);
|
||||
const profile = await fetchProfile(tokens.access_token);
|
||||
const whoopUserId = String(profile.user_id);
|
||||
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
@@ -81,8 +81,8 @@ vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({
|
||||
exchangeCode: vi.fn(),
|
||||
vi.mock("@/lib/withings/oauth", () => ({
|
||||
withingsOAuth: { exchange: vi.fn() },
|
||||
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
|
||||
}));
|
||||
|
||||
@@ -157,8 +157,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
} as never);
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
vi.mocked(exchangeCode).mockResolvedValue({
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
vi.mocked(withingsOAuth.exchange).mockResolvedValue({
|
||||
userid: "wuid",
|
||||
access_token: "atok",
|
||||
refresh_token: "rtok",
|
||||
@@ -221,8 +221,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
} as never);
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
vi.mocked(exchangeCode).mockResolvedValue({
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
vi.mocked(withingsOAuth.exchange).mockResolvedValue({
|
||||
userid: "wuid",
|
||||
access_token: "atok",
|
||||
refresh_token: "rtok",
|
||||
@@ -300,7 +300,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
expiredRow as never,
|
||||
);
|
||||
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
@@ -310,7 +310,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
"withings=error&reason=expired",
|
||||
);
|
||||
// Single atomic round-trip consumed the row; no token exchange.
|
||||
expect(exchangeCode).not.toHaveBeenCalled();
|
||||
expect(withingsOAuth.exchange).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
crossUserRow as never,
|
||||
);
|
||||
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
@@ -334,7 +334,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=cross_user",
|
||||
);
|
||||
expect(exchangeCode).not.toHaveBeenCalled();
|
||||
expect(withingsOAuth.exchange).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
meta: { reason: "cross_user" },
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
|
||||
import { WITHINGS_OAUTH_SCOPE, withingsOAuth } from "@/lib/withings/oauth";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
|
||||
import { setupWebhook } from "@/lib/withings/sync";
|
||||
@@ -209,7 +209,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await withingsOAuth.exchange(code, creds);
|
||||
|
||||
// One Withings account, one record. A guardian signed in to their own
|
||||
// Health Mate account must not attach it to the profile they manage:
|
||||
|
||||
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { hasActivityScope } from "@/lib/withings/oauth";
|
||||
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
|
||||
import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor";
|
||||
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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", " ");
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
@@ -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 }`. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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) ───────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 }`. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user