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