diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 451084b43..63616a85d 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -70,11 +70,12 @@ const { // these cases pin. vi.mock("../credentials", () => ({ getOuraConnection: getConnMock, - refreshOuraAccessToken: async ( - userId: string, - spentCiphertext: string, - creds: unknown, - ) => { + syncOuraScopeDenied: scopeDeniedMock, +})); +vi.mock("../oauth-descriptor", () => ({ + refreshOuraAfterRefusal: async (userId: string, spentCiphertext: string) => { + const creds = await getCredsMock(userId); + if (!creds) return null; const rotated = await refreshMock("ref", creds); return storeTokensMock( userId, @@ -83,8 +84,6 @@ vi.mock("../credentials", () => ({ spentCiphertext, ); }, - getOuraClientCredentials: getCredsMock, - syncOuraScopeDenied: scopeDeniedMock, })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index 4804cfeb7..95b06898e 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -13,15 +13,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; -import { - acquireProviderTokenRefreshLock, - PROVIDER_REFRESH_TRANSACTION_OPTIONS, -} from "@/lib/integrations/oauth-refresh"; -import { - getOuraCredentials, - refreshAccessToken, - type OuraCredentials, -} from "./client"; +import { getOuraCredentials, type OuraCredentials } from "./client"; /** * Resolve the user's Oura OAuth client id/secret, DB-first then env. @@ -87,7 +79,7 @@ export interface OuraConnection { accessToken: string; /** * The exact stored refresh-token ciphertext (NOT the decrypted value). - * `refreshOuraAccessToken` compares against it under the refresh lock to + * `refreshOuraAfterRefusal` compares against it under the refresh lock to * tell whether a peer already rotated the token this sync started with. */ refreshTokenCiphertext: string; @@ -116,76 +108,6 @@ export async function getOuraConnection( }; } -/** - * Refresh the user's Oura access token once, however many syncs ask at the - * same moment. - * - * 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 refreshOuraAccessToken( - userId: string, - spentRefreshCiphertext: string, - creds: OuraCredentials, -): Promise { - 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); -} - /** * Collections the current Oura grant does not cover, by collection name. Read * by the settings envelope; an absent or malformed value reads as none. diff --git a/src/lib/oura/oauth-descriptor.ts b/src/lib/oura/oauth-descriptor.ts new file mode 100644 index 000000000..cfc43a51b --- /dev/null +++ b/src/lib/oura/oauth-descriptor.ts @@ -0,0 +1,66 @@ +/** + * Where an Oura grant lives and how its token endpoint is called, for the + * shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * Oura's tokens sit on `User`, with no expiry column: the sync refreshes after + * a refused read, and Oura rotates the refresh token on every use. + */ +import { + obtainAccessToken, + type OAuthTokenDescriptor, +} from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type OuraCredentials } from "./client"; +import { getOuraClientCredentials } from "./credentials"; + +export const ouraTokenDescriptor: OAuthTokenDescriptor = { + provider: "oura", + tracksExpiry: false, + expiryBufferMs: 0, + async readGrant(db, userId) { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + }, + }); + if (!row?.ouraAccessTokenEncrypted || !row.ouraRefreshTokenEncrypted) { + return null; + } + return { + accessCiphertext: row.ouraAccessTokenEncrypted, + refreshCiphertext: row.ouraRefreshTokenEncrypted, + expiresAt: null, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.user.updateMany({ + where: { id: userId, ouraRefreshTokenEncrypted: spent }, + data: { + ouraAccessTokenEncrypted: next.accessCiphertext, + ouraRefreshTokenEncrypted: next.refreshCiphertext, + }, + }); + return count; + }, + loadCredentials: (userId) => getOuraClientCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), +}; + +/** + * Refresh after Oura refused the token read with `spentRefreshCiphertext`, + * once however many syncs ask at the same moment. Returns the access token to + * retry with, or null when there is nothing to retry with (no connection, or + * no OAuth client to refresh as). A refused refresh throws for the caller to + * classify. + */ +export async function refreshOuraAfterRefusal( + userId: string, + spentRefreshCiphertext: string, +): Promise { + const result = await obtainAccessToken(ouraTokenDescriptor, userId, { + afterRefusalOf: spentRefreshCiphertext, + }); + return result.kind === "token" ? result.accessToken : null; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index b6e6e035c..39aee9733 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -86,12 +86,8 @@ import { mapVo2Max, type MappedMeasurement, } from "./client"; -import { - getOuraClientCredentials, - getOuraConnection, - refreshOuraAccessToken, - syncOuraScopeDenied, -} from "./credentials"; +import { getOuraConnection, syncOuraScopeDenied } from "./credentials"; +import { refreshOuraAfterRefusal } from "./oauth-descriptor"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; import { isOuraScopeGatedCollection } from "./scope"; @@ -524,21 +520,9 @@ async function fetchAll( // 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, - // 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, - }; - - const usableToken = await refreshOuraAccessToken( + const usableToken = await refreshOuraAfterRefusal( userId, refreshTokenCiphertext, - creds, ); if (!usableToken) return { diff --git a/tests/integration/oura-refresh-serialization.test.ts b/tests/integration/oura-refresh-serialization.test.ts index 488ae9a1b..d42f145fe 100644 --- a/tests/integration/oura-refresh-serialization.test.ts +++ b/tests/integration/oura-refresh-serialization.test.ts @@ -41,7 +41,7 @@ vi.mock("@/lib/oura/client", async (importOriginal) => { import { decrypt, encrypt } from "@/lib/crypto"; import { OuraApiError } from "@/lib/oura/response-classifier"; -import { refreshOuraAccessToken } from "@/lib/oura/credentials"; +import { refreshOuraAfterRefusal } from "@/lib/oura/oauth-descriptor"; import { syncUserOura } from "@/lib/oura/sync"; const USER = "user-oura-refresh"; @@ -142,10 +142,7 @@ describe("Oura token refresh (real Postgres)", () => { }; }); - const token = await refreshOuraAccessToken(USER, spent, { - clientId: "oura-client", - clientSecret: "oura-secret", - }); + const token = await refreshOuraAfterRefusal(USER, spent); expect(token).toBe("reconnect-access"); expect(await storedTokens()).toEqual({