feat(web): minimal shared-password auth gate

The web app had no auth — anyone reaching the host could browse the library,
trigger downloads, and read/overwrite the stored credentials in Settings. Adds
an opt-in single-shared-password gate:

- Next.js middleware redirects unauthenticated page requests to /login and
  returns 401 for /api/*; static assets are excluded via matcher.
- Auth is enabled ONLY when LYRA_PASSWORD is set (unset ⇒ app stays open, so
  existing deployments are unchanged). Documented in README "Security".
- The session cookie is httpOnly and carries HMAC-SHA256(password) keyed by
  LYRA_SECRET_KEY — unforgeable, and the plaintext password never touches the
  client. Edge-safe Web Crypto so one lib/auth.ts serves middleware + route.
- Cookie is NOT Secure: Lyra serves plain HTTP on the LAN; a Secure cookie would
  loop. Works behind an HTTPS proxy too.
- /login page + form, a Sign out button (shown only when auth is enabled), and
  chrome (nav + worker-status poll) suppressed on /login.
- docker-compose passes LYRA_PASSWORD to web; .env.example documents it.

Verified live end-to-end (built + next start): no-cookie page→307 /login,
API→401, wrong password→401, correct→200+cookie, authed page/API→200, forged
cookie→401, and the auth-disabled (no LYRA_PASSWORD) path stays fully open.
web 133 tests green, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 10:12:51 +02:00
parent 851f725a98
commit 190e0ef4b6
14 changed files with 304 additions and 1 deletions
+1
View File
@@ -13,6 +13,7 @@ const LINKS: { href: string; label: string }[] = [
export function ContentsNav() {
const pathname = usePathname();
if (pathname === "/login") return null; // no section nav on the login screen
const active = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
return (
<nav className="contents">
+3
View File
@@ -1,7 +1,9 @@
import { ThemeToggle } from "./theme-toggle";
import { WorkerStatus } from "./worker-status";
import { SignOut } from "./sign-out";
export function Masthead() {
const authEnabled = !!process.env.LYRA_PASSWORD;
return (
<header className="masthead">
<div className="wordmark">
@@ -13,6 +15,7 @@ export function Masthead() {
<div className="press-status">
<WorkerStatus />
<ThemeToggle />
{authEnabled ? <SignOut /> : null}
</div>
</header>
);
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { usePathname } from "next/navigation";
/** Clears the auth cookie and returns to /login. Rendered only when auth is enabled. */
export function SignOut() {
const pathname = usePathname();
if (pathname === "/login") return null;
async function signOut() {
await fetch("/api/auth", { method: "DELETE" }).catch(() => {});
window.location.assign("/login");
}
return (
<button type="button" className="sign-out" onClick={signOut} aria-label="sign out">
Sign out
</button>
);
}
+6 -1
View File
@@ -1,12 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
/** Live worker liveness in the masthead — polls /api/worker (heartbeat recency). */
export function WorkerStatus() {
const [online, setOnline] = useState<boolean | null>(null);
const pathname = usePathname();
const onLogin = pathname === "/login";
useEffect(() => {
if (onLogin) return; // don't poll (would 401) on the login screen
let alive = true;
async function check() {
try {
@@ -22,8 +26,9 @@ export function WorkerStatus() {
alive = false;
clearInterval(id);
};
}, []);
}, [onLogin]);
if (onLogin) return null;
const label = online === null ? "…" : online ? "listening" : "offline";
return (
<div className={`worker-status${online === false ? " off" : ""}`}>