diff --git a/src/lib/strava/__tests__/credentials.test.ts b/src/lib/strava/__tests__/credentials.test.ts index 138e8a187..708f56d22 100644 --- a/src/lib/strava/__tests__/credentials.test.ts +++ b/src/lib/strava/__tests__/credentials.test.ts @@ -22,7 +22,7 @@ vi.mock("../client", () => ({ getStravaCredentials: () => getStravaCredentials(), })); -import { getStravaClientCredentials, storeStravaTokens } from "../credentials"; +import { getStravaClientCredentials } from "../credentials"; describe("getStravaClientCredentials — DB-first then env", () => { beforeEach(() => { @@ -57,58 +57,3 @@ describe("getStravaClientCredentials — DB-first then env", () => { expect(getStravaCredentials).toHaveBeenCalledOnce(); }); }); - -describe("storeStravaTokens — rotating-refresh compare-and-swap", () => { - beforeEach(() => { - findUnique.mockReset(); - updateMany.mockReset(); - }); - - it("persists and returns the fresh access token when the CAS wins", async () => { - updateMany.mockResolvedValue({ count: 1 }); - const token = await storeStravaTokens( - "user-1", - "fresh-access", - "fresh-refresh", - "stored-refresh-ciphertext", - ); - expect(token).toBe("fresh-access"); - // The guard keys on the exact stored ciphertext, not a re-encryption. - expect(updateMany).toHaveBeenCalledWith({ - where: { - id: "user-1", - stravaRefreshTokenEncrypted: "stored-refresh-ciphertext", - }, - data: { - stravaAccessTokenEncrypted: "enc(fresh-access)", - stravaRefreshTokenEncrypted: "enc(fresh-refresh)", - }, - }); - }); - - it("returns the peer's rotated access token when the CAS loses the race", async () => { - updateMany.mockResolvedValue({ count: 0 }); - findUnique.mockResolvedValue({ - stravaAccessTokenEncrypted: "peer-access-cipher", - }); - const token = await storeStravaTokens( - "user-1", - "fresh-access", - "fresh-refresh", - "stale-ciphertext", - ); - expect(token).toBe("dec(peer-access-cipher)"); - }); - - it("returns null when the connection vanished mid-race", async () => { - updateMany.mockResolvedValue({ count: 0 }); - findUnique.mockResolvedValue({ stravaAccessTokenEncrypted: null }); - const token = await storeStravaTokens( - "user-1", - "fresh-access", - "fresh-refresh", - "stale-ciphertext", - ); - expect(token).toBeNull(); - }); -}); diff --git a/src/lib/strava/credentials.ts b/src/lib/strava/credentials.ts index 768911c8e..4410aac16 100644 --- a/src/lib/strava/credentials.ts +++ b/src/lib/strava/credentials.ts @@ -12,7 +12,6 @@ */ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; -import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; import { getStravaCredentials, type StravaCredentials } from "./client"; /** @@ -91,8 +90,8 @@ export interface StravaConnection { refreshToken: string; /** * The exact stored refresh-token ciphertext (NOT the decrypted value). The - * rotating-refresh compare-and-swap in `storeStravaTokens` matches on this so - * a concurrent sync that already rotated the token is not clobbered. + * refresh after a refusal (`refreshStravaAfterRefusal`) compares against it + * so a concurrent sync that already rotated the token is not clobbered. */ refreshTokenCiphertext: string; } @@ -116,46 +115,3 @@ export async function getStravaConnection( refreshTokenCiphertext: user.stravaRefreshTokenEncrypted, }; } - -/** - * Persist a rotated Strava token pair with compare-and-swap on the stored - * refresh ciphertext. Strava'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 storeStravaTokens( - userId: string, - accessToken: string, - refreshToken: string, - expectedRefreshCiphertext: string, -): Promise { - return persistRotatedToken(accessToken, { - conditionalUpdate: async () => { - const { count } = await prisma.user.updateMany({ - where: { - id: userId, - stravaRefreshTokenEncrypted: expectedRefreshCiphertext, - }, - data: { - stravaAccessTokenEncrypted: encrypt(accessToken), - stravaRefreshTokenEncrypted: encrypt(refreshToken), - }, - }); - return count; - }, - readPeerAccessToken: async () => { - const fresh = await prisma.user.findUnique({ - where: { id: userId }, - select: { stravaAccessTokenEncrypted: true }, - }); - return fresh?.stravaAccessTokenEncrypted - ? decrypt(fresh.stravaAccessTokenEncrypted) - : null; - }, - }); -} diff --git a/src/lib/strava/oauth-descriptor.ts b/src/lib/strava/oauth-descriptor.ts new file mode 100644 index 000000000..52e9c1aa9 --- /dev/null +++ b/src/lib/strava/oauth-descriptor.ts @@ -0,0 +1,64 @@ +/** + * Where a Strava grant lives and how its token endpoint is called, for the + * shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * Strava's tokens sit on `User`, with no expiry column: the sync refreshes + * after a refused read, and Strava rotates the refresh token on every use. + */ +import { + obtainAccessToken, + type OAuthTokenDescriptor, +} from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type StravaCredentials } from "./client"; +import { getStravaClientCredentials } from "./credentials"; + +export const stravaTokenDescriptor: OAuthTokenDescriptor = { + provider: "strava", + tracksExpiry: false, + expiryBufferMs: 0, + async readGrant(db, userId) { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { + stravaAccessTokenEncrypted: true, + stravaRefreshTokenEncrypted: true, + }, + }); + if (!row?.stravaAccessTokenEncrypted || !row.stravaRefreshTokenEncrypted) { + return null; + } + return { + accessCiphertext: row.stravaAccessTokenEncrypted, + refreshCiphertext: row.stravaRefreshTokenEncrypted, + expiresAt: null, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.user.updateMany({ + where: { id: userId, stravaRefreshTokenEncrypted: spent }, + data: { + stravaAccessTokenEncrypted: next.accessCiphertext, + stravaRefreshTokenEncrypted: next.refreshCiphertext, + }, + }); + return count; + }, + loadCredentials: (userId) => getStravaClientCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), +}; + +/** + * Refresh after Strava refused the token read with `spentRefreshCiphertext`, + * once however many syncs ask together. Returns the access token to retry + * with, or null when there is nothing to retry with. A refused refresh throws. + */ +export async function refreshStravaAfterRefusal( + userId: string, + spentRefreshCiphertext: string, +): Promise { + const result = await obtainAccessToken(stravaTokenDescriptor, userId, { + afterRefusalOf: spentRefreshCiphertext, + }); + return result.kind === "token" ? result.accessToken : null; +} diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 35752d1b3..739e7a1b1 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -44,16 +44,12 @@ import { fetchActivities, fetchActivityById, mapActivity, - refreshAccessToken, summaryHasHeartRate, type StravaDetailedActivity, type StravaWorkoutRow, } from "./client"; -import { - getStravaClientCredentials, - getStravaConnection, - storeStravaTokens, -} from "./credentials"; +import { getStravaConnection } from "./credentials"; +import { refreshStravaAfterRefusal } from "./oauth-descriptor"; import { StravaApiError, classifyStravaError, @@ -173,13 +169,8 @@ async function runStravaSync( async function refreshOnce(): Promise { if (refreshDone) return false; - const creds = await getStravaClientCredentials(userId); - if (!creds) return false; - const rotated = await refreshAccessToken(conn!.refreshToken, creds); - const usable = await storeStravaTokens( + const usable = await refreshStravaAfterRefusal( userId, - rotated.access_token, - rotated.refresh_token, conn!.refreshTokenCiphertext, ); if (!usable) return false;