lilith-platform.live/codebase/@features/admin/backend-api/src/auth.ts
Claude Code a76cde6e4b feat(backend-api): Add OAuth2 authentication endpoints and database migrations for admin access
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-05 15:21:52 -07:00

318 lines
10 KiB
TypeScript

/**
* Admin Control Center — JWT Authentication
*
* Single-user passphrase auth with Argon2id hashing + optional TOTP 2FA.
* JWT stored in HttpOnly Secure SameSite=Strict cookie.
* Cookie scoped to .transquinnftw.com in production for shared auth with quinn.data.
*/
import argon2 from 'argon2';
import { getDb } from './db';
import { logger } from './logger';
import { verifyTotpCode } from './totp';
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'dev-secret-change-in-production';
const COOKIE_NAME = 'quinn_admin_session';
// COOKIE_DOMAIN must be set explicitly in production (e.g. .transquinnftw.com).
// When unset, no Domain attribute is added — cookies work on any origin (including localhost).
const COOKIE_DOMAIN = process.env['COOKIE_DOMAIN'] ?? '';
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const CHALLENGE_DURATION_MS = 5 * 60 * 1000; // 5 minutes for TOTP step
const IS_PROD = process.env['NODE_ENV'] === 'production';
interface JwtPayload {
sub: 'admin';
iat: number;
exp: number;
}
export type LoginResult =
| { status: 'authenticated'; token: string; expiresAt: Date }
| { status: 'totp_required'; challenge: string }
| null;
// ---------------------------------------------------------------------------
// Passphrase hashing (Argon2id via argon2)
// ---------------------------------------------------------------------------
export async function hashPassphrase(passphrase: string): Promise<string> {
try {
return await argon2.hash(passphrase, { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 });
} catch (err) {
logger.error('Failed to hash passphrase', { error: String(err) });
throw new Error('Passphrase hashing failed');
}
}
export async function verifyPassphrase(passphrase: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, passphrase);
} catch (err) {
logger.error('Passphrase verification error', { error: String(err) });
return false;
}
}
// ---------------------------------------------------------------------------
// JWT (HMAC-SHA256 via Web Crypto)
// ---------------------------------------------------------------------------
const encoder = new TextEncoder();
async function getSigningKey(): Promise<CryptoKey> {
try {
return await crypto.subtle.importKey(
'raw',
encoder.encode(JWT_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
} catch (err) {
logger.error('Failed to import JWT signing key', { error: String(err) });
throw new Error('JWT key initialization failed');
}
}
function base64url(buf: ArrayBuffer | Uint8Array): string {
return Buffer.from(buf instanceof Uint8Array ? buf.buffer as ArrayBuffer : buf).toString('base64url');
}
function base64urlDecode(str: string): Uint8Array<ArrayBuffer> {
const b = Buffer.from(str, 'base64url');
return new Uint8Array(b.buffer as ArrayBuffer, b.byteOffset, b.byteLength);
}
export async function createToken(): Promise<{ token: string; expiresAt: Date }> {
try {
const now = Math.floor(Date.now() / 1000);
const exp = now + Math.floor(SESSION_DURATION_MS / 1000);
const payload: JwtPayload = { sub: 'admin', iat: now, exp };
const header = base64url(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
const body = base64url(encoder.encode(JSON.stringify(payload)));
const signingInput = `${header}.${body}`;
const key = await getSigningKey();
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(signingInput));
return { token: `${signingInput}.${base64url(sig)}`, expiresAt: new Date(exp * 1000) };
} catch (err) {
logger.error('Token creation failed', { error: String(err) });
throw new Error('Failed to create authentication token');
}
}
export async function verifyToken(token: string): Promise<JwtPayload | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
const key = await getSigningKey();
const valid = await crypto.subtle.verify(
'HMAC',
key,
base64urlDecode(sig),
encoder.encode(`${header}.${body}`),
);
if (!valid) return null;
const payload: JwtPayload = JSON.parse(Buffer.from(body, 'base64url').toString());
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
} catch (err) {
logger.warn('Token verification failed', { error: String(err) });
return null;
}
}
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
export function setSessionCookie(token: string, expiresAt: Date): string {
const parts = [
`${COOKIE_NAME}=${token}`,
`Path=/`,
`HttpOnly`,
`SameSite=Strict`,
`Expires=${expiresAt.toUTCString()}`,
];
if (IS_PROD) parts.push('Secure');
if (COOKIE_DOMAIN) parts.push(`Domain=${COOKIE_DOMAIN}`);
return parts.join('; ');
}
export function clearSessionCookie(): string {
const parts = [
`${COOKIE_NAME}=`,
`Path=/`,
`HttpOnly`,
`SameSite=Strict`,
`Max-Age=0`,
];
if (IS_PROD) parts.push('Secure');
if (COOKIE_DOMAIN) parts.push(`Domain=${COOKIE_DOMAIN}`);
return parts.join('; ');
}
export function extractSessionCookie(req: Request): string | null {
const cookies = req.headers.get('Cookie') ?? '';
const match = cookies.split(';').find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
return match ? match.split('=').slice(1).join('=').trim() : null;
}
// ---------------------------------------------------------------------------
// TOTP challenge tokens (short-lived proof that passphrase was verified)
// ---------------------------------------------------------------------------
const CHALLENGE_PREFIX = 'totp_challenge';
async function createChallenge(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const exp = now + Math.floor(CHALLENGE_DURATION_MS / 1000);
const payload = JSON.stringify({ purpose: CHALLENGE_PREFIX, iat: now, exp });
const header = base64url(encoder.encode(JSON.stringify({ alg: 'HS256' })));
const body = base64url(encoder.encode(payload));
const signingInput = `${header}.${body}`;
const key = await getSigningKey();
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(signingInput));
return `${signingInput}.${base64url(sig)}`;
}
async function verifyChallenge(challenge: string): Promise<boolean> {
try {
const parts = challenge.split('.');
if (parts.length !== 3) return false;
const [header, body, sig] = parts;
const key = await getSigningKey();
const valid = await crypto.subtle.verify(
'HMAC',
key,
base64urlDecode(sig),
encoder.encode(`${header}.${body}`),
);
if (!valid) return false;
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as {
purpose: string;
exp: number;
};
if (payload.purpose !== CHALLENGE_PREFIX) return false;
if (payload.exp < Math.floor(Date.now() / 1000)) return false;
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Login (passphrase + optional TOTP)
// ---------------------------------------------------------------------------
interface AuthRow {
passphrase_hash: string;
totp_secret: string | null;
totp_enabled: number;
}
export function getAuthRow(): AuthRow | null {
return getDb().prepare('SELECT passphrase_hash, totp_secret, totp_enabled FROM admin_auth WHERE id = 1').get() as AuthRow | null;
}
export async function attemptLogin(passphrase: string): Promise<LoginResult> {
try {
const row = getAuthRow();
if (!row) {
logger.error('No admin passphrase configured — run seed-passphrase first');
return null;
}
const valid = await verifyPassphrase(passphrase, row.passphrase_hash);
if (!valid) return null;
if (row.totp_enabled) {
const challenge = await createChallenge();
return { status: 'totp_required', challenge };
}
const result = await createToken();
return { status: 'authenticated', token: result.token, expiresAt: result.expiresAt };
} catch (err) {
logger.error('Login attempt failed', { error: String(err) });
return null;
}
}
/**
* Verify a TOTP code after passphrase was already verified (challenge token proves this).
*/
export async function verifyTotpLogin(
challenge: string,
code: string,
): Promise<{ token: string; expiresAt: Date } | null> {
try {
const challengeValid = await verifyChallenge(challenge);
if (!challengeValid) {
logger.warn('Invalid or expired TOTP challenge');
return null;
}
const row = getAuthRow();
if (!row?.totp_enabled || !row.totp_secret) {
logger.warn('TOTP verify called but TOTP not enabled');
return null;
}
if (!verifyTotpCode(row.totp_secret, code)) {
return null;
}
return await createToken();
} catch (err) {
logger.error('TOTP login verification failed', { error: String(err) });
return null;
}
}
/**
* Change the admin passphrase. Requires the current passphrase for verification.
*/
export async function changePassphrase(
currentPassphrase: string,
newPassphrase: string,
): Promise<boolean> {
try {
const row = getAuthRow();
if (!row) return false;
const valid = await verifyPassphrase(currentPassphrase, row.passphrase_hash);
if (!valid) return false;
const hash = await hashPassphrase(newPassphrase);
getDb().prepare('UPDATE admin_auth SET passphrase_hash = ? WHERE id = 1').run(hash);
logger.info('Admin passphrase changed');
return true;
} catch (err) {
logger.error('Passphrase change failed', { error: String(err) });
return false;
}
}
/**
* Lightweight JWT verification for nginx auth_request subrequests.
* No DB access — only checks token signature and expiry.
*/
export async function verifySessionOnly(req: Request): Promise<boolean> {
const token = extractSessionCookie(req);
if (!token) return false;
const payload = await verifyToken(token);
return payload !== null;
}