113 lines
3.9 KiB
TypeScript
113 lines
3.9 KiB
TypeScript
/**
|
|
* Stateless admin session — a single password gates the whole CMS.
|
|
*
|
|
* On login we mint an HMAC-signed token (payload + signature) and store it in
|
|
* an httpOnly cookie. Verification re-computes the HMAC and checks expiry.
|
|
* Everything here uses the Web Crypto API only (no `node:crypto`) so the same
|
|
* code runs in Edge middleware AND in Node route handlers.
|
|
*/
|
|
|
|
export const SESSION_COOKIE = 'sa_admin';
|
|
export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days, in seconds
|
|
|
|
const enc = new TextEncoder();
|
|
const dec = new TextDecoder();
|
|
|
|
/** Signing secret. In production set ADMIN_SESSION_SECRET (or it derives from
|
|
* ADMIN_PASSWORD). A loud, obviously-insecure default keeps dev frictionless. */
|
|
function getSecret(): string {
|
|
return (
|
|
process.env.ADMIN_SESSION_SECRET ||
|
|
process.env.ADMIN_PASSWORD ||
|
|
'dev-insecure-secret-change-me'
|
|
);
|
|
}
|
|
|
|
/** The single admin password. Falls back to "admin" in non-production only. */
|
|
function getPassword(): string | undefined {
|
|
if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD;
|
|
if (process.env.NODE_ENV !== 'production') return 'admin';
|
|
return undefined;
|
|
}
|
|
|
|
function toB64url(bytes: ArrayBuffer | Uint8Array): string {
|
|
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
let bin = '';
|
|
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
|
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
function fromB64url(str: string): Uint8Array {
|
|
const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice((str.length + 3) % 4);
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
async function hmacKey(): Promise<CryptoKey> {
|
|
return crypto.subtle.importKey(
|
|
'raw',
|
|
enc.encode(getSecret()),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign', 'verify'],
|
|
);
|
|
}
|
|
|
|
async function sha256Hex(input: string): Promise<string> {
|
|
const digest = await crypto.subtle.digest('SHA-256', enc.encode(input));
|
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
/** Constant-time comparison of two equal-length hex strings. */
|
|
function timingSafeEqual(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;
|
|
}
|
|
|
|
/** Mint a signed session token. */
|
|
export async function createSession(): Promise<string> {
|
|
const payload = { iat: Date.now(), exp: Date.now() + SESSION_MAX_AGE * 1000 };
|
|
const data = toB64url(enc.encode(JSON.stringify(payload)));
|
|
const sig = await crypto.subtle.sign('HMAC', await hmacKey(), enc.encode(data));
|
|
return `${data}.${toB64url(sig)}`;
|
|
}
|
|
|
|
/** True when the token's signature is valid and it has not expired. */
|
|
export async function verifySession(token?: string | null): Promise<boolean> {
|
|
if (!token) return false;
|
|
const dot = token.indexOf('.');
|
|
if (dot <= 0) return false;
|
|
const data = token.slice(0, dot);
|
|
const sig = token.slice(dot + 1);
|
|
try {
|
|
const valid = await crypto.subtle.verify(
|
|
'HMAC',
|
|
await hmacKey(),
|
|
fromB64url(sig),
|
|
enc.encode(data),
|
|
);
|
|
if (!valid) return false;
|
|
const payload = JSON.parse(dec.decode(fromB64url(data))) as { exp?: number };
|
|
return typeof payload.exp === 'number' && payload.exp > Date.now();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Check a submitted password against the configured one (constant-time). */
|
|
export async function verifyPassword(input: string): Promise<boolean> {
|
|
const expected = getPassword();
|
|
if (!expected) return false; // No password set in production => locked out.
|
|
const [a, b] = await Promise.all([sha256Hex(input), sha256Hex(expected)]);
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
/** Whether an admin password is configured (used to warn in the UI). */
|
|
export function passwordConfigured(): boolean {
|
|
return Boolean(getPassword());
|
|
}
|