diff --git a/.env.example b/.env.example index 83946ee..584938d 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= + # A public demo with sample data and a shared login. Replaces everything in # the database on start and every night. Never on a real instance. See # docs/DEMO.md. diff --git a/README.md b/README.md index 467fc0c..18b26a3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,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). To run a public demo with sample data instead, see [docs/DEMO.md](docs/DEMO.md). +### 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: @@ -65,7 +69,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 0a5496a..fef70d3 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(), /** * A public demo: seeded with sample data on start and every night, with a * shared login and sign-ups, uploads and account changes turned off. @@ -61,6 +68,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, @@ -88,7 +98,13 @@ 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. */ + // Off on the demo, where everyone shares one login. + intervalsOAuth: + !e.DEMO_MODE && intervalsUrl && intervalsClientId && intervalsClientSecret + ? { clientId: intervalsClientId, clientSecret: intervalsClientSecret } + : null, demo: e.DEMO_MODE, vapidKeys: e.VAPID_PUBLIC_KEY && e.VAPID_PRIVATE_KEY diff --git a/apps/api/src/demo/demo.spec.ts b/apps/api/src/demo/demo.spec.ts index 3b8bafa..f2e57dc 100644 --- a/apps/api/src/demo/demo.spec.ts +++ b/apps/api/src/demo/demo.spec.ts @@ -83,6 +83,11 @@ describe('demo mode', () => { 403, ); } + // Signing in to intervals.icu starts with a GET. + expect( + (await get(app, '/api/integrations/intervals/oauth/start', cookie)) + .status, + ).toBe(403); // Everything else works as normal. expect((await get(app, '/api/stats', cookie)).status).toBe(200); expect( diff --git a/apps/api/src/demo/limits.ts b/apps/api/src/demo/limits.ts index cc05167..1884fd6 100644 --- a/apps/api/src/demo/limits.ts +++ b/apps/api/src/demo/limits.ts @@ -19,7 +19,9 @@ const BLOCKED: [method: string, path: RegExp][] = [ export const demoLimits = createMiddleware(async (c, next) => { const { method, path } = c.req; - if (method === 'GET' || method === 'HEAD') return next(); + // Signing in to intervals.icu starts with a GET, so it's checked too. + const oauth = path.startsWith('/api/integrations/intervals/oauth/'); + if ((method === 'GET' || method === 'HEAD') && !oauth) return next(); const blocked = path.startsWith('/api/auth/') ? !ALLOWED_AUTH.has(path) : BLOCKED.some(([m, re]) => (m === '*' || m === method) && re.test(path)); 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 b249096..1a016aa 100644 --- a/apps/web-e2e/src/smoke.spec.ts +++ b/apps/web-e2e/src/smoke.spec.ts @@ -495,12 +495,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(); @@ -518,7 +518,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 0dea03a..cc2f36f 100644 --- a/apps/web/src/components/intervals-settings.tsx +++ b/apps/web/src/components/intervals-settings.tsx @@ -1,5 +1,10 @@ -import { type FormEvent, 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. -

    +