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 = `
We sent a sign-in link to ${esc(data.email)}. It works once and expires in 20 minutes.
Private health tracker
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.
, 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');
});
});