Workflow-App/Workflow/lib/auth.ts
sjcoetzeesj-coder b3eaeb7287 feat(auth): password gate replacing the IP-only middleware guard
middleware.ts previously 404'd every external request, which meant the
app was unusable once hosted. There are no per-user accounts, so this
adds a single-password gate:

- /login page + POST /api/auth/login, POST /api/auth/logout
- lib/auth.ts: HMAC-SHA256 signed session cookie (lib/auth), Web Crypto
  only so the same code runs in Edge middleware and Node handlers
- middleware now admits: the signing flow (unchanged, still public),
  /login + /api/auth/*, local requests, and valid sessions. Everything
  else redirects to /login (pages) or returns 401 JSON (API calls).
- password compared with a constant-time compare, wrong attempts
  delayed 600ms to slow online guessing
- cookie is HttpOnly + SameSite=Lax, 12h, Secure when x-forwarded-proto
  says https (Coolify serves the proxy on http today)

APP_PASSWORD / AUTH_SECRET added to .env.example, with a warning: an
unquoted # in an env value starts a comment, which silently truncated
the first generated password and made login fail.

Verified against a real built server: anon->307 /login, anon API->401,
wrong password->401, correct password->200+cookie, signed-in request
reaches PocketBase, /resign/ still 200 without a cookie, forged
cookie->401.
2026-10-18 18:39:27 +02:00

103 lines
No EOL
3.6 KiB
TypeScript

// lib/auth.ts
//
// Session helpers for the app password gate.
//
// Design notes:
// • Uses only Web Crypto (crypto.subtle), never node:crypto, so the same
// code runs in the Edge middleware and in Node route handlers.
// • The session cookie carries an expiry plus an HMAC-SHA256 signature.
// It is NOT a PocketBase token: the app keeps using its server-side
// PB_TOKEN, this only proves "a human who knows APP_PASSWORD got here".
// • Nothing secret is baked into the client bundle — the password is only
// ever read inside a route handler on the server.
export const SESSION_COOKIE = "wf_session";
/** 12 hours. Long enough for a working day, short enough to expire. */
export const SESSION_TTL_SECONDS = 60 * 60 * 12;
const enc = new TextEncoder();
const dec = new TextDecoder();
function b64u(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function unb64u(s: string): Uint8Array {
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
"verify",
]);
}
/** Returns "<base64url payload>.<base64url signature>". */
export async function createSessionToken(secret: string, ttlSeconds = SESSION_TTL_SECONDS): Promise<string> {
if (!secret) throw new Error("AUTH_SECRET is not set");
const payload = b64u(
enc.encode(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttlSeconds }))
);
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(payload)));
return `${payload}.${b64u(sig)}`;
}
export async function verifySessionToken(
secret: string | undefined,
token: string | undefined | null
): Promise<boolean> {
if (!secret || !token) return false;
const dot = token.lastIndexOf(".");
if (dot <= 0) return false;
const payload = token.slice(0, dot);
let sig: Uint8Array;
try {
sig = unb64u(token.slice(dot + 1));
} catch {
return false;
}
try {
// crypto.subtle.verify is a constant-time comparison for us.
const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret), sig, enc.encode(payload));
if (!ok) return false;
const { exp } = JSON.parse(dec.decode(unb64u(payload)));
return typeof exp === "number" && exp > Math.floor(Date.now() / 1000);
} catch {
return false;
}
}
/**
* Constant-time-ish string compare. Prevents an attacker from learning the
* password one character at a time by timing the response.
*/
export function secretsMatch(a: string, b: string): boolean {
const ab = enc.encode(a);
const bb = enc.encode(b);
let diff = ab.length ^ bb.length;
const max = Math.max(ab.length, bb.length);
for (let i = 0; i < max; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
return diff === 0;
}
/** Cookie options shared by the login and logout routes. */
export function sessionCookieOptions(req: { headers: Headers; nextUrl: URL }, maxAge: number) {
// Behind Traefik the browser talks https to the proxy, which forwards http
// to the container — so trust x-forwarded-proto rather than nextUrl.protocol.
const proto = req.headers.get("x-forwarded-proto") ?? req.nextUrl.protocol;
return {
httpOnly: true,
sameSite: "lax" as const,
secure: proto === "https:",
path: "/",
maxAge,
};
}