Files
HealthLog/src/lib/strava/credentials.ts
T
Marc-André Bombeck 75eb0637e6 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.
2026-07-09 21:25:25 +02:00

146 lines
4.8 KiB
TypeScript

/**
* v1.28.x — per-user Strava token + BYO-key accessors.
*
* Like WHOOP / Oura / Polar, Strava is a per-user BYO-key integration: each
* user registers their own Strava app and stores the client id/secret encrypted
* on `User`, with a fall-back to the shared env app for single-app deploys.
* Also per-user is the granted access + refresh token. The schema carries NO
* token-expiry column, so the sync layer refreshes REACTIVELY on a 401 rather
* than proactively on an expiry timestamp — and because Strava ROTATES its
* refresh token on every refresh, both rotated tokens are persisted with a
* compare-and-set on the stored refresh ciphertext (Oura's model verbatim).
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
import { getStravaCredentials, type StravaCredentials } from "./client";
/**
* Resolve the user's Strava OAuth client id/secret, DB-first then env. A
* self-hoster's own BYO pair wins; when none is stored we fall back to the
* shared env app (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`). Returns null
* when neither source is configured.
*/
export async function getStravaClientCredentials(
userId: string,
): Promise<StravaCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
},
});
if (user?.stravaClientIdEncrypted && user?.stravaClientSecretEncrypted) {
return {
clientId: decrypt(user.stravaClientIdEncrypted),
clientSecret: decrypt(user.stravaClientSecretEncrypted),
};
}
return getStravaCredentials();
}
/** Persist the user's Strava OAuth client id/secret, encrypted at rest. */
export async function storeStravaClientCredentials(
userId: string,
clientId: string,
clientSecret: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
stravaClientIdEncrypted: encrypt(clientId),
stravaClientSecretEncrypted: encrypt(clientSecret),
},
});
}
/** Clear the user's stored Strava OAuth client id/secret. */
export async function clearStravaClientCredentials(
userId: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
},
});
}
export interface StravaConnection {
accessToken: string;
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.
*/
refreshTokenCiphertext: string;
}
export async function getStravaConnection(
userId: string,
): Promise<StravaConnection | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
stravaAccessTokenEncrypted: true,
stravaRefreshTokenEncrypted: true,
},
});
if (!user?.stravaAccessTokenEncrypted || !user?.stravaRefreshTokenEncrypted) {
return null;
}
return {
accessToken: decrypt(user.stravaAccessTokenEncrypted),
refreshToken: decrypt(user.stravaRefreshTokenEncrypted),
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;
},
});
}