mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
refactor(oura): refresh tokens through the shared OAuth lifecycle
This commit is contained in:
1 parent
23f6fe4c6e
commit
2331faa36c
5 files changed
+79
-111
No files matched your search
@@ -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", () => ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user