mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
b710cb732b
commit
a12174bcaa
4 files changed
+70
-114
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user