From eda90eeb560e142192520c6fa70a620fd2a46606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 07:31:15 +0200 Subject: [PATCH] fix(strava): read a dead refresh token from Strava's own fault shape Strava's token endpoint does not answer RFC 6749 errors. A refresh token it no longer honours, after the athlete revoked the app or the token was rotated away, is a 400 with the API's generic Fault: {"message":"Bad Request","errors":[{"resource":"RefreshToken", "field":"refresh_token","code":"invalid"}]}. The classifier looked for invalid_grant, which never arrives, so the failure was booked persistent and the connection never asked for a reconnect. The token error now carries the fault reduced to its resource, field and code (or an RFC error code, should one arrive), and the classifier lifts a 400 naming RefreshToken to reauth_required, from the error and from its message after a retry loses the prototype. Every other refusal is classified by its status as before, including the faults that name the Application. Strava documents the Fault and Error model but not these values; the body is the one several unrelated client libraries captured from live traffic between 2023 and 2026. --- .../strava/__tests__/token-refusal.test.ts | 140 ++++++++++++++++++ src/lib/strava/client.ts | 11 +- src/lib/strava/response-classifier.ts | 75 ++++++++-- src/lib/strava/sync.ts | 5 +- 4 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 src/lib/strava/__tests__/token-refusal.test.ts diff --git a/src/lib/strava/__tests__/token-refusal.test.ts b/src/lib/strava/__tests__/token-refusal.test.ts new file mode 100644 index 000000000..207f99f4a --- /dev/null +++ b/src/lib/strava/__tests__/token-refusal.test.ts @@ -0,0 +1,140 @@ +/** + * What Strava's token endpoint answers when it refuses, and what the sync + * makes of it. + * + * Strava does not answer RFC 6749 errors. A refused refresh is the API's + * generic `Fault`; the bodies below are the ones the endpoint sends, as + * captured from live traffic by several unrelated client libraries (Strava + * documents the `Fault` / `Error` model, not these particular values). The + * HTTP layer is the only thing replaced: `postToken`, the error it throws and + * the classifier all run as they do in production. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() })); + +import { safeFetch } from "@/lib/safe-fetch"; + +import { exchangeCode, refreshAccessToken } from "../client"; +import { + classifyStravaError, + stravaFaultSummary, + StravaApiError, +} from "../response-classifier"; +import { classifyStravaFailure } from "../sync"; + +const creds = { clientId: "12345", clientSecret: "not-a-real-secret" }; + +const DEAD_REFRESH_TOKEN = { + message: "Bad Request", + errors: [ + { resource: "RefreshToken", field: "refresh_token", code: "invalid" }, + ], +}; +const BAD_CLIENT_ID = { + message: "Bad Request", + errors: [{ resource: "Application", field: "client_id", code: "invalid" }], +}; +const BAD_CLIENT = { + message: "Authorization Error", + errors: [{ resource: "Application", field: "", code: "invalid" }], +}; + +function answer(status: number, body: unknown) { + vi.mocked(safeFetch).mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +async function refusal(run: () => Promise): Promise { + const err = await run().then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(StravaApiError); + return err as StravaApiError; +} + +beforeEach(() => { + vi.mocked(safeFetch).mockReset(); +}); + +describe("a refused Strava token request", () => { + it("asks for a reconnect when the refresh token is dead", async () => { + answer(400, DEAD_REFRESH_TOKEN); + const err = await refusal(() => refreshAccessToken("rt-dead", creds)); + + expect(err.httpStatus).toBe(400); + expect(err.upstreamError).toBe("RefreshToken.refresh_token:invalid"); + expect(classifyStravaError(err)).toBe("reauth_required"); + expect(classifyStravaFailure(err)).toBe("reauth_required"); + }); + + it("still asks for a reconnect when the error lost its prototype on a retry", async () => { + answer(400, DEAD_REFRESH_TOKEN); + const err = await refusal(() => refreshAccessToken("rt-dead", creds)); + const replayed = new Error(err.message); + + expect(replayed.message).toBe( + "Strava refreshAccessToken error: 400 - RefreshToken.refresh_token:invalid", + ); + expect(classifyStravaError(replayed)).toBe("reauth_required"); + }); + + it("leaves a fault that names the application to the status, as before", async () => { + // A client id or secret the instance holds wrongly. Whether Strava ever + // answers a per-person condition this way is not documented, so the + // status decides, as it always has: 400 persistent, 401 reconnect. + answer(400, BAD_CLIENT_ID); + const bad = await refusal(() => refreshAccessToken("rt-live", creds)); + expect(bad.upstreamError).toBe("Application.client_id:invalid"); + expect(classifyStravaError(bad)).toBe("persistent"); + + answer(401, BAD_CLIENT); + const unauthorised = await refusal(() => + refreshAccessToken("rt-live", creds), + ); + expect(unauthorised.upstreamError).toBe("Application:invalid"); + expect(classifyStravaError(unauthorised)).toBe("reauth_required"); + }); + + it("leaves a 400 that names neither as persistent and a 5xx as transient", async () => { + answer(400, { message: "Bad Request", errors: [] }); + const plain = await refusal(() => exchangeCode("code", creds)); + expect(plain.upstreamError).toBe("Bad Request"); + expect(classifyStravaError(plain)).toBe("persistent"); + + answer(503, { message: "Service Unavailable" }); + const outage = await refusal(() => refreshAccessToken("rt-live", creds)); + expect(classifyStravaError(outage)).toBe("transient"); + }); + + it("still honours a standards-shaped invalid_grant", async () => { + answer(400, { error: "invalid_grant" }); + const err = await refusal(() => refreshAccessToken("rt-dead", creds)); + expect(err.upstreamError).toBe("invalid_grant"); + expect(classifyStravaError(err)).toBe("reauth_required"); + }); +}); + +describe("stravaFaultSummary", () => { + it("names only the resource, field and code, and drops anything else", () => { + expect( + stravaFaultSummary({ + message: "Bad Request", + errors: [ + { + resource: "RefreshToken", + field: "refresh_token