mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/v1439-oura' into release/v1.43.9
This commit is contained in:
commit
31e0957903
43 files changed
+2154
-624
No files matched your search
@@ -153,9 +153,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -4657,7 +4657,7 @@
|
||||
"scoreCrossLink": "종합 회복 점수 보기",
|
||||
"emptyTitle": "아직 회복 데이터가 없어요",
|
||||
"empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.",
|
||||
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
|
||||
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 스트레스 데이터 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
|
||||
"emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.",
|
||||
"emptyAction": "통합 열기",
|
||||
"block": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>,
|
||||
) => run(),
|
||||
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
|
||||
run(),
|
||||
consumeLedgerState: async (
|
||||
_p: string,
|
||||
_s: string,
|
||||
consume: (tx: unknown) => Promise<unknown>,
|
||||
) => consume((await import("@/lib/db")).prisma),
|
||||
duplicateOAuthCallback: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<unknown>,
|
||||
) => run(),
|
||||
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
|
||||
run(),
|
||||
consumeLedgerState: async (
|
||||
_p: string,
|
||||
_s: string,
|
||||
consume: (tx: unknown) => Promise<unknown>,
|
||||
) => consume((await import("@/lib/db")).prisma),
|
||||
duplicateOAuthCallback: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<unknown>,
|
||||
) => run(),
|
||||
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
|
||||
run(),
|
||||
consumeLedgerState: async (
|
||||
_p: string,
|
||||
_s: string,
|
||||
consume: (tx: unknown) => Promise<unknown>,
|
||||
) => consume((await import("@/lib/db")).prisma),
|
||||
duplicateOAuthCallback: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<typeof import("@/lib/oauth/callback-claim")>()),
|
||||
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
|
||||
run(),
|
||||
duplicateOAuthCallback: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/oauth/callback-claim")>()),
|
||||
settleOAuthCallback: (_p: string, _s: string, run: () => Promise<unknown>) =>
|
||||
run(),
|
||||
duplicateOAuthCallback: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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() }));
|
||||
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ beforeEach(() => {
|
||||
|
||||
describe("<RecoverySection> 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);
|
||||
|
||||
@@ -348,6 +348,8 @@ export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
|
||||
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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Prisma.TransactionClient, "oAuthCallbackClaim">;
|
||||
|
||||
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<boolean> {
|
||||
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<T>(
|
||||
provider: OAuthCallbackProvider,
|
||||
state: string,
|
||||
consume: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<NextResponse>,
|
||||
): Promise<NextResponse> {
|
||||
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<NextResponse | null> {
|
||||
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<NextResponse>,
|
||||
): Promise<NextResponse> {
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+51
-23
@@ -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<typeof classifyOuraResponse>,
|
||||
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<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
@@ -508,12 +541,7 @@ async function fetchCollection<T>(
|
||||
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 };
|
||||
},
|
||||
|
||||
+75
-43
@@ -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<string | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+153
-12
@@ -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<string>([
|
||||
...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<string> = 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<string> | 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<OuraCollectionEntry, { name: OuraScopeGatedCollection }> =>
|
||||
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;
|
||||
}
|
||||
+109
-75
@@ -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 = <resource>:<day>:<fieldTag>` for the day-keyed
|
||||
* collections; sleep rows carry a record-scoped `sleep:<record-id>:<fieldTag>`
|
||||
@@ -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<string> = 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<string>(
|
||||
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,
|
||||
|
||||
@@ -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<string> {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -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<typeof OuraClientModule>();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,9 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
"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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user