mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
`oauth-kit/client.ts` builds a provider's consent URL, code exchange, refresh and deauthorization from a declaration of what differs: endpoints and scope, client authentication (form or HTTP Basic, with or without the client id beside it), the fields a grant adds, how an answer is classified and the refusal named, the error type, and where the token sits in the body. The seven hand-written copies of the redirect URI, authorization URL, token post, Basic header and PKCE pair are gone; each provider keeps an `oauth.ts` with its declaration. Shared behaviour is now uniform: a 2xx without a token object (an empty or non-JSON body) is a transient `empty_token_body` failure for every provider. WHOOP, Strava and Google Health used to return null, and Withings threw an unclassified SyntaxError; the OAuth wire golden records exactly these lines changing and nothing else. Oura's live/legacy endpoint fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's errorType are declared per provider.
160 lines
4.8 KiB
TypeScript
160 lines
4.8 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,
|
|
fetchReadiness: collection,
|
|
fetchSleep: collection,
|
|
fetchDailyActivity: collection,
|
|
fetchDailySleep: collection,
|
|
fetchDailySpo2: collection,
|
|
fetchVo2Max: collection,
|
|
fetchCardiovascularAge: collection,
|
|
fetchResilience: collection,
|
|
fetchDailyCyclePhases: collection,
|
|
};
|
|
});
|
|
vi.mock("@/lib/oura/oauth", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/oura/oauth")>();
|
|
return {
|
|
...actual,
|
|
ouraOAuth: { ...actual.ouraOAuth, refresh: ouraRefresh },
|
|
};
|
|
});
|
|
|
|
import { decrypt, encrypt } from "@/lib/crypto";
|
|
import { OuraApiError } from "@/lib/oura/response-classifier";
|
|
import { refreshOuraAfterRefusal } from "@/lib/oura/oauth-descriptor";
|
|
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 refreshOuraAfterRefusal(USER, spent);
|
|
|
|
expect(token).toBe("reconnect-access");
|
|
expect(await storedTokens()).toEqual({
|
|
access: "reconnect-access",
|
|
refresh: "reconnect-refresh",
|
|
});
|
|
});
|
|
});
|