Files
HealthLog/src/lib/oura/credentials.ts
T
Marc-André Bombeck 0f5e563403 fix(integrations): make rotating-refresh persistence race-safe
Fitbit, WHOOP, and Oura all issue one-time-use refresh tokens. The token
persistence did an unguarded read → refresh → write, so two overlapping
syncs for one user both read the same stored refresh token and both POST
it. The provider honours only the first; the loser's refresh comes back 401,
gets classified as reauth, and the connection is parked at `error_reauth`
spuriously even though the peer rotated the token successfully.

Add a shared `persistRotatedToken` helper that writes the rotated pair with
a compare-and-swap on the stored ciphertext (matched verbatim, since AES-GCM
re-encryption of the same plaintext differs byte-for-byte). When the
conditional update touches zero rows a peer already won the race; re-read the
row and reuse the peer's freshly rotated access token instead of erroring.

Wire it into all three providers — Fitbit and WHOOP `getValidToken`
(proactive expiry refresh, keyed by connection id) and Oura `storeOuraTokens`
(reactive 401 refresh, keyed by user id). Both racing callers now end with a
usable token and neither drops the connection.
2026-06-22 08:16:04 +02:00

151 lines
5.0 KiB
TypeScript

/**
* v1.17.0 (F4) — per-user Oura token accessors.
* v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
*
* Like WHOOP / Fitbit, Oura is now a per-user BYO-key integration: each user may
* register their own Oura app and store the client id/secret encrypted on
* `User`, with a fall-back to the shared env app for existing single-app
* deploys. Also per-user is the granted access + refresh token. The schema
* carries NO token-expiry column, so the sync layer refreshes REACTIVELY on a
* 401 rather than proactively on an expiry timestamp (persisting both rotated
* tokens).
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
import { getOuraCredentials, type OuraCredentials } from "./client";
/**
* Resolve the user's Oura OAuth client id/secret, DB-first then env.
*
* v1.17.1 makes Oura a per-user BYO-key integration (mirroring WHOOP / Fitbit):
* a self-hoster registers their own Oura app and pastes the client id/secret
* into Settings, stored encrypted on `User`. When the user has not configured
* per-user keys we fall back to the shared env app (`OURA_CLIENT_ID` /
* `OURA_CLIENT_SECRET`) so existing single-app deploys keep working. Returns
* null when neither source is configured.
*/
export async function getOuraClientCredentials(
userId: string,
): Promise<OuraCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
if (user?.ouraClientIdEncrypted && user?.ouraClientSecretEncrypted) {
return {
clientId: decrypt(user.ouraClientIdEncrypted),
clientSecret: decrypt(user.ouraClientSecretEncrypted),
};
}
// Fall back to the shared env-configured OAuth app.
return getOuraCredentials();
}
/** Persist the user's Oura OAuth client id/secret, encrypted at rest. */
export async function storeOuraClientCredentials(
userId: string,
clientId: string,
clientSecret: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
ouraClientIdEncrypted: encrypt(clientId),
ouraClientSecretEncrypted: encrypt(clientSecret),
},
});
}
/** Clear the user's stored Oura OAuth client id/secret. */
export async function clearOuraClientCredentials(
userId: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
},
});
}
export interface OuraConnection {
accessToken: string;
refreshToken: string;
/**
* The exact stored refresh-token ciphertext (NOT the decrypted value). The
* rotating-refresh compare-and-swap in `storeOuraTokens` matches on this so a
* concurrent sync that already rotated the token is not clobbered.
*/
refreshTokenCiphertext: string;
}
export async function getOuraConnection(
userId: string,
): Promise<OuraConnection | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
ouraAccessTokenEncrypted: true,
ouraRefreshTokenEncrypted: true,
},
});
if (!user?.ouraAccessTokenEncrypted || !user?.ouraRefreshTokenEncrypted) {
return null;
}
return {
accessToken: decrypt(user.ouraAccessTokenEncrypted),
refreshToken: decrypt(user.ouraRefreshTokenEncrypted),
refreshTokenCiphertext: user.ouraRefreshTokenEncrypted,
};
}
/**
* Persist a rotated Oura token pair with compare-and-swap on the stored refresh
* ciphertext (the inverse of Fitbit / WHOOP, which key by connection id). Oura's
* tokens live on `User` and the refresh is REACTIVE on a 401, so two overlapping
* syncs can race the same one-time-use refresh token. The CAS guard writes only
* when the stored ciphertext still equals the one this caller spent; on a lost
* race (0 rows) it re-reads and returns the peer's freshly rotated access token.
*
* Returns the access token the caller should use for the current sync (its own
* on a win, the peer's on a loss), or null if the connection vanished.
*/
export async function storeOuraTokens(
userId: string,
accessToken: string,
refreshToken: string,
expectedRefreshCiphertext: string,
): Promise<string | null> {
return persistRotatedToken(accessToken, {
conditionalUpdate: async () => {
const { count } = await prisma.user.updateMany({
where: {
id: userId,
ouraRefreshTokenEncrypted: expectedRefreshCiphertext,
},
data: {
ouraAccessTokenEncrypted: encrypt(accessToken),
ouraRefreshTokenEncrypted: encrypt(refreshToken),
},
});
return count;
},
readPeerAccessToken: async () => {
const fresh = await prisma.user.findUnique({
where: { id: userId },
select: { ouraAccessTokenEncrypted: true },
});
return fresh?.ouraAccessTokenEncrypted
? decrypt(fresh.ouraAccessTokenEncrypted)
: null;
},
});
}