Files
HealthLog/prisma/migrations/0419_oauth_callback_claims/migration.sql
T
Marc-André Bombeck 6224409678 fix(oura): request the stress scope and exchange a repeated callback once
Resilience sits behind Oura's `stress` scope, not `heart_health`, so a
connection that granted every scope HealthLog asked for still answered 401
on `daily_resilience`. `src/lib/oura/scope.ts` now maps every collection the
sync reads to the scope the live API enforces and is the one source for the
authorize request, the per-collection permission detection, the reconnect
hint and the settings card. The request asks for `stress` as well.

Oura names a missing scope in its 401 body ("Token is not authorized access
stress scope."), while a dead token reads differently. A collection refusal
now carries that scope: it spends no token refresh, never counts toward the
reconnect threshold and marks only the collections it names. A 403 (a lapsed
membership) is a persistent failure rather than a reconnect. The callback
also records what the token response says was granted, so an unticked
permission shows on the card before the first sync.

A browser can deliver an OAuth callback twice with the same code. Both
requests exchanged it, and Oura answers a reused code by revoking the token
the first exchange issued. Every connect callback (Oura, Polar, Strava,
WHOOP, Withings, Fitbit, Google Health) now claims its state in
`oauth_callback_claims` before the exchange: one request exchanges, the
duplicate waits for it and redirects to the same page without touching any
token. The ledger flows claim inside the transaction that consumes their
state row. A failed exchange settles with its error page, and a new connect
mints a new state.

Oura token refreshes are serialised per user with an advisory lock, so
overlapping syncs no longer spend the same single-use refresh token, and the
write stays conditional so a reconnect's new grant is never overwritten by a
refresh of the old one.
2026-10-21 00:05:22 +02:00

29 lines
1.1 KiB
SQL

-- 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");