import { error, json, redirect, type Handle } from '@sveltejs/kit'; import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js'; import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js'; import { roleAllows, routePermission, type RoutePermission } from './access.js'; function publicAsset(routeId: string | null, path: string, method: string) { return routeId === null && ['GET','HEAD'].includes(method) && (path.startsWith('/_app/') || path === '/favicon.svg' || ['/agent/ec-agent.py','/agent/ec-agent-update.py','/agent/install.sh','/agent/manifest.json'].includes(path)); } /** Streaming responses must lose access when the underlying session does. */ export function protectEventStream(response: Response, tokenHash: string, permission: RoutePermission, signal: AbortSignal): Response { if (!response.body) return response; const reader = response.body.getReader(); let timer: ReturnType | undefined; let closed = false; let closeStream: () => void = () => {}; const cleanup = () => { if (timer) clearInterval(timer); signal.removeEventListener('abort', closeStream); }; const stream = new ReadableStream({ start(controller) { closeStream = () => { if (closed) return; closed = true; cleanup(); void reader.cancel().catch(() => {}); controller.close(); }; timer = setInterval(() => { try { const current = readSessionHash(tokenHash); if (!current || current.user.mustChangePassword || !roleAllows(current.user.role,permission)) closeStream(); } catch { closeStream(); } },15_000); signal.addEventListener('abort',closeStream,{once:true}); if (signal.aborted) { closeStream(); return; } }, async pull(controller) { try { const next = await reader.read(); if (closed) return; if (next.done) { closed=true; cleanup(); controller.close(); } else controller.enqueue(next.value); } catch (cause) { if (!closed) { closed=true; cleanup(); controller.error(cause); } } }, cancel() { closed=true; cleanup(); return reader.cancel(); } }); return new Response(stream,{status:response.status,statusText:response.statusText,headers:response.headers}); } export const authenticationHandle: Handle = async ({ event, resolve }) => { event.locals.user = null; event.locals.sessionHash = null; const routeId = event.route.id; const method = event.request.method.toUpperCase(); if (publicAsset(routeId,event.url.pathname,method)) return resolve(event); const permission = routePermission(routeId,method); if (!routeId?.startsWith('/api/agent/') && /^Bearer ec_(?:host|enroll)_/i.test(event.request.headers.get('authorization')??'')) { return json({error:'Host credentials only authorize the host agent API.'},{status:403}); } if (permission === 'machine') return resolve(event); const api = routeId?.startsWith('/api/') || event.url.pathname.startsWith('/api/'); try { requireUserTransport(event.url); if (!['GET','HEAD','OPTIONS'].includes(method)) requireSameOrigin(event.request,event.url); } catch (cause) { if (api) return authError(cause); throw cause; } const session = readSession(event.cookies.get(sessionCookie(event.url))); event.locals.user = session?.user??null; event.locals.sessionHash = session?.tokenHash??null; const setup = setupRequired(); if (permission !== 'public') { if (!session) { if (api) return json({error:setup?'Complete administrator setup before using the management API.':'Sign in to continue.',setupRequired:setup},{status:401,headers:{'Cache-Control':'no-store'}}); redirect(303,setup?'/setup':`/login?next=${encodeURIComponent(event.url.pathname+event.url.search)}`); } if (session.user.mustChangePassword && permission !== 'authenticated') { if (api) return json({error:'Change your temporary password from Account before continuing.',mustChangePassword:true},{status:403,headers:{'Cache-Control':'no-store'}}); redirect(303,'/account'); } if (!roleAllows(session.user.role,permission)) { const message = permission==='admin'?'Administrator access is required.':'Operator access is required for this action.'; if (api) return json({error:message},{status:403,headers:{'Cache-Control':'no-store'}}); error(403,message); } // Launch APIs can also save credentials. Operators may launch with saved // keys, but only administrators may introduce or replace server keys. if (session.user.role === 'operator' && method === 'POST' && ['/api/servers','/api/deployment-collections'].includes(routeId??'')) { try { const body = await authBody(event.request.clone(),1024*1024); if (body.plutoKey || (body.keys && typeof body.keys==='object' && Object.values(body.keys).some(Boolean))) { return json({error:'An administrator must save server keys before an operator launches these servers.'},{status:403}); } } catch (cause) { return authError(cause); } } } else if (['/login','/setup'].includes(routeId??'')) { if (session) redirect(303,session.user.mustChangePassword?'/account':'/'); if (routeId==='/setup'&&!setup) redirect(303,'/login'); if (routeId==='/login'&&setup) redirect(303,'/setup'); } let response = await resolve(event); if (session && !['GET','HEAD','OPTIONS'].includes(method) && response.status < 400) { recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname}); } if (session && response.headers.get('content-type')?.startsWith('text/event-stream')) { response = protectEventStream(response,session.tokenHash,permission,event.request.signal); } const headers = new Headers(response.headers); headers.set('Cache-Control','no-store'); headers.set('Vary',[headers.get('Vary'),'Cookie'].filter(Boolean).join(', ')); headers.set('Referrer-Policy','same-origin'); headers.set('X-Content-Type-Options','nosniff'); headers.set('X-Frame-Options','DENY'); return new Response(response.body,{status:response.status,statusText:response.statusText,headers}); };