Skip to content
Merged
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
14 changes: 8 additions & 6 deletions apps/web/public/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div class="card"><div class="card-b"><h2>Check your email</h2><p class="muted">We sent a sign-in link to <b>${esc(data.email)}</b>. It works once and expires in 20 minutes.</p></div></div>`;
} catch (err) {
showFieldErrors(e.target, err.errors);
Expand All @@ -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 = `<div class="or">or</div><a class="btn" href="/auth/coinpay">Continue with CoinPay</a><div class="or">or</div>`;
const next = q.get('next') === '/health/' ? '?next=%2Fhealth%2F' : '';
document.getElementById('signin-alternatives').innerHTML = `<div class="or">or</div><a class="btn" href="/auth/coinpay${next}">Continue with CoinPay</a><div class="or">or</div>`;
}
}).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);
}
Expand Down Expand Up @@ -1551,7 +1553,7 @@ async function renderPortal() {
root.innerHTML = `<div class="portal">
${portalHead('/portal')}
<h1>${name ? `Hi, ${esc(name)}` : 'Your health'}</h1>
<section class="card"><div class="card-b row" style="justify-content:space-between"><span><b>Free health tracker</b><div class="dim small">Track readings like blood pressure, weight and glucose. Encrypted in your browser; no account required.</div></span><a class="btn primary sm" href="/health/">Open tracker</a></div></section>
<section class="card"><div class="card-b row" style="justify-content:space-between"><span><b>Free health tracker</b><div class="dim small">Sign in to track readings like blood pressure, weight and glucose. Encrypted in your browser.</div></span><a class="btn primary sm" href="/health/">Open tracker</a></div></section>
<section class="card"><div class="card-b row" style="justify-content:space-between"><span><b>Records from other providers</b><div class="dim small">Bring in visits, after-visit summaries, labs, imaging and notes from MyChart and other patient portals.</div></span><a class="btn sm" href="/portal/records">Health records</a></div></section>
${body}</div>`;
document.getElementById('signout').onclick = signOut;
Expand Down
24 changes: 24 additions & 0 deletions apps/web/public/health/health.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/public/health/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0A0D0C">
<meta name="description" content="A private health tracker that saves readings on this device.">
<meta name="description" content="A private health tracker for signed-in users that saves readings on this device.">
<meta name="robots" content="noindex">
<title>Health tracker · tleehealth</title>
<link rel="manifest" href="/health/manifest.webmanifest">
Expand All @@ -21,7 +21,7 @@
<section class="intro">
<p class="eyebrow">Private health tracker</p>
<h1>Your readings, on this device.</h1>
<p class="lede">Record measurements and review changes over time. This tracker does not require an account and does not send readings to tleehealth.</p>
<p class="lede">Sign in to record measurements and review changes over time. Readings stay encrypted on this device and are never sent to tleehealth.</p>
</section>
<section class="notice" aria-label="Storage details">
<strong>Encrypted on this device before saving.</strong> 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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/health/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 3 additions & 9 deletions apps/web/public/health/sw.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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));
});
2 changes: 1 addition & 1 deletion apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ api.post('/auth/link', async (c) => {
// An invite (from /i/<code>, 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}`);
}
Expand Down
60 changes: 34 additions & 26 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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));
Expand All @@ -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) {
Expand All @@ -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.
Expand All @@ -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}`);
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

/**
Expand Down Expand Up @@ -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();
Expand All @@ -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. */
Expand Down
27 changes: 25 additions & 2 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading