Add JWT session client and tenant-scoped storage

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 <jwt> to
  every call. On 401 it clears the session and redirects to
  /login.html?next=<current-path> 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:25:07 +02:00
co-authored by Claude Opus 4.7
parent fab1896056
commit a42b7fa345
5 changed files with 299 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes — librenotes</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
</head>
<body class="app-page">
<header class="app-header">
<a class="brand" href="/">librenotes</a>
<span class="who" id="who"></span>
<button id="theme-toggle" type="button">Toggle theme</button>
<button id="logout" type="button">Sign out</button>
</header>
<main class="app-main">
<h1>Welcome</h1>
<p>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.</p>
<pre id="whoami-output"></pre>
</main>
<script src="/auth-client.js"></script>
<script src="/app.js"></script>
</body>
</html>
+44
View File
@@ -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;
});
})();
+147
View File
@@ -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 <jwt>" 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,
};
})();
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing in — librenotes</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="title">
<h1 id="title"><a href="/" class="brand">librenotes</a></h1>
<div id="status" role="status">
<p>Verifying your sign-in link…</p>
</div>
<div id="error" class="error" role="alert" hidden>
<p>This sign-in link is invalid or has expired.</p>
<p><a href="/login.html">Request a new one</a>.</p>
</div>
<div id="success" hidden>
<p>Signed in as <strong id="email"></strong>.</p>
<p><a href="/app.html" class="btn primary">Go to your notes</a></p>
</div>
</main>
<script src="/auth-client.js"></script>
<script src="/verify.js"></script>
</body>
</html>
+52
View File
@@ -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();
})();