diff --git a/apps/web/public/app/app.js b/apps/web/public/app/app.js index e214049..e37e94f 100644 --- a/apps/web/public/app/app.js +++ b/apps/web/public/app/app.js @@ -346,7 +346,7 @@ function renderSignin(q, signup = false) { const data = formData(e.target); try { const invite = q.get('invite') ?? undefined; - await post('/auth/link', signup ? { ...data, mode: 'signup', invite } : { email: data.email, invite }); + await post('/auth/link', signup ? { ...data, mode: 'signup', invite, next: q.get('next') } : { email: data.email, invite, next: q.get('next') }); e.target.innerHTML = `

Check your email

We sent a sign-in link to ${esc(data.email)}. It works once and expires in 20 minutes.

`; } catch (err) { showFieldErrors(e.target, err.errors); @@ -359,22 +359,24 @@ function renderSignin(q, signup = false) { s.src = '/assets/profile.js'; root.append(s); } else { - document.getElementById('passkey-btn').onclick = passkeySignIn; + document.getElementById('passkey-btn').onclick = () => passkeySignIn(q.get('next') === '/health/' ? '/health/' : null); get('/auth/providers').then((providers) => { if (providers.coinpay) { - document.getElementById('signin-alternatives').innerHTML = `
or
Continue with CoinPay
or
`; + const next = q.get('next') === '/health/' ? '?next=%2Fhealth%2F' : ''; + document.getElementById('signin-alternatives').innerHTML = `
or
Continue with CoinPay
or
`; } }).catch(() => {}); } } -async function passkeySignIn() { +async function passkeySignIn(next = null) { try { if (!window.SimpleWebAuthnBrowser) throw new Error('Passkeys are not available in this browser'); const { options, challengeId } = await post('/auth/passkey/login/options'); const response = await SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: options }); await post('/auth/passkey/login/verify', { response, challengeId }); - go('/app'); + if (next === '/health/') location.assign('/health/'); + else go('/app'); } catch (err) { toast(err.name === 'NotAllowedError' ? 'Passkey sign-in was cancelled' : err.message, true); } @@ -1551,7 +1553,7 @@ async function renderPortal() { root.innerHTML = `
${portalHead('/portal')}

${name ? `Hi, ${esc(name)}` : 'Your health'}

-
Free health tracker
Track readings like blood pressure, weight and glucose. Encrypted in your browser; no account required.
Open tracker
+
Free health tracker
Sign in to track readings like blood pressure, weight and glucose. Encrypted in your browser.
Open tracker
Records from other providers
Bring in visits, after-visit summaries, labs, imaging and notes from MyChart and other patient portals.
Health records
${body}
`; document.getElementById('signout').onclick = signOut; diff --git a/apps/web/public/health/health.js b/apps/web/public/health/health.js index 3653cc4..4d50c0e 100644 --- a/apps/web/public/health/health.js +++ b/apps/web/public/health/health.js @@ -1,5 +1,11 @@ import * as vaultCrypto from '/assets/vault-crypto.js'; +const sessionResponse = await fetch('/api/v1/me', { credentials: 'same-origin', cache: 'no-store' }).catch(() => null); +if (!sessionResponse?.ok) { + location.replace('/signin?next=%2Fhealth%2F'); + throw new Error('Sign in required to use the health tracker.'); +} + const DB_NAME = 'tleehealth-health-tracker'; const DB_VERSION = 2; const RECORDS = 'readings'; @@ -397,6 +403,24 @@ document.getElementById('lock').addEventListener('click', lockTracker); window.addEventListener('pointerdown', resetIdleTimer, { passive: true }); window.addEventListener('keydown', resetIdleTimer); +async function checkAccountSession() { + try { + const response = await fetch('/api/v1/me', { credentials: 'same-origin', cache: 'no-store' }); + if (response.ok) return; + } catch {} + lockTracker(); + location.replace('/signin?next=%2Fhealth%2F'); +} +document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') { + lockTracker(); + return; + } + checkAccountSession(); +}); +setInterval(() => { + if (document.visibilityState === 'visible') checkAccountSession(); +}, 60_000); window.addEventListener('pagehide', () => { if (vaultHandle) vaultCrypto.lockVault(vaultHandle); vaultHandle = null; diff --git a/apps/web/public/health/index.html b/apps/web/public/health/index.html index 3130102..b23c1c0 100644 --- a/apps/web/public/health/index.html +++ b/apps/web/public/health/index.html @@ -4,7 +4,7 @@ - + Health tracker · tleehealth @@ -21,7 +21,7 @@

Private health tracker

Your readings, on this device.

-

Record measurements and review changes over time. This tracker does not require an account and does not send readings to tleehealth.

+

Sign in to record measurements and review changes over time. Readings stay encrypted on this device and are never sent to tleehealth.

Encrypted on this device before saving. Your passphrase and recovery key are never sent to tleehealth. Readings never reach the server. Export an encrypted backup to move them to another device. If you lose both keys, the readings cannot be recovered. diff --git a/apps/web/public/health/manifest.webmanifest b/apps/web/public/health/manifest.webmanifest index 4325e51..f4a53d8 100644 --- a/apps/web/public/health/manifest.webmanifest +++ b/apps/web/public/health/manifest.webmanifest @@ -2,7 +2,7 @@ "id": "/health/", "name": "tleehealth · Health tracker", "short_name": "Health tracker", - "description": "A private health tracker that stores readings on this device.", + "description": "A private health tracker for signed-in users that stores readings on this device.", "start_url": "/health/", "scope": "/health/", "display": "standalone", diff --git a/apps/web/public/health/sw.js b/apps/web/public/health/sw.js index 5cbe7e5..c411905 100644 --- a/apps/web/public/health/sw.js +++ b/apps/web/public/health/sw.js @@ -1,8 +1,7 @@ -const CACHE = 'tleehealth-health-v3'; -const SHELL = ['/health/', '/health/health.css', '/health/health.js', '/health/manifest.webmanifest', '/assets/vault-crypto.js', '/icon.svg']; +const CACHE = 'tleehealth-health-v4'; self.addEventListener('install', (event) => { - event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); + event.waitUntil(self.skipWaiting()); }); self.addEventListener('activate', (event) => { @@ -16,10 +15,5 @@ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (url.origin !== self.location.origin || event.request.method !== 'GET') return; if (!url.pathname.startsWith('/health/') && url.pathname !== '/assets/vault-crypto.js') return; - event.respondWith( - caches.match(event.request).then((cached) => cached ?? fetch(event.request).then((response) => { - if (response.ok) caches.open(CACHE).then((cache) => cache.put(event.request, response.clone())); - return response; - }).catch(() => event.request.mode === 'navigate' ? caches.match('/health/') : Response.error())), - ); + event.respondWith(fetch(event.request)); }); diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index b7dbacc..abaf7dd 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,6 +1,6 @@ // Minimal service worker: makes the app installable and keeps the shell offline. // Bump VERSION on every shell change or returning visitors keep the old page. -const VERSION = 'tleehealth-v9'; +const VERSION = 'tleehealth-v10'; self.addEventListener('install', (e) => { e.waitUntil(caches.open(VERSION).then((c) => c.addAll(['/', '/app', '/assets/app.js', '/assets/app.css', '/assets/vault-crypto.js', '/icon.svg', '/icon-192.png', '/icon-512.png', '/manifest.webmanifest']))); self.skipWaiting(); diff --git a/apps/web/src/api.js b/apps/web/src/api.js index 228715a..2e0c01d 100644 --- a/apps/web/src/api.js +++ b/apps/web/src/api.js @@ -138,7 +138,8 @@ api.post('/auth/link', async (c) => { // An invite (from /i/, or typed) rides on the emailed link, so opening // it on another device still counts. Only a link that creates the account uses it. const invite = credits.cleanInvite(b.invite) ?? credits.cleanInvite(getCookie(c, credits.INVITE_COOKIE)); - await sendLoginLink({ email: e, url: await auth.createLoginLink(e, { profile, invite }) }); + const next = b.next === '/health/' ? '/health/' : null; + await sendLoginLink({ email: e, url: await auth.createLoginLink(e, { profile, invite, next }) }); } catch (err) { console.error(`[auth] could not send link: ${err?.message ?? err}`); } diff --git a/apps/web/src/app.js b/apps/web/src/app.js index dc1d558..6a36f29 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -43,24 +43,29 @@ const page = async (c, name) => app.get('/', (c) => page(c, 'index.html')); app.get('/healthz', (c) => c.text('ok')); -// The personal health tracker is a local-first PWA. It has no account or API -// calls; readings live in the visitor's IndexedDB and never reach the server. -app.get('/health', (c) => c.redirect('/health/', 301)); -app.get('/health/', (c) => - c.body(pub('health/index.html'), 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }), -); -app.get('/health/health.css', (c) => - c.body(pub('health/health.css'), 200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-cache' }), -); -app.get('/health/health.js', (c) => - c.body(pub('health/health.js'), 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }), -); -app.get('/health/manifest.webmanifest', (c) => - c.body(pub('health/manifest.webmanifest'), 200, { 'content-type': 'application/manifest+json', 'cache-control': 'no-cache' }), -); -app.get('/health/sw.js', (c) => - c.body(pub('health/sw.js'), 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache', 'service-worker-allowed': '/health/' }), -); +// Tracker readings stay encrypted in this browser, but opening the app requires +// an active account session. Keep every route under /health behind that session. +const healthUser = (c) => auth.userFromSession(getCookie(c, config.session.cookie)); +const healthSignIn = (c) => { + c.header('cache-control', 'no-store'); + return c.redirect('/signin?next=%2Fhealth%2F', 302); +}; +const healthAsset = (name, type, headers = {}) => async (c) => { + if (!(await healthUser(c))) return c.body(null, 401, { 'cache-control': 'no-store' }); + return c.body(pub(`health/${name}`), 200, { 'content-type': type, 'cache-control': 'private, no-store', ...headers }); +}; +app.get('/health', async (c) => { + if (!(await healthUser(c))) return healthSignIn(c); + return c.redirect('/health/', 301); +}); +app.get('/health/', async (c) => { + if (!(await healthUser(c))) return healthSignIn(c); + return c.body(pub('health/index.html'), 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'private, no-store' }); +}); +app.get('/health/health.css', healthAsset('health.css', 'text/css; charset=utf-8')); +app.get('/health/health.js', healthAsset('health.js', 'text/javascript; charset=utf-8')); +app.get('/health/manifest.webmanifest', healthAsset('manifest.webmanifest', 'application/manifest+json')); +app.get('/health/sw.js', healthAsset('sw.js', 'text/javascript; charset=utf-8', { 'service-worker-allowed': '/health/' })); // The app: one shell for the team, patients and sign-in. It talks only to /api/v1. // The shared profile fields (@profullstack/profile) ride in the shell as a @@ -124,7 +129,8 @@ app.get('/auth/coinpay', async (c) => { if (!user) return c.redirect('/signin?error=coinpay-link-session', 302); userId = user.id; } - const { state, challenge } = await auth.createCoinPayOAuthState(userId); + const next = c.req.query('next') === '/health/' ? '/health/' : null; + const { state, challenge } = await auth.createCoinPayOAuthState(userId, next); const authorize = new URL(config.coinpayOAuth.authorizeUrl); authorize.search = new URLSearchParams({ response_type: 'code', @@ -142,8 +148,9 @@ app.get('/auth/coinpay/callback', async (c) => { const query = c.req.query(); const pending = await auth.takeCoinPayOAuthState(query.state); if (!pending) return c.redirect('/signin?error=coinpay-expired', 302); - if (query.error) return c.redirect(pending.userId ? '/app/account?coinpay=denied' : '/signin?error=coinpay-denied', 302); - if (!query.code) return c.redirect(pending.userId ? '/app/account?coinpay=failed' : '/signin?error=coinpay-failed', 302); + const signInError = (error) => `/signin?error=${error}${pending.next ? '&next=%2Fhealth%2F' : ''}`; + if (query.error) return c.redirect(pending.userId ? '/app/account?coinpay=denied' : signInError('coinpay-denied'), 302); + if (!query.code) return c.redirect(pending.userId ? '/app/account?coinpay=failed' : signInError('coinpay-failed'), 302); if (pending.userId) { const sessionUser = await auth.userFromSession(getCookie(c, config.session.cookie)); @@ -155,7 +162,7 @@ app.get('/auth/coinpay/callback', async (c) => { try { subject = await auth.coinPaySubjectFromCode(query.code, pending.codeVerifier); } catch { - return c.redirect(pending.userId ? '/app/account?coinpay=failed' : '/signin?error=coinpay-failed', 302); + return c.redirect(pending.userId ? '/app/account?coinpay=failed' : signInError('coinpay-failed'), 302); } if (pending.userId) { @@ -165,9 +172,9 @@ app.get('/auth/coinpay/callback', async (c) => { } const session = await auth.signInWithCoinPay(subject, c.req.header('user-agent')); - if (!session) return c.redirect('/signin?error=coinpay-not-linked', 302); + if (!session) return c.redirect(signInError('coinpay-not-linked'), 302); c.header('set-cookie', auth.sessionCookie(session.sessionId)); - return c.redirect('/app', 302); + return c.redirect(pending.next ?? '/app', 302); }); // The emailed sign-in link lands here, sets the session and goes to the app. @@ -177,8 +184,9 @@ app.get('/auth/magic', async (c) => { if (!s) return c.redirect('/signin?error=expired', 302); await saveSignup(s.user, s.profile); // The link proved the mailbox; a link that just created the account takes its invite. - let to = '/app'; - if (s.created && s.invite) { + const requestedNext = c.req.query('next') === '/health/' ? '/health/' : null; + let to = requestedNext ?? '/app'; + if (!requestedNext && s.created && s.invite) { try { const r = await credits.acceptInvite(s.user.id, s.invite); console.log(`[invite] ${s.user.id} ${r.ok ? 'accepted' : r.reason}`); diff --git a/apps/web/src/auth.js b/apps/web/src/auth.js index bbdbc50..66bc0a3 100644 --- a/apps/web/src/auth.js +++ b/apps/web/src/auth.js @@ -36,13 +36,14 @@ export const expectedOrigins = () => { * the token because the account row does not exist until the link is spent. * Bound as text and cast, so the driver never JSON-encodes the string a second time. */ -export async function createLoginLink(email, { profile = null, invite = null } = {}) { +export async function createLoginLink(email, { profile = null, invite = null, next = null } = {}) { const token = randomBytes(32).toString('base64url'); await db()` insert into login_tokens (token_hash, email, expires_at, profile, invite) values (${sha(token)}, ${email.trim().toLowerCase()}, ${new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000)}, ${profile ? JSON.stringify(profile) : null}::text::jsonb, ${invite})`; - return `${config.siteUrl}/auth/magic?t=${token}`; + const afterSignIn = next === '/health/' ? '&next=%2Fhealth%2F' : ''; + return `${config.siteUrl}/auth/magic?t=${token}${afterSignIn}`; } /** @@ -118,8 +119,9 @@ async function startSession(userId, userAgent) { } /** Start CoinPay OAuth with server-held PKCE material and one-use state. */ -export async function createCoinPayOAuthState(userId = null) { - const state = randomBytes(32).toString('base64url'); +export async function createCoinPayOAuthState(userId = null, next = null) { + const nonce = randomBytes(32).toString('base64url'); + const state = next === '/health/' ? `${nonce}.health` : nonce; const verifier = randomBytes(48).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); const sql = db(); @@ -137,7 +139,7 @@ export async function takeCoinPayOAuthState(state) { delete from coinpay_oauth_states where state_hash = ${sha(state)} and expires_at > now() returning code_verifier, user_id`; - return row ? { codeVerifier: row.code_verifier, userId: row.user_id ?? null } : null; + return row ? { codeVerifier: row.code_verifier, userId: row.user_id ?? null, next: state.endsWith('.health') ? '/health/' : null } : null; } /** Exchange the authorization code, then ask CoinPay's userinfo endpoint for its stable subject. */ diff --git a/test/api.test.js b/test/api.test.js index 1ffbde4..cc6d604 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -49,6 +49,24 @@ describe.skipIf(!configured())('api', () => { expect((await client('').get('/me')).status).toBe(401); }); + test('health tracker requires sign-in and the magic link returns to it', async () => { + const signedOut = await app.request('/health/'); + expect(signedOut.status).toBe(302); + expect(signedOut.headers.get('location')).toBe('/signin?next=%2Fhealth%2F'); + + const email = `health${tag}@example.com`; + const link = new URL(await auth.createLoginLink(email, { next: '/health/' })); + expect(link.searchParams.get('next')).toBe('/health/'); + const signedIn = await app.request(`${link.pathname}${link.search}`); + expect(signedIn.status).toBe(302); + expect(signedIn.headers.get('location')).toBe('/health/'); + const cookie = signedIn.headers.get('set-cookie').split(';')[0]; + const page = await app.request('/health/', { headers: { cookie } }); + expect(page.status).toBe(200); + expect(await page.text()).toContain('Sign in to record measurements'); + expect((await app.request('/health/health.js', { headers: { cookie } })).status).toBe(200); + }); + test('CoinPay links to the signed-in account by provider subject and signs into that account', async () => { const userCookie = await signIn(`coinpay${tag}@example.com`); const before = await client(userCookie).get('/me'); @@ -92,13 +110,18 @@ describe.skipIf(!configured())('api', () => { expect(callback.headers.get('location')).toBe('/app/account?coinpay=linked'); expect((await client(userCookie).get('/me')).body.coinpay_connected).toBe(true); - const loginStart = await app.request('/auth/coinpay'); + const loginStart = await app.request('/auth/coinpay?next=%2Fhealth%2F'); const loginAuthorize = new URL(loginStart.headers.get('location')); const login = await app.request(`/auth/coinpay/callback?code=another-one-time-code&state=${encodeURIComponent(loginAuthorize.searchParams.get('state'))}`); expect(login.status).toBe(302); - expect(login.headers.get('location')).toBe('/app'); + expect(login.headers.get('location')).toBe('/health/'); const newCookie = login.headers.get('set-cookie').split(';')[0]; expect((await client(newCookie).get('/me')).body.user.id).toBe(before.body.user.id); + + const defaultStart = await app.request('/auth/coinpay'); + const defaultAuthorize = new URL(defaultStart.headers.get('location')); + const defaultLogin = await app.request(`/auth/coinpay/callback?code=default-login-code&state=${encodeURIComponent(defaultAuthorize.searchParams.get('state'))}`); + expect(defaultLogin.headers.get('location')).toBe('/app'); const replay = await app.request(`/auth/coinpay/callback?code=replay&state=${encodeURIComponent(loginAuthorize.searchParams.get('state'))}`); expect(replay.headers.get('location')).toBe('/signin?error=coinpay-expired'); } finally { diff --git a/test/app.test.js b/test/app.test.js index 26211f2..9d527a4 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -52,35 +52,22 @@ describe('web', () => { expect(foot.match(/©/g)?.length).toBe(1); }); test('healthz', async () => expect((await get('/healthz')).status).toBe(200)); - test('health tracker is served offline-first and owns the /health/ service-worker scope', async () => { - const redirect = await get('/health'); - expect(redirect.status).toBe(301); - expect(redirect.headers.get('location')).toBe('/health/'); - const page = await get('/health/'); - expect(page.status).toBe(200); - const html = await page.text(); - expect(html).toContain('Encrypted on this device before saving'); - expect(html).toContain('href="/portal">My health portal'); - expect((await get('/health/health.css')).status).toBe(200); - const script = await get('/health/health.js'); - expect(script.status).toBe(200); - const source = await script.text(); - expect(source).toContain("from '/assets/vault-crypto.js'"); - expect(source).toContain('vaultCrypto.encryptObject'); - expect(source).toContain('vaultCrypto.decryptObject'); - expect((await get('/health/manifest.webmanifest')).status).toBe(200); - const worker = await get('/health/sw.js'); - expect(worker.status).toBe(200); - expect(worker.headers.get('service-worker-allowed')).toBe('/health/'); - const workerSource = await worker.text(); - expect(workerSource).toContain("tleehealth-health-v3"); - expect(workerSource).toContain('/assets/vault-crypto.js'); + test('health tracker requires a session for its shell and assets', async () => { + for (const path of ['/health', '/health/']) { + const page = await get(path); + expect(page.status).toBe(302); + expect(page.headers.get('location')).toBe('/signin?next=%2Fhealth%2F'); + } + for (const path of ['/health/health.css', '/health/health.js', '/health/manifest.webmanifest', '/health/sw.js']) { + expect((await get(path)).status).toBe(401); + } }); test('patient portal links to the free local health tracker', async () => { const source = await (await get('/assets/app.js')).text(); expect(source).toContain("link('/health/', 'Free tracker')"); expect(source).toContain('Free health tracker'); expect(source).toContain('href="/health/">Open tracker'); + expect(source).toContain('Sign in to track readings'); }); test('sign out clears the browser session cookie', async () => { const res = await app.request('/api/v1/auth/signout', { method: 'POST' }); @@ -105,7 +92,9 @@ describe('web', () => { }); test('pwa + installer are served', async () => { expect((await get('/manifest.webmanifest')).status).toBe(200); - expect((await get('/sw.js')).status).toBe(200); + const worker = await get('/sw.js'); + expect(worker.status).toBe(200); + expect(await worker.text()).toContain('tleehealth-v10'); expect(await (await get('/install.sh')).text()).toContain('@profullstack/tleehealth'); }); });