From 62244096788b163b110460a7ec4d9881238c7dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 00:05:22 +0200 Subject: [PATCH] fix(oura): request the stress scope and exchange a repeated callback once Resilience sits behind Oura's `stress` scope, not `heart_health`, so a connection that granted every scope HealthLog asked for still answered 401 on `daily_resilience`. `src/lib/oura/scope.ts` now maps every collection the sync reads to the scope the live API enforces and is the one source for the authorize request, the per-collection permission detection, the reconnect hint and the settings card. The request asks for `stress` as well. Oura names a missing scope in its 401 body ("Token is not authorized access stress scope."), while a dead token reads differently. A collection refusal now carries that scope: it spends no token refresh, never counts toward the reconnect threshold and marks only the collections it names. A 403 (a lapsed membership) is a persistent failure rather than a reconnect. The callback also records what the token response says was granted, so an unticked permission shows on the card before the first sync. A browser can deliver an OAuth callback twice with the same code. Both requests exchanged it, and Oura answers a reused code by revoking the token the first exchange issued. Every connect callback (Oura, Polar, Strava, WHOOP, Withings, Fitbit, Google Health) now claims its state in `oauth_callback_claims` before the exchange: one request exchanges, the duplicate waits for it and redirects to the same page without touching any token. The ledger flows claim inside the transaction that consumes their state row. A failed exchange settles with its error page, and a new connect mints a new state. Oura token refreshes are serialised per user with an advisory lock, so overlapping syncs no longer spend the same single-use refresh token, and the write stays conditional so a reconnect's new grant is never overwritten by a refresh of the old one. --- .env.production.example | 8 +- docs/api/openapi.yaml | 5 +- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- .../0419_oauth_callback_claims/migration.sql | 28 ++ prisma/schema.prisma | 28 ++ src/app/api/fitbit/callback/route.ts | 214 +++++++------ src/app/api/google-health/callback/route.ts | 234 ++++++++------- .../api/oura/callback/__tests__/route.test.ts | 20 ++ src/app/api/oura/callback/route.ts | 54 +++- .../polar/callback/__tests__/route.test.ts | 20 ++ src/app/api/polar/callback/route.ts | 29 +- .../strava/callback/__tests__/route.test.ts | 20 ++ src/app/api/strava/callback/route.ts | 29 +- .../whoop/callback/__tests__/route.test.ts | 22 +- src/app/api/whoop/callback/route.ts | 216 +++++++------ .../withings/callback/__tests__/route.test.ts | 22 +- src/app/api/withings/callback/route.ts | 230 +++++++------- .../__tests__/recovery-empty-state.test.tsx | 2 +- src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/insights/recovery-empty.ts | 7 +- src/lib/integrations/oauth-refresh.ts | 2 +- src/lib/oauth/callback-claim.ts | 207 +++++++++++++ src/lib/oauth/signed-state.ts | 2 +- src/lib/openapi/routes/integrations.ts | 2 +- src/lib/oura/__tests__/client.test.ts | 47 ++- src/lib/oura/__tests__/scope.test.ts | 101 +++++++ src/lib/oura/__tests__/sync.test.ts | 163 +++++++++- src/lib/oura/client.ts | 74 +++-- src/lib/oura/credentials.ts | 118 +++++--- src/lib/oura/response-classifier.ts | 22 +- src/lib/oura/scope.ts | 165 +++++++++- src/lib/oura/sync.ts | 184 +++++++----- .../oauth-callback-single-use.test.ts | 283 ++++++++++++++++++ .../oura-refresh-serialization.test.ts | 156 ++++++++++ tests/integration/setup.ts | 3 + tests/integration/withings-oauth-flow.test.ts | 43 +-- 43 files changed, 2154 insertions(+), 624 deletions(-) create mode 100644 prisma/migrations/0419_oauth_callback_claims/migration.sql create mode 100644 src/lib/oauth/callback-claim.ts create mode 100644 src/lib/oura/__tests__/scope.test.ts create mode 100644 tests/integration/oauth-callback-single-use.test.ts create mode 100644 tests/integration/oura-refresh-serialization.test.ts diff --git a/.env.production.example b/.env.production.example index 2d2884592..0428201a7 100644 --- a/.env.production.example +++ b/.env.production.example @@ -153,9 +153,11 @@ API_TOKEN_HMAC_KEY="" # Oura Cloud v2 -- optional integration (full OAuth, shared app from env) # ----------------------------------------------------------------------------- # Register an app at https://cloud.ouraring.com/oauth/applications and set its -# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together -# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes -# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback. +# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Enable the Daily, +# Personal, SpO2, Heart health and Stress permissions on it; HealthLog asks for +# exactly those. Both vars together enable the Oura connect button. Oura uses +# refresh tokens; the sync refreshes reactively on a 401. OURA_REDIRECT_URI +# overrides the derived callback. # OURA_CLIENT_ID="" # OURA_CLIENT_SECRET="" # OURA_REDIRECT_URI="" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c17830d99..0ce16651c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -79303,8 +79303,9 @@ components: type: boolean scopeReconnect: deprecated: true - description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: they - answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward + description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: Oura + refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the + same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads." type: array diff --git a/messages/de.json b/messages/de.json index 030c254cc..9a2c80392 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", "empty": "Diese Seite füllt sich mit den Erholungs-, Belastungs- und Trainingslastwerten von WHOOP oder Polar und mit Ouras Resilienz-Stufe.", - "emptyOuraPermission": "Ouras Resilienz-Stufe braucht Zugriff auf Herzgesundheitsdaten, also verbinde Oura einmal neu unter Integrationen und erlaube ihn.", + "emptyOuraPermission": "Ouras Resilienz-Stufe braucht die Berechtigung für Stressdaten, also verbinde Oura einmal neu unter Integrationen und erlaube sie.", "emptyOuraNoResilience": "Oura ist verbunden, und seine Resilienz-Stufe erscheint hier, sobald Oura eine liefert.", "emptyAction": "Integrationen öffnen", "block": { diff --git a/messages/en.json b/messages/en.json index a486f636a..8d6ab2cd4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", "empty": "This page fills with the recharge, strain and load readings from WHOOP or Polar and with Oura's resilience level.", - "emptyOuraPermission": "Oura's resilience level needs heart-health access, so reconnect Oura once under Integrations and allow it.", + "emptyOuraPermission": "Oura's resilience level needs the stress permission, so reconnect Oura once under Integrations and allow it.", "emptyOuraNoResilience": "Oura is connected, and its resilience level appears here as soon as Oura provides one.", "emptyAction": "Open integrations", "block": { diff --git a/messages/es.json b/messages/es.json index a154ba9bd..f86b42a52 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "Ver la puntuación de recuperación combinada", "emptyTitle": "Aún no hay datos de recuperación", "empty": "Esta página se llena con los valores de recuperación, esfuerzo y carga de WHOOP o Polar y con el nivel de resiliencia de Oura.", - "emptyOuraPermission": "El nivel de resiliencia de Oura necesita acceso a los datos de salud cardíaca, así que vuelve a conectar Oura una vez en Integraciones y permítelo.", + "emptyOuraPermission": "El nivel de resiliencia de Oura necesita el permiso de datos de estrés, así que vuelve a conectar Oura una vez en Integraciones y permítelo.", "emptyOuraNoResilience": "Oura está conectado y su nivel de resiliencia aparecerá aquí en cuanto Oura proporcione uno.", "emptyAction": "Abrir integraciones", "block": { diff --git a/messages/fr.json b/messages/fr.json index 0807d9df1..3b3a92f2f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", "empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.", - "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnectez Oura une fois dans Intégrations et autorisez-le.", + "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'autorisation d'accès aux données de stress : reconnectez Oura une fois dans Intégrations et autorisez-la.", "emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.", "emptyAction": "Ouvrir les intégrations", "block": { diff --git a/messages/it.json b/messages/it.json index 52629264f..85e7d2cf8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "Vedi il punteggio di recupero composito", "emptyTitle": "Ancora nessun dato di recupero", "empty": "Questa pagina si riempie con i valori di recupero, sforzo e carico di WHOOP o Polar e con il livello di resilienza di Oura.", - "emptyOuraPermission": "Il livello di resilienza di Oura richiede l'accesso ai dati sulla salute del cuore, quindi ricollega Oura una volta in Integrazioni e consentilo.", + "emptyOuraPermission": "Il livello di resilienza di Oura richiede l'autorizzazione ai dati sullo stress, quindi ricollega Oura una volta in Integrazioni e consentila.", "emptyOuraNoResilience": "Oura è collegato e il suo livello di resilienza apparirà qui non appena Oura ne fornirà uno.", "emptyAction": "Apri integrazioni", "block": { diff --git a/messages/ko.json b/messages/ko.json index 3b245cf71..cc848901c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "종합 회복 점수 보기", "emptyTitle": "아직 회복 데이터가 없어요", "empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.", - "emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.", + "emptyOuraPermission": "Oura의 회복탄력성 수준에는 스트레스 데이터 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.", "emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.", "emptyAction": "통합 열기", "block": { diff --git a/messages/nl.json b/messages/nl.json index 6732ea4dd..745094620 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -4668,7 +4668,7 @@ }, "emptyAction": "Integraties openen", "emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.", - "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe.", + "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toestemming voor stressgegevens nodig, dus verbind Oura één keer opnieuw onder Integraties en sta die toe.", "loadError": "Je herstelwaarden konden niet worden geladen." }, "bodyTempDeviation": { diff --git a/messages/pl.json b/messages/pl.json index 21304ed2c..d87da90db 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4657,7 +4657,7 @@ "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", "empty": "Ta strona wypełnia się wartościami regeneracji, obciążenia i obciążenia treningowego z WHOOP lub Polar oraz poziomem odporności z Oura.", - "emptyOuraPermission": "Poziom odporności Oura wymaga dostępu do danych o zdrowiu serca, więc połącz Oura ponownie raz w Integracjach i zezwól na niego.", + "emptyOuraPermission": "Poziom odporności Oura wymaga uprawnienia do danych o stresie, więc połącz Oura ponownie raz w Integracjach i zezwól na nie.", "emptyOuraNoResilience": "Oura jest połączona, a jej poziom odporności pojawi się tutaj, gdy tylko Oura go dostarczy.", "emptyAction": "Otwórz integracje", "block": { diff --git a/prisma/migrations/0419_oauth_callback_claims/migration.sql b/prisma/migrations/0419_oauth_callback_claims/migration.sql new file mode 100644 index 000000000..5f2a45ab4 --- /dev/null +++ b/prisma/migrations/0419_oauth_callback_claims/migration.sql @@ -0,0 +1,28 @@ +-- Single-use OAuth callbacks. +-- +-- A browser can deliver an OAuth callback twice with the same code and state, +-- a few hundred milliseconds apart. The connect flows that sign their state +-- (Oura, Polar, Strava) kept no record of it, so both requests exchanged the +-- code; the second was refused, and Oura answers a reused code by revoking the +-- token the first exchange had just issued. A row here is the claim on one +-- callback: the first request inserts it before the exchange, a duplicate +-- finds it taken and is sent where the first one went. No user id, token or +-- code is stored; the key is a hash of the provider and the state. +-- +-- Reversibility: +-- DROP TABLE IF EXISTS "oauth_callback_claims"; + +-- CreateTable +CREATE TABLE "oauth_callback_claims" ( + "key" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "settled_at" TIMESTAMP(3), + "location" TEXT, + + CONSTRAINT "oauth_callback_claims_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE INDEX "oauth_callback_claims_expires_at_idx" ON "oauth_callback_claims"("expires_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c802f3fd4..e972d56af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4549,6 +4549,34 @@ model GoogleHealthOAuthState { @@map("google_health_oauth_states") } +/// One row per OAuth callback the connect flows have begun to act on, keyed on +/// a hash of the provider and the `state` it returned with. Inserting it is +/// the claim: a second request carrying the same `state` (a browser that sends +/// the callback twice, a reload, a retry) finds the row taken and never +/// exchanges the code again. An authorization code works once, and Oura +/// revokes the token the first exchange issued when it sees the code reused. +/// +/// The claiming request records where it sent the browser, so the duplicate +/// sends it to the same place instead of to an error page. Holds no user id, +/// token or code: `location` is the app's own result URL (`?oura=connected`, +/// `?whoop=error&reason=…`). Rows outlive the state they guard by a few +/// minutes and are swept by expiry on the next settle +/// (`src/lib/oauth/callback-claim.ts`). +model OAuthCallbackClaim { + /// sha256(provider + NUL + state), hex. + key String @id + provider String + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + /// When the claiming request finished, with or without a result URL. + settledAt DateTime? @map("settled_at") + /// The redirect the claiming request answered with. Null when it threw. + location String? + + @@index([expiresAt]) + @@map("oauth_callback_claims") +} + // ─── Mood Entries ───────────────────────────────────────────── model MoodEntry { diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 984698e3c..84af494a0 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -15,6 +15,11 @@ import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; +import { + consumeLedgerState, + duplicateOAuthCallback, + settleOAuthCallback, +} from "@/lib/oauth/callback-claim"; import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; @@ -37,6 +42,12 @@ import { timingSafeEqual } from "node:crypto"; * On success: exchange the code, fetch the Fitbit profile for the external * `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior * reauth state, and enqueue the self-converging history backfill. + * + * A repeat of a consumed nonce is told apart from a replay by the callback + * claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a + * browser that delivers the callback twice gets one exchange, and the repeat + * follows the first request to the same page. Only a nonce with no claim left + * lands on `replay`. */ const ERR = (reason: string) => NextResponse.redirect( @@ -79,14 +90,24 @@ export const GET = apiHandler(async (request: NextRequest) => { codeVerifier: string | null; } | null = null; try { - stateRow = await prisma.fitbitOAuthState.delete({ - where: { nonce: state }, - }); + stateRow = await consumeLedgerState("fitbit", state, (tx) => + tx.fitbitOAuthState.delete({ where: { nonce: state } }), + ); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2025" ) { + // A duplicate of a callback that is consuming or has consumed this + // row: follow that request's result instead of showing an error. + const duplicate = await duplicateOAuthCallback("fitbit", state, { + fallbackUrl: new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(), + stateCookie: FITBIT_OAUTH_STATE_COOKIE, + }); + if (duplicate) return duplicate; annotate({ meta: { reason: "replay" } }); return ERR("replay"); } @@ -96,105 +117,108 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("state"); } - if (stateRow.expiresAt <= new Date()) { - annotate({ meta: { reason: "expired" } }); - return ERR("expired"); - } - if (stateRow.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); - return ERR("cross_user"); - } - - if (!code) { - return ERR("nocode"); - } - - // The PKCE verifier minted at connect rides on the ledger row. Its absence - // means a malformed / legacy handshake — refuse rather than send a code - // exchange Fitbit will reject for a missing verifier. - if (!stateRow.codeVerifier) { - annotate({ meta: { reason: "no_verifier" } }); - return ERR("no_verifier"); - } - - try { - const creds = await getUserFitbitCredentials(user.id); - if (!creds) { - return ERR("nocreds"); + const consumed = stateRow; + return settleOAuthCallback("fitbit", state, async () => { + if (consumed.expiresAt <= new Date()) { + annotate({ meta: { reason: "expired" } }); + return ERR("expired"); + } + if (consumed.userId !== user.id) { + annotate({ meta: { reason: "cross_user" } }); + return ERR("cross_user"); } - const tokens = await exchangeCode(code, stateRow.codeVerifier, creds); - - // The initial code exchange MUST return a refresh token. If Fitbit 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. - if (!tokens.refresh_token) { - getEvent()?.addWarning( - `fitbit callback: token response carried no refresh_token for user ${user.id}`, - ); - annotate({ meta: { reason: "no_refresh_token" } }); - return ERR("no_refresh_token"); + if (!code) { + return ERR("nocode"); } - const profile = await fetchProfile(tokens.access_token); - const fitbitUserId = resolveFitbitUserId(profile); - const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + // The PKCE verifier minted at connect rides on the ledger row. Its absence + // means a malformed / legacy handshake — refuse rather than send a code + // exchange Fitbit will reject for a missing verifier. + if (!consumed.codeVerifier) { + annotate({ meta: { reason: "no_verifier" } }); + return ERR("no_verifier"); + } - await prisma.fitbitConnection.upsert({ - where: { userId: user.id }, - update: { - fitbitUserId, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope: tokens.scope ?? FITBIT_OAUTH_SCOPE, - backfillCompletedAt: null, - }, - create: { - userId: user.id, - fitbitUserId, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope: tokens.scope ?? FITBIT_OAUTH_SCOPE, - }, - }); + try { + const creds = await getUserFitbitCredentials(user.id); + if (!creds) { + return ERR("nocreds"); + } - await auditLog("fitbit.connect", { - userId: user.id, - details: { fitbitUserId }, - }); + const tokens = await exchangeCode(code, consumed.codeVerifier, creds); - // Re-completing OAuth clears any prior reauth-required state. - await markReconnected(user.id, "fitbit"); - - // Enqueue the self-converging history backfill. Best-effort: the boot-time - // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a - // missing boss instance here doesn't strand the connection. - const boss = getGlobalBoss(); - if (boss) { - await boss - .send(FITBIT_BACKFILL_QUEUE, { - userId: user.id, - enqueuedAt: new Date().toISOString(), - }) - .catch((err) => - getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`), + // The initial code exchange MUST return a refresh token. If Fitbit 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. + if (!tokens.refresh_token) { + getEvent()?.addWarning( + `fitbit callback: token response carried no refresh_token for user ${user.id}`, ); - } + annotate({ meta: { reason: "no_refresh_token" } }); + return ERR("no_refresh_token"); + } - const response = NextResponse.redirect( - new URL( - "/settings/integrations?fitbit=connected", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - response.cookies.delete(FITBIT_OAUTH_STATE_COOKIE); - return response; - } catch (err) { - getEvent()?.setError(err); - return ERR("token"); - } + const profile = await fetchProfile(tokens.access_token); + const fitbitUserId = resolveFitbitUserId(profile); + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + + await prisma.fitbitConnection.upsert({ + where: { userId: user.id }, + update: { + fitbitUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope: tokens.scope ?? FITBIT_OAUTH_SCOPE, + backfillCompletedAt: null, + }, + create: { + userId: user.id, + fitbitUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope: tokens.scope ?? FITBIT_OAUTH_SCOPE, + }, + }); + + await auditLog("fitbit.connect", { + userId: user.id, + details: { fitbitUserId }, + }); + + // Re-completing OAuth clears any prior reauth-required state. + await markReconnected(user.id, "fitbit"); + + // Enqueue the self-converging history backfill. Best-effort: the boot-time + // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a + // missing boss instance here doesn't strand the connection. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(FITBIT_BACKFILL_QUEUE, { + userId: user.id, + enqueuedAt: new Date().toISOString(), + }) + .catch((err) => + getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`), + ); + } + + const response = NextResponse.redirect( + new URL( + "/settings/integrations?fitbit=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + response.cookies.delete(FITBIT_OAUTH_STATE_COOKIE); + return response; + } catch (err) { + getEvent()?.setError(err); + return ERR("token"); + } + }); }); diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 6c8c8d8f2..9f87a2fcf 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -15,6 +15,11 @@ import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill" import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; +import { + consumeLedgerState, + duplicateOAuthCallback, + settleOAuthCallback, +} from "@/lib/oauth/callback-claim"; import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; @@ -36,6 +41,12 @@ import { timingSafeEqual } from "node:crypto"; * Health profile for the external `googleUserId`, persist the encrypted * `GoogleHealthConnection`, clear any prior reauth state (`needsReauth:false` + * the status ledger), and enqueue the self-converging history backfill. + * + * A repeat of a consumed nonce is told apart from a replay by the callback + * claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a + * browser that delivers the callback twice gets one exchange, and the repeat + * follows the first request to the same page. Only a nonce with no claim left + * lands on `replay`. */ const ERR = (reason: string) => NextResponse.redirect( @@ -81,14 +92,24 @@ export const GET = apiHandler(async (request: NextRequest) => { codeVerifier: string | null; } | null = null; try { - stateRow = await prisma.googleHealthOAuthState.delete({ - where: { nonce: state }, - }); + stateRow = await consumeLedgerState("google-health", state, (tx) => + tx.googleHealthOAuthState.delete({ where: { nonce: state } }), + ); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2025" ) { + // A duplicate of a callback that is consuming or has consumed this + // row: follow that request's result instead of showing an error. + const duplicate = await duplicateOAuthCallback("google-health", state, { + fallbackUrl: new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(), + stateCookie: GOOGLE_HEALTH_OAUTH_STATE_COOKIE, + }); + if (duplicate) return duplicate; annotate({ meta: { reason: "replay" } }); return ERR("replay"); } @@ -98,114 +119,117 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("state"); } - if (stateRow.expiresAt <= new Date()) { - annotate({ meta: { reason: "expired" } }); - return ERR("expired"); - } - if (stateRow.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); - return ERR("cross_user"); - } - if (!stateRow.codeVerifier) { - annotate({ meta: { reason: "no_verifier" } }); - return ERR("no_verifier"); - } - - if (!code) { - return ERR("nocode"); - } - - try { - const creds = await getUserGoogleHealthCredentials(user.id); - if (!creds) { - return ERR("nocreds"); + const consumed = stateRow; + return settleOAuthCallback("google-health", state, async () => { + if (consumed.expiresAt <= new Date()) { + annotate({ meta: { reason: "expired" } }); + return ERR("expired"); + } + if (consumed.userId !== user.id) { + annotate({ meta: { reason: "cross_user" } }); + return ERR("cross_user"); + } + if (!consumed.codeVerifier) { + annotate({ meta: { reason: "no_verifier" } }); + return ERR("no_verifier"); } - const tokens = await exchangeCode(code, stateRow.codeVerifier, creds); - - // 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. - if (!tokens.refresh_token) { - getEvent()?.addWarning( - `google-health callback: token response carried no refresh_token for user ${user.id}`, - ); - annotate({ meta: { reason: "no_refresh_token" } }); - return ERR("no_refresh_token"); + if (!code) { + return ERR("nocode"); } - const profile = await fetchProfile(tokens.access_token); - const googleUserId = resolveGoogleHealthUserId(profile); - const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); - const scope = tokens.scope ?? getGoogleHealthScopeString(); + try { + const creds = await getUserGoogleHealthCredentials(user.id); + if (!creds) { + return ERR("nocreds"); + } - await prisma.googleHealthConnection.upsert({ - where: { userId: user.id }, - update: { - googleUserId, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope, - // Fresh consent clears the soft-disconnect flag so the card drops the - // reconnect CTA and the sync path stops short-circuiting. - needsReauth: false, - backfillCompletedAt: null, - // A new grant walks history from the start: what an earlier - // backfill finished was read under the old one. - backfillProgress: Prisma.DbNull, - }, - create: { - userId: user.id, - googleUserId, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope, - needsReauth: false, - }, - }); + const tokens = await exchangeCode(code, consumed.codeVerifier, creds); - await auditLog("google_health.connect", { - userId: user.id, - details: { googleUserId }, - }); - - // Re-completing OAuth clears any prior reauth-required state on the status - // ledger too (the `needsReauth` column above is the connection-local flag; - // this resets the integration-status streak). - await markReconnected(user.id, "google-health"); - - // Enqueue the self-converging history backfill. Best-effort: the boot-time - // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a - // missing boss instance here doesn't strand the connection. - const boss = getGlobalBoss(); - if (boss) { - await boss - .send(GOOGLE_HEALTH_BACKFILL_QUEUE, { - userId: user.id, - enqueuedAt: new Date().toISOString(), - }) - .catch((err) => - getEvent()?.addWarning( - `google-health-backfill enqueue failed: ${err}`, - ), + // 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. + if (!tokens.refresh_token) { + getEvent()?.addWarning( + `google-health callback: token response carried no refresh_token for user ${user.id}`, ); - } + annotate({ meta: { reason: "no_refresh_token" } }); + return ERR("no_refresh_token"); + } - const response = NextResponse.redirect( - new URL( - "/settings/integrations?googleHealth=connected", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - response.cookies.delete(GOOGLE_HEALTH_OAUTH_STATE_COOKIE); - return response; - } catch (err) { - getEvent()?.setError(err); - return ERR("token"); - } + const profile = await fetchProfile(tokens.access_token); + const googleUserId = resolveGoogleHealthUserId(profile); + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + const scope = tokens.scope ?? getGoogleHealthScopeString(); + + await prisma.googleHealthConnection.upsert({ + where: { userId: user.id }, + update: { + googleUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope, + // Fresh consent clears the soft-disconnect flag so the card drops the + // reconnect CTA and the sync path stops short-circuiting. + needsReauth: false, + backfillCompletedAt: null, + // A new grant walks history from the start: what an earlier + // backfill finished was read under the old one. + backfillProgress: Prisma.DbNull, + }, + create: { + userId: user.id, + googleUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope, + needsReauth: false, + }, + }); + + await auditLog("google_health.connect", { + userId: user.id, + details: { googleUserId }, + }); + + // Re-completing OAuth clears any prior reauth-required state on the status + // ledger too (the `needsReauth` column above is the connection-local flag; + // this resets the integration-status streak). + await markReconnected(user.id, "google-health"); + + // Enqueue the self-converging history backfill. Best-effort: the boot-time + // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a + // missing boss instance here doesn't strand the connection. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(GOOGLE_HEALTH_BACKFILL_QUEUE, { + userId: user.id, + enqueuedAt: new Date().toISOString(), + }) + .catch((err) => + getEvent()?.addWarning( + `google-health-backfill enqueue failed: ${err}`, + ), + ); + } + + const response = NextResponse.redirect( + new URL( + "/settings/integrations?googleHealth=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + response.cookies.delete(GOOGLE_HEALTH_OAUTH_STATE_COOKIE); + return response; + } catch (err) { + getEvent()?.setError(err); + return ERR("token"); + } + }); }); diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index efaf97c1d..898390087 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -1,5 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +// Single-use claiming is covered against Postgres in +// tests/integration/oauth-callback-single-use.test.ts; here every callback +// is the first and only one. +vi.mock("@/lib/oauth/callback-claim", () => ({ + runOAuthCallbackOnce: ( + _p: string, + _s: string, + _o: unknown, + run: () => Promise, + ) => run(), + settleOAuthCallback: (_p: string, _s: string, run: () => Promise) => + run(), + consumeLedgerState: async ( + _p: string, + _s: string, + consume: (tx: unknown) => Promise, + ) => consume((await import("@/lib/db")).prisma), + duplicateOAuthCallback: async () => null, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index a68ff917d..8871b3a12 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -12,6 +12,8 @@ import { stateMatchesCookie, verifySignedState, } from "@/lib/oauth/signed-state"; +import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; +import { ouraCollectionsMissingFromGrant } from "@/lib/oura/scope"; import { markReconnected } from "@/lib/integrations/status"; import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -26,9 +28,22 @@ import { NextRequest, NextResponse } from "next/server"; * binding. The `userId` is read from inside the verified token, cross-checked * against the web session when present. * + * The callback is single-use: the verified state is claimed before anything + * else (`src/lib/oauth/callback-claim.ts`). A browser that delivers it twice + * gets one exchange; the duplicate is sent where the first request went and + * never touches the code, because Oura revokes the first exchange's token + * when it sees the code reused. + * * On success: exchange the code, persist the encrypted access + refresh token - * on `User`, clear any prior reauth state, and redirect back to Settings. + * on `User`, note the collections the granted scopes leave out, clear any + * prior reauth state, and redirect back to Settings. */ +const SETTINGS_URL = () => + new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(); + const ERR = (reason: string) => NextResponse.redirect( new URL( @@ -40,9 +55,7 @@ const ERR = (reason: string) => export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "oura.callback" } }); - const { searchParams } = new URL(request.url); - const code = searchParams.get("code"); - const state = searchParams.get("state"); + const state = new URL(request.url).searchParams.get("state"); const cookieState = request.cookies.get(oauthStateCookieName("oura"))?.value; if (!stateMatchesCookie(state, cookieState)) { @@ -57,6 +70,24 @@ export const GET = apiHandler(async (request: NextRequest) => { } const userId = verified.userId; + return runOAuthCallbackOnce( + "oura", + state, + { + fallbackUrl: SETTINGS_URL(), + stateCookie: oauthStateCookieName("oura"), + }, + () => completeCallback(request, userId), + ); +}); + +async function completeCallback( + request: NextRequest, + userId: string, +): Promise { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); @@ -83,14 +114,21 @@ export const GET = apiHandler(async (request: NextRequest) => { const tokens = await exchangeCode(code, creds, iss); + // The token response names the granted scopes when Oura sends them; a + // permission the person unticked on the consent screen is known from it + // at once. Without them, the first sync finds out per collection. + const missing = ouraCollectionsMissingFromGrant(tokens.scope); + if (missing && missing.length > 0) { + annotate({ meta: { oura_scope_denied: missing } }); + } + await prisma.user.update({ where: { id: userId }, data: { ouraAccessTokenEncrypted: encrypt(tokens.access_token), ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token), - // A new grant carries the scopes asked for now; whatever the old one - // lacked is found out again by the first sync. - ouraScopeDenied: Prisma.DbNull, + ouraScopeDenied: + missing && missing.length > 0 ? missing : Prisma.DbNull, }, }); @@ -124,4 +162,4 @@ export const GET = apiHandler(async (request: NextRequest) => { getEvent()?.setError(err); return ERR("token"); } -}); +} diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index ac69bba3e..18e5c6847 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -22,6 +22,26 @@ const { bossMock: vi.fn(), })); +// Single-use claiming is covered against Postgres in +// tests/integration/oauth-callback-single-use.test.ts; here every callback +// is the first and only one. +vi.mock("@/lib/oauth/callback-claim", () => ({ + runOAuthCallbackOnce: ( + _p: string, + _s: string, + _o: unknown, + run: () => Promise, + ) => run(), + settleOAuthCallback: (_p: string, _s: string, run: () => Promise) => + run(), + consumeLedgerState: async ( + _p: string, + _s: string, + consume: (tx: unknown) => Promise, + ) => consume((await import("@/lib/db")).prisma), + duplicateOAuthCallback: async () => null, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index bb52eed61..b18b1be34 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -11,6 +11,7 @@ import { stateMatchesCookie, verifySignedState, } from "@/lib/oauth/signed-state"; +import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; import { markReconnected } from "@/lib/integrations/status"; import { POLAR_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -42,9 +43,7 @@ const ERR = (reason: string) => export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "polar.callback" } }); - const { searchParams } = new URL(request.url); - const code = searchParams.get("code"); - const state = searchParams.get("state"); + const state = new URL(request.url).searchParams.get("state"); const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value; // CSRF double-submit: the URL state must equal the cookie (constant-time). @@ -61,6 +60,28 @@ export const GET = apiHandler(async (request: NextRequest) => { } const userId = verified.userId; + // Single-use: a callback delivered twice exchanges its code once, and the + // duplicate follows the first request's result (src/lib/oauth/callback-claim.ts). + return runOAuthCallbackOnce( + "polar", + state, + { + fallbackUrl: new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(), + stateCookie: oauthStateCookieName("polar"), + }, + () => completeCallback(request, userId), + ); +}); + +async function completeCallback( + request: NextRequest, + userId: string, +): Promise { + const code = new URL(request.url).searchParams.get("code"); + const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); @@ -129,4 +150,4 @@ export const GET = apiHandler(async (request: NextRequest) => { getEvent()?.setError(err); return ERR("token"); } -}); +} diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index ec238315d..407cff3b5 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -25,6 +25,26 @@ const { markReconnectedMock: vi.fn(async () => {}), })); +// Single-use claiming is covered against Postgres in +// tests/integration/oauth-callback-single-use.test.ts; here every callback +// is the first and only one. +vi.mock("@/lib/oauth/callback-claim", () => ({ + runOAuthCallbackOnce: ( + _p: string, + _s: string, + _o: unknown, + run: () => Promise, + ) => run(), + settleOAuthCallback: (_p: string, _s: string, run: () => Promise) => + run(), + consumeLedgerState: async ( + _p: string, + _s: string, + consume: (tx: unknown) => Promise, + ) => consume((await import("@/lib/db")).prisma), + duplicateOAuthCallback: async () => null, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index 40934189e..807c36d4c 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -11,6 +11,7 @@ import { stateMatchesCookie, verifySignedState, } from "@/lib/oauth/signed-state"; +import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; import { markReconnected } from "@/lib/integrations/status"; import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -42,9 +43,7 @@ const ERR = (reason: string) => export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "strava.callback" } }); - const { searchParams } = new URL(request.url); - const code = searchParams.get("code"); - const state = searchParams.get("state"); + const state = new URL(request.url).searchParams.get("state"); const cookieState = request.cookies.get( oauthStateCookieName("strava"), )?.value; @@ -61,6 +60,28 @@ export const GET = apiHandler(async (request: NextRequest) => { } const userId = verified.userId; + // Single-use: a callback delivered twice exchanges its code once, and the + // duplicate follows the first request's result (src/lib/oauth/callback-claim.ts). + return runOAuthCallbackOnce( + "strava", + state, + { + fallbackUrl: new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(), + stateCookie: oauthStateCookieName("strava"), + }, + () => completeCallback(request, userId), + ); +}); + +async function completeCallback( + request: NextRequest, + userId: string, +): Promise { + const code = new URL(request.url).searchParams.get("code"); + const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); @@ -139,4 +160,4 @@ export const GET = apiHandler(async (request: NextRequest) => { getEvent()?.setError(err); return ERR("token"); } -}); +} diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index a335131d7..2f8d632fb 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -1,15 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +// Single-use claiming is covered against Postgres in +// tests/integration/oauth-callback-single-use.test.ts. Here the state row is +// consumed for real against the mocked client, the claim always wins, and +// every callback is the first and only one. +vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({ + ...(await importOriginal()), + settleOAuthCallback: (_p: string, _s: string, run: () => Promise) => + run(), + duplicateOAuthCallback: async () => null, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); -vi.mock("@/lib/db", () => ({ - prisma: { +vi.mock("@/lib/db", () => { + const prisma = { whoopOAuthState: { delete: vi.fn() }, whoopConnection: { findUnique: vi.fn(), upsert: vi.fn() }, - }, -})); + oAuthCallbackClaim: { createMany: vi.fn(async () => ({ count: 1 })) }, + $transaction: async (run: (tx: unknown) => unknown) => run(prisma), + }; + return { prisma }; +}); vi.mock("@/lib/auth/session", () => ({ // Native ticket path → no web session present. diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 418ab11f5..63d190764 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -17,6 +17,11 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; import { isP2002 } from "@/lib/prisma-errors"; +import { + consumeLedgerState, + duplicateOAuthCallback, + settleOAuthCallback, +} from "@/lib/oauth/callback-claim"; import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; @@ -51,6 +56,12 @@ import { timingSafeEqual } from "node:crypto"; * On success: exchange the code, fetch the WHOOP profile for `whoopUserId`, * persist the encrypted `WhoopConnection`, clear any prior reauth state, and * enqueue the self-converging history backfill. + * + * A repeat of a consumed nonce is told apart from a replay by the callback + * claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a + * browser that delivers the callback twice gets one exchange, and the repeat + * follows the first request to the same page. Only a nonce with no claim left + * lands on `replay`. */ /** Web-URL redirect (default + every pre-row-resolution path). */ @@ -119,12 +130,24 @@ export const GET = apiHandler(async (request: NextRequest) => { returnScheme: string | null; } | null = null; try { - stateRow = await prisma.whoopOAuthState.delete({ where: { nonce: state } }); + stateRow = await consumeLedgerState("whoop", state, (tx) => + tx.whoopOAuthState.delete({ where: { nonce: state } }), + ); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2025" ) { + // A duplicate of a callback that is consuming or has consumed this + // row: follow that request's result instead of showing an error. + const duplicate = await duplicateOAuthCallback("whoop", state, { + fallbackUrl: new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(), + stateCookie: WHOOP_OAUTH_STATE_COOKIE, + }); + if (duplicate) return duplicate; annotate({ meta: { reason: "replay" } }); return WEB_ERR("replay"); } @@ -134,109 +157,112 @@ export const GET = apiHandler(async (request: NextRequest) => { return WEB_ERR("state"); } - // The validated native return scheme (if any) is now known; every redirect - // below honours it. Identity is the row's userId (bound at connect time from - // the session or the one-time ticket). - const returnScheme = stateRow.returnScheme; - const userId = stateRow.userId; + const consumed = stateRow; + return settleOAuthCallback("whoop", state, async () => { + // The validated native return scheme (if any) is now known; every redirect + // below honours it. Identity is the row's userId (bound at connect time from + // the session or the one-time ticket). + const returnScheme = consumed.returnScheme; + const userId = consumed.userId; - const evt = getEvent(); - if (evt) { - // Identity resolved from the consumed state row (bound at connect time - // from the session or the one-time ticket); no role is asserted here. - evt.setAuth({ user_id: userId, auth_method: "session" }); - } - - if (stateRow.expiresAt <= new Date()) { - annotate({ meta: { reason: "expired" } }); - return outcomeRedirect(returnScheme, "error", "expired"); - } - - // Optional session cross-check: only when a web session cookie is present. - // The native ticket path carries no session — the nonce cookie + atomic - // single-use delete already bind the row to its originator. - const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); - return outcomeRedirect(returnScheme, "error", "cross_user"); - } - - if (!code) { - return outcomeRedirect(returnScheme, "error", "nocode"); - } - - try { - const creds = await getUserWhoopCredentials(userId); - if (!creds) { - return outcomeRedirect(returnScheme, "error", "nocreds"); + const evt = getEvent(); + if (evt) { + // Identity resolved from the consumed state row (bound at connect time + // from the session or the one-time ticket); no role is asserted here. + evt.setAuth({ user_id: userId, auth_method: "session" }); } - const tokens = await exchangeCode(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); - const owner = await prisma.whoopConnection.findUnique({ - where: { whoopUserId }, - select: { userId: true }, - }); - if (owner && owner.userId !== userId) { - annotate({ meta: { reason: "owner_conflict" } }); - return outcomeRedirect(returnScheme, "error", "owner_conflict"); + if (consumed.expiresAt <= new Date()) { + annotate({ meta: { reason: "expired" } }); + return outcomeRedirect(returnScheme, "error", "expired"); + } + + // Optional session cross-check: only when a web session cookie is present. + // The native ticket path carries no session — the nonce cookie + atomic + // single-use delete already bind the row to its originator. + const sessionData = await getSession(); + if (sessionData && sessionData.user.id !== userId) { + annotate({ meta: { reason: "cross_user" } }); + return outcomeRedirect(returnScheme, "error", "cross_user"); + } + + if (!code) { + return outcomeRedirect(returnScheme, "error", "nocode"); } try { - await prisma.whoopConnection.upsert({ - where: { userId }, - update: { - whoopUserId, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope: tokens.scope ?? WHOOP_OAUTH_SCOPE, - backfillCompletedAt: null, - }, - create: { - userId, - whoopUserId, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope: tokens.scope ?? WHOOP_OAUTH_SCOPE, - }, + const creds = await getUserWhoopCredentials(userId); + if (!creds) { + return outcomeRedirect(returnScheme, "error", "nocreds"); + } + + const tokens = await exchangeCode(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); + const owner = await prisma.whoopConnection.findUnique({ + where: { whoopUserId }, + select: { userId: true }, }); - } catch (err) { - if (isP2002(err)) { + if (owner && owner.userId !== userId) { annotate({ meta: { reason: "owner_conflict" } }); return outcomeRedirect(returnScheme, "error", "owner_conflict"); } - throw err; + + try { + await prisma.whoopConnection.upsert({ + where: { userId }, + update: { + whoopUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope: tokens.scope ?? WHOOP_OAUTH_SCOPE, + backfillCompletedAt: null, + }, + create: { + userId, + whoopUserId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope: tokens.scope ?? WHOOP_OAUTH_SCOPE, + }, + }); + } catch (err) { + if (isP2002(err)) { + annotate({ meta: { reason: "owner_conflict" } }); + return outcomeRedirect(returnScheme, "error", "owner_conflict"); + } + throw err; + } + + await auditLog("whoop.connect", { userId }); + + // Re-completing OAuth clears any prior reauth-required state. + await markReconnected(userId, "whoop"); + + // Enqueue the self-converging history backfill. Best-effort: the boot-time + // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a + // missing boss instance here doesn't strand the connection. + const boss = getGlobalBoss(); + if (boss) { + await boss + .send(WHOOP_BACKFILL_QUEUE, { + userId, + enqueuedAt: new Date().toISOString(), + }) + .catch((err) => + getEvent()?.addWarning(`whoop-backfill enqueue failed: ${err}`), + ); + } + + const response = outcomeRedirect(returnScheme, "connected"); + response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE); + return response; + } catch (err) { + getEvent()?.setError(err); + return outcomeRedirect(returnScheme, "error", "token"); } - - await auditLog("whoop.connect", { userId }); - - // Re-completing OAuth clears any prior reauth-required state. - await markReconnected(userId, "whoop"); - - // Enqueue the self-converging history backfill. Best-effort: the boot-time - // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a - // missing boss instance here doesn't strand the connection. - const boss = getGlobalBoss(); - if (boss) { - await boss - .send(WHOOP_BACKFILL_QUEUE, { - userId, - enqueuedAt: new Date().toISOString(), - }) - .catch((err) => - getEvent()?.addWarning(`whoop-backfill enqueue failed: ${err}`), - ); - } - - const response = outcomeRedirect(returnScheme, "connected"); - response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE); - return response; - } catch (err) { - getEvent()?.setError(err); - return outcomeRedirect(returnScheme, "error", "token"); - } + }); }); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index f63707762..12a5da528 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -20,6 +20,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// Single-use claiming is covered against Postgres in +// tests/integration/oauth-callback-single-use.test.ts. Here the state row is +// consumed for real against the mocked client, the claim always wins, and +// every callback is the first and only one. +vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({ + ...(await importOriginal()), + settleOAuthCallback: (_p: string, _s: string, run: () => Promise) => + run(), + duplicateOAuthCallback: async () => null, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ @@ -36,16 +47,19 @@ vi.mock("@/lib/api-handler", () => ({ }, })); -vi.mock("@/lib/db", () => ({ - prisma: { +vi.mock("@/lib/db", () => { + const prisma = { withingsOAuthState: { delete: vi.fn(), }, withingsConnection: { upsert: vi.fn(), }, - }, -})); + oAuthCallbackClaim: { createMany: vi.fn(async () => ({ count: 1 })) }, + $transaction: async (run: (tx: unknown) => unknown) => run(prisma), + }; + return { prisma }; +}); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index e3c7d169c..faed894ae 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -9,6 +9,11 @@ import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state"; import { setupWebhook } from "@/lib/withings/sync"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; +import { + consumeLedgerState, + duplicateOAuthCallback, + settleOAuthCallback, +} from "@/lib/oauth/callback-claim"; import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; @@ -39,6 +44,12 @@ import { timingSafeEqual } from "node:crypto"; * carries only the opaque nonce, so a future refactor flipping it to * non-httpOnly cannot leak the user id into request logs / network * captures. + * + * A repeat of a consumed nonce is told apart from a replay by the callback + * claim taken in the same transaction (`src/lib/oauth/callback-claim.ts`): a + * browser that delivers the callback twice gets one exchange, and the repeat + * follows the first request to the same page. Only a nonce with no claim left + * lands on `replay`. */ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -88,15 +99,25 @@ export const GET = apiHandler(async (request: NextRequest) => { // lookup before either issues the delete. let stateRow: { userId: string; expiresAt: Date } | null = null; try { - stateRow = await prisma.withingsOAuthState.delete({ - where: { nonce: state }, - }); + stateRow = await consumeLedgerState("withings", state, (tx) => + tx.withingsOAuthState.delete({ where: { nonce: state } }), + ); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2025" ) { // Row already consumed (replay) or never existed. + // A duplicate of a callback that is consuming or has consumed this + // row: follow that request's result instead of showing an error. + const duplicate = await duplicateOAuthCallback("withings", state, { + fallbackUrl: new URL( + "/settings/integrations", + process.env.NEXT_PUBLIC_APP_URL!, + ).toString(), + stateCookie: WITHINGS_OAUTH_STATE_COOKIE, + }); + if (duplicate) return duplicate; annotate({ meta: { reason: "replay" } }); return NextResponse.redirect( new URL( @@ -125,116 +146,119 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } - // Validity checks against the consumed row. The row is already - // gone from the ledger by this point — single-use semantics — - // whether the expiry tripped, the userId mismatched, or - // everything looked good. - if (stateRow.expiresAt <= new Date()) { - annotate({ meta: { reason: "expired" } }); - return NextResponse.redirect( - new URL( - "/settings/integrations?withings=error&reason=expired", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - } - if (stateRow.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); - return NextResponse.redirect( - new URL( - "/settings/integrations?withings=error&reason=cross_user", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - } - - if (!code) { - return NextResponse.redirect( - new URL( - "/settings/integrations?withings=error&reason=nocode", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - } - - try { - const creds = await getUserWithingsCredentials(user.id); - if (!creds) { + const consumed = stateRow; + return settleOAuthCallback("withings", state, async () => { + // Validity checks against the consumed row. The row is already + // gone from the ledger by this point — single-use semantics — + // whether the expiry tripped, the userId mismatched, or + // everything looked good. + if (consumed.expiresAt <= new Date()) { + annotate({ meta: { reason: "expired" } }); return NextResponse.redirect( new URL( - "/settings/integrations?withings=error&reason=nocreds", + "/settings/integrations?withings=error&reason=expired", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + if (consumed.userId !== user.id) { + annotate({ meta: { reason: "cross_user" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?withings=error&reason=cross_user", process.env.NEXT_PUBLIC_APP_URL!, ), ); } - const tokens = await exchangeCode(code, creds); + if (!code) { + return NextResponse.redirect( + new URL( + "/settings/integrations?withings=error&reason=nocode", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } - const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); - const subscriptionRetryAt = new Date(); + try { + const creds = await getUserWithingsCredentials(user.id); + if (!creds) { + return NextResponse.redirect( + new URL( + "/settings/integrations?withings=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } - // v1.4.25 — persist the OAuth scope we requested. The token - // response doesn't echo the granted scopes back as a top-level - // field, but Withings honours the scope param verbatim — there's - // no scope downgrade unless the user explicitly revokes one in - // their Health Mate account, which would fail the token exchange - // entirely. Storing the requested set is therefore a safe - // representation of what the connection holds. - await prisma.withingsConnection.upsert({ - where: { userId: user.id }, - update: { - withingsUserId: tokens.userid, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope: WITHINGS_OAUTH_SCOPE, - webhookSubscriptionState: Prisma.DbNull, - webhookSubscriptionRetryAt: subscriptionRetryAt, - // A reconnect may come with another developer app (another plan), so - // what the old grant's plan refused is asked for again. - apiPlanState: Prisma.DbNull, - }, - create: { + const tokens = await exchangeCode(code, creds); + + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + const subscriptionRetryAt = new Date(); + + // v1.4.25 — persist the OAuth scope we requested. The token + // response doesn't echo the granted scopes back as a top-level + // field, but Withings honours the scope param verbatim — there's + // no scope downgrade unless the user explicitly revokes one in + // their Health Mate account, which would fail the token exchange + // entirely. Storing the requested set is therefore a safe + // representation of what the connection holds. + await prisma.withingsConnection.upsert({ + where: { userId: user.id }, + update: { + withingsUserId: tokens.userid, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope: WITHINGS_OAUTH_SCOPE, + webhookSubscriptionState: Prisma.DbNull, + webhookSubscriptionRetryAt: subscriptionRetryAt, + // A reconnect may come with another developer app (another plan), so + // what the old grant's plan refused is asked for again. + apiPlanState: Prisma.DbNull, + }, + create: { + userId: user.id, + withingsUserId: tokens.userid, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + scope: WITHINGS_OAUTH_SCOPE, + webhookSubscriptionState: Prisma.DbNull, + webhookSubscriptionRetryAt: subscriptionRetryAt, + }, + }); + + // Reconcile every category from a clean state after connect/reconnect. + setupWebhook(user.id).catch(() => + getEvent()?.addWarning("Webhook setup failed"), + ); + + await auditLog("withings.connect", { userId: user.id, - withingsUserId: tokens.userid, - accessToken: encrypt(tokens.access_token), - refreshToken: encrypt(tokens.refresh_token), - tokenExpiresAt: expiresAt, - scope: WITHINGS_OAUTH_SCOPE, - webhookSubscriptionState: Prisma.DbNull, - webhookSubscriptionRetryAt: subscriptionRetryAt, - }, - }); + details: { withingsUserId: tokens.userid }, + }); - // Reconcile every category from a clean state after connect/reconnect. - setupWebhook(user.id).catch(() => - getEvent()?.addWarning("Webhook setup failed"), - ); + // Re-completing OAuth clears any prior `error_reauth` state. We + // don't write a fresh success-time — that's the next sync's job. + await markReconnected(user.id, "withings"); - await auditLog("withings.connect", { - userId: user.id, - details: { withingsUserId: tokens.userid }, - }); - - // Re-completing OAuth clears any prior `error_reauth` state. We - // don't write a fresh success-time — that's the next sync's job. - await markReconnected(user.id, "withings"); - - const response = NextResponse.redirect( - new URL( - "/settings/integrations?withings=connected", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE); - return response; - } catch { - getEvent()?.setError(new Error("Withings callback failed")); - return NextResponse.redirect( - new URL( - "/settings/integrations?withings=error&reason=token", - process.env.NEXT_PUBLIC_APP_URL!, - ), - ); - } + const response = NextResponse.redirect( + new URL( + "/settings/integrations?withings=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE); + return response; + } catch { + getEvent()?.setError(new Error("Withings callback failed")); + return NextResponse.redirect( + new URL( + "/settings/integrations?withings=error&reason=token", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + }); }); diff --git a/src/components/insights/__tests__/recovery-empty-state.test.tsx b/src/components/insights/__tests__/recovery-empty-state.test.tsx index fdc4547b8..74e1c5dca 100644 --- a/src/components/insights/__tests__/recovery-empty-state.test.tsx +++ b/src/components/insights/__tests__/recovery-empty-state.test.tsx @@ -80,7 +80,7 @@ beforeEach(() => { describe(" empty note", () => { it.each([ - ["oura_permission", "heart-health access"], + ["oura_permission", "stress permission"], ["general", "WHOOP or Polar"], ])("renders the server's %s reason with the one action", (reason, text) => { const html = render(reason); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 436278879..f02b0d0e7 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -348,6 +348,8 @@ export const INSTANCE_SCOPED: Readonly> = { EncryptionKeyCanary: "one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account", RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring", + OAuthCallbackClaim: + "single-use claims on in-flight OAuth callbacks, keyed on a hash of the state, minutes-lived and swept by expiry; no user id", HostMetric: "instance-wide host telemetry, not attributable to an account", OffhostBackupKeyUse: "per encryption key, when the nightly off-host run last wrote an object needing it; says nothing about any account", diff --git a/src/lib/insights/recovery-empty.ts b/src/lib/insights/recovery-empty.ts index c824d300e..40d48065d 100644 --- a/src/lib/insights/recovery-empty.ts +++ b/src/lib/insights/recovery-empty.ts @@ -9,9 +9,10 @@ * - `oura_permission`: Oura is connected and the stored grant leaves out * the scope its resilience level sits behind; one reconnect fixes it. * - `oura_no_resilience`: Oura is connected and no level has arrived. The - * server cannot tell "the grant covers it" from "not recorded yet" (the - * scope note is cleared on every reconnect and written only by a sync - * that reached Oura), so this case never claims coverage. + * server cannot always tell "the grant covers it" from "not recorded yet" + * (the scope note is rewritten on every reconnect from the scopes Oura + * reports, when it reports them, and otherwise only by a sync that + * reached Oura), so this case never claims coverage. * - `general`: anyone else. * * `null` means the page has something to chart. diff --git a/src/lib/integrations/oauth-refresh.ts b/src/lib/integrations/oauth-refresh.ts index 89c424051..ca0da45e3 100644 --- a/src/lib/integrations/oauth-refresh.ts +++ b/src/lib/integrations/oauth-refresh.ts @@ -61,7 +61,7 @@ export async function persistRotatedToken( return opts.readPeerAccessToken(); } -export type SerializedRefreshProvider = "whoop" | "withings"; +export type SerializedRefreshProvider = "whoop" | "withings" | "oura"; /** * Provider refreshes hold a database transaction open around one bounded diff --git a/src/lib/oauth/callback-claim.ts b/src/lib/oauth/callback-claim.ts new file mode 100644 index 000000000..dd88588d0 --- /dev/null +++ b/src/lib/oauth/callback-claim.ts @@ -0,0 +1,207 @@ +/** + * Single-use OAuth callbacks for every provider connect flow. + * + * A browser can deliver the same callback twice, a few hundred milliseconds + * apart, with the same `code` and `state`. An authorization code works once + * (RFC 6749 4.1.2), so the second exchange is refused, and the same section + * tells the authorization server to revoke what the first exchange issued. + * Oura does: the connection then fails its first sync with 401 everywhere and + * asks for a reconnect. + * + * The callback therefore claims its `state` before it touches the code. The + * claim is an insert that skips on conflict, so of any number of concurrent + * requests exactly one wins and exchanges the code. Every other request is a + * duplicate: it waits briefly for the winner to finish and sends the browser to + * the same place, success page or error page, without reading or writing any + * token. When the winner has not finished in time, the duplicate lands on the + * integrations page, which shows the connection's real state. + * + * Where the claim sits differs by flow: + * - signed-state flows (Oura, Polar, Strava) claim right after the signature + * check, so only a state this server minted can create a row; + * - ledger flows (WHOOP, Withings, Fitbit, Google Health) already consume + * their state row with an atomic delete. The claim rides the same + * transaction (`consumeLedgerState`), so a duplicate's delete waits on the + * row lock, finds the row gone, and then sees the committed claim. + * + * A failed exchange settles the claim with its error page. Trying again means + * starting a new connect, which mints a new state and so a new claim. + */ +import { createHash } from "node:crypto"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; +import { annotate, getEvent } from "@/lib/logging/context"; + +/** Providers whose callbacks claim their state here. */ +export type OAuthCallbackProvider = + | "oura" + | "polar" + | "strava" + | "whoop" + | "withings" + | "fitbit" + | "google-health"; + +/** + * How long a claim is kept. Longer than any state lives (10 minutes), so a + * state that still verifies can never find its claim already swept. + */ +const OAUTH_CALLBACK_CLAIM_TTL_MS = 15 * 60 * 1000; + +/** How long a duplicate waits for the winning request to finish. */ +const DUPLICATE_WAIT_MS = 10_000; +const DUPLICATE_POLL_MS = 150; + +/** Result URLs are short app paths; anything longer is not stored. */ +const MAX_LOCATION_LENGTH = 2048; + +type ClaimDb = Pick; + +function claimKey(provider: OAuthCallbackProvider, state: string): string { + return createHash("sha256").update(`${provider}\0${state}`).digest("hex"); +} + +/** + * Claim one callback. True for the one request that may exchange the code, + * false for every duplicate. `db` lets a ledger flow claim inside the + * transaction that consumes its state row. + */ +export async function claimOAuthCallback( + provider: OAuthCallbackProvider, + state: string, + db: ClaimDb = prisma, +): Promise { + const { count } = await db.oAuthCallbackClaim.createMany({ + data: [ + { + key: claimKey(provider, state), + provider, + expiresAt: new Date(Date.now() + OAUTH_CALLBACK_CLAIM_TTL_MS), + }, + ], + skipDuplicates: true, + }); + return count === 1; +} + +/** + * Consume a ledger flow's state row and claim the callback in one + * transaction. `consume` is the flow's own atomic delete; it throws P2025 for + * a row already consumed, which aborts the claim with it. + */ +export async function consumeLedgerState( + provider: OAuthCallbackProvider, + state: string, + consume: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return prisma.$transaction(async (tx) => { + const row = await consume(tx); + await claimOAuthCallback(provider, state, tx); + return row; + }); +} + +/** + * Run the claimed callback and record where it sent the browser, so a + * duplicate can follow. A throw is recorded as settled without a result, and + * re-thrown for the route's own error handling. + */ +export async function settleOAuthCallback( + provider: OAuthCallbackProvider, + state: string, + run: () => Promise, +): Promise { + let location: string | null = null; + try { + const response = await run(); + location = response.headers.get("location"); + return response; + } finally { + const now = new Date(); + try { + await prisma.oAuthCallbackClaim.updateMany({ + where: { key: claimKey(provider, state) }, + data: { + settledAt: now, + location: + location && location.length <= MAX_LOCATION_LENGTH + ? location + : null, + }, + }); + await prisma.oAuthCallbackClaim.deleteMany({ + where: { expiresAt: { lt: now } }, + }); + } catch (err) { + getEvent()?.addWarning(`oauth callback claim settle failed: ${err}`); + } + } +} + +/** + * Answer a duplicate callback: wait for the claiming request to finish and + * redirect to the same place, or to `fallbackUrl` when it does not finish in + * time or finished without a result. Returns null when no claim exists for + * this state at all, which a ledger flow reads as a genuine replay. + * + * Never touches tokens. Clears the state cookie like the winning request does, + * since the browser may render this response instead of that one. + */ +export async function duplicateOAuthCallback( + provider: OAuthCallbackProvider, + state: string, + opts: { + fallbackUrl: string; + stateCookie: string; + waitMs?: number; + }, +): Promise { + const key = claimKey(provider, state); + const deadline = Date.now() + (opts.waitMs ?? DUPLICATE_WAIT_MS); + let row = await prisma.oAuthCallbackClaim.findUnique({ + where: { key }, + select: { settledAt: true, location: true }, + }); + if (!row) return null; + while (!row?.settledAt && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, DUPLICATE_POLL_MS)); + row = await prisma.oAuthCallbackClaim.findUnique({ + where: { key }, + select: { settledAt: true, location: true }, + }); + } + const settled = Boolean(row?.settledAt); + annotate({ + meta: { + reason: "duplicate_callback", + duplicate_callback_settled: settled, + }, + }); + const response = NextResponse.redirect( + settled && row?.location ? row.location : opts.fallbackUrl, + ); + response.cookies.delete(opts.stateCookie); + return response; +} + +/** + * The whole single-use step for a flow whose state carries no ledger row: + * claim, then either run the callback and record its result, or answer as the + * duplicate. + */ +export async function runOAuthCallbackOnce( + provider: OAuthCallbackProvider, + state: string, + opts: { fallbackUrl: string; stateCookie: string }, + run: () => Promise, +): Promise { + if (await claimOAuthCallback(provider, state)) { + return settleOAuthCallback(provider, state, run); + } + const duplicate = await duplicateOAuthCallback(provider, state, opts); + if (duplicate) return duplicate; + const response = NextResponse.redirect(opts.fallbackUrl); + response.cookies.delete(opts.stateCookie); + return response; +} diff --git a/src/lib/oauth/signed-state.ts b/src/lib/oauth/signed-state.ts index 0abc5f87b..fdfc4c317 100644 --- a/src/lib/oauth/signed-state.ts +++ b/src/lib/oauth/signed-state.ts @@ -139,7 +139,7 @@ export function verifySignedState( export function stateMatchesCookie( urlState: string | null | undefined, cookieState: string | null | undefined, -): boolean { +): urlState is string { if (!urlState || !cookieState) return false; if (urlState.length !== cookieState.length) return false; return timingSafeEqual(Buffer.from(urlState), Buffer.from(cookieState)); diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 9e36dfcb6..d0e0500b2 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -1146,7 +1146,7 @@ const integrationEnvelopeEntry = z .optional() .meta( legacy( - "Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.", + "Oura only. Collections the stored grant does not cover: Oura refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.", ), ), hasToken: z.boolean().optional().describe("Nightscout only."), diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index e4c6148df..13535d03c 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OURA_OAUTH_LEGACY_TOKEN_URL, - OURA_OAUTH_SCOPE, OURA_OAUTH_TOKEN_URL, RESILIENCE_LEVELS, derivePeriodDaysFromCyclePhases, @@ -78,7 +77,7 @@ describe("getAuthorizationUrl", () => { it("builds the Oura authorize URL", () => { const url = getAuthorizationUrl("signed", CREDS); expect(url).toContain("cloud.ouraring.com/oauth/authorize"); - expect(url).toContain("scope=daily+personal"); + expect(url).toContain("scope=daily+spo2+heart_health+stress+personal"); expect(url).toContain("state=signed"); }); }); @@ -704,14 +703,44 @@ describe("mapResilience", () => { }); }); -describe("OURA_OAUTH_SCOPE", () => { - it("requests daily, personal and the scopes behind SpO2 and heart health", () => { - expect(OURA_OAUTH_SCOPE.split(" ")).toEqual([ - "daily", - "personal", - "spo2", - "heart_health", +describe("collection refusals", () => { + const query = { startDate: "2026-06-01", endDate: "2026-06-10" }; + + it("reads a 401 that names a scope as a missing permission, not a dead token", async () => { + installFetchMock([ + { + status: 401, + body: { detail: "Token is not authorized access stress scope." }, + }, ]); + const err = await fetchResilience("tok", query).catch((e) => e); + expect(err).toBeInstanceOf(OuraApiError); + expect(err.httpStatus).toBe(401); + expect(err.missingScope).toBe("stress"); + expect(err.reason).toBe("scope_missing"); + }); + + it("keeps a dead token's 401 a token refusal", async () => { + installFetchMock([ + { + status: 401, + body: { + detail: + "The access token provided is expired, revoked, malformed, or invalid for other reasons.", + }, + }, + ]); + const err = await fetchReadiness("tok", query).catch((e) => e); + expect(err.httpStatus).toBe(401); + expect(err.missingScope).toBeUndefined(); + expect(err.classification).toBe("reauth_required"); + }); + + it("classifies a 403 (lapsed membership) as persistent rather than a reconnect", async () => { + installFetchMock([{ status: 403, body: { detail: "forbidden" } }]); + const err = await fetchReadiness("tok", query).catch((e) => e); + expect(err.httpStatus).toBe(403); + expect(err.classification).toBe("persistent"); }); }); diff --git a/src/lib/oura/__tests__/scope.test.ts b/src/lib/oura/__tests__/scope.test.ts new file mode 100644 index 000000000..caf2bdfab --- /dev/null +++ b/src/lib/oura/__tests__/scope.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + OURA_COLLECTIONS, + OURA_OAUTH_SCOPE, + OURA_SCOPE_GATED_COLLECTIONS, + ouraCollectionsMissingFromGrant, + ouraScopeReconnectList, + parseOuraGrantedScopes, + parseOuraMissingScope, +} from "../scope"; + +describe("Oura scope map", () => { + it("asks for the scope of every collection the sync reads", () => { + const requested = new Set(OURA_OAUTH_SCOPE.split(" ")); + for (const c of OURA_COLLECTIONS) expect(requested).toContain(c.scope); + expect(requested).toContain("personal"); + }); + + it("puts resilience behind stress, not heart_health", () => { + const scopeOf = Object.fromEntries( + OURA_COLLECTIONS.map((c) => [c.name, c.scope]), + ); + expect(scopeOf.resilience).toBe("stress"); + expect(scopeOf.spo2).toBe("spo2"); + expect(scopeOf.vo2max).toBe("heart_health"); + expect(scopeOf.cardio_age).toBe("heart_health"); + expect(OURA_OAUTH_SCOPE.split(" ")).toEqual([ + "daily", + "spo2", + "heart_health", + "stress", + "personal", + ]); + }); + + it("derives the gated collections from the map, in display order", () => { + expect([...OURA_SCOPE_GATED_COLLECTIONS]).toEqual([ + "spo2", + "vo2max", + "cardio_age", + "resilience", + ]); + expect(ouraScopeReconnectList(["resilience", "readiness", "spo2"])).toEqual( + ["spo2", "resilience"], + ); + }); +}); + +describe("granted scopes", () => { + it("reads Oura's extapi-prefixed grant", () => { + expect( + parseOuraGrantedScopes("extapi:daily extapi:personal extapi:spo2"), + ).toEqual(new Set(["daily", "personal", "spo2"])); + }); + + it("knows nothing when the response names no scopes", () => { + expect(parseOuraGrantedScopes(undefined)).toBeNull(); + expect(parseOuraGrantedScopes(" ")).toBeNull(); + expect(ouraCollectionsMissingFromGrant(undefined)).toBeNull(); + }); + + it("lists the gated collections a grant leaves out", () => { + expect( + ouraCollectionsMissingFromGrant( + "extapi:daily extapi:personal extapi:spo2 extapi:heart_health", + ), + ).toEqual(["resilience"]); + expect( + ouraCollectionsMissingFromGrant( + "daily personal spo2 heart_health stress", + ), + ).toEqual([]); + }); +}); + +describe("parseOuraMissingScope", () => { + it("reads the scope a refusal names", () => { + expect( + parseOuraMissingScope({ + detail: "Token is not authorized access stress scope.", + }), + ).toBe("stress"); + expect( + parseOuraMissingScope({ + detail: "Token is not authorized access extapi:heart_health scope.", + }), + ).toBe("heart_health"); + }); + + it("returns null for a dead token or any other body", () => { + expect( + parseOuraMissingScope({ + detail: + "The access token provided is expired, revoked, malformed, or invalid for other reasons.", + }), + ).toBeNull(); + expect(parseOuraMissingScope(null)).toBeNull(); + expect(parseOuraMissingScope({ detail: 42 })).toBeNull(); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 1f2d22728..451084b43 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -64,9 +64,25 @@ const { scopeDeniedMock: vi.fn(), })); +// The serialised refresh itself is covered against Postgres in +// `tests/integration/oura-refresh-serialization.test.ts`; here it stands in as +// "refresh with the client, then persist", so the sync's own branching is what +// these cases pin. vi.mock("../credentials", () => ({ getOuraConnection: getConnMock, - storeOuraTokens: storeTokensMock, + refreshOuraAccessToken: async ( + userId: string, + spentCiphertext: string, + creds: unknown, + ) => { + const rotated = await refreshMock("ref", creds); + return storeTokensMock( + userId, + rotated.access_token, + rotated.refresh_token, + spentCiphertext, + ); + }, getOuraClientCredentials: getCredsMock, syncOuraScopeDenied: scopeDeniedMock, })); @@ -145,7 +161,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; const CONN = { accessToken: "acc", - refreshToken: "ref", refreshTokenCiphertext: "enc(ref)", }; @@ -230,6 +245,26 @@ function err401() { }); } +/** The 401 Oura sends when the token is fine but the grant lacks `scope`. */ +function errScope401(scope: string) { + return new OuraApiError({ + verb: "fetchResilience", + classification: "reauth_required", + httpStatus: 401, + reason: "scope_missing", + missingScope: scope, + }); +} + +function err403() { + return new OuraApiError({ + verb: "fetchReadiness", + classification: "persistent", + httpStatus: 403, + reason: "http_403", + }); +} + function createdRows() { return reconcileMock.mock.calls.map((call) => call[1]); } @@ -1128,3 +1163,127 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () = expect(scopeDeniedMock).not.toHaveBeenCalled(); }); }); + +describe("syncUserOura — Oura names the missing scope", () => { + it("records resilience as a permission gap without a refresh, even when nothing else succeeded", async () => { + getConnMock.mockResolvedValue(CONN); + // Every core collection is merely empty: the token is never proven by a + // sibling, only by Oura naming the scope it lacks. + fetchReadinessMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchReadiness", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }), + ); + fetchSleepMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchSleep", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }), + ); + fetchActivityMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchDailyActivity", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }), + ); + fetchDailySleepMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchDailySleep", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }), + ); + fetchSpo2Mock.mockRejectedValue(errScope401("spo2")); + fetchVo2MaxMock.mockRejectedValue(errScope401("heart_health")); + fetchCardioAgeMock.mockRejectedValue(errScope401("heart_health")); + fetchResilienceMock.mockRejectedValue(errScope401("stress")); + + await syncUserOura("u1"); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", [ + "spo2", + "vo2max", + "cardio_age", + "resilience", + ]); + // The outage stays a failure; the permission gap never joins it. + const failure = recordFailureMock.mock.calls[0]![0] as { message: string }; + expect(failure.message).not.toContain("resilience"); + }); + + it("spends no refresh when the only 401 names a scope", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + fetchResilienceMock.mockRejectedValue(errScope401("stress")); + + const result = await syncUserOura("u1"); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(result).toEqual({ imported: 1, failed: false }); + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", ["resilience"]); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); + }); + + it("does not retry a named-scope refusal after a refresh another collection needed", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock + .mockRejectedValueOnce(err401()) + .mockResolvedValueOnce([{ id: "x", day: "2026-06-10", score: 70 }]); + fetchResilienceMock.mockRejectedValue(errScope401("stress")); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(fetchResilienceMock).toHaveBeenCalledTimes(1); + expect(result.failed).toBe(false); + expect(scopeDeniedMock).toHaveBeenCalledWith("u1", ["resilience"]); + }); + + it("keeps a named-scope 401 on a core collection a failure", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock.mockRejectedValue(errScope401("daily")); + fetchSleepMock.mockRejectedValue(errScope401("daily")); + fetchActivityMock.mockRejectedValue(errScope401("daily")); + fetchDailySleepMock.mockRejectedValue(errScope401("daily")); + + const result = await syncUserOura("u1"); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(result.failed).toBe(true); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura", kind: "reauth_required" }), + ); + }); + + it("does not refresh over a 403 and does not count it toward a reconnect", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + fetchSleepMock.mockRejectedValue(err403()); + + const result = await syncUserOura("u1"); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(result.failed).toBe(true); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura", kind: "persistent" }), + ); + }); +}); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index cf21d02bd..ed99a0505 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -23,6 +23,7 @@ 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 { OURA_OAUTH_SCOPE, parseOuraMissingScope } from "./scope"; const OURA_API_BASE = "https://api.ouraring.com"; const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; @@ -112,24 +113,11 @@ export function getOuraRedirectUri(): string { } /** - * The scopes HealthLog asks Oura for. - * - * - `daily` : readiness, sleep, daily sleep, daily activity - * - `personal` : profile info - * - `spo2` : `daily_spo2` - * - `heart_health` : `vO2_max`, `daily_cardiovascular_age`, - * `daily_resilience` - * - * Oura's current authorization server enforces these per collection: a grant - * without `spo2` or `heart_health` answers 401 on those collections while every - * other one succeeds. The published OpenAPI security scheme (openapi-1.41) - * lists `spo2` and `heart_health` but maps no endpoint to them; the mapping - * above is what the live API enforces, as recorded by integrations that probed - * it. Grants issued before these were requested keep the old set until the - * user reconnects once — see `syncOuraScopeDenied`. + * 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 const OURA_OAUTH_SCOPE = "daily personal spo2 heart_health" as const; - export function getAuthorizationUrl( state: string, creds: OuraCredentials, @@ -471,6 +459,51 @@ interface DateRangeQuery { maxPages?: number; } +/** + * The error for a refused collection read. Oura's status alone does not say + * what went wrong; its body does (see `./scope`): + * + * - 401 naming a scope: the token is fine, the grant does not cover this + * collection. `missingScope` carries the name, and the sync neither + * refreshes over it nor counts it toward the reconnect threshold. + * - any other 401: the token is dead or expired (refresh, then reconnect). + * - 403: a lapsed Oura membership, per the API reference. A reconnect does + * not fix that, so it is a persistent failure rather than a reauth. + */ +function collectionRefusal( + verb: string, + status: number, + verdict: ReturnType, + body: unknown, +): OuraApiError { + if (status === 401) { + const missingScope = parseOuraMissingScope(body); + if (missingScope) { + return new OuraApiError({ + verb, + classification: "reauth_required", + httpStatus: status, + reason: "scope_missing", + missingScope, + }); + } + } + if (status === 403) { + return new OuraApiError({ + verb, + classification: "persistent", + httpStatus: status, + reason: "http_403", + }); + } + return new OuraApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); +} + async function fetchCollection( path: string, accessToken: string, @@ -508,12 +541,7 @@ async function fetchCollection( verdict.classification === "success" ? undefined : verdict.reason, }); if (verdict.classification !== "success") { - throw new OuraApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - }); + throw collectionRefusal(verb, res.status, verdict, json); } return { items: json?.data ?? [], next: json?.next_token ?? null }; }, diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index c7088eb70..4804cfeb7 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -13,8 +13,15 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; -import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; -import { getOuraCredentials, type OuraCredentials } from "./client"; +import { + acquireProviderTokenRefreshLock, + PROVIDER_REFRESH_TRANSACTION_OPTIONS, +} from "@/lib/integrations/oauth-refresh"; +import { + getOuraCredentials, + refreshAccessToken, + type OuraCredentials, +} from "./client"; /** * Resolve the user's Oura OAuth client id/secret, DB-first then env. @@ -78,11 +85,10 @@ export async function clearOuraClientCredentials( export interface OuraConnection { accessToken: string; - refreshToken: string; /** - * The exact stored refresh-token ciphertext (NOT the decrypted value). The - * rotating-refresh compare-and-swap in `storeOuraTokens` matches on this so a - * concurrent sync that already rotated the token is not clobbered. + * The exact stored refresh-token ciphertext (NOT the decrypted value). + * `refreshOuraAccessToken` compares against it under the refresh lock to + * tell whether a peer already rotated the token this sync started with. */ refreshTokenCiphertext: string; /** Collections the grant was last seen not to cover (`ouraScopeDenied`). */ @@ -105,53 +111,79 @@ export async function getOuraConnection( } return { accessToken: decrypt(user.ouraAccessTokenEncrypted), - refreshToken: decrypt(user.ouraRefreshTokenEncrypted), refreshTokenCiphertext: user.ouraRefreshTokenEncrypted, scopeDenied: readOuraScopeDenied(user.ouraScopeDenied), }; } /** - * Persist a rotated Oura token pair with compare-and-swap on the stored refresh - * ciphertext (the inverse of Fitbit / WHOOP, which key by connection id). Oura's - * tokens live on `User` and the refresh is REACTIVE on a 401, so two overlapping - * syncs can race the same one-time-use refresh token. The CAS guard writes only - * when the stored ciphertext still equals the one this caller spent; on a lost - * race (0 rows) it re-reads and returns the peer's freshly rotated access token. + * Refresh the user's Oura access token once, however many syncs ask at the + * same moment. * - * Returns the access token the caller should use for the current sync (its own - * on a win, the peer's on a loss), or null if the connection vanished. + * Oura's refresh tokens are single-use, and spending one twice is what an + * authorization server treats as theft: the second request is refused and the + * grant behind it may be revoked, parking a healthy connection at reconnect. + * The refresh is reactive on a 401, so an hourly tick, a manual sync and the + * first sync after a reconnect can all reach it together. A per-user advisory + * lock lets one of them refresh; each later one re-reads the stored refresh + * ciphertext under the lock and, finding it changed from the one its own sync + * started with, takes the access token stored beside it instead of refreshing + * again. The write is still conditional on the ciphertext the refresh spent, + * because a reconnect stores a new grant without taking the lock: a refresh of + * the old grant finishing after it must not put the old grant back. + * + * Returns the access token to retry with, or null when the connection is gone. + * A refused refresh throws for the caller to classify. */ -export async function storeOuraTokens( +export async function refreshOuraAccessToken( userId: string, - accessToken: string, - refreshToken: string, - expectedRefreshCiphertext: string, + spentRefreshCiphertext: string, + creds: OuraCredentials, ): Promise { - return persistRotatedToken(accessToken, { - conditionalUpdate: async () => { - const { count } = await prisma.user.updateMany({ - where: { - id: userId, - ouraRefreshTokenEncrypted: expectedRefreshCiphertext, - }, - data: { - ouraAccessTokenEncrypted: encrypt(accessToken), - ouraRefreshTokenEncrypted: encrypt(refreshToken), - }, - }); - return count; - }, - readPeerAccessToken: async () => { - const fresh = await prisma.user.findUnique({ - where: { id: userId }, - select: { ouraAccessTokenEncrypted: true }, - }); - return fresh?.ouraAccessTokenEncrypted - ? decrypt(fresh.ouraAccessTokenEncrypted) - : null; - }, - }); + return prisma.$transaction(async (tx) => { + await acquireProviderTokenRefreshLock(tx, "oura", userId); + const current = await tx.user.findUnique({ + where: { id: userId }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + }, + }); + if ( + !current?.ouraAccessTokenEncrypted || + !current.ouraRefreshTokenEncrypted + ) { + return null; + } + if (current.ouraRefreshTokenEncrypted !== spentRefreshCiphertext) { + // Rotated by a peer, or replaced by a reconnect, while this sync ran. + return decrypt(current.ouraAccessTokenEncrypted); + } + + const rotated = await refreshAccessToken( + decrypt(current.ouraRefreshTokenEncrypted), + creds, + ); + const { count } = await tx.user.updateMany({ + where: { + id: userId, + ouraRefreshTokenEncrypted: spentRefreshCiphertext, + }, + data: { + ouraAccessTokenEncrypted: encrypt(rotated.access_token), + ouraRefreshTokenEncrypted: encrypt(rotated.refresh_token), + }, + }); + if (count > 0) return rotated.access_token; + // A reconnect stored a new grant while Oura answered; that one wins. + const fresh = await tx.user.findUnique({ + where: { id: userId }, + select: { ouraAccessTokenEncrypted: true }, + }); + return fresh?.ouraAccessTokenEncrypted + ? decrypt(fresh.ouraAccessTokenEncrypted) + : null; + }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); } /** diff --git a/src/lib/oura/response-classifier.ts b/src/lib/oura/response-classifier.ts index 9dbb18f0a..46a748ba0 100644 --- a/src/lib/oura/response-classifier.ts +++ b/src/lib/oura/response-classifier.ts @@ -8,6 +8,10 @@ * - `transient` → 429 (rate-limited), 5xx, 3xx, unknown. * - `reauth_required` → 401 / 403 — token rejected / grant revoked. * - `persistent` → any other 4xx — malformed request / contract drift. + * + * A collection read refines this from the body (`collectionRefusal` in + * `./client`): a 401 that names a scope is a missing permission, a 403 a lapsed + * membership. */ import { @@ -26,15 +30,31 @@ export function classifyOuraResponse( } export class OuraApiError extends IntegrationApiError { + /** + * The scope Oura named when it refused a collection read for a missing + * permission (`401 {"detail":"Token is not authorized access stress + * scope."}`). Undefined for every other failure, a dead token included. + */ + readonly missingScope: string | undefined; + constructor(opts: { verb: string; classification: IntegrationClassification; httpStatus: number | undefined; reason: string; upstreamError?: string; + missingScope?: string; }) { - super({ vendor: "Oura", ...opts }); + const { missingScope, ...rest } = opts; + super({ + vendor: "Oura", + ...rest, + upstreamError: + rest.upstreamError ?? + (missingScope ? `scope_missing:${missingScope}` : undefined), + }); this.name = "OuraApiError"; + this.missingScope = missingScope; } } diff --git a/src/lib/oura/scope.ts b/src/lib/oura/scope.ts index e1c74f11b..8356c6246 100644 --- a/src/lib/oura/scope.ts +++ b/src/lib/oura/scope.ts @@ -1,19 +1,106 @@ /** - * Oura collections that sit behind a scope beyond `daily` (`spo2`, - * `heart_health`). A grant issued before HealthLog requested those scopes - * answers 401 on exactly these, so they are the only names the settings card - * can be asked to explain. Each has a label under - * `settings.integrations.oura.scope.items`. + * Which Oura OAuth scope each collection HealthLog reads sits behind. The one + * source for the authorize request (`OURA_OAUTH_SCOPE`), the per-collection + * missing-permission detection in the sync, the "reconnect once to grant the + * new permissions" hint and the settings card. + * + * Oura's published material does not carry this map. The OpenAPI document + * (https://cloud.ouraring.com/v2/static/json/openapi-1.42.json, the spec behind + * https://cloud.ouraring.com/v2/docs) declares nine scopes and binds no + * endpoint to any of them, and its list lacks `stress` altogether; the + * authentication page (https://cloud.ouraring.com/docs/authentication) still + * names `spo2Daily`, which the authorization server ignores. The map below is + * what the live API enforces. It names the scope in its own refusal: + * + * 401 {"detail":"Token is not authorized access stress scope."} + * + * and integrations that read those refusals record the same assignments + * (tidepool-org/platform `oura/oura.go`, `ScopesForDataType`). The developer + * portal lists `stress`, `heart_health` and `spo2` among an app's permissions. + * A dead token reads differently: + * + * 401 {"detail":"The access token provided is expired, revoked, malformed, + * or invalid for other reasons."} + * + * so the body, not the status, tells a missing permission from a dead token + * (`parseOuraMissingScope`). A 403 is a lapsed Oura membership per the spec. + * + * collection endpoint (/v2/usercollection/…) scope + * readiness daily_readiness daily + * sleep sleep daily + * activity daily_activity daily + * daily_sleep daily_sleep daily + * spo2 daily_spo2 spo2 + * vo2max vO2_max heart_health + * cardio_age daily_cardiovascular_age heart_health + * resilience daily_resilience stress + * (profile) personal_info personal + * (cycle) daily_cycle_phases reproductive_cycle + * + * `reproductive_cycle` is not requested: the developer portal does not list it + * for a self-registered app, and asking for a scope the app does not hold + * would risk the whole authorization request. Cycle phases stay a best-effort read (`./cycle-sync`) + * that never touches the connection's health, and no reconnect is suggested + * for them because none would help. */ -export const OURA_SCOPE_GATED_COLLECTIONS = [ - "spo2", - "vo2max", - "cardio_age", - "resilience", + +export const OURA_COLLECTIONS = [ + { name: "readiness", scope: "daily" }, + { name: "sleep", scope: "daily" }, + { name: "activity", scope: "daily" }, + { name: "daily_sleep", scope: "daily" }, + { name: "spo2", scope: "spo2" }, + { name: "vo2max", scope: "heart_health" }, + { name: "cardio_age", scope: "heart_health" }, + { name: "resilience", scope: "stress" }, ] as const; -export type OuraScopeGatedCollection = - (typeof OURA_SCOPE_GATED_COLLECTIONS)[number]; +type OuraCollectionEntry = (typeof OURA_COLLECTIONS)[number]; + +/** The scope the profile probe (`personal_info`) needs. */ +const OURA_PROFILE_SCOPE = "personal"; + +/** + * Every scope HealthLog asks for, in a stable order: the scopes of the + * collections it reads, plus the profile scope. + */ +const OURA_REQUESTED_SCOPES: readonly string[] = [ + ...new Set([ + ...OURA_COLLECTIONS.map((c) => c.scope), + OURA_PROFILE_SCOPE, + ]), +]; + +/** The authorize request's `scope` parameter. */ +export const OURA_OAUTH_SCOPE = OURA_REQUESTED_SCOPES.join(" "); + +/** + * Collections behind a scope beyond `daily`. A grant issued before HealthLog + * requested that scope, or one where the person unticked it on the consent + * screen, answers 401 on exactly these while the rest succeed, so they are the + * only names the settings card is asked to explain. Each has a label under + * `settings.integrations.oura.scope.items`. A collection behind `daily` is not + * here: without `daily` nothing syncs, and that is a reconnect, not a hint. + */ +export type OuraScopeGatedCollection = Exclude< + OuraCollectionEntry, + { scope: "daily" } +>["name"]; + +export const OURA_SCOPE_GATED_COLLECTIONS = OURA_COLLECTIONS.filter( + (c) => c.scope !== "daily", +).map((c) => c.name) as unknown as readonly [ + OuraScopeGatedCollection, + ...OuraScopeGatedCollection[], +]; + +const GATED: ReadonlySet = new Set(OURA_SCOPE_GATED_COLLECTIONS); + +export function isOuraScopeGatedCollection( + name: string, +): name is OuraScopeGatedCollection { + return GATED.has(name); +} /** Narrow a stored list to the known names, in display order. */ export function ouraScopeReconnectList( @@ -21,3 +108,57 @@ export function ouraScopeReconnectList( ): OuraScopeGatedCollection[] { return OURA_SCOPE_GATED_COLLECTIONS.filter((c) => denied.includes(c)); } + +/** + * The scope a granted-scope string or a refusal names, without the `extapi:` + * prefix Oura's authorization server puts on the scopes it grants (a request + * for `daily` comes back as `extapi:daily`). + */ +function bareScope(s: string): string { + return s.startsWith("extapi:") ? s.slice("extapi:".length) : s; +} + +/** + * The scopes a token response says were granted, or null when it carried no + * `scope` (older apps; the field is optional). Space-separated per RFC 6749. + */ +export function parseOuraGrantedScopes( + scope: string | null | undefined, +): Set | null { + if (typeof scope !== "string" || scope.trim() === "") return null; + return new Set(scope.trim().split(/\s+/).map(bareScope)); +} + +/** + * The gated collections a granted-scope string leaves out, in display order. + * Null when the response named no scopes: then nothing is known until the + * first sync reads the collections. + */ +export function ouraCollectionsMissingFromGrant( + scope: string | null | undefined, +): OuraScopeGatedCollection[] | null { + const granted = parseOuraGrantedScopes(scope); + if (!granted) return null; + return OURA_COLLECTIONS.filter( + ( + c, + ): c is Extract => + c.scope !== "daily" && !granted.has(c.scope), + ).map((c) => c.name); +} + +const MISSING_SCOPE_DETAIL = + /not authori[sz]ed (?:to )?access(?: the)? ([a-z0-9_:]{1,60}) scope/i; + +/** + * The scope a 401 body names when Oura refused the request for a missing + * permission (`Token is not authorized access stress scope.`), or null for any + * other body, a dead token's included. + */ +export function parseOuraMissingScope(body: unknown): string | null { + if (body === null || typeof body !== "object") return null; + const detail = (body as { detail?: unknown }).detail; + if (typeof detail !== "string") return null; + const m = MISSING_SCOPE_DETAIL.exec(detail); + return m ? bareScope(m[1]!.toLowerCase()) : null; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 0a5c9b384..b6e6e035c 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -20,9 +20,11 @@ * unknown / missing level mints no row. See `RESILIENCE_LEVELS` in `./client`. * * Token model: Oura uses refresh tokens. The merged schema has no expiry - * column, so the sync refreshes REACTIVELY — the first read that 401s triggers - * one refresh (persisting BOTH rotated tokens) and a single retry. A failed - * refresh (`invalid_grant`) records `reauth_required` on the `oura` ledger. + * column, so the sync refreshes REACTIVELY — the first read refused for its + * token triggers one refresh (persisting BOTH rotated tokens, serialised per + * user) and a single retry. A 401 that names a missing scope is not a token + * refusal and spends no refresh. A failed refresh (`invalid_grant`) records + * `reauth_required` on the `oura` ledger. * * Idempotency: `externalId = ::` for the day-keyed * collections; sleep rows carry a record-scoped `sleep::` @@ -82,18 +84,17 @@ import { mapResilience, mapSleep, mapVo2Max, - refreshAccessToken, type MappedMeasurement, } from "./client"; import { getOuraClientCredentials, getOuraConnection, - storeOuraTokens, + refreshOuraAccessToken, syncOuraScopeDenied, } from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; -import { OURA_SCOPE_GATED_COLLECTIONS } from "./scope"; +import { isOuraScopeGatedCollection } from "./scope"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -254,53 +255,81 @@ export interface OuraFetchResult { */ sleepRetirements: string[]; /** - * Collections refused with 401 while a sibling succeeded with the same - * token: the token is fine, the grant does not cover them. Not failures; the - * card asks for one reconnect instead. `null` when the run never proved the - * token (no collection succeeded, or the refresh was abandoned): it learned - * nothing about the grant, so the note from the last run that did stands. + * Collections the grant does not cover: refused with a 401 that names the + * missing scope, or with a bare 401 while a sibling succeeded with the same + * token. Not failures; the card asks for one reconnect instead. `null` when + * the run never proved the token (no collection succeeded, none was refused + * by name, or the refresh was abandoned): it learned nothing about the + * grant, so the note from the last run that did stands. */ scopeDenied: string[] | null; } /** - * A 401 from Oura: the token, or the grant's scope, was refused. A 403 stays a - * plain failure: Oura also answers 403 for a lapsed membership, which a - * reconnect does not fix. + * A 401 whose body names the missing scope (see `./scope`): Oura checked the + * token and found it valid, the grant just does not cover this collection. A + * refresh cannot widen a grant, so none is spent on it. */ -function isAuthRefusal(err: unknown): boolean { - return err instanceof OuraApiError && err.httpStatus === 401; -} - -const SCOPE_GATED: ReadonlySet = new Set(OURA_SCOPE_GATED_COLLECTIONS); - -/** - * A 401 that can be the grant's scope rather than the token: only the - * collections behind `spo2` / `heart_health` sit outside the `daily` scope - * every grant carries. A 401 on sleep, readiness or activity is a real auth - * failure whatever its siblings did. - */ -function isScopeRefusal(f: OuraCollectionFailure): boolean { - return SCOPE_GATED.has(f.name) && isAuthRefusal(f.err); +function isNamedScopeRefusal(err: unknown): boolean { + return ( + err instanceof OuraApiError && + err.httpStatus === 401 && + err.missingScope !== undefined + ); } /** - * Split the scope refusals out of a run's failures once a sibling collection - * has proven the token. Oura's authorization server enforces scopes per - * collection, so a grant issued without `spo2` / `heart_health` answers 401 on - * exactly the collections behind them and 200 everywhere else. Counting those - * toward the reconnect ladder painted a working connection as broken on every - * sync, and refreshing the token over them changed nothing. Any other failure, - * a 401 on a core collection included, stays a failure. + * Any other 401: the token was refused, or an older authorization server sent + * no body to tell. This is what the one refresh is for. A 403 is neither: Oura + * answers it for a lapsed membership, which no refresh or reconnect fixes. + */ +function isTokenRefusal(err: unknown): boolean { + return ( + err instanceof OuraApiError && + err.httpStatus === 401 && + err.missingScope === undefined + ); +} + +/** + * A failure that is the grant's scope rather than the token. Only the + * collections behind a scope beyond `daily` qualify: without `daily` nothing + * syncs, and that is a reconnect, not a hint. On those, a 401 naming its scope + * always qualifies; a bare 401 qualifies only once a sibling collection has + * proven the token, the inference that predates Oura naming the scope. + */ +function isScopeRefusal( + f: OuraCollectionFailure, + tokenProven: boolean, +): boolean { + if (!isOuraScopeGatedCollection(f.name)) return false; + return isNamedScopeRefusal(f.err) || (tokenProven && isTokenRefusal(f.err)); +} + +/** + * Split the scope refusals out of a run's failures. Oura's authorization + * server enforces scopes per collection, so a grant issued without `spo2`, + * `heart_health` or `stress` answers 401 on exactly the collections behind + * them and 200 everywhere else. Counting those toward the reconnect ladder + * painted a working connection as broken on every sync, and refreshing the + * token over them changed nothing. Any other failure, a 401 on a core + * collection included, stays a failure. + * + * A 401 that names its scope proves the token by itself: Oura validated it in + * order to say which permission it lacks. */ function splitScopeDenied( failures: OuraCollectionFailure[], - tokenProven: boolean, + siblingSucceeded: boolean, ): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } { + const tokenProven = + siblingSucceeded || failures.some((f) => isNamedScopeRefusal(f.err)); if (!tokenProven) return { failures, scopeDenied: null }; return { - failures: failures.filter((f) => !isScopeRefusal(f)), - scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name), + failures: failures.filter((f) => !isScopeRefusal(f, true)), + scopeDenied: failures + .filter((f) => isScopeRefusal(f, true)) + .map((f) => f.name), }; } @@ -323,7 +352,6 @@ function splitScopeDenied( async function fetchAll( userId: string, accessToken: string, - refreshToken: string, refreshTokenCiphertext: string, lookbackDays: number, knownScopeDenied: readonly string[] = [], @@ -448,18 +476,14 @@ async function fetchAll( specs: OuraCollectionSpec[], ): Promise<{ readings: OuraMeasurementUpsert[]; - failed: OuraCollectionSpec[]; failures: OuraCollectionFailure[]; - auth401: boolean; succeeded: number; }> => { const settled = await Promise.allSettled( specs.map((spec) => spec.collect(token)), ); const readings: OuraMeasurementUpsert[] = []; - const failed: OuraCollectionSpec[] = []; const failures: OuraCollectionFailure[] = []; - let auth401 = false; let succeeded = 0; settled.forEach((res, i) => { const spec = specs[i]!; @@ -467,28 +491,26 @@ async function fetchAll( succeeded++; readings.push(...res.value); } else { - const err = res.reason; - if (err instanceof OuraApiError && err.httpStatus === 401) - auth401 = true; - failed.push(spec); - failures.push({ name: spec.name, err }); + failures.push({ name: spec.name, err: res.reason }); } }); - return { readings, failed, failures, auth401, succeeded }; + return { readings, failures, succeeded }; }; const first = await attempt(accessToken, collections); - // A 401 limited to collections already known to sit outside the grant, next - // to collections that succeeded, is the grant's scope again: a refresh cannot - // widen it, so none is spent. Any other 401 still takes the one refresh, and - // whatever 401s after it beside a success is recorded as scope. - const knownDenied = new Set( - knownScopeDenied.filter((name) => SCOPE_GATED.has(name)), + // The refresh is for a refused token. A 401 that names its scope is not one, + // and neither is a bare 401 on a collection already known to sit outside the + // grant while a sibling succeeded with the same token: a refresh cannot + // widen a grant, so none is spent on either. + const knownDenied = new Set( + knownScopeDenied.filter((name) => isOuraScopeGatedCollection(name)), ); - const onlyKnownScope401 = first.failures - .filter((f) => isAuthRefusal(f.err)) - .every((f) => knownDenied.has(f.name)); - if (!first.auth401 || (first.succeeded > 0 && onlyKnownScope401)) { + const needsRefresh = first.failures.some( + (f) => + isTokenRefusal(f.err) && + !(first.succeeded > 0 && knownDenied.has(f.name)), + ); + if (!needsRefresh) { return { readings: first.readings, ...splitScopeDenied(first.failures, first.succeeded > 0), @@ -498,43 +520,56 @@ async function fetchAll( } // Reactive refresh: the access token expired (a 401 on the shared token). - // Refresh once (rotating both tokens) and retry ONLY the collections that - // failed. A failed refresh throws so the caller parks reauth_required. + // Refresh once, serialised per user so an overlapping sync never spends the + // same one-time refresh token twice, and retry the collections that failed + // for any reason but a named scope. A failed refresh throws so the caller + // parks reauth_required. const creds = await getOuraClientCredentials(userId); if (!creds) return { readings: first.readings, - failures: first.failures, + // The refresh never happened, so no sibling's success can be trusted + // over it; a refusal that names its scope still says what it says. + ...splitScopeDenied(first.failures, false), sleepSweeps, sleepRetirements, - scopeDenied: null, }; - const rotated = await refreshAccessToken(refreshToken, creds); - // Compare-and-swap persist: on a lost race against a concurrent sync this - // returns the peer's freshly rotated access token rather than the (now - // invalidated) one we just minted, so neither sync parks the connection. - const usableToken = await storeOuraTokens( + const usableToken = await refreshOuraAccessToken( userId, - rotated.access_token, - rotated.refresh_token, refreshTokenCiphertext, + creds, ); if (!usableToken) return { readings: first.readings, - failures: first.failures, + // The refresh never happened, so no sibling's success can be trusted + // over it; a refusal that names its scope still says what it says. + ...splitScopeDenied(first.failures, false), sleepSweeps, sleepRetirements, - scopeDenied: null, }; - const retry = await attempt(usableToken, first.failed); + const settledByScope = first.failures.filter((f) => + isNamedScopeRefusal(f.err), + ); + const retryNames = new Set( + first.failures + .filter((f) => !isNamedScopeRefusal(f.err)) + .map((f) => f.name), + ); + const retry = await attempt( + usableToken, + collections.filter((c) => retryNames.has(c.name)), + ); return { readings: [...first.readings, ...retry.readings], // Proven by either pass: a collection that succeeded before the refresh // proves the grant as much as one that succeeded after it. - ...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0), + ...splitScopeDenied( + [...settledByScope, ...retry.failures], + first.succeeded + retry.succeeded > 0, + ), sleepSweeps, sleepRetirements, }; @@ -615,7 +650,6 @@ export async function syncUserOura( result = await fetchAll( userId, conn.accessToken, - conn.refreshToken, conn.refreshTokenCiphertext, lookbackDays, conn.scopeDenied, diff --git a/tests/integration/oauth-callback-single-use.test.ts b/tests/integration/oauth-callback-single-use.test.ts new file mode 100644 index 000000000..ec4e181e9 --- /dev/null +++ b/tests/integration/oauth-callback-single-use.test.ts @@ -0,0 +1,283 @@ +/** + * A browser that delivers the same OAuth callback twice must cause one code + * exchange, against real Postgres. + * + * Chrome was seen sending `/api/oura/callback` twice with the same code about + * 150 ms apart. Both requests exchanged the code; the second was refused, and + * Oura answered the reuse by revoking the token the first exchange had issued, + * so the first sync failed with 401 everywhere. The callback now claims its + * state before the exchange: one request exchanges, the other follows it to + * the same page and touches nothing. + * + * Covered for a signed-state flow (Oura) and a ledger flow (Withings), the two + * shapes every connect callback takes. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +process.env.API_TOKEN_HMAC_KEY ??= + "integration-hmac-key-0123456789abcdef0123456789"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "user-oauth-single-use"; +const OURA_TOKEN_URL = "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; +const OURA_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; +const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2"; +const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify"; + +/** Long enough that two callbacks fired together overlap at the provider. */ +const PROVIDER_LATENCY_MS = 150; + +function urlOf(input: unknown): string { + return typeof input === "string" ? input : (input as Request).url; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + delete process.env.OURA_REDIRECT_URI; + + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().user.create({ + data: { + id: USER, + username: "oauth-single-use", + email: "oauth-single-use@example.test", + role: "USER", + ouraClientIdEncrypted: encrypt("oura-client"), + ouraClientSecretEncrypted: encrypt("oura-secret"), + withingsClientIdEncrypted: encrypt("withings-client"), + withingsClientSecretEncrypted: encrypt("withings-secret"), + }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function ouraCallback(state: string, code = "oura-code") { + const { GET } = await import("@/app/api/oura/callback/route"); + const params = new URLSearchParams({ code, state, iss: OURA_ISS }); + return GET( + new NextRequest(`http://localhost/api/oura/callback?${params}`, { + method: "GET", + headers: { cookie: `oura_oauth_state=${state}` }, + }), + ); +} + +async function mintOuraState(): Promise { + const { mintSignedState } = await import("@/lib/oauth/signed-state"); + return mintSignedState("oura", USER); +} + +describe("Oura callback delivered twice", () => { + it("exchanges the code once and sends both requests to the connected page", async () => { + const tokenCalls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = urlOf(input); + if (url === OURA_TOKEN_URL) { + tokenCalls.push(String(init?.body ?? "")); + await new Promise((r) => setTimeout(r, PROVIDER_LATENCY_MS)); + // A reused code is refused, as Oura does. + if (tokenCalls.length > 1) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ + access_token: "oura-access", + refresh_token: "oura-refresh", + expires_in: 86400, + scope: + "extapi:daily extapi:personal extapi:spo2 extapi:heart_health extapi:stress", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + const state = await mintOuraState(); + const [a, b] = await Promise.all([ + ouraCallback(state), + ouraCallback(state), + ]); + + expect(tokenCalls).toHaveLength(1); + expect(a.headers.get("location")).toContain("oura=connected"); + expect(b.headers.get("location")).toBe(a.headers.get("location")); + + const { decrypt } = await import("@/lib/crypto"); + const user = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: USER }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + ouraScopeDenied: true, + }, + }); + expect(decrypt(user.ouraAccessTokenEncrypted!)).toBe("oura-access"); + expect(decrypt(user.ouraRefreshTokenEncrypted!)).toBe("oura-refresh"); + // Every requested scope was granted, so nothing awaits a reconnect. + expect(user.ouraScopeDenied).toBeNull(); + }); + + it("notes the collections a grant leaves out, straight from the token response", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (urlOf(input) !== OURA_TOKEN_URL) throw new Error("unexpected"); + return new Response( + JSON.stringify({ + access_token: "oura-access", + refresh_token: "oura-refresh", + expires_in: 86400, + scope: "extapi:daily extapi:personal extapi:spo2 extapi:heart_health", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + const res = await ouraCallback(await mintOuraState()); + + expect(res.headers.get("location")).toContain("oura=connected"); + const user = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: USER }, + select: { ouraScopeDenied: true }, + }); + expect(user.ouraScopeDenied).toEqual(["resilience"]); + }); + + it("lets both requests see a failed exchange, and a new connect still works", async () => { + let tokenCalls = 0; + let refuse = true; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (urlOf(input) !== OURA_TOKEN_URL) throw new Error("unexpected"); + tokenCalls++; + await new Promise((r) => setTimeout(r, PROVIDER_LATENCY_MS)); + if (refuse) { + return new Response(JSON.stringify({ error: "invalid_request" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ + access_token: "second-access", + refresh_token: "second-refresh", + expires_in: 86400, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + const first = await mintOuraState(); + const [a, b] = await Promise.all([ + ouraCallback(first), + ouraCallback(first), + ]); + expect(tokenCalls).toBe(1); + expect(a.headers.get("location")).toContain("oura=error&reason=token"); + expect(b.headers.get("location")).toBe(a.headers.get("location")); + + // Starting over mints a new state, which is a new claim. + refuse = false; + const retry = await ouraCallback(await mintOuraState(), "fresh-code"); + expect(tokenCalls).toBe(2); + expect(retry.headers.get("location")).toContain("oura=connected"); + }); +}); + +describe("Withings callback delivered twice", () => { + it("exchanges the code once and sends the duplicate where the first went", async () => { + const prisma = getPrismaClient(); + const session = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + let tokenCalls = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = urlOf(input); + if (url === WITHINGS_TOKEN_URL) { + tokenCalls++; + await new Promise((r) => setTimeout(r, PROVIDER_LATENCY_MS)); + return new Response( + JSON.stringify({ + status: 0, + body: { + userid: "withings-single-use", + access_token: "w-access", + refresh_token: "w-refresh", + expires_in: 3600, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url === WITHINGS_NOTIFY_URL) { + return new Response(JSON.stringify({ status: 0 }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + const nonce = "single-use-nonce-22chr"; + await prisma.withingsOAuthState.create({ + data: { + nonce, + userId: USER, + expiresAt: new Date(Date.now() + 600_000), + }, + }); + + const { GET } = await import("@/app/api/withings/callback/route"); + const request = () => + new NextRequest( + `http://localhost/api/withings/callback?code=w-code&state=${nonce}`, + { + method: "GET", + headers: { + cookie: `healthlog_session=${session.id}; withings_state=${nonce}`, + }, + }, + ); + const [a, b] = await Promise.all([GET(request()), GET(request())]); + + expect(tokenCalls).toBe(1); + expect(a.headers.get("location")).toContain("withings=connected"); + expect(b.headers.get("location")).toBe(a.headers.get("location")); + }); +}); diff --git a/tests/integration/oura-refresh-serialization.test.ts b/tests/integration/oura-refresh-serialization.test.ts new file mode 100644 index 000000000..488ae9a1b --- /dev/null +++ b/tests/integration/oura-refresh-serialization.test.ts @@ -0,0 +1,156 @@ +/** + * Oura's refresh tokens are single-use. Overlapping syncs that all meet an + * expired access token must refresh it once between them, against real + * Postgres: spending the same refresh token twice is refused, and the + * authorization server may revoke the grant behind it. + * + * Also pinned: a reconnect that stores a new grant while a refresh of the old + * one is in flight keeps the new grant. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as OuraClientModule from "@/lib/oura/client"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +delete process.env.ENCRYPTION_KEYS; +delete process.env.ENCRYPTION_ACTIVE_KEY_ID; + +const { ouraRefresh, collection } = vi.hoisted(() => ({ + ouraRefresh: vi.fn(), + collection: vi.fn(), +})); + +vi.mock("@/lib/oura/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + refreshAccessToken: ouraRefresh, + fetchReadiness: collection, + fetchSleep: collection, + fetchDailyActivity: collection, + fetchDailySleep: collection, + fetchDailySpo2: collection, + fetchVo2Max: collection, + fetchCardiovascularAge: collection, + fetchResilience: collection, + fetchDailyCyclePhases: collection, + }; +}); + +import { decrypt, encrypt } from "@/lib/crypto"; +import { OuraApiError } from "@/lib/oura/response-classifier"; +import { refreshOuraAccessToken } from "@/lib/oura/credentials"; +import { syncUserOura } from "@/lib/oura/sync"; + +const USER = "user-oura-refresh"; +const CONTENDERS = 12; + +function expired() { + return new OuraApiError({ + verb: "fetchReadiness", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }); +} + +async function seed() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "oura-refresh", + email: "oura-refresh@example.test", + ouraClientIdEncrypted: encrypt("oura-client"), + ouraClientSecretEncrypted: encrypt("oura-secret"), + ouraAccessTokenEncrypted: encrypt("stale-access"), + ouraRefreshTokenEncrypted: encrypt("stale-refresh"), + }, + }); +} + +async function storedTokens() { + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: USER }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + }, + }); + return { + access: decrypt(row.ouraAccessTokenEncrypted!), + refresh: decrypt(row.ouraRefreshTokenEncrypted!), + }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + vi.clearAllMocks(); + await seed(); + // Every collection refuses the stale token and serves the fresh one. + collection.mockImplementation(async (token: string) => { + if (token === "stale-access") throw expired(); + return []; + }); +}); + +describe("Oura token refresh (real Postgres)", () => { + it("refreshes once for any number of overlapping syncs", async () => { + ouraRefresh.mockImplementation(async (refreshToken: string) => { + expect(refreshToken).toBe("stale-refresh"); + await new Promise((r) => setTimeout(r, 100)); + return { + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 86400, + }; + }); + + const results = await Promise.all( + Array.from({ length: CONTENDERS }, () => syncUserOura(USER)), + ); + + expect(ouraRefresh).toHaveBeenCalledTimes(1); + expect(results.every((r) => r.failed === false)).toBe(true); + expect(await storedTokens()).toEqual({ + access: "fresh-access", + refresh: "fresh-refresh", + }); + }); + + it("keeps a grant a reconnect stored while a refresh of the old one ran", async () => { + const prisma = getPrismaClient(); + const spent = (await prisma.user.findUniqueOrThrow({ + where: { id: USER }, + select: { ouraRefreshTokenEncrypted: true }, + }))!.ouraRefreshTokenEncrypted!; + + ouraRefresh.mockImplementation(async () => { + // The callback writes a new grant without taking the refresh lock. + await prisma.user.update({ + where: { id: USER }, + data: { + ouraAccessTokenEncrypted: encrypt("reconnect-access"), + ouraRefreshTokenEncrypted: encrypt("reconnect-refresh"), + }, + }); + return { + access_token: "old-grant-access", + refresh_token: "old-grant-refresh", + expires_in: 86400, + }; + }); + + const token = await refreshOuraAccessToken(USER, spent, { + clientId: "oura-client", + clientSecret: "oura-secret", + }); + + expect(token).toBe("reconnect-access"); + expect(await storedTokens()).toEqual({ + access: "reconnect-access", + refresh: "reconnect-refresh", + }); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 44d3d9996..fca0c34da 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -66,6 +66,9 @@ export async function truncateAllTables(client: PrismaClient): Promise { "mood_entries", "notification_channels", "notification_preferences", + // Instance-wide OAuth callback claims, keyed on a hash of the state: not + // reached by the users cascade. + "oauth_callback_claims", // Host-wide off-host backup state: not keyed to a user, so the users // cascade does not reach it, and a file that reads it whole (the purge // job, the encryption view) saw what earlier files left behind. diff --git a/tests/integration/withings-oauth-flow.test.ts b/tests/integration/withings-oauth-flow.test.ts index a1fb8fa40..1ba5e6197 100644 --- a/tests/integration/withings-oauth-flow.test.ts +++ b/tests/integration/withings-oauth-flow.test.ts @@ -10,9 +10,10 @@ * 1. `withings/connect` mints a row and stamps the cookie. * 2. `withings/callback` resolves the user via the row's `userId`, * verifies it matches the session, and deletes the row. - * 3. A second callback with the same nonce hits the "row not - * found" branch and redirects to the error page (replay - * protection). + * 3. A second callback with the same nonce never reaches the token + * endpoint: while the first callback's claim stands it follows + * that callback to the same page, and without one it hits the + * "row not found" branch and the error page (replay protection). * 4. A callback whose row has expired hits the same branch. * 5. The connect handler refuses to start when the user has no * Withings credentials — no ledger row is minted. @@ -157,7 +158,7 @@ describe("Withings OAuth nonce ledger (real Postgres)", () => { expect(rows).toHaveLength(0); }); - it("connect → callback consumes the row (single-use); replay of the same nonce hits the error page", async () => { + it("connect → callback consumes the row (single-use); a repeat of the same nonce never exchanges again", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { const url = typeof input === "string" ? input : (input as Request).url; if (url === WITHINGS_TOKEN_URL) { @@ -214,22 +215,30 @@ describe("Withings OAuth nonce ledger (real Postgres)", () => { }); expect(afterCallback).toBeNull(); - // Leg 3 — replay the same nonce. The cookie compare still passes - // (matching strings) but the row lookup misses, so the handler - // bounces to the error page without hitting the token endpoint - // again. + // Leg 3 — the browser delivers the same callback again. The cookie + // compare still passes (matching strings) and the row is gone, but the + // first callback's claim stands: the repeat follows it to the same page + // without hitting the token endpoint again. const fetchSpy = vi.mocked(globalThis.fetch); fetchSpy.mockClear(); - const replayReq = new NextRequest( - `http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`, - { - method: "GET", - headers: { - cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`, + const replayRequest = () => + new NextRequest( + `http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`, + { + method: "GET", + headers: { + cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`, + }, }, - }, - ); - const replayRes = await callback(replayReq); + ); + const repeatRes = await callback(replayRequest()); + expect(repeatRes.status).toBe(307); + expect(repeatRes.headers.get("location")).toContain("withings=connected"); + + // Leg 4 — once the claim has been swept, the same nonce is a plain + // replay and lands on the error page. + await prisma.oAuthCallbackClaim.deleteMany(); + const replayRes = await callback(replayRequest()); expect(replayRes.status).toBe(307); expect(replayRes.headers.get("location")).toContain( "withings=error&reason=replay",