trails/apps/journal/app/lib/connected-services/oauth-state.server.ts
Ullrich Schäfer 0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00

59 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// OAuth state encoding for the connect/callback flow. The state param is
// reflected back to the callback unchanged, so we use it to carry
// post-callback intent (where to return to, whether a push should resume).
export interface PushOAuthState {
pushAfter?: { routeId: string };
returnTo?: string;
}
export function encodeOAuthState(state: PushOAuthState): string {
return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
}
export function decodeOAuthState(raw: string | null | undefined): PushOAuthState {
if (!raw) return {};
try {
const json = Buffer.from(raw, "base64url").toString("utf8");
const parsed = JSON.parse(json) as PushOAuthState;
return typeof parsed === "object" && parsed != null ? parsed : {};
} catch {
return {};
}
}
// --- PKCE (RFC 7636) ----------------------------------------------------
//
// Providers with `pkce: true` (Garmin) need a code verifier that survives
// the connect → provider → callback redirect without ever appearing in a
// URL (the whole point of PKCE is that the verifier stays out of the
// authorization response). The `state` param is visible in redirects, so
// the verifier rides a short-lived httpOnly cookie scoped to the callback
// path instead.
import { createHash, randomBytes } from "node:crypto";
const PKCE_COOKIE = "__oauth_pkce";
const PKCE_MAX_AGE_S = 600;
export function generatePkcePair(): { verifier: string; challenge: string } {
// 32 random bytes → 43-char base64url verifier (within RFC 7636's 43128).
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
export function pkceCookieHeader(verifier: string): string {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`;
}
export function clearPkceCookieHeader(): string {
return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`;
}
export function readPkceVerifier(request: Request): string | null {
const cookie = request.headers.get("Cookie") ?? "";
const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`));
return match?.[1] ?? null;
}