From 274c7054d0eabe406c55def231560f09b790b419 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 22:25:07 +0200 Subject: [PATCH] Add JWT session client and tenant-scoped storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/librenotes/web/public/auth-client.js exposes window.authClient with the full session API used by the rest of the frontend: Session storage (#14): - saveSession / loadSession / clearSession / isAuthenticated - Backed by sessionStorage, not localStorage: tokens are isolated per tab and cleared on tab close. localStorage would survive tab close on a shared device, which we want to avoid. - loadSession returns null when expires_at has passed, so callers treat expired sessions as logged-out without a network round trip. API wrapper (#14): - apiFetch(url, init) attaches Authorization: Bearer to every call. On 401 it clears the session and redirects to /login.html?next= so the user returns where they started. Throws after the redirect so the caller's .then does not run with stale data. Tenant-scoped localStorage (#15): - tenantStore() returns a get/set/remove wrapper whose keys are prefixed "librenotes:{user_id}:". Two users on the same browser therefore have fully independent UI state. JSON serialisation with try/catch fallbacks for corrupted or quota-exceeded storage so a bad blob never crashes the app. - clearTenantStore(userID) removes every key with that prefix. Called from clearSession() so logout wipes both the JWT and the user's preferences. verify.html + verify.js complete the magic-link flow: read ?token=, POST /auth/verify, hand the response to saveSession(), strip the token from the URL via history.replaceState. Errors route the user back to /login.html. app.html + app.js are a minimal authenticated landing demonstrating the full stack end-to-end: apiFetch hits /api/whoami, tenantStore persists a theme preference, logout clears both. The full notes UI is left to a later phase — this is the seam. Closes #14 and #15. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/librenotes/web/public/app.html | 28 +++++ cmd/librenotes/web/public/app.js | 44 +++++++ cmd/librenotes/web/public/auth-client.js | 147 +++++++++++++++++++++++ cmd/librenotes/web/public/verify.html | 28 +++++ cmd/librenotes/web/public/verify.js | 52 ++++++++ 5 files changed, 299 insertions(+) create mode 100644 cmd/librenotes/web/public/app.html create mode 100644 cmd/librenotes/web/public/app.js create mode 100644 cmd/librenotes/web/public/auth-client.js create mode 100644 cmd/librenotes/web/public/verify.html create mode 100644 cmd/librenotes/web/public/verify.js diff --git a/cmd/librenotes/web/public/app.html b/cmd/librenotes/web/public/app.html new file mode 100644 index 0000000..808d815 --- /dev/null +++ b/cmd/librenotes/web/public/app.html @@ -0,0 +1,28 @@ + + + + + + Notes — librenotes + + + + +
+ librenotes + + + +
+ +
+

Welcome

+

You're signed in. The full notes UI ships in a later phase; this + page exists to verify the auth + tenant-context wiring end to end.

+

+  
+ + + + + diff --git a/cmd/librenotes/web/public/app.js b/cmd/librenotes/web/public/app.js new file mode 100644 index 0000000..dccc4e8 --- /dev/null +++ b/cmd/librenotes/web/public/app.js @@ -0,0 +1,44 @@ +// app.js — minimal authenticated landing inside the app shell. +// +// Demonstrates: +// - apiFetch() automatically attaching Authorization (issue #14) +// - 401 -> redirect-to-login behaviour (issue #14) +// - tenantStore() for per-user UI state (issue #15) +// - logout clearing both session and tenant store (issue #15) + +(function () { + "use strict"; + + if (!window.authClient.isAuthenticated()) { + window.location.replace("/login.html"); + return; + } + + const session = window.authClient.loadSession(); + document.getElementById("who").textContent = session.email; + + // Tenant-scoped UI state demo: theme preference. + const store = window.authClient.tenantStore(); + const savedTheme = store.get("theme", "light"); + document.body.dataset.theme = savedTheme; + + document.getElementById("theme-toggle").addEventListener("click", function () { + const next = document.body.dataset.theme === "dark" ? "light" : "dark"; + document.body.dataset.theme = next; + store.set("theme", next); + }); + + document.getElementById("logout").addEventListener("click", function () { + window.authClient.logout(); + }); + + // Sanity check the auth wiring round-trip. + window.authClient.apiFetch("/api/whoami") + .then(function (resp) { return resp.json(); }) + .then(function (data) { + document.getElementById("whoami-output").textContent = JSON.stringify(data, null, 2); + }) + .catch(function (e) { + document.getElementById("whoami-output").textContent = "error: " + e.message; + }); +})(); diff --git a/cmd/librenotes/web/public/auth-client.js b/cmd/librenotes/web/public/auth-client.js new file mode 100644 index 0000000..28739fc --- /dev/null +++ b/cmd/librenotes/web/public/auth-client.js @@ -0,0 +1,147 @@ +// auth-client.js — JWT session handling and API wrapper. +// +// Storage strategy (per issue #14): +// We use sessionStorage rather than localStorage. sessionStorage is +// isolated per tab and cleared on tab close, which limits exposure +// if the user shares a machine. We accept the XSS risk inherent in +// any JS-readable token store; an httpOnly cookie would be stronger +// but requires server-set cookies and CSRF protection that the +// minimal /auth/verify response does not provide today. +// +// Tenant-scoped storage (per issue #15): +// tenantStore() returns a wrapper whose keys are prefixed with +// "librenotes:{user_id}:". On logout we clear every key with that +// prefix. + +(function () { + "use strict"; + + const SESSION_KEY = "librenotes.session"; + + function saveSession(data) { + // data: { jwt, user_id, email, expires_at } + if (!data || !data.jwt || !data.user_id) { + throw new Error("auth-client: invalid session data"); + } + sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); + } + + function loadSession() { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed.jwt || !parsed.user_id) return null; + if (parsed.expires_at && parsed.expires_at * 1000 < Date.now()) { + // Expired — caller should treat as logged out. + return null; + } + return parsed; + } catch (_) { + return null; + } + } + + function clearSession() { + const session = loadSession(); + sessionStorage.removeItem(SESSION_KEY); + if (session && session.user_id) { + clearTenantStore(session.user_id); + } + } + + function isAuthenticated() { + return loadSession() !== null; + } + + // Build the "Authorization: Bearer " header lazily so the + // caller always picks up the freshest session. + function authHeader() { + const s = loadSession(); + return s ? { Authorization: "Bearer " + s.jwt } : {}; + } + + // apiFetch wraps fetch() with the auth header attached. On 401 it + // clears the session and redirects to /login.html (issue #14 + // requirement: "401 responses trigger re-authentication flow"). + async function apiFetch(input, init) { + init = init || {}; + init.headers = Object.assign({}, init.headers || {}, authHeader()); + const resp = await fetch(input, init); + if (resp.status === 401) { + clearSession(); + const next = encodeURIComponent(window.location.pathname + window.location.search); + window.location.replace("/login.html?next=" + next); + // Throw so the caller's .then doesn't run with a stale resp. + throw new Error("unauthorized"); + } + return resp; + } + + // Tenant-scoped key/value store backed by localStorage. + // Keys live under "librenotes:{user_id}:{name}". This means two + // users on the same browser have fully independent UI state, and + // we can clear all of a user's preferences in one pass on logout. + function tenantStore(userID) { + if (!userID) { + const s = loadSession(); + userID = s ? s.user_id : null; + } + const prefix = userID ? "librenotes:" + userID + ":" : null; + return { + get(key, fallback) { + if (!prefix) return fallback; + try { + const raw = localStorage.getItem(prefix + key); + if (raw === null) return fallback; + return JSON.parse(raw); + } catch (_) { + // Corrupted / unparseable — return fallback rather than + // crash. Issue #15 requires graceful handling. + return fallback; + } + }, + set(key, value) { + if (!prefix) return; + try { + localStorage.setItem(prefix + key, JSON.stringify(value)); + } catch (_) { + // Quota or private-mode failure — non-fatal. + } + }, + remove(key) { + if (!prefix) return; + localStorage.removeItem(prefix + key); + }, + }; + } + + function clearTenantStore(userID) { + if (!userID) return; + const prefix = "librenotes:" + userID + ":"; + // Collect first; deletion mid-iteration shifts indices. + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.indexOf(prefix) === 0) keys.push(k); + } + for (const k of keys) localStorage.removeItem(k); + } + + function logout() { + clearSession(); + window.location.replace("/login.html"); + } + + window.authClient = { + saveSession, + loadSession, + clearSession, + isAuthenticated, + authHeader, + apiFetch, + tenantStore, + clearTenantStore, + logout, + }; +})(); diff --git a/cmd/librenotes/web/public/verify.html b/cmd/librenotes/web/public/verify.html new file mode 100644 index 0000000..832ffe7 --- /dev/null +++ b/cmd/librenotes/web/public/verify.html @@ -0,0 +1,28 @@ + + + + + + Signing in — librenotes + + + + +
+

librenotes

+
+

Verifying your sign-in link…

+
+ + +
+ + + + diff --git a/cmd/librenotes/web/public/verify.js b/cmd/librenotes/web/public/verify.js new file mode 100644 index 0000000..2c956ab --- /dev/null +++ b/cmd/librenotes/web/public/verify.js @@ -0,0 +1,52 @@ +// verify.js — completes the magic-link flow. +// +// Flow: +// 1. Read ?token=... from the URL. +// 2. POST /auth/verify -> { jwt, user_id, email } +// 3. Hand the response to authClient.saveSession() which stores it +// in sessionStorage (cleared on tab close — issue #14). +// 4. Replace history so the token doesn't linger in the address bar. + +(function () { + "use strict"; + + const $error = document.getElementById("error"); + const $success = document.getElementById("success"); + const $status = document.getElementById("status"); + const $email = document.getElementById("email"); + + function showError() { + $status.hidden = true; + $error.hidden = false; + } + + async function run() { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) { + showError(); + return; + } + try { + const resp = await fetch("/auth/verify?token=" + encodeURIComponent(token), { + method: "POST", + }); + if (!resp.ok) { + showError(); + return; + } + const data = await resp.json(); + window.authClient.saveSession(data); + $status.hidden = true; + $success.hidden = false; + $email.textContent = data.email; + // Strip the token from the URL — defence-in-depth against the + // user pasting it into chat or it ending up in browser history. + history.replaceState({}, "", "/verify.html"); + } catch (e) { + showError(); + } + } + + run(); +})();