Merge branch 'fix/v1439-oura' into release/v1.43.9

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 00:05:57 +02:00
commit 31e0957903
43 files changed
+2154 -624

No files matched your search

+5 -3
View File
@@ -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=""
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -4657,7 +4657,7 @@
"scoreCrossLink": "종합 회복 점수 보기",
"emptyTitle": "아직 회복 데이터가 없어요",
"empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.",
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 스트레스 데이터 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
"emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.",
"emptyAction": "통합 열기",
"block": {
+1 -1
View File
@@ -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
View File
@@ -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");
+28
View File
@@ -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 {
+119 -95
View File
@@ -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");
}
});
});
+129 -105
View File
@@ -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,
}));
+46 -8
View File
@@ -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,
}));
+25 -4
View File
@@ -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,
}));
+25 -4
View File
@@ -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.
+121 -95
View File
@@ -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() }));
+127 -103
View File
@@ -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);
+2
View File
@@ -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",
+4 -3
View File
@@ -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.
+1 -1
View File
@@ -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
+207
View File
@@ -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;
}
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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."),
+38 -9
View File
@@ -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");
});
});
+101
View File
@@ -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();
});
});
+161 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
/**
+21 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
});
});
});
+3
View File
@@ -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.
+26 -17
View File
@@ -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",