fix(strava): serialise the token refresh like the other providers

Strava rotates its refresh token on every use. The sync relied on a
compare-and-swap alone, so two overlapping runs could both spend the same
token; the refresh now runs under the per-user lock.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:13:35 +02:00
1 parent b710cb732b
commit a12174bcaa
4 files changed
+70 -114

No files matched your search

+1 -56
View File
@@ -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();
});
});
+2 -46
View File
@@ -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<string | null> {
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;
},
});
}
+64
View File
@@ -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<StravaCredentials> = {
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<string | null> {
const result = await obtainAccessToken(stravaTokenDescriptor, userId, {
afterRefusalOf: spentRefreshCiphertext,
});
return result.kind === "token" ? result.accessToken : null;
}
+3 -12
View File
@@ -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<boolean> {
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;