mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
151 lines
5.0 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|