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:
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user