atlilith/@platform/codebase/@features/sso/backend-api/src/server.ts
2026-05-16 22:03:16 -07:00

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