318 lines
10 KiB
TypeScript
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;
|
|
}
|