feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs

Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 21:25:25 +02:00
1 parent 89634e20ee
commit 75eb0637e6
49 files changed
+2683 -15

No files matched your search

+20
View File
@@ -30,6 +30,7 @@ import {
} from "@/lib/integrations/metric-freshness";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
@@ -47,6 +48,7 @@ export const GET = apiHandler(async () => {
googleHealthStatus,
polarStatus,
ouraStatus,
stravaStatus,
dbUser,
withingsConn,
whoopConn,
@@ -58,6 +60,7 @@ export const GET = apiHandler(async () => {
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
stravaAvailable,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
@@ -66,6 +69,7 @@ export const GET = apiHandler(async () => {
getIntegrationStatus(user.id, "google-health"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -83,6 +87,9 @@ export const GET = apiHandler(async () => {
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
@@ -133,6 +140,7 @@ export const GET = apiHandler(async () => {
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
getStravaClientCredentials(user.id).then((c) => !!c),
]);
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
@@ -259,6 +267,18 @@ export const GET = apiHandler(async () => {
!!dbUser?.ouraClientSecretEncrypted,
metricFreshness: metricFreshness.oura ?? [],
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...stravaStatus,
connected: !!dbUser?.stravaAccessTokenEncrypted,
configured: !!dbUser?.stravaAccessTokenEncrypted,
available: stravaAvailable,
hasOwnCredentials:
!!dbUser?.stravaClientIdEncrypted &&
!!dbUser?.stravaClientSecretEncrypted,
// Strava writes Workout rows, not Measurements, so it has no
// per-metric measurement freshness (like moodLog).
metricFreshness: metricFreshness.strava ?? [],
} satisfies IntegrationViewModel & OAuthProviderExtras,
],
});
});
+122
View File
@@ -0,0 +1,122 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { NextRequest, NextResponse } from "next/server";
/**
* v1.28.x — OAuth callback from Strava.
*
* Identity + CSRF mirror the Polar / Oura callbacks: the `state` param is a
* stateless HMAC-signed token, enforced as a byte-exact double-submit against
* the httpOnly cookie AND verified for signature / expiry / `strava` provider
* binding. The `userId` is read from inside the verified token, cross-checked
* against the web session when present.
*
* On success: exchange the code, persist the encrypted access + rotated refresh
* token + the numeric athlete id on `User`, clear any prior reauth state, and
* redirect back to Settings. A re-auth that returns a DIFFERENT athlete id than
* the one already stored is rejected (`cross_user`) so a second Strava account
* cannot silently overwrite the first's grant.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?strava=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "strava.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const cookieState = request.cookies.get(
oauthStateCookieName("strava"),
)?.value;
if (!stateMatchesCookie(state, cookieState)) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
const verified = verifySignedState("strava", state);
if (!verified) {
annotate({ meta: { reason: "state" } });
return ERR("state");
}
const userId = verified.userId;
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) return ERR("nocode");
try {
const creds = await getStravaClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
const athleteId =
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
// Reject a re-auth that swaps the connected Strava account. A different
// athlete id than the one already pinned means a second account is trying
// to overwrite the first's grant.
const existing = await prisma.user.findUnique({
where: { id: userId },
select: { stravaAthleteId: true },
});
if (
existing?.stravaAthleteId &&
athleteId &&
existing.stravaAthleteId !== athleteId
) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
await prisma.user.update({
where: { id: userId },
data: {
stravaAccessTokenEncrypted: encrypt(tokens.access_token),
stravaRefreshTokenEncrypted: encrypt(tokens.refresh_token),
stravaAthleteId: athleteId,
},
});
await auditLog("strava.connect", { userId });
await markReconnected(userId, "strava");
const response = NextResponse.redirect(
new URL(
"/settings/integrations?strava=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(oauthStateCookieName("strava"));
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+66
View File
@@ -0,0 +1,66 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { NextResponse } from "next/server";
/**
* v1.28.x — redirect the user to the Strava consent screen.
*
* Credentials resolve DB-first then env (`getStravaClientCredentials`): a
* user's BYO Strava client id/secret wins, falling back to the shared env app
* (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via
* the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.connect" } });
const rl = await checkRateLimit(
`strava:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "strava.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?strava=error&reason=rate_limited",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const creds = await getStravaClientCredentials(user.id);
if (!creds) {
return apiError(
"Strava integration is not configured on this server.",
400,
);
}
const state = mintSignedState("strava", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("strava"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
+101
View File
@@ -0,0 +1,101 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
import {
storeStravaClientCredentials,
clearStravaClientCredentials,
} from "@/lib/strava/credentials";
import { stravaCredentialsSchema } from "@/lib/validations/strava";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.28.x — per-user Strava BYO-key credentials.
*
* Whether the user has their own Strava client id/secret stored. The
* connect/callback/status routes resolve credentials DB-first then env, so an
* unset pair simply falls back to the shared env app.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.stravaClientIdEncrypted &&
!!dbUser?.stravaClientSecretEncrypted,
});
});
/**
* Save Strava OAuth client credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(stravaCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await storeStravaClientCredentials(
user.id,
result.data.clientId,
result.data.clientSecret,
);
return apiSuccess({ updated: true });
});
/**
* Delete Strava credentials and the active connection.
*
* Deleting the BYO credentials drops a live connection (a token minted against
* the deleted app is now orphaned), so when an access token was present this
* mirrors `/api/strava/disconnect`: audit the event and park the integration
* ledger at `disconnected` rather than leaving it stale at its last state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.credentials.delete" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { stravaAccessTokenEncrypted: true },
});
const wasConnected = !!dbUser?.stravaAccessTokenEncrypted;
await prisma.user.update({
where: { id: user.id },
data: {
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
},
});
await clearStravaClientCredentials(user.id);
if (wasConnected) {
await auditLog("strava.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "strava");
}
return apiSuccess({ deleted: true });
});
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
import { deauthorize } from "@/lib/strava/client";
import { getStravaConnection } from "@/lib/strava/credentials";
/**
* v1.28.x — disconnect Strava for the current user.
*
* Best-effort deauthorize at Strava, then clear the stored access + refresh
* token + athlete id on `User` and park the integration status at
* `disconnected`. Imported `source = STRAVA` workouts are left intact; a
* reconnect resumes sync.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.disconnect" } });
const rl = await checkRateLimit(`strava-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const connection = await getStravaConnection(user.id);
if (!connection) {
return apiError("No Strava connection", 404);
}
// Best-effort revoke at Strava; a Strava-side outage must never block the
// local disconnect.
try {
await deauthorize(connection.accessToken);
} catch (err) {
getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`);
}
await prisma.user.update({
where: { id: user.id },
data: {
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
},
});
await auditLog("strava.disconnect", { userId: user.id });
await markDisconnected(user.id, "strava");
return apiSuccess({ disconnected: true });
});
+57
View File
@@ -0,0 +1,57 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.28.x — Strava connection status for the current user.
*
* `available` reports whether usable OAuth credentials resolve for this user —
* per-user BYO keys first, then the shared env app. `hasOwnCredentials` reports
* whether the user has stored their own BYO pair. `connected` is whether a
* token is stored. The ledger snapshot comes off the shared `strava`
* IntegrationKey. The access / refresh tokens + client secret are NEVER
* returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
},
});
const available = !!(await getStravaClientCredentials(user.id));
const connected = !!dbUser?.stravaAccessTokenEncrypted;
const hasOwnCredentials =
!!dbUser?.stravaClientIdEncrypted && !!dbUser?.stravaClientSecretEncrypted;
if (!connected) {
return apiSuccess({
connected: false,
configured: false,
available,
hasOwnCredentials,
});
}
const status = await getIntegrationStatus(user.id, "strava");
return apiSuccess({
connected: true,
configured: true,
available,
hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
});
});
+91
View File
@@ -0,0 +1,91 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { getStravaConnection } from "@/lib/strava/credentials";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Lightweight token probe — fetch the authenticated athlete, the cheapest
// authenticated Strava call that confirms the stored grant is still valid.
// Reuses `getStravaConnection`; no new credential path.
const STRAVA_ATHLETE_URL = "https://www.strava.com/api/v3/athlete";
const TIMEOUT_MS = 5_000;
function categoriseHttpStatus(status: number): {
code: string;
message: string;
} {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Strava rejected the token",
};
}
if (status === 429) {
return { code: "rate_limited", message: "Strava rate-limited the request" };
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Strava returned a server error",
};
}
return { code: "connection_failed", message: "Strava connection failed" };
}
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.test" } });
const rl = await checkRateLimit(`strava-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const connection = await getStravaConnection(user.id);
if (!connection) {
return apiError("Strava not connected", 422, {
errorCode: "not_configured",
});
}
const start = performance.now();
try {
const res = await safeFetch(
STRAVA_ATHLETE_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${connection.accessToken}`,
Accept: "application/json",
},
},
{ timeoutMs: TIMEOUT_MS },
);
const latencyMs = Math.round(performance.now() - start);
if (!res.ok) {
const cat = categoriseHttpStatus(res.status);
annotate({
meta: { strava_test_status: res.status, strava_test_code: cat.code },
});
return apiError(cat.message, 502, { errorCode: cat.code });
}
return apiSuccess({ ok: true, latencyMs });
} catch (e) {
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
const code = isTimeout ? "timeout" : "connection_failed";
annotate({ meta: { strava_test_code: code } });
return apiError(
isTimeout ? "Strava request timed out" : "Strava connection failed",
502,
{ errorCode: code },
);
}
});