Files
HealthLog/src/lib/integrations/oauth-refresh.ts
T
Marc-André Bombeck 23f6fe4c6e feat(integrations): add one token lifecycle for every OAuth provider
A provider describes where its grant lives and how its token endpoint is
called; the kit reads the grant, refreshes it under the per-user advisory
lock, re-reads under the lock so a peer's refresh or a reconnect wins, and
writes back only over the refresh token it spent. Retry-After is read in
one place, both forms, with a cap.
2026-10-21 19:13:16 +02:00

100 lines
4.2 KiB
TypeScript

import type { Prisma } from "@/generated/prisma/client";
/**
* Shared OAuth rotating-refresh-token persistence helper.
*
* Fitbit, WHOOP, and Oura all issue ONE-TIME-USE refresh tokens: every refresh
* response carries a fresh `refresh_token` that invalidates the one just spent.
* The naive read → refresh → persist sequence is racy. When two syncs for the
* same user overlap (a scheduled tick collides with a manual sync, or two
* resource syncs each call `getValidToken`), both read the SAME stored refresh
* token, both POST it, and the provider honours only the first. The loser's POST
* comes back 401, the caller classifies it as `reauth_required`, and the
* connection is parked at `error_reauth` — spuriously, because the peer already
* rotated the token successfully and the connection is perfectly healthy.
*
* The fix is a compare-and-swap on persistence: write the rotated tokens with a
* `WHERE refreshToken = <the exact ciphertext we read>` guard. The tokens are
* AES-256-GCM encrypted with a random IV, so the guard matches the STORED
* ciphertext (passed in verbatim), never a re-encryption of the plaintext —
* re-encrypting the same plaintext yields different bytes. If the conditional
* update touches zero rows a peer refresh already won; re-read the row and reuse
* the peer's freshly-rotated access token instead of erroring.
*
* Both racing callers thus end up with a usable access token and neither parks
* the connection. The window is small but real on the shared scheduler, and a
* single spurious reauth forces the user back through the consent flow.
*/
export interface PersistRotatedTokenOptions {
/**
* Conditional persist: update the connection row ONLY when its stored refresh
* token still equals the ciphertext this caller read (compare-and-swap on the
* stored bytes). Returns the number of rows written — 0 means a peer rotated
* first. The closure owns the encryption + the `WHERE` clause so the helper
* stays storage-agnostic across the three provider tables.
*/
conditionalUpdate: () => Promise<number>;
/**
* Re-read the connection AFTER a lost race and return the peer's freshly
* rotated, DECRYPTED access token. Returns null only if the row vanished
* (connection deleted mid-flight) — the caller then treats it as no token.
*/
readPeerAccessToken: () => Promise<string | null>;
}
/**
* Persist a rotated token pair with compare-and-swap semantics, falling back to
* the peer's token on a lost race. Returns the access token the caller should
* use for the current sync — the one it just minted on a win, or the peer's on a
* loss — or null when the connection no longer exists.
*/
export async function persistRotatedToken(
freshAccessToken: string,
opts: PersistRotatedTokenOptions,
): Promise<string | null> {
const rows = await opts.conditionalUpdate();
if (rows > 0) return freshAccessToken;
// Lost the race: a concurrent refresh already rotated the stored token, which
// invalidated the one we spent. Reuse the peer's freshly rotated access token
// rather than parking the connection at reauth.
return opts.readPeerAccessToken();
}
export type SerializedRefreshProvider =
| "whoop"
| "withings"
| "oura"
| "strava"
| "google-health"
| "polar"
| "codex"
| "admin-codex";
/**
* Provider refreshes hold a database transaction open around one bounded
* external request. `maxWait` bounds pool acquisition and `timeout` bounds the
* advisory-lock wait, provider call, and durable token replacement together.
*/
export const PROVIDER_REFRESH_TRANSACTION_OPTIONS = {
maxWait: 10_000,
timeout: 60_000,
} as const;
/**
* Serialize one provider/user refresh attempt for the transaction lifetime.
* Callers must re-read token state after this returns: a prior contender may
* have committed a fresh token while this transaction waited for the lock.
*/
export async function acquireProviderTokenRefreshLock(
tx: Pick<Prisma.TransactionClient, "$queryRaw">,
provider: SerializedRefreshProvider,
userId: string,
): Promise<void> {
const key = `provider-token-refresh:${provider}:${userId}`;
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${key}, 0))
`;
}