Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# <ORIGIN>/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.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your openRun address>/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:
Expand All @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 17 additions & 1 deletion apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/demo/demo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/demo/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const BLOCKED: [method: string, path: RegExp][] = [

export const demoLimits = createMiddleware<AppEnv>(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));
Expand Down
249 changes: 244 additions & 5 deletions apps/api/src/integrations.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +17,7 @@ afterEach(() => {
});

const KEY = 'good-api-key-123';
const TOKEN = 'good-oauth-token-456';
const DAY = 86_400_000;

type FakeActivity = {
Expand All @@ -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' });
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<typeof setup>,
cookie: string,
fake: ReturnType<typeof fakeIntervals>,
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<typeof setup>,
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,
});
});
});
});
Loading
Loading