auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
All checks were successful
build-and-push / build (push) Successful in 18s
release-client / build-release (push) Successful in 48s

Ersätter den delade AGENT_HELM_TOKEN med en riktig auth-modell:

- Webben loggar in med lokala konton ELLER OIDC (Authentik/Keycloak/…), valt
  per server. Inloggning ger en HMAC-signerad cookie; WS autentiseras via cookien.
- Klienten (daemon) kopplas via device-flödet (RFC 8628-likt): `agent-helm connect`
  skriver ut en URL, du loggar in + godkänner i webben, och får ett klient-token
  som identifierar din användare. Token sparas i ~/.config/agent-helm/.
- Sessioner är per användare (web ser bara sina egna; admin ser alla). Flera
  klient-instanser = flera parallella sessioner; --new tvingar ny, annars resume.
- Identitet är utbytbar (lokal/OIDC) men device-flödet + sessionerna är alltid
  agent-helms egna → funkar för vem som helst som hostar detta, med eller utan IdP.
- Bootstrap-admin via env (engångs) eller first-run-setup i UI:t. Klient-creds
  kan listas/återkallas; minimal admin-UI (skapa användare, konfigurera OIDC).

Nya filer: server/store.ts (JSON-store, scrypt, cookies), server/oidc.ts
(auth-code+PKCE), web/lib/api.ts. Verifierat end-to-end lokalt (login → device
→ approve → daemon-WS → web ser sessionen; web utan cookie nekas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
This commit is contained in:
2026-06-29 01:21:29 +02:00
parent 98c4d13360
commit 94259b3456
12 changed files with 1704 additions and 403 deletions

View File

@@ -1,52 +1,94 @@
/**
* agent-helm control-plane (körs på Pi5).
* agent-helm control-plane.
*
* - WebSocket-mux: både daemons och frontends ansluter hit. Rollen avgörs av
* det första meddelandet (daemon:hello / web:hello), båda kräver rätt token.
* - Session-registry: håller reda på vilken daemon som äger vilken session.
* - Scrollback: en headless xterm per session matas med all output, så en
* frontend som ansluter (eller en mobil som väcks) får en ögonblicksbild via
* SerializeAddon — inte en tom skärm.
* - Routing: web:input -> rätt daemons pty. daemon:output -> alla prenumeranter.
* Auth (nytt): ingen delad token längre.
* - Webben loggar in (lokala konton ELLER OIDC) och får en signerad cookie.
* WS-anslutningen autentiseras via cookien på handskaket.
* - Klienten (daemon) skaffar ett klient-token via device-flödet (RFC 8628-likt):
* skriver ut en URL, du loggar in i webben och godkänner. Tokenet identifierar
* *vilken användare* klienten tillhör. Sessioner filtreras per användare.
*
* Identitet (lokal/OIDC) är utbytbar; device-flödet + sessionerna är alltid
* agent-helms egna -> funkar för vem som helst som hostar detta, med eller utan IdP.
*
* Behåller: WS-mux, headless-xterm-scrollback per session, statisk web-serving.
*/
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { WebSocketServer, WebSocket } from "ws";
// @xterm/headless och addon-serialize är CommonJS — default-importera värdet och
// destrukturera; ta typen via `import type` (raderas i runtime).
import XtermHeadless from "@xterm/headless";
import type { Terminal as TerminalType } from "@xterm/headless";
import XtermSerialize from "@xterm/addon-serialize";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type {
AuthMode,
DaemonHello,
DaemonMessage,
DeviceCodeResponse,
DeviceInfoResponse,
DeviceTokenResponse,
MeResponse,
PublicConfig,
ServerToWeb,
SessionMeta,
WebMessage,
} from "@agent-helm/shared";
import { parseMessage } from "@agent-helm/shared";
import { Store, type User } from "./store";
import { buildAuthUrl, exchangeCode, pkce } from "./oidc";
const { Terminal } = XtermHeadless;
const { SerializeAddon } = XtermSerialize;
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
const PORT = Number(process.env.PORT ?? 8787);
const DATA_DIR = process.env.DATA_DIR || "./data";
fs.mkdirSync(DATA_DIR, { recursive: true });
const PUBLIC_URL = (process.env.AGENT_HELM_PUBLIC_URL || "").replace(/\/$/, "");
const COOKIE_SECURE = process.env.COOKIE_SECURE
? process.env.COOKIE_SECURE === "true"
: PUBLIC_URL.startsWith("https");
const SESSION_TTL = 7 * 24 * 3600; // 7 dagar
const DEVICE_TTL = 600; // 10 min
const POLL_INTERVAL = 5; // s
if (!TOKEN) {
console.error("[server] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
process.exit(1);
const store = new Store(DATA_DIR, process.env.AGENT_HELM_SECRET || undefined);
/* ------------------------- bootstrap från miljövariabler ------------------- */
const ENV_AUTH = process.env.AGENT_HELM_AUTH as AuthMode | undefined;
if (ENV_AUTH === "local" || ENV_AUTH === "oidc") store.setAuthMode(ENV_AUTH);
if (process.env.AGENT_HELM_OIDC_ISSUER && process.env.AGENT_HELM_OIDC_CLIENT_ID) {
store.setOidc({
issuer: process.env.AGENT_HELM_OIDC_ISSUER,
clientId: process.env.AGENT_HELM_OIDC_CLIENT_ID,
clientSecret: process.env.AGENT_HELM_OIDC_CLIENT_SECRET || "",
label: process.env.AGENT_HELM_OIDC_LABEL || "OIDC",
allowedGroup: process.env.AGENT_HELM_OIDC_GROUP || undefined,
});
}
// Persistent katalog (host-mountad på Pi5: /srv/docker/agent-helm -> /app/data).
// Här hamnar framtida db/auth-state. Skapas vid start om den är satt.
const DATA_DIR = process.env.DATA_DIR ?? "";
if (DATA_DIR) fs.mkdirSync(DATA_DIR, { recursive: true });
const ADMIN_USER = process.env.AGENT_HELM_ADMIN_USER;
const ADMIN_PASS = process.env.AGENT_HELM_ADMIN_PASSWORD;
if (!store.setupComplete && ADMIN_USER && ADMIN_PASS) {
store.createLocalUser(ADMIN_USER, ADMIN_PASS, ADMIN_USER, true);
console.log(`[server] admin '${ADMIN_USER}' seedat från env — ta bort AGENT_HELM_ADMIN_* efter inloggning.`);
}
if (!store.setupComplete) {
console.log(
"[server] inget admin-konto än. Sätt AGENT_HELM_ADMIN_USER/PASSWORD i miljön och starta om, " +
"eller öppna web-UI:t och kör first-run-setupen (lokalt läge).",
);
}
/* -------------------------------- sessioner -------------------------------- */
interface Session {
meta: SessionMeta;
userId: string;
daemon: WebSocket | null;
term: TerminalType;
serializer: SerializeAddonType;
@@ -56,14 +98,17 @@ interface Session {
type Role =
| { kind: "pending" }
| { kind: "daemon"; sessionId: string }
| { kind: "web" };
| { kind: "web"; userId: string; isAdmin: boolean };
const sessions = new Map<string, Session>();
const webClients = new Set<WebSocket>();
const roles = new WeakMap<WebSocket, Role>();
const pendingCookieUser = new WeakMap<WebSocket, User | null>();
function sessionList(): SessionMeta[] {
return [...sessions.values()].map((s) => s.meta);
function sessionListForRole(role: { userId: string; isAdmin: boolean }): SessionMeta[] {
return [...sessions.values()]
.filter((s) => role.isAdmin || s.userId === role.userId)
.map((s) => s.meta);
}
function sendWeb(ws: WebSocket, msg: ServerToWeb): void {
@@ -71,8 +116,11 @@ function sendWeb(ws: WebSocket, msg: ServerToWeb): void {
}
function broadcastSessions(): void {
const msg: ServerToWeb = { type: "server:sessions", sessions: sessionList() };
for (const ws of webClients) sendWeb(ws, msg);
for (const ws of webClients) {
const role = roles.get(ws);
if (role?.kind !== "web") continue;
sendWeb(ws, { type: "server:sessions", sessions: sessionListForRole(role) });
}
}
function closeWithError(ws: WebSocket, message: string): void {
@@ -80,14 +128,342 @@ function closeWithError(ws: WebSocket, message: string): void {
ws.close();
}
function registerDaemon(ws: WebSocket, hello: DaemonHello): void {
roles.set(ws, { kind: "daemon", sessionId: hello.sessionId });
function ownsSession(role: Role, s: Session): boolean {
return role.kind === "web" && (role.isAdmin || s.userId === role.userId);
}
/* --------------------------------- device-flöde ---------------------------- */
interface DeviceReq {
deviceCode: string;
userCode: string;
clientName: string;
status: "pending" | "approved" | "denied";
userId?: string;
expiresAt: number;
lastPollAt: number;
issued?: { token: string; sessionId: string; clientName: string; user: string };
}
const devicesByCode = new Map<string, DeviceReq>();
const devicesByUserCode = new Map<string, DeviceReq>();
const USERCODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // utan I/O/0/1
function genUserCode(): string {
const bytes = crypto.randomBytes(8);
let s = "";
for (let i = 0; i < 8; i++) s += USERCODE_ALPHABET[bytes[i] % USERCODE_ALPHABET.length];
return `${s.slice(0, 4)}-${s.slice(4)}`;
}
function newDevice(clientName: string): DeviceReq {
const dev: DeviceReq = {
deviceCode: crypto.randomBytes(32).toString("base64url"),
userCode: genUserCode(),
clientName,
status: "pending",
expiresAt: Date.now() + DEVICE_TTL * 1000,
lastPollAt: 0,
};
devicesByCode.set(dev.deviceCode, dev);
devicesByUserCode.set(dev.userCode, dev);
return dev;
}
setInterval(() => {
const now = Date.now();
for (const [k, d] of devicesByCode) if (d.expiresAt < now) devicesByCode.delete(k);
for (const [k, d] of devicesByUserCode) if (d.expiresAt < now) devicesByUserCode.delete(k);
}, 60_000).unref();
/* --------------------------------- OIDC-state ------------------------------ */
const oidcStates = new Map<string, { verifier: string; next: string; at: number }>();
setInterval(() => {
const now = Date.now();
for (const [k, s] of oidcStates) if (now - s.at > 600_000) oidcStates.delete(k);
}, 60_000).unref();
/* ------------------------------- HTTP-helpers ------------------------------ */
function parseCookies(req: IncomingMessage): Record<string, string> {
const out: Record<string, string> = {};
const raw = req.headers.cookie;
if (!raw) return out;
for (const part of raw.split(";")) {
const i = part.indexOf("=");
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
function readJson(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let body = "";
req.on("data", (c) => {
body += c;
if (body.length > 1_000_000) req.destroy();
});
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
req.on("error", () => resolve({}));
});
}
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
const buf = Buffer.from(JSON.stringify(obj));
res.writeHead(status, { "content-type": "application/json", "content-length": buf.length });
res.end(buf);
}
function setSessionCookie(res: ServerResponse, token: string): void {
res.setHeader(
"Set-Cookie",
`helm_session=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_TTL}${COOKIE_SECURE ? "; Secure" : ""}`,
);
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `helm_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${COOKIE_SECURE ? "; Secure" : ""}`);
}
function currentUser(req: IncomingMessage): User | null {
return store.verifySession(parseCookies(req)["helm_session"]);
}
function publicBase(req: IncomingMessage): string {
if (PUBLIC_URL) return PUBLIC_URL;
const proto = ((req.headers["x-forwarded-proto"] as string) || "http").split(",")[0];
const host = (req.headers["x-forwarded-host"] as string) || req.headers.host || `localhost:${PORT}`;
return `${proto}://${host}`;
}
function me(u: User): MeResponse {
return { userId: u.id, username: u.username, displayName: u.displayName, isAdmin: u.isAdmin };
}
/* --------------------------------- API-router ------------------------------ */
async function handleApi(req: IncomingMessage, res: ServerResponse, p: string, qs: URLSearchParams): Promise<boolean> {
const method = req.method ?? "GET";
if (p === "/api/config" && method === "GET") {
const cfg: PublicConfig = {
authMode: store.authMode,
oidcLabel: store.oidc?.label,
setupComplete: store.setupComplete,
};
sendJson(res, 200, cfg);
return true;
}
if (p === "/api/setup" && method === "POST") {
if (store.setupComplete) return sendJson(res, 409, { error: "redan konfigurerad" }), true;
const b = await readJson(req);
if (!b.username || !b.password) return sendJson(res, 400, { error: "username + password krävs" }), true;
const u = store.createLocalUser(b.username, b.password, b.displayName || b.username, true);
setSessionCookie(res, store.signSession(u.id, SESSION_TTL));
sendJson(res, 200, me(u));
return true;
}
if (p === "/api/login" && method === "POST") {
if (store.authMode !== "local") return sendJson(res, 400, { error: "servern använder OIDC" }), true;
const b = await readJson(req);
const u = store.verifyLocalLogin(b.username ?? "", b.password ?? "");
if (!u) return sendJson(res, 401, { error: "fel användarnamn eller lösenord" }), true;
setSessionCookie(res, store.signSession(u.id, SESSION_TTL));
sendJson(res, 200, me(u));
return true;
}
if (p === "/api/logout" && method === "POST") {
clearSessionCookie(res);
sendJson(res, 200, { ok: true });
return true;
}
if (p === "/api/me" && method === "GET") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
sendJson(res, 200, me(u));
return true;
}
/* ---- OIDC ---- */
if (p === "/auth/oidc/login" && method === "GET") {
const oidc = store.oidc;
if (store.authMode !== "oidc" || !oidc) return sendJson(res, 400, { error: "OIDC ej konfigurerat" }), true;
const { verifier, challenge } = pkce();
const state = crypto.randomBytes(16).toString("hex");
oidcStates.set(state, { verifier, next: qs.get("next") || "/", at: Date.now() });
const authUrl = await buildAuthUrl(oidc, `${publicBase(req)}/auth/oidc/callback`, state, challenge);
res.writeHead(302, { Location: authUrl });
res.end();
return true;
}
if (p === "/auth/oidc/callback" && method === "GET") {
const oidc = store.oidc;
const st = oidcStates.get(qs.get("state") || "");
oidcStates.delete(qs.get("state") || "");
const code = qs.get("code") || "";
if (!oidc || !st || !code) {
res.writeHead(400);
res.end("ogiltig OIDC-callback");
return true;
}
try {
const claims = await exchangeCode(oidc, `${publicBase(req)}/auth/oidc/callback`, code, st.verifier);
if (oidc.allowedGroup && !claims.groups.includes(oidc.allowedGroup)) {
res.writeHead(403);
res.end(`Åtkomst nekad: saknar grupp '${oidc.allowedGroup}'.`);
return true;
}
const u = store.upsertOidcUser(claims.sub, claims.username, claims.displayName, true);
setSessionCookie(res, store.signSession(u.id, SESSION_TTL));
res.writeHead(302, { Location: st.next || "/" });
res.end();
} catch (e) {
res.writeHead(500);
res.end(`OIDC-fel: ${(e as Error).message}`);
}
return true;
}
/* ---- device-flöde ---- */
if (p === "/api/device/code" && method === "POST") {
const b = await readJson(req);
const dev = newDevice((b.clientName || "klient").toString().slice(0, 60));
const base = PUBLIC_URL || publicBase(req);
const resp: DeviceCodeResponse = {
deviceCode: dev.deviceCode,
userCode: dev.userCode,
verificationUri: `${base}/device`,
verificationUriComplete: `${base}/device?code=${dev.userCode}`,
interval: POLL_INTERVAL,
expiresIn: DEVICE_TTL,
};
sendJson(res, 200, resp);
return true;
}
if (p === "/api/device/info" && method === "GET") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
const dev = devicesByUserCode.get((qs.get("user_code") || "").toUpperCase());
if (!dev || dev.expiresAt < Date.now()) return sendJson(res, 404, { error: "okänd eller utgången kod" }), true;
const info: DeviceInfoResponse = { clientName: dev.clientName, status: dev.status };
sendJson(res, 200, info);
return true;
}
if (p === "/api/device/approve" && method === "POST") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
const b = await readJson(req);
const dev = devicesByUserCode.get((b.userCode || "").toUpperCase());
if (!dev || dev.expiresAt < Date.now()) return sendJson(res, 404, { error: "okänd eller utgången kod" }), true;
if (b.decision === "deny") {
dev.status = "denied";
} else {
dev.status = "approved";
dev.userId = u.id;
}
sendJson(res, 200, { ok: true });
return true;
}
if (p === "/api/device/token" && method === "POST") {
const b = await readJson(req);
const dev = devicesByCode.get(b.deviceCode || "");
if (!dev || dev.expiresAt < Date.now()) return sendJson(res, 200, { status: "expired" } as DeviceTokenResponse), true;
const now = Date.now();
if (now - dev.lastPollAt < (POLL_INTERVAL - 1) * 1000) {
dev.lastPollAt = now;
return sendJson(res, 200, { status: "slow_down" } as DeviceTokenResponse), true;
}
dev.lastPollAt = now;
if (dev.status === "denied") return sendJson(res, 200, { status: "denied" } as DeviceTokenResponse), true;
if (dev.status !== "approved" || !dev.userId) return sendJson(res, 200, { status: "pending" } as DeviceTokenResponse), true;
if (!dev.issued) {
const owner = store.userById(dev.userId);
const { token } = store.issueClient(dev.userId, dev.clientName);
dev.issued = { token, sessionId: crypto.randomUUID(), clientName: dev.clientName, user: owner?.displayName ?? "?" };
}
sendJson(res, 200, { status: "ready", ...dev.issued } as DeviceTokenResponse);
return true;
}
/* ---- inloggad användares egna klient-creds ---- */
if (p === "/api/clients" && method === "GET") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
sendJson(res, 200, { clients: store.listClients(u.id) });
return true;
}
if (p.startsWith("/api/clients/") && method === "DELETE") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
sendJson(res, 200, { ok: store.revokeClient(p.split("/").pop()!, u.id) });
return true;
}
/* ---- admin ---- */
if (p.startsWith("/api/admin/")) {
const u = currentUser(req);
if (!u || !u.isAdmin) return sendJson(res, 403, { error: "admin krävs" }), true;
if (p === "/api/admin/users" && method === "GET") return sendJson(res, 200, { users: store.listUsers() }), true;
if (p === "/api/admin/users" && method === "POST") {
const b = await readJson(req);
try {
const nu = store.createLocalUser(b.username, b.password, b.displayName || b.username, !!b.isAdmin);
return sendJson(res, 200, me(nu)), true;
} catch (e) {
return sendJson(res, 400, { error: (e as Error).message }), true;
}
}
if (p.startsWith("/api/admin/users/") && method === "DELETE") {
store.deleteUser(p.split("/").pop()!);
return sendJson(res, 200, { ok: true }), true;
}
if (p === "/api/admin/oidc" && method === "POST") {
const b = await readJson(req);
store.setOidc({
issuer: b.issuer,
clientId: b.clientId,
clientSecret: b.clientSecret,
label: b.label || "OIDC",
allowedGroup: b.allowedGroup || undefined,
});
return sendJson(res, 200, { ok: true }), true;
}
if (p === "/api/admin/authmode" && method === "POST") {
const b = await readJson(req);
if (b.mode === "local" || b.mode === "oidc") store.setAuthMode(b.mode);
return sendJson(res, 200, { ok: true }), true;
}
if (p === "/api/admin/clients" && method === "GET") return sendJson(res, 200, { clients: store.listClients() }), true;
return sendJson(res, 404, { error: "okänd admin-endpoint" }), true;
}
return false;
}
/* -------------------------------- WS-routing ------------------------------- */
function registerDaemon(ws: WebSocket, hello: DaemonHello, userId: string, userDisplay: string): void {
roles.set(ws, { kind: "daemon", sessionId: hello.sessionId });
const existing = sessions.get(hello.sessionId);
if (existing) {
// Daemonen återansluter — behåll scrollback, byt ut socketen.
existing.daemon = ws;
existing.userId = userId;
existing.meta.status = "running";
existing.meta.user = userDisplay;
existing.meta.title = hello.clientName;
} else {
const term = new Terminal({ cols: hello.cols, rows: hello.rows, allowProposedApi: true });
const serializer = new SerializeAddon();
@@ -95,18 +471,20 @@ function registerDaemon(ws: WebSocket, hello: DaemonHello): void {
sessions.set(hello.sessionId, {
meta: {
sessionId: hello.sessionId,
title: hello.title,
title: hello.clientName,
status: "running",
cols: hello.cols,
rows: hello.rows,
user: userDisplay,
},
userId,
daemon: ws,
term,
serializer,
subscribers: new Set(),
});
}
console.log(`[server] daemon registrerad: ${hello.sessionId} (${hello.title})`);
console.log(`[server] daemon registrerad: ${hello.sessionId} (${hello.clientName}) ägare=${userDisplay}`);
broadcastSessions();
}
@@ -115,15 +493,21 @@ function handleHello(ws: WebSocket, text: string): void {
if (!msg) return closeWithError(ws, "ogiltigt meddelande");
if (msg.type === "daemon:hello") {
if (msg.token !== TOKEN) return closeWithError(ws, "fel token");
return registerDaemon(ws, msg);
const cred = store.clientByToken(msg.token);
if (!cred) return closeWithError(ws, "ogiltig klient-token");
store.touchClient(cred.id);
const owner = store.userById(cred.userId);
return registerDaemon(ws, msg, cred.userId, owner?.displayName ?? cred.userId);
}
if (msg.type === "web:hello") {
if (msg.token !== TOKEN) return closeWithError(ws, "fel token");
roles.set(ws, { kind: "web" });
const user = pendingCookieUser.get(ws);
if (!user) return closeWithError(ws, "ej inloggad");
const role: Role = { kind: "web", userId: user.id, isAdmin: user.isAdmin };
roles.set(ws, role);
webClients.add(ws);
sendWeb(ws, { type: "server:welcome" });
sendWeb(ws, { type: "server:sessions", sessions: sessionList() });
sendWeb(ws, { type: "server:sessions", sessions: sessionListForRole(role) });
return;
}
return closeWithError(ws, "förväntade hello");
@@ -142,9 +526,7 @@ function handleDaemonMessage(sessionId: string, text: string): void {
} else if (msg.type === "daemon:exit") {
session.meta.status = "exited";
session.meta.exitCode = msg.code;
for (const sub of session.subscribers) {
sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
}
for (const sub of session.subscribers) sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
broadcastSessions();
} else if (msg.type === "daemon:approval") {
const out: ServerToWeb = {
@@ -158,13 +540,13 @@ function handleDaemonMessage(sessionId: string, text: string): void {
}
}
function handleWebMessage(ws: WebSocket, text: string): void {
function handleWebMessage(ws: WebSocket, role: Role, text: string): void {
const msg = parseMessage<WebMessage>(text);
if (!msg) return;
if (!msg || role.kind !== "web") return;
if (msg.type === "web:subscribe") {
const session = sessions.get(msg.sessionId);
if (!session) return sendWeb(ws, { type: "server:error", message: "okänd session" });
if (!session || !ownsSession(role, session)) return sendWeb(ws, { type: "server:error", message: "okänd session" });
session.subscribers.add(ws);
sendWeb(ws, {
type: "server:snapshot",
@@ -174,13 +556,14 @@ function handleWebMessage(ws: WebSocket, text: string): void {
});
} else if (msg.type === "web:input") {
const session = sessions.get(msg.sessionId);
if (!session?.daemon || session.daemon.readyState !== WebSocket.OPEN) return;
// Terminal-input (text/tangenter) -> pty. Hook-svar går via web:approval-decision.
session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data }));
if (!session || !ownsSession(role, session)) return;
if (session.daemon?.readyState === WebSocket.OPEN) {
session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data }));
}
} else if (msg.type === "web:approval-decision") {
const session = sessions.get(msg.sessionId);
if (!session) return;
if (session.daemon && session.daemon.readyState === WebSocket.OPEN) {
if (!session || !ownsSession(role, session)) return;
if (session.daemon?.readyState === WebSocket.OPEN) {
session.daemon.send(
JSON.stringify({
type: "server:approval-decision",
@@ -190,7 +573,6 @@ function handleWebMessage(ws: WebSocket, text: string): void {
}),
);
}
// Dölj kortet hos alla andra klienter som ser samma session.
const resolved: ServerToWeb = {
type: "server:approval-resolved",
sessionId: msg.sessionId,
@@ -204,22 +586,23 @@ function handleWebMessage(ws: WebSocket, text: string): void {
function handleClose(ws: WebSocket): void {
const role = roles.get(ws);
roles.delete(ws);
pendingCookieUser.delete(ws);
if (!role) return;
if (role.kind === "web") {
webClients.delete(ws);
for (const s of sessions.values()) s.subscribers.delete(ws);
} else if (role.kind === "daemon") {
const session = sessions.get(role.sessionId);
if (session && session.daemon === ws) {
session.daemon = null; // behåll session + scrollback ifall daemonen kommer tillbaka
session.daemon = null;
console.log(`[server] daemon för ${role.sessionId} kopplade ner`);
}
}
}
const WEB_DIST = process.env.WEB_DIST ?? "";
/* ------------------------------ statisk web-UI ----------------------------- */
const WEB_DIST = process.env.WEB_DIST ?? "";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
@@ -234,16 +617,12 @@ const MIME: Record<string, string> = {
".map": "application/json",
};
/** Serverar den byggda web-frontenden (om WEB_DIST är satt) med SPA-fallback. */
function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
if (!WEB_DIST) return false;
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
const indexHtml = path.join(WEB_DIST, "index.html");
let filePath = path.normalize(path.join(WEB_DIST, urlPath));
// Path-traversal-skydd + SPA: "/" och rutter utan filändelse -> index.html
if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) {
filePath = indexHtml;
}
if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) filePath = indexHtml;
fs.readFile(filePath, (err, buf) => {
if (err) {
fs.readFile(indexHtml, (err2, idx) => {
@@ -263,10 +642,25 @@ function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
return true;
}
/* ---------------------------------- server --------------------------------- */
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
const u = new URL(req.url ?? "/", "http://localhost");
if (u.pathname === "/health") {
return sendJson(res, 200, { ok: true, sessions: sessions.size });
}
if (u.pathname.startsWith("/api/") || u.pathname.startsWith("/auth/")) {
handleApi(req, res, u.pathname, u.searchParams)
.then((handled) => {
if (!handled) {
res.writeHead(404);
res.end();
}
})
.catch((e) => {
console.error("[server] api-fel:", e);
if (!res.headersSent) sendJson(res, 500, { error: "internt fel" });
});
return;
}
if (serveStatic(req, res)) return;
@@ -275,23 +669,23 @@ const server = http.createServer((req, res) => {
});
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
wss.on("connection", (ws, req) => {
roles.set(ws, { kind: "pending" });
pendingCookieUser.set(ws, store.verifySession(parseCookies(req)["helm_session"]));
ws.on("message", (raw) => {
const role = roles.get(ws);
if (!role) return;
const text = raw.toString();
if (role.kind === "pending") handleHello(ws, text);
else if (role.kind === "daemon") handleDaemonMessage(role.sessionId, text);
else handleWebMessage(ws, text);
else handleWebMessage(ws, role, text);
});
ws.on("close", () => handleClose(ws));
ws.on("error", () => {});
});
server.listen(PORT, () => {
console.log(`[server] lyssnar på :${PORT} (WebSocket + /health)`);
console.log(`[server] lyssnar på :${PORT} (auth=${store.authMode}, publicUrl=${PUBLIC_URL || "(auto)"})`);
if (WEB_DIST) console.log(`[server] serverar web-UI från ${WEB_DIST}`);
if (DATA_DIR) console.log(`[server] data-dir: ${DATA_DIR}`);
console.log(`[server] data-dir: ${DATA_DIR}`);
});

112
packages/server/src/oidc.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Minimal OIDC Authorization-Code + PKCE-klient (beroendefri).
*
* Vi byter koden mot tokens server-till-server över TLS, så id_token-signaturen
* behöver inte verifieras separat (OIDC Core §3.1.3.7: TLS mot token-endpointen
* räcker som äkthetskontroll i code-flödet). Vi validerar iss/aud/exp ur payloaden.
*
* Funkar mot vilken OIDC-provider som helst med discovery: Authentik, Keycloak,
* Authelia, Google, m.fl. — agent-helm är inte bundet till någon särskild IdP.
*/
import crypto from "node:crypto";
import type { OidcConfig } from "./store";
interface Discovery {
authorization_endpoint: string;
token_endpoint: string;
}
export interface OidcClaims {
sub: string;
username: string;
displayName: string;
groups: string[];
email?: string;
}
const discoveryCache = new Map<string, { at: number; doc: Discovery }>();
async function discover(issuer: string): Promise<Discovery> {
const cached = discoveryCache.get(issuer);
if (cached && Date.now() - cached.at < 3600_000) return cached.doc;
const base = issuer.replace(/\/$/, "");
const res = await fetch(`${base}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`OIDC discovery misslyckades (${res.status})`);
const doc = (await res.json()) as Discovery;
discoveryCache.set(issuer, { at: Date.now(), doc });
return doc;
}
export function pkce(): { verifier: string; challenge: string } {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
export async function buildAuthUrl(
cfg: OidcConfig,
redirectUri: string,
state: string,
challenge: string,
): Promise<string> {
const { authorization_endpoint } = await discover(cfg.issuer);
const q = new URLSearchParams({
response_type: "code",
client_id: cfg.clientId,
redirect_uri: redirectUri,
scope: "openid profile email groups",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
return `${authorization_endpoint}?${q.toString()}`;
}
export async function exchangeCode(
cfg: OidcConfig,
redirectUri: string,
code: string,
verifier: string,
): Promise<OidcClaims> {
const { token_endpoint } = await discover(cfg.issuer);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
code_verifier: verifier,
});
const res = await fetch(token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) throw new Error(`token-utbyte misslyckades (${res.status})`);
const tok = (await res.json()) as { id_token?: string };
if (!tok.id_token) throw new Error("inget id_token i svaret");
return decodeIdToken(cfg, tok.id_token);
}
function decodeIdToken(cfg: OidcConfig, idToken: string): OidcClaims {
const parts = idToken.split(".");
if (parts.length !== 3) throw new Error("trasigt id_token");
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, any>;
// iss/aud/exp-kontroll (signaturen täcks av TLS i code-flödet).
const issBase = cfg.issuer.replace(/\/$/, "");
if (payload.iss && payload.iss.replace(/\/$/, "") !== issBase) throw new Error("fel issuer");
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!aud.includes(cfg.clientId)) throw new Error("fel audience");
if (typeof payload.exp === "number" && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error("id_token utgånget");
}
return {
sub: String(payload.sub),
username: payload.preferred_username || payload.email || String(payload.sub),
displayName: payload.name || payload.preferred_username || String(payload.sub),
groups: Array.isArray(payload.groups) ? payload.groups.map(String) : [],
email: payload.email,
};
}

View File

@@ -0,0 +1,256 @@
/**
* Beroendefri JSON-store + auth-primitiver för agent-helm-servern.
*
* Allt state (config, användare, klient-credentials) ligger i en enda fil i
* DATA_DIR. Inga native-beroenden -> bundlas rakt av esbuild och funkar för vem
* som helst som hostar imagen. Lösenord hashas med scrypt; cookies signeras med
* HMAC. För en homelab-server med måttlig samtidighet räcker det gott.
*/
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export type AuthMode = "local" | "oidc";
export interface User {
id: string;
username: string; // unik nyckel. lokal: valfri. oidc: preferred_username/sub
displayName: string;
isAdmin: boolean;
source: "local" | "oidc";
passwordHash?: string; // endast lokal: "saltHex:hashHex"
oidcSub?: string; // endast oidc
}
export interface ClientCred {
id: string;
userId: string;
name: string;
tokenHash: string; // sha256(token) hex — själva tokenet lagras aldrig
createdAt: number;
lastSeenAt: number;
revoked: boolean;
}
export interface OidcConfig {
issuer: string; // discovery-bas, t.ex. https://authentik.../application/o/agent-helm/
clientId: string;
clientSecret: string;
label?: string;
allowedGroup?: string; // valfri grupp-gate
}
interface StoreData {
version: 1;
secret: string; // HMAC-nyckel för cookies (genereras en gång om ej satt via env)
authMode: AuthMode;
oidc?: OidcConfig;
users: User[];
clients: ClientCred[];
}
const b64url = (b: Buffer) => b.toString("base64url");
const sha256 = (s: string) => crypto.createHash("sha256").update(s).digest("hex");
export const hashToken = sha256;
export function hashPassword(pw: string): string {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(pw, salt, 64);
return `${salt.toString("hex")}:${hash.toString("hex")}`;
}
function verifyPassword(pw: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(":");
if (!saltHex || !hashHex) return false;
const expected = Buffer.from(hashHex, "hex");
const got = crypto.scryptSync(pw, Buffer.from(saltHex, "hex"), 64);
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
export class Store {
private data: StoreData;
private readonly file: string;
constructor(dataDir: string, envSecret?: string) {
this.file = path.join(dataDir, "store.json");
if (fs.existsSync(this.file)) {
this.data = JSON.parse(fs.readFileSync(this.file, "utf8")) as StoreData;
} else {
this.data = {
version: 1,
secret: envSecret || b64url(crypto.randomBytes(32)),
authMode: "local",
users: [],
clients: [],
};
this.save();
}
// Env-secret vinner alltid (stabil över data-wipe).
if (envSecret && this.data.secret !== envSecret) {
this.data.secret = envSecret;
this.save();
}
}
private save(): void {
const tmp = `${this.file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
fs.renameSync(tmp, this.file); // atomiskt
}
/* --------------------------------- config -------------------------------- */
get authMode(): AuthMode {
return this.data.authMode;
}
setAuthMode(mode: AuthMode): void {
this.data.authMode = mode;
this.save();
}
get oidc(): OidcConfig | undefined {
return this.data.oidc;
}
setOidc(cfg: OidcConfig): void {
this.data.oidc = cfg;
this.save();
}
get setupComplete(): boolean {
return this.data.users.some((u) => u.isAdmin);
}
/* --------------------------------- users --------------------------------- */
listUsers(): User[] {
return this.data.users.map((u) => ({ ...u, passwordHash: undefined }));
}
userById(id: string): User | undefined {
return this.data.users.find((u) => u.id === id);
}
userByUsername(username: string): User | undefined {
return this.data.users.find((u) => u.username.toLowerCase() === username.toLowerCase());
}
userByOidcSub(sub: string): User | undefined {
return this.data.users.find((u) => u.oidcSub === sub);
}
createLocalUser(username: string, password: string, displayName: string, isAdmin: boolean): User {
if (this.userByUsername(username)) throw new Error("användarnamnet finns redan");
const user: User = {
id: crypto.randomUUID(),
username,
displayName: displayName || username,
isAdmin,
source: "local",
passwordHash: hashPassword(password),
};
this.data.users.push(user);
this.save();
return user;
}
/** Skapa eller uppdatera en OIDC-användare utifrån token-claims. */
upsertOidcUser(sub: string, username: string, displayName: string, makeAdminIfFirst: boolean): User {
let user = this.userByOidcSub(sub);
if (user) {
user.displayName = displayName || user.displayName;
user.username = username || user.username;
this.save();
return user;
}
const isFirst = this.data.users.length === 0;
user = {
id: crypto.randomUUID(),
username: username || sub,
displayName: displayName || username || sub,
isAdmin: makeAdminIfFirst && isFirst,
source: "oidc",
oidcSub: sub,
};
this.data.users.push(user);
this.save();
return user;
}
verifyLocalLogin(username: string, password: string): User | null {
const user = this.userByUsername(username);
if (!user || user.source !== "local" || !user.passwordHash) return null;
return verifyPassword(password, user.passwordHash) ? user : null;
}
deleteUser(id: string): void {
this.data.users = this.data.users.filter((u) => u.id !== id);
this.data.clients = this.data.clients.filter((c) => c.userId !== id);
this.save();
}
/* ------------------------------ klient-creds ----------------------------- */
/** Skapa ett klient-credential och returnera det klartext-token som visas en gång. */
issueClient(userId: string, name: string): { token: string; cred: ClientCred } {
const token = b64url(crypto.randomBytes(32));
const cred: ClientCred = {
id: crypto.randomUUID(),
userId,
name,
tokenHash: sha256(token),
createdAt: Date.now(),
lastSeenAt: Date.now(),
revoked: false,
};
this.data.clients.push(cred);
this.save();
return { token, cred };
}
clientByToken(token: string): ClientCred | undefined {
const h = sha256(token);
return this.data.clients.find((c) => c.tokenHash === h && !c.revoked);
}
listClients(userId?: string): ClientCred[] {
return this.data.clients.filter((c) => !userId || c.userId === userId);
}
touchClient(id: string): void {
const c = this.data.clients.find((x) => x.id === id);
if (c) {
c.lastSeenAt = Date.now();
this.save();
}
}
revokeClient(id: string, userId?: string): boolean {
const c = this.data.clients.find((x) => x.id === id && (!userId || x.userId === userId));
if (!c) return false;
c.revoked = true;
this.save();
return true;
}
/* --------------------------------- cookies ------------------------------- */
/** Signera en stateless session-cookie: base64url("userId.exp.hmac"). */
signSession(userId: string, ttlSec: number): string {
const exp = Math.floor(Date.now() / 1000) + ttlSec;
const body = `${userId}.${exp}`;
const sig = crypto.createHmac("sha256", this.data.secret).update(body).digest("base64url");
return Buffer.from(`${body}.${sig}`).toString("base64url");
}
verifySession(cookie: string | undefined): User | null {
if (!cookie) return null;
let decoded: string;
try {
decoded = Buffer.from(cookie, "base64url").toString("utf8");
} catch {
return null;
}
const parts = decoded.split(".");
if (parts.length !== 3) return null;
const [userId, expStr, sig] = parts;
const body = `${userId}.${expStr}`;
const expected = crypto.createHmac("sha256", this.data.secret).update(body).digest("base64url");
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return null;
}
if (Number(expStr) < Math.floor(Date.now() / 1000)) return null;
return this.userById(userId) ?? null;
}
}