import { createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto"; import { readFileSync } from "node:fs"; export const SESSION_COOKIE = "tabularium_session"; export const SESSION_SECONDS = 7 * 24 * 60 * 60; export type AuthConfig = { secret: string; jwtKey: string }; export function authConfig(): AuthConfig | null { const path = process.env.AUTH_CONFIG_FILE; if (!path) return null; let value: AuthConfig; try { value = JSON.parse(readFileSync(path, "utf8")) as AuthConfig; if ( !/^[A-Z2-7]{32}$/.test(value.secret) || !/^[a-f0-9]{64}$/.test(value.jwtKey) ) throw new Error(); } catch { throw new Error("Invalid authentication configuration"); } return value; } export function base32(bytes: Buffer) { let bits = 0, value = 0, output = ""; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; output += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[(value >>> bits) & 31]; } } if (bits) output += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[(value << (5 - bits)) & 31]; return output; } export function newAuthConfig(): AuthConfig { return { secret: base32(randomBytes(20)), jwtKey: randomBytes(32).toString("hex"), }; } export function authId(config: AuthConfig) { return createHash("sha256").update(config.jwtKey).digest("hex"); } export function totp(secret: string, step: number, digits = 6) { let bits = 0, value = 0; const bytes: number[] = []; for (const char of secret) { const index = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(char); if (index < 0) throw Error("Invalid TOTP key"); value = (value << 5) | index; bits += 5; if (bits >= 8) { bits -= 8; bytes.push((value >>> bits) & 255); } } const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(step)); const hmac = createHmac("sha1", Buffer.from(bytes)).update(counter).digest(); return String( (hmac.readUInt32BE(hmac[19] & 15) & 0x7fffffff) % 10 ** digits, ).padStart(digits, "0"); } export function matchingStep(secret: string, code: string, now = Date.now()) { if (!/^\d{6}$/.test(code)) return null; const current = Math.floor(now / 30000); for (const step of [current, current - 1, current + 1]) if (timingSafeEqual(Buffer.from(totp(secret, step)), Buffer.from(code))) return step; return null; } export function signSession( config: AuthConfig, now = Math.floor(Date.now() / 1000), lifetime = SESSION_SECONDS, ) { const header = Buffer.from( JSON.stringify({ alg: "HS256", typ: "JWT" }), ).toString("base64url"); const payload = Buffer.from( JSON.stringify({ iss: "tabularium", aud: "tabularium-web", sub: "shared", iat: now, exp: now + lifetime, jti: randomBytes(16).toString("hex"), }), ).toString("base64url"); const content = header + "." + payload; return ( content + "." + createHmac("sha256", Buffer.from(config.jwtKey, "hex")) .update(content) .digest("base64url") ); } export function validSession( token: string | undefined, config: AuthConfig, now = Math.floor(Date.now() / 1000), ) { try { if (!token || token.length > 2048) return false; const parts = token.split("."); if (parts.length !== 3) return false; const [header, payload, signature] = parts; const h = JSON.parse(Buffer.from(header, "base64url").toString()); if (h.alg !== "HS256" || h.typ !== "JWT") return false; const expected = createHmac("sha256", Buffer.from(config.jwtKey, "hex")) .update(header + "." + payload) .digest(); const actual = Buffer.from(signature, "base64url"); if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return false; const p = JSON.parse(Buffer.from(payload, "base64url").toString()); return ( p.iss === "tabularium" && p.aud === "tabularium-web" && p.sub === "shared" && Number.isInteger(p.exp) && Number.isInteger(p.iat) && p.iat <= now + 30 && p.exp > now && p.exp > p.iat && p.exp - p.iat <= SESSION_SECONDS ); } catch { return false; } } export function sessionCookie(token: string, clear = false) { return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_SECONDS}${process.env.AUTH_COOKIE_SECURE === "false" ? "" : "; Secure"}`; } export function safeReturn(value: unknown) { return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") && !/[\\\r\n]/.test(value) && !value.startsWith("/login") ? value : "/"; } export function isLoginRedirect(value: string | null, base: string) { if (!value) return false; try { const target = new URL(value, base); return ( target.origin === new URL(base).origin && (target.pathname === "/login" || /^\/_next\/data\/[^/]+\/login\.json$/.test(target.pathname)) ); } catch { return false; } }