refactor(oura): refresh tokens through the shared OAuth lifecycle

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:13:33 +02:00
1 parent 23f6fe4c6e
commit 2331faa36c
5 files changed
+79 -111

No files matched your search

+6 -7
View File
@@ -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", () => ({
+2 -80
View File
@@ -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<string | 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);
}
/**
* Collections the current Oura grant does not cover, by collection name. Read
* by the settings envelope; an absent or malformed value reads as none.
+66
View File
@@ -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<OuraCredentials> = {
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<string | null> {
const result = await obtainAccessToken(ouraTokenDescriptor, userId, {
afterRefusalOf: spentRefreshCiphertext,
});
return result.kind === "token" ? result.accessToken : null;
}
+3 -19
View File
@@ -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 {
@@ -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({