From 1eb9e0a69f52464797c60be496cf8816125d461b Mon Sep 17 00:00:00 2001 From: Andrew McCallum Date: Fri, 30 Oct 2026 14:18:04 +0000 Subject: [PATCH] Connect intervals.icu by signing in (OAuth), keeping API keys Servers that register an intervals.icu OAuth app and set INTERVALS_CLIENT_ID and INTERVALS_CLIENT_SECRET offer a "Connect with intervals.icu" button that asks only to read activities. Pasting an API key still works everywhere, and is all there is on servers without an app. Existing connections keep working (auth_method defaults to api_key). The sign-in uses PKCE and a signed, short-lived cookie for the state. Disconnecting revokes the token. Settings refreshes while a sync runs, so the first sync after the redirect shows up, and the PWA no longer caches navigations under /api. Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 + README.md | 6 +- ROADMAP.md | 3 +- apps/api/src/config.ts | 17 +- apps/api/src/integrations.spec.ts | 249 +- apps/api/src/integrations/intervals.ts | 119 +- apps/api/src/integrations/sync.ts | 19 +- apps/api/src/routes/integrations.ts | 261 +- apps/web-e2e/fake-intervals.mjs | 38 +- apps/web-e2e/playwright.config.mts | 2 + apps/web-e2e/src/smoke.spec.ts | 34 +- .../web/src/components/intervals-settings.tsx | 247 +- apps/web/src/lib/integrations.ts | 3 + apps/web/src/pages/privacy.tsx | 7 +- apps/web/vite.config.mts | 6 +- docs/ARCHITECTURE.md | 12 +- docs/PRIVACY.md | 5 +- .../migrations/0008_intervals_oauth.sql | 1 + .../migrations/meta/0008_snapshot.json | 3140 +++++++++++++++++ .../migrations/meta/_journal.json | 7 + libs/db/src/schema/integrations.ts | 10 +- libs/schemas/src/integrations.ts | 22 + 22 files changed, 4069 insertions(+), 147 deletions(-) create mode 100644 libs/db-migrations/migrations/0008_intervals_oauth.sql create mode 100644 libs/db-migrations/migrations/meta/0008_snapshot.json diff --git a/.env.example b/.env.example index 35a8165..18b8377 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,14 @@ TRUST_PROXY=false # from it. Leave empty to turn watch sync off. # INTERVALS_URL=https://intervals.icu +# Optional: an intervals.icu OAuth app, so members connect with a button +# instead of pasting an API key (pasting a key keeps working). Apply at +# https://intervals.icu/oauth/apply with this redirect URI: +# /api/integrations/intervals/oauth/callback +# and wait for intervals.icu to approve it. Use the client id and secret here. +# INTERVALS_CLIENT_ID= +# INTERVALS_CLIENT_SECRET= + # Web Push keys for notifications. If unset, a pair is generated on first # start and kept in DATA_DIR/vapid.json. Changing them means everyone has to # turn notifications on again. Generate with: npx web-push generate-vapid-keys diff --git a/README.md b/README.md index 13f36ba..c891de9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Open the URL. The first account you create becomes the admin. Everything is stor Passkeys need HTTPS (or `localhost`), so put openRun behind a reverse proxy such as Caddy or Traefik for anything beyond local use. When you do, set `TRUST_PROXY=true`. All settings are listed in [.env.example](.env.example). +### Watch sync + +Members sync their watches through [intervals.icu](https://intervals.icu), which works out of the box: each member pastes their intervals.icu API key in Settings. To give them a "Connect with intervals.icu" button instead, [apply for an intervals.icu app](https://intervals.icu/oauth/apply) with the redirect URI `https:///api/integrations/intervals/oauth/callback`. Once intervals.icu approves it, set `INTERVALS_CLIENT_ID` and `INTERVALS_CLIENT_SECRET` in `.env` and restart. Members who pasted a key keep syncing; pasting a key still works for anyone who prefers it. + ### Upgrading Back up first, then: @@ -48,7 +52,7 @@ Database changes are applied when openRun starts; `docker compose logs openrun` ### Backups -Everything lives in two places: the `./data` folder and your `.env`. Keep `.env` safe as well, because `BETTER_AUTH_SECRET` signs everyone's sessions and encrypts their intervals.icu keys. A database restored without it works, but everyone has to sign in and paste their intervals.icu key again. +Everything lives in two places: the `./data` folder and your `.env`. Keep `.env` safe as well, because `BETTER_AUTH_SECRET` signs everyone's sessions and encrypts their intervals.icu keys and sign-ins. A database restored without it works, but everyone has to sign in and connect intervals.icu again. `./data` holds: diff --git a/ROADMAP.md b/ROADMAP.md index 085ae5b..05f37f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,4 +67,5 @@ Through intervals.icu; see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) → Inte - Capacitor apps for iOS and Android - Postgres option, OIDC sign-in - Phone app reading Apple Health and Health Connect (the best route for Apple Watch and Android watches) -- intervals.icu OAuth and webhooks instead of API keys and polling; ICS feed for club runs +- ✅ Connecting intervals.icu by signing in (OAuth), on servers with an intervals.icu app; pasting an API key still works +- intervals.icu webhooks instead of polling, for servers with an app; ICS feed for club runs diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 3e3b497..662c1a3 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -37,6 +37,13 @@ const envSchema = z.object({ WEATHER_URL: z.string().default('https://api.open-meteo.com/v1/forecast'), /** intervals.icu, for syncing runs from watches. Empty turns it off. */ INTERVALS_URL: z.string().default('https://intervals.icu'), + /** + * An intervals.icu OAuth app, so members connect with a button instead of + * pasting an API key. Register one at https://intervals.icu/oauth/apply + * with ORIGIN/api/integrations/intervals/oauth/callback as the redirect URI. + */ + INTERVALS_CLIENT_ID: z.string().optional(), + INTERVALS_CLIENT_SECRET: z.string().optional(), /** Web Push keys. Generated and kept in DATA_DIR if not set. */ VAPID_PUBLIC_KEY: z.string().optional(), VAPID_PRIVATE_KEY: z.string().optional(), @@ -55,6 +62,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) { ); } const dataDir = resolve(e.DATA_DIR); + const intervalsUrl = e.INTERVALS_URL.trim().replace(/\/+$/, '') || null; + const intervalsClientId = e.INTERVALS_CLIENT_ID?.trim(); + const intervalsClientSecret = e.INTERVALS_CLIENT_SECRET?.trim(); return { production, port: e.PORT, @@ -82,7 +92,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) { trustProxy: e.TRUST_PROXY, placeSearchUrl: e.PLACE_SEARCH_URL.trim() || null, weatherUrl: e.WEATHER_URL.trim() || null, - intervalsUrl: e.INTERVALS_URL.trim().replace(/\/+$/, '') || null, + intervalsUrl, + /** Null when members have to paste an API key instead. */ + intervalsOAuth: + intervalsUrl && intervalsClientId && intervalsClientSecret + ? { clientId: intervalsClientId, clientSecret: intervalsClientSecret } + : null, vapidKeys: e.VAPID_PUBLIC_KEY && e.VAPID_PRIVATE_KEY ? { publicKey: e.VAPID_PUBLIC_KEY, privateKey: e.VAPID_PRIVATE_KEY } diff --git a/apps/api/src/integrations.spec.ts b/apps/api/src/integrations.spec.ts index 8baeb16..79040bc 100644 --- a/apps/api/src/integrations.spec.ts +++ b/apps/api/src/integrations.spec.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { gzipSync } from 'node:zlib'; import { eq } from 'drizzle-orm'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -16,6 +17,7 @@ afterEach(() => { }); const KEY = 'good-api-key-123'; +const TOKEN = 'good-oauth-token-456'; const DAY = 86_400_000; type FakeActivity = { @@ -30,19 +32,53 @@ type FakeActivity = { /** * A pretend intervals.icu: one athlete, whose activities each have a FIT - * file starting at `start_date`. Records every request. + * file starting at `start_date`. Takes the API key or, after an OAuth + * sign-in, the access token. Records every request. */ function fakeIntervals(activities: FakeActivity[], athleteId = 'i42') { const requests: URL[] = []; - const state = { status: 200 }; + const state = { + status: 200, + /** The PKCE challenge the code was issued for. */ + codeChallenge: '', + tokenRequests: [] as URLSearchParams[], + revoked: [] as string[], + }; const fetch = vi.fn(async (input: string | URL, init?: RequestInit) => { const url = new URL(input); requests.push(url); + const path = url.pathname; + if (path === '/api/oauth/token') { + const form = new URLSearchParams(String(init?.body)); + state.tokenRequests.push(form); + const challenge = createHash('sha256') + .update(form.get('code_verifier') ?? '') + .digest('base64url'); + if ( + form.get('client_id') !== 'openrun-test' || + form.get('client_secret') !== 'shh' || + form.get('code') !== 'good-code' || + challenge !== state.codeChallenge + ) { + return new Response('Bad request', { status: 400 }); + } + return Response.json({ + token_type: 'Bearer', + access_token: TOKEN, + scope: 'ACTIVITY:READ', + athlete: { id: athleteId, name: 'Ann Runner' }, + }); + } const auth = new Headers(init?.headers).get('authorization'); - const expected = `Basic ${Buffer.from(`API_KEY:${KEY}`).toString('base64')}`; - if (auth !== expected) return new Response('Unauthorized', { status: 401 }); + const key = `Basic ${Buffer.from(`API_KEY:${KEY}`).toString('base64')}`; + if (auth !== key && auth !== `Bearer ${TOKEN}`) { + return new Response('Unauthorized', { status: 401 }); + } if (state.status !== 200) return new Response('', { status: state.status }); - const path = url.pathname; + if (path === '/api/v1/disconnect-app' && init?.method === 'DELETE') { + state.revoked.push(auth); + return new Response(null, { status: 200 }); + } if (path === '/api/v1/athlete/0') { return Response.json({ id: athleteId, name: 'Ann Runner' }); } @@ -313,6 +349,7 @@ describe('intervals.icu sync', () => { const res = await get(t.app, '/api/integrations', cookie); expect(integrationsSchema.parse(await res.json()).intervals).toEqual({ available: false, + oauth: false, connection: null, }); const put = await send( @@ -363,3 +400,205 @@ describe('intervals.icu sync', () => { expect(share?.device).toBe('Garmin Forerunner 965'); }); }); + +describe('intervals.icu sign-in (OAuth)', () => { + const OAUTH_ENV = { + INTERVALS_CLIENT_ID: 'openrun-test', + INTERVALS_CLIENT_SECRET: 'shh', + }; + + /** + * Starts the sign-in like the browser would and returns where intervals.icu + * would send the member back, plus the cookie that remembers it. + */ + async function startSignIn( + t: ReturnType, + cookie: string, + fake: ReturnType, + query = '?syncFrom=2026-03-01', + ) { + const res = await get( + t.app, + `/api/integrations/intervals/oauth/start${query}`, + cookie, + ); + expect(res.status).toBe(302); + const to = new URL(res.headers.get('location') ?? ''); + fake.state.codeChallenge = to.searchParams.get('code_challenge') ?? ''; + const pending = + res.headers.get('set-cookie')?.split(';')[0] ?? 'missing-cookie'; + return { to, state: to.searchParams.get('state') ?? '', pending }; + } + + async function comeBack( + t: ReturnType, + cookie: string, + pending: string, + query: string, + ) { + const res = await get( + t.app, + `/api/integrations/intervals/oauth/callback?${query}`, + `${cookie}; ${pending}`, + ); + expect(res.status).toBe(302); + return res.headers.get('location'); + } + + it('is offered only when the server has an OAuth app', async () => { + const plain = setup(); + const { cookie } = await signUp(plain.app, 'ann@example.com'); + const res = await get(plain.app, '/api/integrations', cookie); + expect(integrationsSchema.parse(await res.json()).intervals.oauth).toBe( + false, + ); + const start = await get( + plain.app, + '/api/integrations/intervals/oauth/start', + cookie, + ); + expect(start.status).toBe(404); + + const t = setup(OAUTH_ENV); + const { cookie: c2 } = await signUp(t.app, 'ann@example.com'); + const res2 = await get(t.app, '/api/integrations', c2); + expect(integrationsSchema.parse(await res2.json()).intervals.oauth).toBe( + true, + ); + }); + + it('connects with a token and syncs with it', async () => { + const t = setup(OAUTH_ENV); + const { cookie } = await signUp(t.app, 'ann@example.com'); + const fake = fakeIntervals([ + { id: 'i1', start_date: daysAgo(1), type: 'Run' }, + ]); + const { to, state, pending } = await startSignIn(t, cookie, fake); + expect(`${to.origin}${to.pathname}`).toBe( + 'https://intervals.icu/oauth/authorize', + ); + expect(Object.fromEntries(to.searchParams)).toMatchObject({ + client_id: 'openrun-test', + redirect_uri: + 'http://localhost:3000/api/integrations/intervals/oauth/callback', + scope: 'ACTIVITY:READ', + code_challenge_method: 'S256', + }); + expect(pending).toMatch(/^openrun_intervals_oauth=/); + + const back = await comeBack( + t, + cookie, + pending, + `code=good-code&state=${state}`, + ); + expect(back).toBe('/settings?intervals=connected'); + + // The first sync runs in the background after the redirect. + await vi.waitFor(async () => { + const res = await get(t.app, '/api/integrations', cookie); + const { connection } = integrationsSchema.parse( + await res.json(), + ).intervals; + expect(connection).toMatchObject({ + accountId: 'i42', + authMethod: 'oauth', + syncFrom: '2026-03-01', + syncing: false, + importedCount: 1, + }); + }); + const row = t.db.select().from(schema.integration).get(); + expect(row?.credentials).not.toContain(TOKEN); + + // Disconnecting revokes the token. + await send(t.app, cookie, 'DELETE', '/api/integrations/intervals'); + expect(fake.state.revoked).toEqual([`Bearer ${TOKEN}`]); + }); + + it('sends the member back with a reason when it goes wrong', async () => { + const t = setup(OAUTH_ENV); + const { cookie } = await signUp(t.app, 'ann@example.com'); + const fake = fakeIntervals([]); + + // They said no on intervals.icu. + let s = await startSignIn(t, cookie, fake); + expect( + await comeBack( + t, + cookie, + s.pending, + `error=access_denied&state=${s.state}`, + ), + ).toBe('/settings?intervals=denied'); + + // A forged or stale state, or no cookie at all. + s = await startSignIn(t, cookie, fake); + expect( + await comeBack(t, cookie, s.pending, 'code=good-code&state=someone-else'), + ).toBe('/settings?intervals=expired'); + expect( + await comeBack(t, cookie, '', `code=good-code&state=${s.state}`), + ).toBe('/settings?intervals=expired'); + + // Someone else's sign-in can't be finished in this member's browser. + s = await startSignIn(t, cookie, fake); + const { cookie: bob } = await signUp(t.app, 'bob@example.com'); + expect( + await comeBack(t, bob, s.pending, `code=good-code&state=${s.state}`), + ).toBe('/settings?intervals=expired'); + + // intervals.icu won't swap the code. + s = await startSignIn(t, cookie, fake); + expect( + await comeBack(t, cookie, s.pending, `code=bad-code&state=${s.state}`), + ).toBe('/settings?intervals=failed'); + + expect(t.db.select().from(schema.integration).all()).toEqual([]); + }); + + it('keeps one account per person, and lets go of the spare token', async () => { + const t = setup(OAUTH_ENV); + const { cookie: ann } = await signUp(t.app, 'ann@example.com'); + const fake = fakeIntervals([]); + await send(t.app, ann, 'PUT', '/api/integrations/intervals', { + apiKey: KEY, + syncFrom: '2026-01-01', + }); + + const { cookie: bob } = await signUp(t.app, 'bob@example.com'); + const s = await startSignIn(t, bob, fake); + expect( + await comeBack(t, bob, s.pending, `code=good-code&state=${s.state}`), + ).toBe('/settings?intervals=taken'); + expect(fake.state.revoked).toEqual([`Bearer ${TOKEN}`]); + }); + + it('switches a key connection to OAuth, keeping its start date and runs', async () => { + const t = setup(OAUTH_ENV); + const { cookie } = await signUp(t.app, 'ann@example.com'); + const fake = fakeIntervals([ + { id: 'i1', start_date: daysAgo(1), type: 'Run' }, + ]); + await send(t.app, cookie, 'PUT', '/api/integrations/intervals', { + apiKey: KEY, + syncFrom: '2026-01-01', + }); + await send(t.app, cookie, 'POST', '/api/integrations/intervals/sync'); + + // Reconnecting from Settings sends no start date. + const s = await startSignIn(t, cookie, fake, ''); + await comeBack(t, cookie, s.pending, `code=good-code&state=${s.state}`); + await vi.waitFor(async () => { + const res = await get(t.app, '/api/integrations', cookie); + expect( + integrationsSchema.parse(await res.json()).intervals.connection, + ).toMatchObject({ + authMethod: 'oauth', + syncFrom: '2026-01-01', + importedCount: 1, + syncing: false, + }); + }); + }); +}); diff --git a/apps/api/src/integrations/intervals.ts b/apps/api/src/integrations/intervals.ts index 3fa1340..f36ee7e 100644 --- a/apps/api/src/integrations/intervals.ts +++ b/apps/api/src/integrations/intervals.ts @@ -1,7 +1,12 @@ import { z } from 'zod'; // The bits of the intervals.icu API we use, authenticated with the member's -// personal API key. https://forum.intervals.icu/t/api-access-to-intervals-icu/609 +// personal API key or an OAuth access token. +// https://forum.intervals.icu/t/api-access-to-intervals-icu/609 +// https://forum.intervals.icu/t/intervals-icu-oauth-support/2759 + +/** How openRun signs in for a member: their API key, or an OAuth token. */ +export type IntervalsAuth = { method: 'api_key' | 'oauth'; secret: string }; export class IntervalsError extends Error { constructor( @@ -11,7 +16,7 @@ export class IntervalsError extends Error { super(message); } - /** The key was refused: revoked, regenerated or mistyped. */ + /** The key or token was refused: revoked, regenerated or mistyped. */ get badKey() { return this.status === 401 || this.status === 403; } @@ -36,13 +41,27 @@ export type IntervalsActivity = z.infer; const TIMEOUT_MS = 30_000; -async function call(baseUrl: string, apiKey: string, path: string) { +const USER_AGENT = 'openRun (self-hosted running log)'; + +function authorization({ method, secret }: IntervalsAuth) { + return method === 'oauth' + ? `Bearer ${secret}` + : `Basic ${Buffer.from(`API_KEY:${secret}`).toString('base64')}`; +} + +async function call( + baseUrl: string, + auth: IntervalsAuth, + path: string, + init: RequestInit = {}, +) { let res: Response; try { res = await fetch(`${baseUrl}/api/v1${path}`, { + ...init, headers: { - authorization: `Basic ${Buffer.from(`API_KEY:${apiKey}`).toString('base64')}`, - 'user-agent': 'openRun (self-hosted running log)', + authorization: authorization(auth), + 'user-agent': USER_AGENT, }, signal: AbortSignal.timeout(TIMEOUT_MS), }); @@ -52,7 +71,9 @@ async function call(baseUrl: string, apiKey: string, path: string) { if (res.ok) return res; if (res.status === 401 || res.status === 403) { throw new IntervalsError( - "intervals.icu didn't accept the API key", + auth.method === 'oauth' + ? 'intervals.icu no longer lets openRun read your runs' + : "intervals.icu didn't accept the API key", res.status, ); } @@ -63,8 +84,8 @@ async function call(baseUrl: string, apiKey: string, path: string) { } /** The athlete the key belongs to. */ -export async function getAthlete(baseUrl: string, apiKey: string) { - const res = await call(baseUrl, apiKey, '/athlete/0'); +export async function getAthlete(baseUrl: string, auth: IntervalsAuth) { + const res = await call(baseUrl, auth, '/athlete/0'); return athleteSchema.parse(await res.json()); } @@ -77,13 +98,13 @@ const ACTIVITY_FIELDS = Object.keys(activitySchema.shape).join(','); /** Activities that started between two days, inclusive. */ export async function listActivities( baseUrl: string, - apiKey: string, + auth: IntervalsAuth, oldest: string, newest: string, ): Promise { const res = await call( baseUrl, - apiKey, + auth, `/athlete/0/activities?oldest=${oldest}&newest=${newest}&fields=${ACTIVITY_FIELDS}`, ); return z.array(activitySchema).parse(await res.json()); @@ -95,18 +116,92 @@ export async function listActivities( */ export async function downloadActivity( baseUrl: string, - apiKey: string, + auth: IntervalsAuth, activity: IntervalsActivity, ): Promise { const original = ['fit', 'gpx'].includes(activity.file_type ?? ''); const res = await call( baseUrl, - apiKey, + auth, `/activity/${encodeURIComponent(activity.id)}/${original ? 'file' : 'fit-file'}`, ); return new Uint8Array(await res.arrayBuffer()); } +export type IntervalsOAuthApp = { clientId: string; clientSecret: string }; + +/** Reading activities is all openRun needs. */ +const OAUTH_SCOPE = 'ACTIVITY:READ'; + +/** Where to send the member to let openRun read their activities. */ +export function authorizeUrl( + baseUrl: string, + app: IntervalsOAuthApp, + params: { redirectUri: string; state: string; codeChallenge: string }, +) { + const url = new URL(`${baseUrl}/oauth/authorize`); + url.search = new URLSearchParams({ + client_id: app.clientId, + redirect_uri: params.redirectUri, + scope: OAUTH_SCOPE, + state: params.state, + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + }).toString(); + return url.toString(); +} + +const tokenSchema = z.object({ + access_token: z.string().min(1), + athlete: athleteSchema, +}); + +/** + * Swaps the code from the redirect for an access token. The code only lasts + * two minutes. Tokens don't expire; there's no refresh token. + */ +export async function exchangeCode( + baseUrl: string, + app: IntervalsOAuthApp, + code: string, + codeVerifier: string, +) { + let res: Response; + try { + res = await fetch(`${baseUrl}/api/oauth/token`, { + method: 'POST', + headers: { 'user-agent': USER_AGENT }, + body: new URLSearchParams({ + client_id: app.clientId, + client_secret: app.clientSecret, + code, + code_verifier: codeVerifier, + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch { + throw new IntervalsError("Couldn't reach intervals.icu", 0); + } + if (!res.ok) { + throw new IntervalsError( + `intervals.icu refused the sign-in (${res.status})`, + res.status, + ); + } + const body = tokenSchema.parse(await res.json()); + return { accessToken: body.access_token, athlete: body.athlete }; +} + +/** Revokes an OAuth token, so intervals.icu stops listing openRun. */ +export async function disconnectApp(baseUrl: string, accessToken: string) { + await call( + baseUrl, + { method: 'oauth', secret: accessToken }, + '/disconnect-app', + { method: 'DELETE' }, + ); +} + /** Running activity types; everything else is left on intervals.icu. */ export const RUN_TYPES = new Set(['Run', 'TrailRun', 'VirtualRun']); diff --git a/apps/api/src/integrations/sync.ts b/apps/api/src/integrations/sync.ts index d75f9ef..5d00ea9 100644 --- a/apps/api/src/integrations/sync.ts +++ b/apps/api/src/integrations/sync.ts @@ -16,6 +16,7 @@ import { getPrivacySettings } from '../privacy.js'; import { decryptSecret } from '../secrets.js'; import { type IntervalsActivity, + type IntervalsAuth, IntervalsError, RUN_TYPES, deviceOf, @@ -69,16 +70,19 @@ export async function syncIntegration( let error: string | null = null; let status: Integration['status'] = 'ok'; try { - const apiKey = decryptSecret(config.authSecret, integration.credentials); - if (!apiKey) { + const secret = decryptSecret(config.authSecret, integration.credentials); + if (!secret) { throw new IntervalsError( - "The saved API key can't be read any more; paste it again", + integration.authMethod === 'oauth' + ? "The saved sign-in can't be read any more; connect again" + : "The saved API key can't be read any more; paste it again", 401, ); } + const auth: IntervalsAuth = { method: integration.authMethod, secret }; const listed = await listActivities( config.intervalsUrl, - apiKey, + auth, day(new Date(integration.syncFrom.getTime() - DAY_MS)), day(new Date(now.getTime() + DAY_MS)), ); @@ -117,7 +121,7 @@ export async function syncIntegration( db, config, integration, - { baseUrl: config.intervalsUrl, apiKey }, + { baseUrl: config.intervalsUrl, auth }, item, ); counts[outcome]++; @@ -176,7 +180,7 @@ async function importOne( db: Db, config: Config, integration: Integration, - { baseUrl, apiKey }: { baseUrl: string; apiKey: string }, + { baseUrl, auth }: { baseUrl: string; auth: IntervalsAuth }, { activity, startedAt }: { activity: IntervalsActivity; startedAt: Date }, ): Promise<'imported' | 'duplicates' | 'failed'> { const remember = ( @@ -197,7 +201,7 @@ async function importOne( .run(); // Network trouble and rate limits end the sync; the run is tried next time. - const data = await downloadActivity(baseUrl, apiKey, activity); + const data = await downloadActivity(baseUrl, auth, activity); let parsed; try { parsed = await parseActivityFile(data); @@ -312,6 +316,7 @@ export function describeConnection( return { accountId: integration.accountId, accountName: integration.accountName, + authMethod: integration.authMethod, syncFrom: day(integration.syncFrom), status: integration.status, lastError: integration.lastError, diff --git a/apps/api/src/routes/integrations.ts b/apps/api/src/routes/integrations.ts index fc7a2e3..0f40402 100644 --- a/apps/api/src/routes/integrations.ts +++ b/apps/api/src/routes/integrations.ts @@ -1,20 +1,32 @@ +import { createHash, randomBytes } from 'node:crypto'; import { zValidator } from '@hono/zod-validator'; import { and, eq } from 'drizzle-orm'; import { type Context, Hono } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { z } from 'zod'; import { schema } from '@openrun/db'; import { type Integrations, + type IntervalsOAuthResult, connectIntervalsSchema, + startIntervalsOAuthSchema, updateIntervalsSchema, } from '@openrun/schemas'; import { type AuthedEnv, requireUser } from '../context.js'; -import { IntervalsError, getAthlete } from '../integrations/intervals.js'; +import { + type IntervalsAuth, + IntervalsError, + authorizeUrl, + disconnectApp, + exchangeCode, + getAthlete, +} from '../integrations/intervals.js'; import { describeConnection, isSyncing, syncIntegration, } from '../integrations/sync.js'; -import { encryptSecret } from '../secrets.js'; +import { decryptSecret, encryptSecret } from '../secrets.js'; type Ctx = Context; @@ -37,6 +49,7 @@ function overview(c: Ctx): Integrations { return { intervals: { available: c.get('config').intervalsUrl !== null, + oauth: c.get('config').intervalsOAuth !== null, connection: row ? describeConnection(c.get('db'), row) : null, }, }; @@ -44,6 +57,83 @@ function overview(c: Ctx): Integrations { const startOfDay = (day: string) => new Date(`${day}T00:00:00Z`); +/** + * Saves the member's connection, replacing any earlier one. A different + * intervals.icu account starts afresh. Returns false if that account is + * already connected to someone else here. + */ +function saveConnection( + c: Ctx, + athlete: { id: string; name?: string | null }, + auth: IntervalsAuth, + syncFrom: Date | null, +) { + const db = c.get('db'); + const userId = c.get('user').id; + const taken = db + .select({ userId: schema.integration.userId }) + .from(schema.integration) + .where( + and( + eq(schema.integration.provider, 'intervals'), + eq(schema.integration.accountId, athlete.id), + ), + ) + .get(); + if (taken && taken.userId !== userId) return false; + + const existing = findIntervals(c); + const values = { + accountId: athlete.id, + accountName: athlete.name ?? null, + credentials: encryptSecret(c.get('config').authSecret, auth.secret), + authMethod: auth.method, + syncFrom: + syncFrom ?? + existing?.syncFrom ?? + new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)), + status: 'ok' as const, + lastError: null, + }; + if (existing && existing.accountId === athlete.id) { + db.update(schema.integration) + .set(values) + .where(eq(schema.integration.id, existing.id)) + .run(); + } else { + db.transaction((tx) => { + if (existing) { + tx.delete(schema.integration) + .where(eq(schema.integration.id, existing.id)) + .run(); + } + tx.insert(schema.integration) + .values({ ...values, userId, provider: 'intervals' }) + .run(); + }); + } + return true; +} + +const TAKEN = + 'That intervals.icu account is already connected to someone else on this server.'; + +/** Remembers an OAuth sign-in in progress between leaving and coming back. */ +const OAUTH_COOKIE = 'openrun_intervals_oauth'; +const OAUTH_PATH = '/api/integrations/intervals/oauth'; +const oauthStateSchema = z.object({ + state: z.string(), + verifier: z.string(), + userId: z.string(), + syncFrom: z.iso.date().nullable(), +}); + +const base64url = (b: Buffer) => b.toString('base64url'); +const redirectUri = (c: Ctx) => + `${c.get('config').origin}${OAUTH_PATH}/callback`; +const backToSettings = (c: Ctx, result: IntervalsOAuthResult) => + c.redirect(`/settings?intervals=${result}`); + export const integrationRoutes = new Hono() .use(requireUser) .get('/', (c) => c.json(overview(c))) @@ -57,7 +147,10 @@ export const integrationRoutes = new Hono() const { apiKey, syncFrom } = c.req.valid('json'); let athlete; try { - athlete = await getAthlete(config.intervalsUrl, apiKey); + athlete = await getAthlete(config.intervalsUrl, { + method: 'api_key', + secret: apiKey, + }); } catch (e) { if (e instanceof IntervalsError) { return c.json( @@ -72,56 +165,118 @@ export const integrationRoutes = new Hono() throw e; } - const db = c.get('db'); - const userId = c.get('user').id; - const taken = db - .select({ userId: schema.integration.userId }) - .from(schema.integration) - .where( - and( - eq(schema.integration.provider, 'intervals'), - eq(schema.integration.accountId, athlete.id), - ), + if ( + !saveConnection( + c, + athlete, + { method: 'api_key', secret: apiKey }, + startOfDay(syncFrom), ) - .get(); - if (taken && taken.userId !== userId) { - return c.json( + ) { + return c.json({ error: TAKEN }, 409); + } + return c.json(overview(c)); + }) + + /** + * Sends the member to intervals.icu to let openRun read their runs. The web + * app navigates here rather than fetching it. + */ + .get( + '/intervals/oauth/start', + zValidator('query', startIntervalsOAuthSchema), + async (c) => { + const config = c.get('config'); + if (!config.intervalsUrl || !config.intervalsOAuth) { + return c.json( + { error: 'intervals.icu sign-in is turned off here' }, + 404, + ); + } + const state = base64url(randomBytes(16)); + const verifier = base64url(randomBytes(32)); + await setSignedCookie( + c, + OAUTH_COOKIE, + JSON.stringify({ + state, + verifier, + userId: c.get('user').id, + syncFrom: c.req.valid('query').syncFrom ?? null, + }), + config.authSecret, { - error: - 'That intervals.icu account is already connected to someone else on this server.', + path: OAUTH_PATH, + httpOnly: true, + // Sent when intervals.icu redirects back, a top-level navigation. + sameSite: 'Lax', + secure: config.origin.startsWith('https:'), + maxAge: 600, }, - 409, ); + return c.redirect( + authorizeUrl(config.intervalsUrl, config.intervalsOAuth, { + redirectUri: redirectUri(c), + state, + codeChallenge: base64url( + createHash('sha256').update(verifier).digest(), + ), + }), + ); + }, + ) + + /** intervals.icu sends the member back here, then on to Settings. */ + .get('/intervals/oauth/callback', async (c) => { + const config = c.get('config'); + if (!config.intervalsUrl || !config.intervalsOAuth) { + return backToSettings(c, 'failed'); + } + const cookie = await getSignedCookie(c, config.authSecret, OAUTH_COOKIE); + deleteCookie(c, OAUTH_COOKIE, { path: OAUTH_PATH }); + let pending; + try { + pending = oauthStateSchema.parse(JSON.parse(cookie || 'null')); + } catch { + return backToSettings(c, 'expired'); } + const { code, state, error } = c.req.query(); + if (pending.state !== state || pending.userId !== c.get('user').id) { + return backToSettings(c, 'expired'); + } + if (error || !code) return backToSettings(c, 'denied'); - const existing = findIntervals(c); - const values = { - accountId: athlete.id, - accountName: athlete.name ?? null, - credentials: encryptSecret(config.authSecret, apiKey), - syncFrom: startOfDay(syncFrom), - status: 'ok' as const, - lastError: null, - }; - if (existing && existing.accountId === athlete.id) { - db.update(schema.integration) - .set(values) - .where(eq(schema.integration.id, existing.id)) - .run(); - } else { - // A different account starts afresh. - db.transaction((tx) => { - if (existing) { - tx.delete(schema.integration) - .where(eq(schema.integration.id, existing.id)) - .run(); - } - tx.insert(schema.integration) - .values({ ...values, userId, provider: 'intervals' }) - .run(); - }); + let token; + try { + token = await exchangeCode( + config.intervalsUrl, + config.intervalsOAuth, + code, + pending.verifier, + ); + } catch (e) { + if (!(e instanceof IntervalsError)) { + console.error('intervals.icu sign-in failed', e); + } + return backToSettings(c, 'failed'); } - return c.json(overview(c)); + const saved = saveConnection( + c, + token.athlete, + { method: 'oauth', secret: token.accessToken }, + pending.syncFrom ? startOfDay(pending.syncFrom) : null, + ); + if (!saved) { + // Let go of the token, so intervals.icu doesn't list openRun for it. + await disconnectApp(config.intervalsUrl, token.accessToken).catch( + () => undefined, + ); + return backToSettings(c, 'taken'); + } + const row = findIntervals(c); + // The first sync carries on after the redirect; Settings shows progress. + if (row) void syncIntegration(c.get('db'), config, row); + return backToSettings(c, 'connected'); }) /** Changes how far back to import; going further back fetches the gap. */ @@ -146,9 +301,19 @@ export const integrationRoutes = new Hono() return c.json(overview(c)); }) - /** Disconnects. Runs already imported stay in the log. */ - .delete('/intervals', (c) => { + /** + * Disconnects. Runs already imported stay in the log. An OAuth token is + * revoked too, if intervals.icu can be reached. + */ + .delete('/intervals', async (c) => { const row = findIntervals(c); + const config = c.get('config'); + if (row?.authMethod === 'oauth' && config.intervalsUrl) { + const token = decryptSecret(config.authSecret, row.credentials); + if (token) { + await disconnectApp(config.intervalsUrl, token).catch(() => undefined); + } + } if (row) { c.get('db') .delete(schema.integration) diff --git a/apps/web-e2e/fake-intervals.mjs b/apps/web-e2e/fake-intervals.mjs index 12e1b4f..f1735e9 100644 --- a/apps/web-e2e/fake-intervals.mjs +++ b/apps/web-e2e/fake-intervals.mjs @@ -1,8 +1,10 @@ // A pretend intervals.icu for the e2e tests: one athlete with one run from -// a Garmin watch yesterday, served as a GPX file. Any key but "wrong" works. +// a Garmin watch yesterday, served as a GPX file. Any key but "wrong" works, +// and the OAuth sign-in says yes straight away. import { createServer } from 'node:http'; const port = Number(process.env.PORT ?? 3109); +const TOKEN = 'e2e-oauth-token'; const start = new Date(Date.now() - 86_400_000); start.setUTCHours(7, 0, 0, 0); @@ -17,18 +19,46 @@ function gpx() { createServer((req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`); const auth = req.headers.authorization ?? ''; - const key = Buffer.from(auth.replace(/^Basic /, ''), 'base64') - .toString() - .replace(/^API_KEY:/, ''); + const key = auth.startsWith('Bearer ') + ? auth.slice(7) === TOKEN && TOKEN + : Buffer.from(auth.replace(/^Basic /, ''), 'base64') + .toString() + .replace(/^API_KEY:/, ''); const json = (body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }; if (url.pathname === '/health') return json({ ok: true }); + if (url.pathname === '/oauth/authorize') { + const back = new URL(url.searchParams.get('redirect_uri') ?? ''); + back.searchParams.set('code', 'e2e-code'); + back.searchParams.set('state', url.searchParams.get('state') ?? ''); + res.writeHead(302, { location: back.toString() }); + return res.end(); + } + if (url.pathname === '/api/oauth/token' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const form = new URLSearchParams(body); + if (form.get('code') !== 'e2e-code' || !form.get('code_verifier')) { + res.writeHead(400); + return res.end(); + } + json({ + token_type: 'Bearer', + access_token: TOKEN, + scope: 'ACTIVITY:READ', + athlete: { id: 'i4242', name: 'Test Runner' }, + }); + }); + return; + } if (!key || key === 'wrong-key') { res.writeHead(401); return res.end(); } + if (url.pathname === '/api/v1/disconnect-app') return json({}); if (url.pathname === '/api/v1/athlete/0') { return json({ id: 'i4242', name: 'Test Runner' }); } diff --git a/apps/web-e2e/playwright.config.mts b/apps/web-e2e/playwright.config.mts index 56fb5e1..83497c6 100644 --- a/apps/web-e2e/playwright.config.mts +++ b/apps/web-e2e/playwright.config.mts @@ -58,6 +58,8 @@ export default defineConfig({ PLACE_SEARCH_URL: 'http://127.0.0.1:9/search', WEATHER_URL: '', INTERVALS_URL: 'http://127.0.0.1:3109', + INTERVALS_CLIENT_ID: 'openrun-e2e', + INTERVALS_CLIENT_SECRET: 'e2e-secret', }, }, ], diff --git a/apps/web-e2e/src/smoke.spec.ts b/apps/web-e2e/src/smoke.spec.ts index cd1d0f4..1a2dfcf 100644 --- a/apps/web-e2e/src/smoke.spec.ts +++ b/apps/web-e2e/src/smoke.spec.ts @@ -434,12 +434,12 @@ test.describe('clubs', () => { test('syncs runs from intervals.icu', async ({ page }) => { await page.goto('/settings'); const card = page.getByRole('region', { name: 'Sync from your watch' }); - await card.getByLabel('intervals.icu API key').fill('wrong-key'); - await card.getByRole('button', { name: 'Connect' }).click(); - await expect(card.getByText(/didn't accept that API key/)).toBeVisible(); - - await card.getByLabel('intervals.icu API key').fill('a-good-api-key'); - await card.getByRole('button', { name: 'Connect' }).click(); + // Signs in on (pretend) intervals.icu, which sends us back to Settings. + await card + .getByRole('button', { name: 'Connect with intervals.icu' }) + .click(); + await expect(page).toHaveURL(/\/settings$/); + await expect(card.getByText('Your runs are on their way')).toBeVisible(); await expect(card.getByText('Connected to Test Runner')).toBeVisible(); await expect(card.getByText(/1 run imported/)).toBeVisible(); await card.getByText('Sync log').click(); @@ -457,7 +457,27 @@ test.describe('clubs', () => { await page.goto('/settings'); page.once('dialog', (d) => d.accept()); await card.getByRole('button', { name: 'Disconnect' }).click(); - await expect(card.getByLabel('intervals.icu API key')).toBeVisible(); + await expect( + card.getByRole('button', { name: 'Connect with intervals.icu' }), + ).toBeVisible(); + + // An API key still works. + await card.getByText('Use an API key instead').click(); + await card.getByLabel('intervals.icu API key').fill('wrong-key'); + await card.getByRole('button', { name: 'Connect with key' }).click(); + await expect(card.getByText(/didn't accept that API key/)).toBeVisible(); + await card.getByLabel('intervals.icu API key').fill('a-good-api-key'); + await card.getByRole('button', { name: 'Connect with key' }).click(); + await expect(card.getByText('Connected to Test Runner')).toBeVisible(); + // The run is already in the log from the first connection. + await card.getByText('Sync log').click(); + await expect(card.getByText('1 already here')).toBeVisible(); + + page.once('dialog', (d) => d.accept()); + await card.getByRole('button', { name: 'Disconnect' }).click(); + await expect( + card.getByRole('button', { name: 'Connect with intervals.icu' }), + ).toBeVisible(); }); }); diff --git a/apps/web/src/components/intervals-settings.tsx b/apps/web/src/components/intervals-settings.tsx index dfc68ad..25befc9 100644 --- a/apps/web/src/components/intervals-settings.tsx +++ b/apps/web/src/components/intervals-settings.tsx @@ -1,5 +1,10 @@ -import { type FormEvent, useEffect, useState } from 'react'; -import type { IntegrationSync, IntervalsConnection } from '@openrun/schemas'; +import { type FormEvent, type ReactNode, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { + IntegrationSync, + IntervalsConnection, + IntervalsOAuthResult, +} from '@openrun/schemas'; import { useConnectIntervals, useDisconnectIntervals, @@ -11,11 +16,41 @@ import { useInstance } from '../lib/queries'; import { FormError } from './auth-card'; const INTERVALS = 'https://intervals.icu'; +/** The web app navigates here; the API sends the member on to intervals.icu. */ +const OAUTH_START = '/api/integrations/intervals/oauth/start'; + +const OAUTH_RESULTS: Record = { + connected: 'Connected. Your runs are on their way.', + denied: "openRun wasn't given access on intervals.icu, so nothing changed.", + expired: 'That sign-in took too long or was started elsewhere. Try again.', + taken: + 'That intervals.icu account is already connected to someone else on this server.', + failed: "intervals.icu didn't finish the sign-in. Try again in a minute.", +}; + +/** How the intervals.icu sign-in ended, read once from `?intervals=`. */ +function useOAuthResult() { + const [params, setParams] = useSearchParams(); + const [result] = useState(() => { + const value = params.get('intervals'); + return value && value in OAUTH_RESULTS + ? (value as IntervalsOAuthResult) + : null; + }); + useEffect(() => { + if (!params.has('intervals')) return; + const next = new URLSearchParams(params); + next.delete('intervals'); + setParams(next, { replace: true }); + }, [params, setParams]); + return result; +} export function IntervalsSettings() { const { data } = useIntegrations(); + const result = useOAuthResult(); if (!data) return null; - const { available, connection } = data.intervals; + const { available, oauth, connection } = data.intervals; return (
@@ -35,49 +70,150 @@ export function IntervalsSettings() { sync to. openRun gets the original file from your watch.

+ {result && ( +

+ {OAUTH_RESULTS[result]} +

+ )} {!available ? (

Syncing is turned off on this server. You can still upload FIT and GPX files.

) : connection ? ( - + ) : ( - + )}
); } -function Connect() { +function ConnectWatchStep() { + return ( +
  • + Create an account at{' '} + + intervals.icu + {' '} + and connect your watch under Settings → Connections. + Connect the watch itself, not Strava: intervals.icu can't pass on runs + that came from Strava. +
  • + ); +} + +function KeySteps() { + return ( + <> +
  • + On intervals.icu, open Settings → Developer Settings{' '} + and copy your API key. +
  • +
  • Paste it below.
  • + + ); +} + +function Connect({ oauth }: { oauth: boolean }) { + if (!oauth) { + return ( +
    +
      + + +
    + +
    + ); + } return (
      -
    1. - Create an account at{' '} - - intervals.icu - {' '} - and connect your watch under Settings → Connections. - Connect the watch itself, not Strava: intervals.icu can't pass on runs - that came from Strava. -
    2. -
    3. - On intervals.icu, open Settings → Developer Settings{' '} - and copy your API key. -
    4. -
    5. Paste it below.
    6. + +
    7. Come back here and connect. intervals.icu asks you to allow it.
    - + +
    + + Use an API key instead + +
    +
      + +
    + +
    +
    ); } +/** + * A plain GET form: the browser goes to the API, which sends it on to + * intervals.icu and back to Settings afterwards. + */ +function OAuthForm({ + label, + withStartDate = false, + secondary = false, +}: { + label: string; + withStartDate?: boolean; + secondary?: boolean; +}) { + const { data: instance } = useInstance(); + return ( +
    + {withStartDate && } + + openRun only asks to read your activities. Whoever runs this server + {instance?.contact ? ` (${instance.contact})` : ''} could read them on + intervals.icu too, but can't change anything there or reach your watch + maker's account. To cut it off, disconnect here. + + + + ); +} + +function StartDate({ id = 'syncFrom' }: { id?: string }) { + return ( +
    + + +
    + ); +} + +const Note = ({ children }: { children: ReactNode }) => ( +

    {children}

    +); + function KeyForm({ label, withStartDate = false, @@ -119,29 +255,14 @@ function KeyForm({ required /> - {withStartDate && ( -
    - - -
    - )} -

    + {withStartDate && } + The key is stored encrypted. Whoever runs this server {instance?.contact ? ` (${instance.contact})` : ''} could still use it to read and change your intervals.icu account, though not your watch maker's account. To cut it off, disconnect here or make a new key on intervals.icu. -

    +