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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 07:31:15 +02:00
1 parent d396945f44
commit eda90eeb56
4 files changed
+217 -14

No files matched your search

@@ -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<unknown>): Promise<StravaApiError> {
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<script>",
code: "invalid",
},
],
}),
).toBe("RefreshToken:invalid");
expect(stravaFaultSummary(null)).toBeUndefined();
expect(stravaFaultSummary({ errors: "nope" })).toBeUndefined();
});
});
+8 -3
View File
@@ -25,7 +25,11 @@ import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after";
import type { Prisma } from "@/generated/prisma/client";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
import {
StravaApiError,
classifyStravaResponse,
stravaFaultSummary,
} from "./response-classifier";
import { mapStravaSportType } from "./sport-map";
import type { WorkoutSportType } from "@/lib/validations/workout";
import { envOr, envValue } from "@/lib/env";
@@ -128,8 +132,9 @@ async function postToken(
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.message === "string" ? json.message : undefined,
// Strava's `Fault`, reduced to the resource it names; the classifier
// tells a dead refresh token from a misconfigured app by it.
upstreamError: stravaFaultSummary(json),
});
}
return json as StravaTokenResponse;
+66 -9
View File
@@ -7,8 +7,9 @@
* - `success` → HTTP 2xx
* - `transient` → 429 (rate-limited — respect X-RateLimit headers),
* 5xx, 3xx, unknown.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus an
* `invalid_grant` on the refresh endpoint.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus a
* dead refresh token on the token endpoint (see
* `isStravaDeadRefreshToken`).
* - `persistent` → any other 4xx — malformed request / contract drift.
*/
@@ -55,16 +56,72 @@ export function isStravaRateLimited(err: unknown): err is StravaApiError {
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
* carrying `invalid_grant` from `persistent` to `reauth_required` so the
* connection surfaces the reconnect prompt.
* Strava's token endpoint does not answer in RFC 6749 form. A refusal is the
* API's generic `Fault` (developers.strava.com/docs/reference, "Fault" and
* "Error"): `{"message":"Bad Request","errors":[{"resource":"RefreshToken",
* "field":"refresh_token","code":"invalid"}]}`, with no `error` member at all,
* so an `invalid_grant` check never matches it. This reduces the fault to one
* stable token for the error message — `<resource>.<field>:<code>` of the first
* entry, e.g. `RefreshToken.refresh_token:invalid` or `Application:invalid` —
* and falls back to an RFC 6749 `error` code, then to `message`. Nothing of
* the body beyond those names reaches the message.
*/
function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Strava");
export function stravaFaultSummary(body: unknown): string | undefined {
if (typeof body !== "object" || body === null) return undefined;
const fault = body as {
message?: unknown;
errors?: unknown;
error?: unknown;
};
// A standards-shaped body (`{"error":"invalid_grant"}`) keeps its code.
if (typeof fault.error === "string" && /^[a-z_]{1,40}$/.test(fault.error)) {
return fault.error;
}
const first = Array.isArray(fault.errors) ? fault.errors[0] : undefined;
if (typeof first === "object" && first !== null) {
const { resource, field, code } = first as Record<string, unknown>;
if (typeof resource === "string" && /^[A-Za-z]{1,40}$/.test(resource)) {
const at =
typeof field === "string" && /^[A-Za-z_]{1,40}$/.test(field)
? `.${field}`
: "";
const why =
typeof code === "string" && /^[A-Za-z_]{1,40}$/.test(code)
? `:${code}`
: "";
return `${resource}${at}${why}`;
}
}
return typeof fault.message === "string"
? fault.message.slice(0, 120)
: undefined;
}
/**
* True for a refresh token Strava no longer honours: a 400 whose fault names
* the `RefreshToken` resource. That is what the token endpoint answers once the
* athlete revoked the app or the token was rotated away, and the only way back
* is a new authorisation, so it lifts the verdict to `reauth_required`. A
* standards-shaped `invalid_grant` is accepted too, in case Strava ever sends
* one.
*
* Robust to a lost prototype across a pg-boss retry, like
* `isOAuthInvalidGrant`: it falls back to the
* `"Strava <verb> error: 400 - RefreshToken…"` message shape.
*/
export function isStravaDeadRefreshToken(err: unknown): boolean {
if (isOAuthInvalidGrant(err, "Strava")) return true;
if (err instanceof IntegrationApiError) {
return (
err.httpStatus === 400 &&
(err.upstreamError?.startsWith("RefreshToken") ?? false)
);
}
const msg = err instanceof Error ? err.message : String(err);
return /Strava\s+\w+\s+error:\s*400\s+-\s+RefreshToken\b/.test(msg);
}
export function classifyStravaError(err: unknown): IntegrationClassification {
if (isInvalidGrant(err)) return "reauth_required";
if (isStravaDeadRefreshToken(err)) return "reauth_required";
return classifyIntegrationError(err, "Strava");
}
+3 -2
View File
@@ -15,8 +15,9 @@
* Token model: Strava ROTATES its refresh token on every refresh. The schema
* has no expiry column, so the sync refreshes REACTIVELY — the first read that
* 401s triggers one refresh (persisting BOTH rotated tokens via a compare-and-
* set) and a single retry. A failed refresh (`invalid_grant`) records
* `reauth_required` on the `strava` ledger.
* set) and a single retry. A refresh Strava refuses for a dead token (a 400
* whose fault names `RefreshToken`) records `reauth_required` on the `strava`
* ledger; any other refusal is classified by its status.
*
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
* (`walkPages`, which reports a walk cut off at its ceiling instead of