Files
soroushasadi/lib/auth/session.ts
T
soroush.asadi add78d8460
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s
first commit
2026-05-31 12:47:02 +03:30

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());
}