190e0ef4b6
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>
41 lines
1.4 KiB
TypeScript
41 lines
1.4 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { expectedToken, safeEqual } from "./auth";
|
|
|
|
beforeEach(() => {
|
|
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
|
delete process.env.LYRA_PASSWORD;
|
|
});
|
|
|
|
describe("auth token", () => {
|
|
it("returns '' when no password is configured (auth disabled)", async () => {
|
|
expect(await expectedToken()).toBe("");
|
|
});
|
|
|
|
it("derives a stable, non-empty token from the password", async () => {
|
|
process.env.LYRA_PASSWORD = "hunter2";
|
|
const a = await expectedToken();
|
|
const b = await expectedToken();
|
|
expect(a).not.toBe("");
|
|
expect(a).toBe(b); // deterministic
|
|
});
|
|
|
|
it("changes with the password and with the secret key", async () => {
|
|
process.env.LYRA_PASSWORD = "hunter2";
|
|
const base = await expectedToken();
|
|
process.env.LYRA_PASSWORD = "different";
|
|
expect(await expectedToken()).not.toBe(base);
|
|
process.env.LYRA_PASSWORD = "hunter2";
|
|
process.env.LYRA_SECRET_KEY = "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmY=";
|
|
expect(await expectedToken()).not.toBe(base);
|
|
});
|
|
});
|
|
|
|
describe("safeEqual", () => {
|
|
it("matches equal strings and rejects others", () => {
|
|
expect(safeEqual("abc", "abc")).toBe(true);
|
|
expect(safeEqual("abc", "abd")).toBe(false);
|
|
expect(safeEqual("abc", "ab")).toBe(false);
|
|
expect(safeEqual("", "")).toBe(true);
|
|
});
|
|
});
|