diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index bfd995c15..4b039dc0a 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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 diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 3a4766a7f..7f96efd81 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/__tests__/env-empty-is-unset-guard.test.ts b/src/__tests__/env-empty-is-unset-guard.test.ts index a50b87bbc..952131da6 100644 --- a/src/__tests__/env-empty-is-unset-guard.test.ts +++ b/src/__tests__/env-empty-is-unset-guard.test.ts @@ -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); diff --git a/src/__tests__/oauth-kit-guard.test.ts b/src/__tests__/oauth-kit-guard.test.ts index 4021ebb08..4d56ef7bd 100644 --- a/src/__tests__/oauth-kit-guard.test.ts +++ b/src/__tests__/oauth-kit-guard.test.ts @@ -10,17 +10,23 @@ * * A. Each kit provider has `src/lib//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 (`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([]); + }); }); diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 0e089baff..57e99199d 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -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 diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts index 003b50857..f73409069 100644 --- a/src/app/api/fitbit/connect/__tests__/route.test.ts +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index 11823963b..f38a1a556 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -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, { diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 21a3638e4..97947aa72 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -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}`, diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 1fe7cf99c..417037307 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -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, diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 215b7a481..649642a27 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -74,13 +74,15 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", async (importOriginal) => ({ - isAcceptedOuraIssuer: ( - await importOriginal() - ).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() + ).isAcceptedOuraIssuer, + exchangeOuraCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 350cdaf5c..61d3634fe 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -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. diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index 84c255e1a..061746acf 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -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, })); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index 84230b171..4aae7e385 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -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. diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index a2f0f15de..a73794284 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -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, })); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index 0bf259bf9..99c8d1624 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -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; diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index 7ceec9334..58aed091c 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -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 () => ({ diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 036c54565..36da98e8b 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -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); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index 98b9b96c1..89fd74f07 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -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" }, }); diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index 07522df24..335a3b3c6 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -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: diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 3af9d631f..943b91cf6 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -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"; diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 30668375c..62f14d191 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -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", diff --git a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts index 6dffa3198..09a5b4f26 100644 --- a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts +++ b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts @@ -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 diff --git a/src/lib/fitbit/__tests__/sync-dead-token.test.ts b/src/lib/fitbit/__tests__/sync-dead-token.test.ts index ece6ddf3a..23e68bdfe 100644 --- a/src/lib/fitbit/__tests__/sync-dead-token.test.ts +++ b/src/lib/fitbit/__tests__/sync-dead-token.test.ts @@ -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 })); diff --git a/src/lib/fitbit/__tests__/sync-plausibility.test.ts b/src/lib/fitbit/__tests__/sync-plausibility.test.ts index 7efe59f99..1ba60d6fa 100644 --- a/src/lib/fitbit/__tests__/sync-plausibility.test.ts +++ b/src/lib/fitbit/__tests__/sync-plausibility.test.ts @@ -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(); + return { + ...actual, + fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() }, + }; +}); import { eventStorage } from "@/lib/logging/context"; import { WideEventBuilder } from "@/lib/logging/event-builder"; diff --git a/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts b/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts index 508bf739c..7a3298379 100644 --- a/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts @@ -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(); + return { + ...actual, + fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() }, + }; +}); import { upsertFitbitMeasurements } from "../sync-core"; diff --git a/src/lib/fitbit/__tests__/sync.test.ts b/src/lib/fitbit/__tests__/sync.test.ts index 9d8990a3b..effbc1506 100644 --- a/src/lib/fitbit/__tests__/sync.test.ts +++ b/src/lib/fitbit/__tests__/sync.test.ts @@ -55,11 +55,14 @@ vi.mock("@/lib/crypto", () => ({ decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), })); -vi.mock("../client", async (orig) => { - const actual = await orig(); +vi.mock("../oauth", async (orig) => { + const actual = await orig(); return { ...actual, - refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a), + fitbitOAuth: { + ...actual.fitbitOAuth, + refresh: (...a: unknown[]) => refreshAccessTokenMock(...a), + }, }; }); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 5f936c61d..5e55799d6 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -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 { - 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 { - 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 { - 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. diff --git a/src/lib/fitbit/credentials.ts b/src/lib/fitbit/credentials.ts index 6725daef8..bdb14b15c 100644 --- a/src/lib/fitbit/credentials.ts +++ b/src/lib/fitbit/credentials.ts @@ -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. diff --git a/src/lib/fitbit/oauth.ts b/src/lib/fitbit/oauth.ts new file mode 100644 index 000000000..c8b66cf70 --- /dev/null +++ b/src/lib/fitbit/oauth.ts @@ -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({ + 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, + }), +}); diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index d78d1e28d..2a2d6d57f 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -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 diff --git a/src/lib/google-health/__tests__/scopes.test.ts b/src/lib/google-health/__tests__/scopes.test.ts index 824511b9d..272bcf00e 100644 --- a/src/lib/google-health/__tests__/scopes.test.ts +++ b/src/lib/google-health/__tests__/scopes.test.ts @@ -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", () => { diff --git a/src/lib/google-health/__tests__/setup-docs-parity.test.ts b/src/lib/google-health/__tests__/setup-docs-parity.test.ts index 4f295e2db..9e8a3a094 100644 --- a/src/lib/google-health/__tests__/setup-docs-parity.test.ts +++ b/src/lib/google-health/__tests__/setup-docs-parity.test.ts @@ -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, diff --git a/src/lib/google-health/__tests__/sync-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts index 64c09bee9..d9684e8e7 100644 --- a/src/lib/google-health/__tests__/sync-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -85,9 +85,15 @@ vi.mock("@/lib/cache/invalidate", () => ({ vi.mock("../credentials", () => ({ getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock, })); -vi.mock("../client", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, refreshAccessToken: refreshAccessTokenMock }; +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { + ...actual.googleHealthOAuth, + refresh: refreshAccessTokenMock, + }, + }; }); vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake })); diff --git a/src/lib/google-health/__tests__/sync-plausibility.test.ts b/src/lib/google-health/__tests__/sync-plausibility.test.ts index 2fb5b3404..7b97e05c8 100644 --- a/src/lib/google-health/__tests__/sync-plausibility.test.ts +++ b/src/lib/google-health/__tests__/sync-plausibility.test.ts @@ -45,12 +45,18 @@ vi.mock("../client", async () => { const actual = await vi.importActual("../client"); return { ...actual, - refreshAccessToken: vi.fn(), noteGoogleHealthMapped: vi.fn(), noteGoogleHealthWritten: vi.fn(), noteGoogleHealthOutcomeFailure: vi.fn(), }; }); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; +}); import { eventStorage } from "@/lib/logging/context"; import { WideEventBuilder } from "@/lib/logging/event-builder"; diff --git a/src/lib/google-health/__tests__/sync-sleep-replace.test.ts b/src/lib/google-health/__tests__/sync-sleep-replace.test.ts index c7302712c..bb5380027 100644 --- a/src/lib/google-health/__tests__/sync-sleep-replace.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-replace.test.ts @@ -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(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; +}); import { replaceStaleGoogleHealthSleep } from "../sync-core"; diff --git a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts index 0704e346c..56b640bde 100644 --- a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts @@ -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(); - return { ...actual, refreshAccessToken: vi.fn() }; +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; }); import { upsertGoogleHealthMeasurements } from "../sync-core"; diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index dbdd331b3..b731f9597 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -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 { - 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 { - 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 { - 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 diff --git a/src/lib/google-health/credentials.ts b/src/lib/google-health/credentials.ts index c39a6a4b8..58020d3ab 100644 --- a/src/lib/google-health/credentials.ts +++ b/src/lib/google-health/credentials.ts @@ -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. diff --git a/src/lib/google-health/oauth-descriptor.ts b/src/lib/google-health/oauth-descriptor.ts index 48d210e0e..045ba0ffc 100644 --- a/src/lib/google-health/oauth-descriptor.ts +++ b/src/lib/google-health/oauth-descriptor.ts @@ -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 getUserGoogleHealthCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + googleHealthOAuth.refresh(refreshToken, credentials), }; diff --git a/src/lib/google-health/oauth.ts b/src/lib/google-health/oauth.ts new file mode 100644 index 000000000..1d89ffabd --- /dev/null +++ b/src/lib/google-health/oauth.ts @@ -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({ + 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, + }), +}); diff --git a/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts index 25924210c..1b5ae515e 100644 --- a/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts +++ b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts @@ -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", " "); diff --git a/src/lib/integrations/callback-urls.ts b/src/lib/integrations/callback-urls.ts index 5f6b00d39..7f9eb384f 100644 --- a/src/lib/integrations/callback-urls.ts +++ b/src/lib/integrations/callback-urls.ts @@ -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 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 { diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts index e0134cb09..6b32e9d9a 100644 --- a/src/lib/integrations/connection-health-load.ts +++ b/src/lib/integrations/connection-health-load.ts @@ -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"; diff --git a/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json b/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json index 35c077e13..0b3d3b29d 100644 --- a/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json +++ b/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json @@ -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 '<', \"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 '<', \"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", diff --git a/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts index fc3cf7c5a..c0bef7882 100644 --- a/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts @@ -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" })), diff --git a/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts index 65fca1c16..fdc5b0fa7 100644 --- a/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts @@ -167,90 +167,92 @@ interface ProviderUnderTest { * file that follows the clients' API; the golden does not. */ async function providers(): Promise> { - 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> { }), ], }, - 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), }, }; } diff --git a/src/lib/integrations/oauth-kit/client.ts b/src/lib/integrations/oauth-kit/client.ts new file mode 100644 index 000000000..db71f9937 --- /dev/null +++ b/src/lib/integrations/oauth-kit/client.ts @@ -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>; + +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; + }; + 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 { + 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; + /** Refresh an access token. Throws for a provider without refresh tokens. */ + refresh( + refreshToken: string, + credentials: OAuthClientCredentials, + ): Promise; + /** Tell the provider the grant is gone. Never throws on a refusal. */ + deauthorize(accessToken: string): Promise; +} + +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( + config: OAuthClientConfig, +): OAuthClient { + 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, + credentials: OAuthClientCredentials, + eventMethod: string, + ): Promise { + const headers: Record = { + "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, + credentials: OAuthClientCredentials, + mayUseLegacy: boolean, + ): Promise { + 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 { + 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}`, + }); + }, + }; +} diff --git a/src/lib/integrations/oauth-kit/connect-start.ts b/src/lib/integrations/oauth-kit/connect-start.ts index 068c9e764..a03f9eec5 100644 --- a/src/lib/integrations/oauth-kit/connect-start.ts +++ b/src/lib/integrations/oauth-kit/connect-start.ts @@ -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), ); } } diff --git a/src/lib/integrations/oauth-kit/disconnect.ts b/src/lib/integrations/oauth-kit/disconnect.ts index 03f8b8e90..1d71453c8 100644 --- a/src/lib/integrations/oauth-kit/disconnect.ts +++ b/src/lib/integrations/oauth-kit/disconnect.ts @@ -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}`, diff --git a/src/lib/integrations/oauth-kit/pkce.ts b/src/lib/integrations/oauth-kit/pkce.ts new file mode 100644 index 000000000..0b21ea73a --- /dev/null +++ b/src/lib/integrations/oauth-kit/pkce.ts @@ -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 }; +} diff --git a/src/lib/integrations/oauth-kit/redirect-uri.ts b/src/lib/integrations/oauth-kit/redirect-uri.ts new file mode 100644 index 000000000..c00b6ba93 --- /dev/null +++ b/src/lib/integrations/oauth-kit/redirect-uri.ts @@ -0,0 +1,88 @@ +/** + * The OAuth `redirect_uri` of a provider integration. + * + * An explicit `_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(); +} diff --git a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts index f768c9799..bbb619a11 100644 --- a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts @@ -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(); + return { + ...actual, + fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() }, + }; +}); import { FITBIT_BACKFILL_DAYS, diff --git a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts index 35d232e7f..cf6c36c81 100644 --- a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts @@ -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(); - return { ...actual, refreshAccessToken: vi.fn() }; + await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; }); import { diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 46d800995..e5715afd2 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -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]); diff --git a/src/lib/oura/__tests__/credentials.test.ts b/src/lib/oura/__tests__/credentials.test.ts index 5e63fc22e..655a9495d 100644 --- a/src/lib/oura/__tests__/credentials.test.ts +++ b/src/lib/oura/__tests__/credentials.test.ts @@ -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(), })); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index cd2de619b..6c9401d76 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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(); + return { + ...actual, + ouraOAuth: { ...actual.ouraOAuth, refresh: refreshMock }, }; }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 2e3ac2569..c2561df67 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -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 { - 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 { - 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 { - 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 { - 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 }`. */ diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index 95b06898e..1c46b33bf 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -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. diff --git a/src/lib/oura/oauth-descriptor.ts b/src/lib/oura/oauth-descriptor.ts index cfc43a51b..933c9cab9 100644 --- a/src/lib/oura/oauth-descriptor.ts +++ b/src/lib/oura/oauth-descriptor.ts @@ -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 = { @@ -45,7 +45,7 @@ export const ouraTokenDescriptor: OAuthTokenDescriptor = { }, loadCredentials: (userId) => getOuraClientCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + ouraOAuth.refresh(refreshToken, credentials), }; /** diff --git a/src/lib/oura/oauth.ts b/src/lib/oura/oauth.ts new file mode 100644 index 000000000..a1a8151a8 --- /dev/null +++ b/src/lib/oura/oauth.ts @@ -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({ + 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 { + return ouraOAuth.exchange(code, credentials, { + useLegacyEndpoint: !issuedByMoi(iss), + }); +} diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 97f3c0702..99617d6d3 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -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", }); diff --git a/src/lib/polar/__tests__/credentials.test.ts b/src/lib/polar/__tests__/credentials.test.ts index 11f8d5b22..d4306c238 100644 --- a/src/lib/polar/__tests__/credentials.test.ts +++ b/src/lib/polar/__tests__/credentials.test.ts @@ -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(), })); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 042aa8183..4eb151cb9 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -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 { - 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 diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts index 5151a1889..2f0abe496 100644 --- a/src/lib/polar/credentials.ts +++ b/src/lib/polar/credentials.ts @@ -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"; /** diff --git a/src/lib/polar/oauth.ts b/src/lib/polar/oauth.ts new file mode 100644 index 000000000..22ae3647a --- /dev/null +++ b/src/lib/polar/oauth.ts @@ -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({ + 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, + }), +}); diff --git a/src/lib/strava/__tests__/token-refusal.test.ts b/src/lib/strava/__tests__/token-refusal.test.ts index 207f99f4a..e4d79e536 100644 --- a/src/lib/strava/__tests__/token-refusal.test.ts +++ b/src/lib/strava/__tests__/token-refusal.test.ts @@ -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"); }); diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 8b74d09d3..6b5fd33a8 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -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 { - 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 { - 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 { - 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 { - 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) ─────────────────────────────────────── diff --git a/src/lib/strava/credentials.ts b/src/lib/strava/credentials.ts index 4410aac16..f0c03aa4e 100644 --- a/src/lib/strava/credentials.ts +++ b/src/lib/strava/credentials.ts @@ -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 diff --git a/src/lib/strava/oauth-descriptor.ts b/src/lib/strava/oauth-descriptor.ts index 52e9c1aa9..3bd28801f 100644 --- a/src/lib/strava/oauth-descriptor.ts +++ b/src/lib/strava/oauth-descriptor.ts @@ -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 = { @@ -45,7 +45,7 @@ export const stravaTokenDescriptor: OAuthTokenDescriptor = { }, loadCredentials: (userId) => getStravaClientCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + stravaOAuth.refresh(refreshToken, credentials), }; /** diff --git a/src/lib/strava/oauth.ts b/src/lib/strava/oauth.ts new file mode 100644 index 000000000..756ea843d --- /dev/null +++ b/src/lib/strava/oauth.ts @@ -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({ + 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 }; +} diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index fa0db0f1b..b935e8a2c 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -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", }); diff --git a/src/lib/whoop/__tests__/sync-dead-token.test.ts b/src/lib/whoop/__tests__/sync-dead-token.test.ts index a5d51027e..a5fadba59 100644 --- a/src/lib/whoop/__tests__/sync-dead-token.test.ts +++ b/src/lib/whoop/__tests__/sync-dead-token.test.ts @@ -84,9 +84,12 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("../credentials", () => ({ getUserWhoopCredentials: getUserWhoopCredentialsMock, })); -vi.mock("../client", async (orig) => { - const actual = await orig(); - return { ...actual, refreshAccessToken: refreshAccessTokenMock }; +vi.mock("../oauth", async (orig) => { + const actual = await orig(); + return { + ...actual, + whoopOAuth: { ...actual.whoopOAuth, refresh: refreshAccessTokenMock }, + }; }); // Each resource resolves the token first (the ledger-registration seam). diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 85462399e..f989d22a0 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -60,11 +60,20 @@ vi.mock("@/lib/crypto", () => ({ decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), })); +vi.mock("../oauth", async (orig) => { + const actual = await orig(); + return { + ...actual, + whoopOAuth: { + ...actual.whoopOAuth, + refresh: (...a: unknown[]) => refreshAccessTokenMock(...a), + }, + }; +}); vi.mock("../client", async (orig) => { const actual = await orig(); return { ...actual, - refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a), fetchRecoveries: (...a: unknown[]) => fetchRecoveriesMock(...a), }; }); diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 17d2aa529..70f3aa859 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -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 { - 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 { - 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 { - 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 }`. */ diff --git a/src/lib/whoop/credentials.ts b/src/lib/whoop/credentials.ts index cdb5f547d..b45a1c77a 100644 --- a/src/lib/whoop/credentials.ts +++ b/src/lib/whoop/credentials.ts @@ -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. diff --git a/src/lib/whoop/oauth-descriptor.ts b/src/lib/whoop/oauth-descriptor.ts index 8a86ac2a4..d3fdfc400 100644 --- a/src/lib/whoop/oauth-descriptor.ts +++ b/src/lib/whoop/oauth-descriptor.ts @@ -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 = { }, loadCredentials: (userId) => getUserWhoopCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + whoopOAuth.refresh(refreshToken, credentials), }; diff --git a/src/lib/whoop/oauth.ts b/src/lib/whoop/oauth.ts new file mode 100644 index 000000000..17107027a --- /dev/null +++ b/src/lib/whoop/oauth.ts @@ -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({ + 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, + }), +}); diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index e3525455e..27e63d2ca 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -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"); }); diff --git a/src/lib/withings/__tests__/subscription-state.test.ts b/src/lib/withings/__tests__/subscription-state.test.ts index 4267e6dfd..cd6718b60 100644 --- a/src/lib/withings/__tests__/subscription-state.test.ts +++ b/src/lib/withings/__tests__/subscription-state.test.ts @@ -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(); + return { + ...actual, + withingsOAuth: { ...actual.withingsOAuth, refresh: vi.fn() }, + }; +}); vi.mock("../credentials", () => ({ getUserWithingsCredentials: vi.fn(async () => ({ diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index 4408c931d..1a3397c9b 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -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(); + 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({ diff --git a/src/lib/withings/__tests__/sync-plausibility.test.ts b/src/lib/withings/__tests__/sync-plausibility.test.ts index b80a6715c..3f4a5e4c9 100644 --- a/src/lib/withings/__tests__/sync-plausibility.test.ts +++ b/src/lib/withings/__tests__/sync-plausibility.test.ts @@ -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(); + return { + ...actual, + withingsOAuth: { ...actual.withingsOAuth, refresh: vi.fn() }, + }; +}); vi.mock("../credentials", () => ({ getUserWithingsCredentials: vi.fn(async () => ({ diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index f5174d02b..c60a34e4a 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -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 { - 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 { - 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 { - 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 diff --git a/src/lib/withings/credentials.ts b/src/lib/withings/credentials.ts index 0f96cce56..f4e9f5659 100644 --- a/src/lib/withings/credentials.ts +++ b/src/lib/withings/credentials.ts @@ -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. diff --git a/src/lib/withings/oauth-descriptor.ts b/src/lib/withings/oauth-descriptor.ts index fa99d0f5b..ac57e124a 100644 --- a/src/lib/withings/oauth-descriptor.ts +++ b/src/lib/withings/oauth-descriptor.ts @@ -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 }, loadCredentials: (userId) => getUserWithingsCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + withingsOAuth.refresh(refreshToken, credentials), }; diff --git a/src/lib/withings/oauth.ts b/src/lib/withings/oauth.ts new file mode 100644 index 000000000..56c697708 --- /dev/null +++ b/src/lib/withings/oauth.ts @@ -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 { + 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({ + 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, +}); diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index b5199cb0a..1708d5fcd 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -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, diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index c8b99eaf6..5ba45ec8e 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -55,7 +55,7 @@ import { reconcileExternalMeasurement, } from "@/lib/measurements/reconcile-external-measurement"; -import { hasActivityScope } from "./client"; +import { hasActivityScope } from "./oauth"; import { nextSleepStageReads, parseSleepStageReads, diff --git a/tests/integration/oura-refresh-serialization.test.ts b/tests/integration/oura-refresh-serialization.test.ts index d42f145fe..aa65c2393 100644 --- a/tests/integration/oura-refresh-serialization.test.ts +++ b/tests/integration/oura-refresh-serialization.test.ts @@ -26,7 +26,6 @@ vi.mock("@/lib/oura/client", async (importOriginal) => { const actual = await importOriginal(); 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(); + return { + ...actual, + ouraOAuth: { ...actual.ouraOAuth, refresh: ouraRefresh }, + }; +}); import { decrypt, encrypt } from "@/lib/crypto"; import { OuraApiError } from "@/lib/oura/response-classifier"; diff --git a/tests/integration/provider-token-refresh-serialization.test.ts b/tests/integration/provider-token-refresh-serialization.test.ts index db22d5cbd..120a34f63 100644 --- a/tests/integration/provider-token-refresh-serialization.test.ts +++ b/tests/integration/provider-token-refresh-serialization.test.ts @@ -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()), - refreshAccessToken: whoopRefresh, fetchRecoveries: whoopRecoveries, })); -vi.mock("@/lib/withings/client", async (importOriginal) => ({ - ...(await importOriginal()), - refreshAccessToken: withingsRefresh, -})); +vi.mock("@/lib/whoop/oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + whoopOAuth: { ...actual.whoopOAuth, refresh: whoopRefresh }, + }; +}); + +vi.mock("@/lib/withings/oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withingsOAuth: { ...actual.withingsOAuth, refresh: withingsRefresh }, + }; +}); import { encrypt, decrypt } from "@/lib/crypto"; import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync-core";