372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
/**
|
|
* atlilith SSO — Node.js HTTP server
|
|
*
|
|
* Port: 3045 (configurable via PORT env var)
|
|
* All protected subdomains point nginx auth_request → GET /auth/validate
|
|
*/
|
|
|
|
import { createServer } from 'node:http';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { initSchema } from './db';
|
|
import { logger } from './logger';
|
|
import {
|
|
attemptLogin,
|
|
changePassphrase,
|
|
clearSessionCookie,
|
|
createToken,
|
|
extractSessionCookie,
|
|
getAuthRow,
|
|
isSafeRedirect,
|
|
setSessionCookie,
|
|
validateSession,
|
|
verifyTotpLogin,
|
|
} from './auth';
|
|
import { generateQrDataUrl, generateTotpSecret, verifyTotpCode } from './totp';
|
|
import { logEvent } from './events';
|
|
import { checkLockout, clearLockout, recordFailedAttempt } from './lockout';
|
|
import { error, getClientIp, getSubdomain, json, parseJson } from './routes/helpers';
|
|
import { queryEvents } from './events';
|
|
import { getDb } from './db';
|
|
|
|
const PORT = parseInt(process.env['PORT'] ?? '3045', 10);
|
|
|
|
// In non-production, /auth/validate always returns 200 and /auth/login auto-issues a token.
|
|
// This is the single, centralised dev bypass — no per-app SKIP_AUTH needed.
|
|
const DEV_MODE = process.env['NODE_ENV'] !== 'production';
|
|
|
|
// Boot
|
|
initSchema();
|
|
logger.info('atlilith SSO starting', { port: PORT });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth guard for protected endpoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function requireAuth(req: Request): Promise<{ ok: true } | Response> {
|
|
const payload = await validateSession(req);
|
|
if (!payload) return error('Unauthorized', 401);
|
|
return { ok: true };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function handle(req: Request): Promise<Response> {
|
|
const url = new URL(req.url);
|
|
const { pathname } = url;
|
|
const method = req.method.toUpperCase();
|
|
const ip = getClientIp(req);
|
|
const ua = req.headers.get('User-Agent') ?? undefined;
|
|
|
|
// Health
|
|
if (pathname === '/health' && method === 'GET') {
|
|
return json({ ok: true });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /auth/validate — nginx auth_request subrequest + backend auth delegation
|
|
// ---------------------------------------------------------------------------
|
|
if (pathname === '/auth/validate' && method === 'GET') {
|
|
// Dev bypass: all consumers delegate here, so one flag covers everything.
|
|
if (DEV_MODE) return new Response(null, { status: 200 });
|
|
|
|
const token = extractSessionCookie(req);
|
|
if (!token) {
|
|
logEvent('validate_fail', { ip, ua, subdomain: getSubdomain(req) });
|
|
return new Response(null, { status: 401 });
|
|
}
|
|
const payload = await validateSession(req);
|
|
if (!payload) {
|
|
logEvent('validate_fail', { ip, ua, subdomain: getSubdomain(req) });
|
|
return new Response(null, { status: 401 });
|
|
}
|
|
logEvent('validate_ok', { ip, ua, subdomain: getSubdomain(req), sessionId: payload.jti });
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /auth/login
|
|
// ---------------------------------------------------------------------------
|
|
if (pathname === '/auth/login' && method === 'POST') {
|
|
// Dev bypass: auto-issue a real token without passphrase check.
|
|
if (DEV_MODE) {
|
|
const { token, expiresAt } = await createToken();
|
|
const body = await parseJson<{ redirect?: string }>(req);
|
|
const redirectTo = body?.redirect && isSafeRedirect(body.redirect) ? body.redirect : undefined;
|
|
const cookieHeader = setSessionCookie(token, expiresAt);
|
|
if (redirectTo) {
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: redirectTo, 'Set-Cookie': cookieHeader },
|
|
});
|
|
}
|
|
return json({ ok: true, expiresAt: expiresAt.toISOString() }, 200, { 'Set-Cookie': cookieHeader });
|
|
}
|
|
|
|
if (checkLockout(ip)) {
|
|
logEvent('lockout_blocked', { ip, ua });
|
|
return error('Too many failed attempts. Try again later.', 429);
|
|
}
|
|
|
|
const body = await parseJson<{ passphrase: string; redirect?: string }>(req);
|
|
if (!body?.passphrase) {
|
|
return error('passphrase required', 400);
|
|
}
|
|
|
|
const result = await attemptLogin(body.passphrase);
|
|
|
|
if (!result) {
|
|
recordFailedAttempt(ip);
|
|
logEvent('login_fail', { ip, ua });
|
|
return error('Invalid passphrase', 401);
|
|
}
|
|
|
|
if (result.status === 'totp_required') {
|
|
logEvent('login_fail', { ip, ua }); // passphrase ok but TOTP pending — don't clear lockout yet
|
|
return json({ status: 'totp_required', challenge: result.challenge });
|
|
}
|
|
|
|
// Authenticated
|
|
clearLockout(ip);
|
|
logEvent('login_ok', { ip, ua, sessionId: result.sessionId });
|
|
|
|
const redirectTo =
|
|
body.redirect && isSafeRedirect(body.redirect) ? body.redirect : undefined;
|
|
|
|
if (redirectTo) {
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
Location: redirectTo,
|
|
'Set-Cookie': setSessionCookie(result.token, result.expiresAt),
|
|
},
|
|
});
|
|
}
|
|
|
|
return json(
|
|
{ ok: true, expiresAt: result.expiresAt.toISOString() },
|
|
200,
|
|
{ 'Set-Cookie': setSessionCookie(result.token, result.expiresAt) },
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /auth/totp/verify — complete TOTP step 2
|
|
// ---------------------------------------------------------------------------
|
|
if (pathname === '/auth/totp/verify' && method === 'POST') {
|
|
if (checkLockout(ip)) {
|
|
logEvent('lockout_blocked', { ip, ua });
|
|
return error('Too many failed attempts. Try again later.', 429);
|
|
}
|
|
|
|
const body = await parseJson<{ challenge: string; code: string; redirect?: string }>(req);
|
|
if (!body?.challenge || !body.code) {
|
|
return error('challenge and code required', 400);
|
|
}
|
|
|
|
const result = await verifyTotpLogin(body.challenge, body.code);
|
|
if (!result) {
|
|
recordFailedAttempt(ip);
|
|
logEvent('totp_fail', { ip, ua });
|
|
return error('Invalid or expired TOTP code', 401);
|
|
}
|
|
|
|
clearLockout(ip);
|
|
logEvent('login_ok', { ip, ua, sessionId: result.sessionId });
|
|
|
|
const redirectTo =
|
|
body.redirect && isSafeRedirect(body.redirect) ? body.redirect : undefined;
|
|
|
|
if (redirectTo) {
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
Location: redirectTo,
|
|
'Set-Cookie': setSessionCookie(result.token, result.expiresAt),
|
|
},
|
|
});
|
|
}
|
|
|
|
return json(
|
|
{ ok: true, expiresAt: result.expiresAt.toISOString() },
|
|
200,
|
|
{ 'Set-Cookie': setSessionCookie(result.token, result.expiresAt) },
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /auth/logout
|
|
// ---------------------------------------------------------------------------
|
|
if (pathname === '/auth/logout' && method === 'POST') {
|
|
const payload = await validateSession(req);
|
|
logEvent('logout', { ip, ua, sessionId: payload?.jti });
|
|
return new Response(null, {
|
|
status: 204,
|
|
headers: { 'Set-Cookie': clearSessionCookie() },
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Protected endpoints — require valid session
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// POST /auth/change-passphrase
|
|
if (pathname === '/auth/change-passphrase' && method === 'POST') {
|
|
const guard = await requireAuth(req);
|
|
if ('status' in guard) return guard;
|
|
|
|
const body = await parseJson<{ current: string; newPassphrase: string }>(req);
|
|
if (!body?.current || !body.newPassphrase) {
|
|
return error('current and newPassphrase required', 400);
|
|
}
|
|
|
|
const ok = await changePassphrase(body.current, body.newPassphrase);
|
|
if (!ok) return error('Invalid current passphrase', 401);
|
|
return json({ ok: true });
|
|
}
|
|
|
|
// POST /auth/totp/setup — generate a new TOTP secret (pending confirmation)
|
|
if (pathname === '/auth/totp/setup' && method === 'POST') {
|
|
const guard = await requireAuth(req);
|
|
if ('status' in guard) return guard;
|
|
|
|
const { secret, uri } = generateTotpSecret();
|
|
const qr = await generateQrDataUrl(uri);
|
|
|
|
// Store pending secret in DB (not enabled yet — requires confirm)
|
|
getDb()
|
|
.prepare('UPDATE sso_auth SET totp_secret = ? WHERE id = 1')
|
|
.run(secret);
|
|
|
|
return json({ secret, uri, qr });
|
|
}
|
|
|
|
// POST /auth/totp/confirm — verify first code then enable TOTP
|
|
if (pathname === '/auth/totp/confirm' && method === 'POST') {
|
|
const guard = await requireAuth(req);
|
|
if ('status' in guard) return guard;
|
|
|
|
const body = await parseJson<{ code: string }>(req);
|
|
if (!body?.code) return error('code required', 400);
|
|
|
|
const row = getAuthRow();
|
|
if (!row?.totp_secret) return error('No pending TOTP setup', 400);
|
|
if (row.totp_enabled) return error('TOTP already enabled', 400);
|
|
|
|
if (!verifyTotpCode(row.totp_secret, body.code)) {
|
|
return error('Invalid TOTP code', 401);
|
|
}
|
|
|
|
getDb().prepare('UPDATE sso_auth SET totp_enabled = 1 WHERE id = 1').run();
|
|
logger.info('TOTP enabled');
|
|
return json({ ok: true });
|
|
}
|
|
|
|
// POST /auth/totp/disable
|
|
if (pathname === '/auth/totp/disable' && method === 'POST') {
|
|
const guard = await requireAuth(req);
|
|
if ('status' in guard) return guard;
|
|
|
|
const body = await parseJson<{ passphrase: string }>(req);
|
|
if (!body?.passphrase) return error('passphrase required', 400);
|
|
|
|
const { verifyPassphrase } = await import('./auth');
|
|
const row = getAuthRow();
|
|
if (!row) return error('Not configured', 500);
|
|
|
|
const valid = await verifyPassphrase(body.passphrase, row.passphrase_hash);
|
|
if (!valid) return error('Invalid passphrase', 401);
|
|
|
|
getDb()
|
|
.prepare('UPDATE sso_auth SET totp_enabled = 0, totp_secret = NULL WHERE id = 1')
|
|
.run();
|
|
logger.info('TOTP disabled');
|
|
return json({ ok: true });
|
|
}
|
|
|
|
// GET /auth/events?page=1&limit=50&event=login_fail
|
|
if (pathname === '/auth/events' && method === 'GET') {
|
|
const guard = await requireAuth(req);
|
|
if ('status' in guard) return guard;
|
|
|
|
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
|
|
const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get('limit') ?? '50', 10)));
|
|
const eventFilter = url.searchParams.get('event') ?? undefined;
|
|
|
|
const result = queryEvents({ page, limit, event: eventFilter });
|
|
return json({ ...result, page, limit });
|
|
}
|
|
|
|
// GET /auth/sessions — JWT is stateless; no live session store
|
|
if (pathname === '/auth/sessions' && method === 'GET') {
|
|
const guard = await requireAuth(req);
|
|
if ('status' in guard) return guard;
|
|
return json({ sessions: [] });
|
|
}
|
|
|
|
return error('Not found', 404);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node.js HTTP server — bridges IncomingMessage → Fetch Request/Response
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function nodeToFetchRequest(nodeReq: IncomingMessage, body: Buffer, baseUrl: string): Request {
|
|
const url = `${baseUrl}${nodeReq.url ?? '/'}`;
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(nodeReq.headers)) {
|
|
if (value == null) continue;
|
|
if (Array.isArray(value)) {
|
|
for (const v of value) headers.append(key, v);
|
|
} else {
|
|
headers.set(key, value);
|
|
}
|
|
}
|
|
const init: RequestInit = {
|
|
method: nodeReq.method ?? 'GET',
|
|
headers,
|
|
};
|
|
if (body.length > 0) {
|
|
init.body = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer;
|
|
}
|
|
return new Request(url, init);
|
|
}
|
|
|
|
async function readBody(nodeReq: IncomingMessage): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
nodeReq.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
nodeReq.on('end', () => resolve(Buffer.concat(chunks)));
|
|
nodeReq.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function sendFetchResponse(fetchRes: Response, nodeRes: ServerResponse): Promise<void> {
|
|
nodeRes.statusCode = fetchRes.status;
|
|
fetchRes.headers.forEach((value, key) => {
|
|
nodeRes.setHeader(key, value);
|
|
});
|
|
const body = await fetchRes.arrayBuffer();
|
|
nodeRes.end(Buffer.from(body));
|
|
}
|
|
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
|
|
const server = createServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
|
|
try {
|
|
const body = await readBody(nodeReq);
|
|
const fetchReq = nodeToFetchRequest(nodeReq, body, BASE_URL);
|
|
const fetchRes = await handle(fetchReq);
|
|
await sendFetchResponse(fetchRes, nodeRes);
|
|
} catch (err) {
|
|
logger.error('Unhandled request error', { error: String(err) });
|
|
nodeRes.statusCode = 500;
|
|
nodeRes.setHeader('Content-Type', 'application/json');
|
|
nodeRes.end(JSON.stringify({ error: 'Internal server error' }));
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
logger.info('atlilith SSO ready', { port: PORT });
|
|
});
|