Files
Lyra/web/src/lib/auth.ts
T
Jonathan 446a4bc2d2 fix(web): address auth review findings (open redirect, dead code, HMAC per-req)
Pre-merge review of the P0 branch:
- Open redirect: login redirected to an unvalidated ?next param, so a crafted
  /login?next=https://evil (or //evil) bounced off-site after login. Now only
  same-origin relative paths are honored (else fall back to "/").
- Remove the unused useRouter import/binding in the login form.
- Memoize expectedToken() keyed on (password, secret) so middleware stops
  recomputing an HMAC on every gated request.

Deferred (low-value/latent, noted): Soulseek cover.jpg isn't retrieved into
staging (UI still shows Cover Art Archive art); _retrieve's basename-keyed map
could undercount same-named files but source_refs come from a single slskd dir;
SignOut visibility rides a server-env read (cosmetic).

web 138 tests green, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:25:37 +02:00

47 lines
1.9 KiB
TypeScript

// Minimal shared-password auth. Enabled ONLY when LYRA_PASSWORD is set; otherwise the
// app stays open (unchanged behavior) — see README "Security". The auth cookie holds a
// token derived from the password via HMAC-SHA256 keyed by LYRA_SECRET_KEY, so it can't
// be forged without the secret and the plaintext password is never stored client-side.
// Edge-safe: uses global Web Crypto + btoa (no node:crypto / Buffer), so the same code
// runs in middleware (Edge runtime) and in the API route (Node runtime).
export const AUTH_COOKIE = "lyra_auth";
function toB64(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
// Memoize the derived token so the middleware doesn't recompute an HMAC on every request
// (password/secret don't change at runtime; keyed on both so a change still recomputes).
let _cache: { password: string; secret: string; token: string } | null = null;
/** The token a valid session cookie must carry, or "" when auth is disabled (no password). */
export async function expectedToken(): Promise<string> {
const password = process.env.LYRA_PASSWORD;
if (!password) return "";
const secret = process.env.LYRA_SECRET_KEY ?? "lyra";
if (_cache && _cache.password === password && _cache.secret === secret) return _cache.token;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(password));
const token = toB64(new Uint8Array(sig));
_cache = { password, secret, token };
return token;
}
/** Constant-time string compare (both operands are our own derived tokens). */
export function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}