mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
/**
|
|
* 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",
|
|
});
|
|
});
|
|
});
|