From 94259b34563fa313e9d22d1cb9ccdcad81b5afd4 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Mon, 29 Jun 2026 01:21:29 +0200 Subject: [PATCH] =?UTF-8?q?auth:=20OIDC/lokal=20inloggning=20+=20device-fl?= =?UTF-8?q?ow=20f=C3=B6r=20klienter,=20sessioner=20per=20anv=C3=A4ndare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4 --- .env.example | 50 +++- README.md | 24 +- deploy/agent-helm.compose.yaml | 22 +- packages/daemon/src/index.ts | 458 +++++++++++++++++++---------- packages/server/src/index.ts | 516 +++++++++++++++++++++++++++++---- packages/server/src/oidc.ts | 112 +++++++ packages/server/src/store.ts | 256 ++++++++++++++++ packages/shared/src/index.ts | 65 ++++- packages/web/src/App.vue | 513 ++++++++++++++++++++++---------- packages/web/src/lib/api.ts | 79 +++++ packages/web/src/lib/helm.ts | 5 +- packages/web/vite.config.ts | 7 + 12 files changed, 1704 insertions(+), 403 deletions(-) create mode 100644 packages/server/src/oidc.ts create mode 100644 packages/server/src/store.ts create mode 100644 packages/web/src/lib/api.ts diff --git a/.env.example b/.env.example index 01ae52f..d301185 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,43 @@ -# Delad token som BÅDE daemon och frontend måste skicka för att få ansluta till servern. -# Byt till något hemligt — den här skyddar fjärrstyrning av en agent som kan köra shell. -AGENT_HELM_TOKEN=change-me +# ============================================================================ +# agent-helm — exempel-miljö. Kopiera till .env (dev) eller sätt i Dockge/compose. +# Ingen delad token längre: webben loggar in (lokalt konto eller OIDC) och +# klienten kopplas via device-flödet. +# ============================================================================ # --- server --- PORT=8787 +DATA_DIR=./data # store.json (konton, klient-creds) lagras här +AGENT_HELM_PUBLIC_URL=https://helm.example.com # publik URL (device-länken + Secure-cookie) +# COOKIE_SECURE=true # default: true om PUBLIC_URL är https +# AGENT_HELM_SECRET= # HMAC-nyckel för cookies; genereras+sparas om ej satt -# --- daemon --- -SERVER_URL=ws://localhost:8787 -# Kommandot som körs i pty:n. Default: gemini. Sätt t.ex. AGENT_CMD=bash för att testa pipelinen. -AGENT_CMD=gemini +# Identitetsläge: local (egna konton) eller oidc (Authentik/Keycloak/…) +AGENT_HELM_AUTH=local + +# Bootstrap-admin (engångs): seedar det första kontot om inget admin finns. +# Ta bort efter första inloggning — servern kör sen från sin egen databas. +# Alternativt: lämna tomt och kör first-run-setupen i web-UI:t (lokalt läge). +AGENT_HELM_ADMIN_USER=admin +AGENT_HELM_ADMIN_PASSWORD=byt-mig-vid-forsta-start + +# --- OIDC (endast om AGENT_HELM_AUTH=oidc; kan även sättas i admin-UI:t) --- +# AGENT_HELM_OIDC_ISSUER=https://authentik.brasse-pc.eu/application/o/agent-helm/ +# AGENT_HELM_OIDC_CLIENT_ID= +# AGENT_HELM_OIDC_CLIENT_SECRET= +# AGENT_HELM_OIDC_LABEL=Logga in med Authentik +# AGENT_HELM_OIDC_GROUP=agent-helm # valfri grupp-gate (kräver claim "groups") +# Registrera redirect-URI i providern: /auth/oidc/callback + +# --- daemon (klienten) --- +# Anges oftast på CLI:n: agent-helm connect --server https://helm.example.com +# SERVER_URL=https://helm.example.com +AGENT_CMD=gemini # kommandot i pty:n; sätt bash för att testa pipelinen # AGENT_ARGS= # AGENT_CWD= -# AGENT_TITLE= +# AGENT_TITLE= # klient-/sessionsnamn (annars "cmd @ hostname") +# AGENT_HELM_CONFIG_DIR=~/.config/agent-helm # var klient-token sparas # --- hook (valfritt: strukturerade verktygsgodkännanden via BeforeTool) --- -# AGENT_HELM_HOOK_PORT=8788 # lokal 127.0.0.1-endpoint som `agent-helm hook` anropar -# AGENT_HELM_HOOK_DEFAULT=allow # beslut när ingen UI är ansluten (allow|deny) -# AGENT_HELM_HOOK_TIMEOUT=120000 # ms att vänta på beslut innan default används - -# --- frontend (Vite läser bara VITE_*-prefix) --- -VITE_SERVER_WS=ws://localhost:8787 -VITE_AGENT_HELM_TOKEN=change-me +# AGENT_HELM_HOOK_PORT=8788 +# AGENT_HELM_HOOK_DEFAULT=allow # beslut när ingen UI är ansluten (allow|deny) +# AGENT_HELM_HOOK_TIMEOUT=120000 diff --git a/README.md b/README.md index 36cc1b6..13f0376 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,14 @@ konfigurerade, och att en trasig hook aldrig låser en session. ## Åtkomst, auth & notiser (Pi5) +- **Auth:** ingen delad token. **Webben** loggar in med **lokala konton** *eller* + **OIDC** (Authentik/Keycloak/…) — väljs per server. **Klienten** kopplas via + **device-flödet**: den skriver ut en URL, du loggar in och godkänner i webben, och + får då ett klient-token som identifierar din användare. Sessioner är per användare; + klienter kan återkallas individuellt. Första admin seedas via + `AGENT_HELM_ADMIN_USER/PASSWORD` (engångs) eller en first-run-setup i UI:t. - **Åtkomst:** primärt via **Tailscale** (inget exponerat publikt). Alternativt via din - NPM-reverse-proxy på t.ex. `agent.brasse-pc.eu` med auth framför. + NPM-reverse-proxy på t.ex. `agent.brasse-pc.eu` (TLS terminerar i NPM → Secure-cookie). - **Notiser:** self-hostad **ntfy** på Pi5 pushar till mobilen när en session väntar på input. Servern postar till ett ntfy-topic; PWA:n / ntfy-appen tar emot. - **Registry:** server- och daemon-images byggs och pushas till `192.168.0.19:5000`. @@ -189,17 +195,21 @@ pnpm install \ --virtual-store-dir /home/brasse/.cache/agent-helm/vstore # (På vanlig Linux-fs räcker: pnpm install) -# Konfig: kopiera och sätt en token -cp .env.example .env # sätt AGENT_HELM_TOKEN + VITE_AGENT_HELM_TOKEN +# Konfig: kopiera och sätt bootstrap-admin (lokalt läge) +cp .env.example .env # sätt AGENT_HELM_ADMIN_USER/PASSWORD (engångs) # Kör de tre delarna i var sin terminal: pnpm dev:server # control-plane på :8787 -pnpm dev:daemon # kör `gemini` i en pty, ansluter till servern -pnpm dev:web # Vite-dev-server — öppna länken i webbläsaren +pnpm dev:web # Vite-dev-server (proxar /api + /ws → :8787) +# I en tredje terminal: logga in klienten + kör agenten +pnpm --filter @agent-helm/daemon start -- connect --server http://localhost:5173 ``` -Logga in i web-UI:t med samma token, välj sessionen och styr den. Sätt -`AGENT_CMD=bash` för daemonen om du vill testa pipelinen utan Gemini-auth. +1. Öppna Vite-länken, logga in som admin (eller kör first-run-setupen). +2. Daemonen skriver ut en URL — öppna den, godkänn klienten. +3. Daemonen ansluter och dyker upp i din session-lista. Styr den i webben. + +Sätt `AGENT_CMD=bash` för daemonen om du vill testa pipelinen utan Gemini-auth. ## Status diff --git a/deploy/agent-helm.compose.yaml b/deploy/agent-helm.compose.yaml index 476c179..234015d 100644 --- a/deploy/agent-helm.compose.yaml +++ b/deploy/agent-helm.compose.yaml @@ -3,8 +3,13 @@ # Uppdatera till senaste: Dockge -> denna stack -> "Update" (pull + recreate). # # Lägg i /srv/dockge-staks/agent-helm/ och starta i Dockge. -# Sätt en stark token i stackens .env (Dockge: env-flik): -# AGENT_HELM_TOKEN= +# Sätt hemligheter i stackens .env (Dockge: env-flik) — INTE i denna fil: +# AGENT_HELM_ADMIN_PASSWORD= +# AGENT_HELM_SECRET= +# (om OIDC) AGENT_HELM_OIDC_CLIENT_SECRET=<…> +# +# Auth: ingen delad token. Webben loggar in (lokalt konto eller OIDC); klienten +# kopplas via device-flödet. Ta bort AGENT_HELM_ADMIN_* efter första inloggning. x-dockge: urls: - https://rcai.brasse-pc.eu @@ -17,9 +22,20 @@ services: expose: - "8787" # NPM binder publikt; ingen host-port behövs environment: - AGENT_HELM_TOKEN: ${AGENT_HELM_TOKEN} PORT: "8787" DATA_DIR: /app/data + AGENT_HELM_PUBLIC_URL: https://rcai.brasse-pc.eu + AGENT_HELM_AUTH: ${AGENT_HELM_AUTH:-local} # local | oidc + AGENT_HELM_SECRET: ${AGENT_HELM_SECRET:-} # cookie-HMAC (sätts i .env) + # Bootstrap-admin (engångs): seedar första kontot. Ta bort efter inloggning. + AGENT_HELM_ADMIN_USER: ${AGENT_HELM_ADMIN_USER:-} + AGENT_HELM_ADMIN_PASSWORD: ${AGENT_HELM_ADMIN_PASSWORD:-} + # OIDC (om AGENT_HELM_AUTH=oidc) — kan även sättas i admin-UI:t + AGENT_HELM_OIDC_ISSUER: ${AGENT_HELM_OIDC_ISSUER:-} + AGENT_HELM_OIDC_CLIENT_ID: ${AGENT_HELM_OIDC_CLIENT_ID:-} + AGENT_HELM_OIDC_CLIENT_SECRET: ${AGENT_HELM_OIDC_CLIENT_SECRET:-} + AGENT_HELM_OIDC_LABEL: ${AGENT_HELM_OIDC_LABEL:-Logga in med Authentik} + AGENT_HELM_OIDC_GROUP: ${AGENT_HELM_OIDC_GROUP:-} volumes: # Host-mountad config/data enligt övriga appars konvention (/srv/docker/). # Containern kör som uid 1000 (node) = brasse på host, så filer blir brasse-ägda. diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index bbdb1a1..67da54c 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -1,36 +1,313 @@ /** - * agent-helm daemon ("appen") + hook-läge. + * agent-helm daemon ("klienten") + hook-läge. * - * Normalläge: spawnar en agent (default gemini) i en pty, strömmar pty över WS till - * servern och skriver tillbaka input. Kör dessutom en LOKAL http-endpoint (endast - * 127.0.0.1) som BeforeTool-hooken anropar för att få ett godkännande-beslut från UI:t. + * Underkommandon: + * agent-helm connect [--server URL] [--name N] [--new] [--login] + * Loggar in mot servern via device-flödet första gången (skriver ut en + * URL att öppna + godkänna i webben), sparar ett klient-token lokalt, och + * kör sedan agenten i en pty och strömmar den över WS. Nästa start är tyst. + * --new -> starta en ny parallell session istället för att återuppta. + * --login -> tvinga ny inloggning (t.ex. byt användare/server). + * agent-helm logout -> radera sparat klient-token. + * agent-helm whoami -> visa sparad inloggning. + * agent-helm hook -> internt: anropas av agentens BeforeTool-hook. * - * Hook-läge (`agent-helm hook`): läser verktygsanropet på stdin, frågar den körande - * daemonen via den lokala endpointen, och skriver beslutet på stdout i Geminis format. - * Samma binär gör båda jobben -> inga extra beroenden på målet. - * - * Persistens (att daemonen själv överlever) är ett yttre lager: tmux eller en - * Docker-container med restart-policy. Hanteras inte här. + * Klient-token + senaste session sparas i ~/.config/agent-helm/credentials.json. */ import { spawn, type IPty } from "@homebridge/node-pty-prebuilt-multiarch"; import { WebSocket } from "ws"; import http from "node:http"; -import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import os from "node:os"; -import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared"; +import { randomUUID } from "node:crypto"; +import type { DaemonMessage, DeviceCodeResponse, DeviceTokenResponse } from "@agent-helm/shared"; import { parseMessage } from "@agent-helm/shared"; type Decision = { decision: "allow" | "deny"; reason?: string }; const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788); +const AGENT_CMD = process.env.AGENT_CMD ?? "gemini"; -if (process.argv[2] === "hook") { - void runHookMode(); -} else { - runDaemon(); +const cmd = process.argv[2]; +if (cmd === "hook") void runHookMode(); +else if (cmd === "logout") logout(); +else if (cmd === "whoami") whoami(); +else void runConnect(); + +/* ------------------------------ creds-hantering ---------------------------- */ + +interface Creds { + server: string; + token: string; + clientName: string; + user: string; + lastSessionId?: string; } -/* --------------------------------- hook-läge -------------------------------- */ +const CONFIG_DIR = process.env.AGENT_HELM_CONFIG_DIR || path.join(os.homedir(), ".config", "agent-helm"); +const CRED_FILE = path.join(CONFIG_DIR, "credentials.json"); + +function loadCreds(): Creds | null { + try { + return JSON.parse(fs.readFileSync(CRED_FILE, "utf8")) as Creds; + } catch { + return null; + } +} +function saveCreds(c: Creds): void { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CRED_FILE, JSON.stringify(c, null, 2), { mode: 0o600 }); +} +function logout(): void { + try { + fs.unlinkSync(CRED_FILE); + console.log("Utloggad — klient-token raderat."); + } catch { + console.log("Inget sparat token."); + } + process.exit(0); +} +function whoami(): void { + const c = loadCreds(); + if (!c) { + console.log("Ej inloggad."); + } else { + console.log(`Inloggad som ${c.user} mot ${c.server} (klient: ${c.clientName})`); + } + process.exit(0); +} + +/* ------------------------------ CLI-argument ------------------------------- */ + +function flag(name: string): string | undefined { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} +function has(name: string): boolean { + return process.argv.includes(name); +} + +function deriveUrls(input: string): { http: string; ws: string } { + const u = input.replace(/\/$/, ""); + if (u.startsWith("ws://")) return { ws: u, http: "http://" + u.slice(5) }; + if (u.startsWith("wss://")) return { ws: u, http: "https://" + u.slice(6) }; + if (u.startsWith("http://")) return { http: u, ws: "ws://" + u.slice(7) }; + if (u.startsWith("https://")) return { http: u, ws: "wss://" + u.slice(8) }; + return { http: "https://" + u, ws: "wss://" + u }; // bara host angiven -> anta TLS +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/* -------------------------------- device-flöde ----------------------------- */ + +async function deviceFlow( + httpBase: string, + clientName: string, +): Promise<{ token: string; sessionId: string; clientName: string; user: string }> { + const codeRes = await fetch(`${httpBase}/api/device/code`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clientName }), + }); + if (!codeRes.ok) throw new Error(`device/code misslyckades (HTTP ${codeRes.status})`); + const code = (await codeRes.json()) as DeviceCodeResponse; + + console.log("\n ┌─────────────────────────────────────────────────────────────"); + console.log(" │ Öppna i webbläsaren, logga in och godkänn klienten:"); + console.log(" │"); + console.log(` │ ${code.verificationUriComplete}`); + console.log(" │"); + console.log(` │ Kod: ${code.userCode} (väntar på godkännande …)`); + console.log(" └─────────────────────────────────────────────────────────────\n"); + + const interval = Math.max(2, code.interval) * 1000; + const deadline = Date.now() + code.expiresIn * 1000; + while (Date.now() < deadline) { + await sleep(interval); + const tRes = await fetch(`${httpBase}/api/device/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceCode: code.deviceCode }), + }); + const t = (await tRes.json()) as DeviceTokenResponse; + if (t.status === "ready" && t.token && t.sessionId) { + return { token: t.token, sessionId: t.sessionId, clientName: t.clientName ?? clientName, user: t.user ?? "?" }; + } + if (t.status === "denied") throw new Error("godkännandet nekades i webben"); + if (t.status === "expired") throw new Error("koden gick ut innan godkännande"); + // pending / slow_down -> fortsätt polla + } + throw new Error("tidsgräns nådd utan godkännande"); +} + +/* --------------------------------- connect --------------------------------- */ + +async function runConnect(): Promise { + const input = flag("--server") || process.env.SERVER_URL || loadCreds()?.server; + if (!input) { + console.error("Ingen server angiven. Kör: agent-helm connect --server https://din-helm-server"); + process.exit(1); + } + const { http: httpBase, ws: wsBase } = deriveUrls(input); + const clientName = flag("--name") || process.env.AGENT_TITLE || `${AGENT_CMD} @ ${os.hostname()}`; + + let creds = loadCreds(); + const sameServer = creds && deriveUrls(creds.server).ws === wsBase; + if (!creds || !sameServer || has("--login")) { + console.log(`[daemon] loggar in mot ${httpBase} …`); + try { + const r = await deviceFlow(httpBase, clientName); + creds = { server: input, token: r.token, clientName: r.clientName, user: r.user, lastSessionId: r.sessionId }; + saveCreds(creds); + console.log(`[daemon] ✓ ansluten som ${r.user}.`); + } catch (e) { + console.error(`[daemon] inloggning misslyckades: ${(e as Error).message}`); + process.exit(1); + } + } + + // Sessionsval: --new -> ny parallell session; annars återuppta den senaste. + const isNew = has("--new") || !creds!.lastSessionId; + const sessionId = isNew ? randomUUID() : creds!.lastSessionId!; + creds!.lastSessionId = sessionId; + saveCreds(creds!); + + console.log(`[daemon] ${isNew ? "ny" : "återupptar"} session ${sessionId.slice(0, 8)} (${clientName})`); + runAgent(wsBase, creds!.token, sessionId, clientName); +} + +/* ----------------------------- agent + WS-loop ----------------------------- */ + +function runAgent(wsUrl: string, token: string, sessionId: string, clientName: string): void { + const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : []; + const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd(); + const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow"; + const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000); + const COLS = 100; + const ROWS = 30; + + const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, { + name: "xterm-256color", + cols: COLS, + rows: ROWS, + cwd: AGENT_CWD, + env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record, + }); + console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid})`); + + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let authFailed = false; + const pending = new Map void>(); + + function send(msg: DaemonMessage): void { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); + } + + function connect(): void { + console.log(`[daemon] ansluter till ${wsUrl} …`); + ws = new WebSocket(wsUrl); + ws.on("open", () => { + send({ type: "daemon:hello", token, sessionId, clientName, cols: COLS, rows: ROWS }); + }); + ws.on("message", (raw) => { + const msg = parseMessage<{ type: string; [k: string]: any }>(raw.toString()); + if (!msg) return; + if (msg.type === "server:input") { + pty.write(msg.data); + } else if (msg.type === "server:approval-decision") { + const resolve = pending.get(msg.approvalId); + if (resolve) { + pending.delete(msg.approvalId); + resolve({ decision: msg.decision, reason: msg.reason }); + } + } else if (msg.type === "server:error") { + console.error(`[daemon] server: ${msg.message}`); + if (String(msg.message).toLowerCase().includes("token") || String(msg.message).includes("inloggad")) { + authFailed = true; + console.error("[daemon] klient-token avvisat — kör 'agent-helm connect --login' för att logga in på nytt."); + } + } + }); + ws.on("close", () => { + if (authFailed) { + pty.kill(); + process.exit(1); + } + console.log("[daemon] anslutning stängd — återansluter om 2s"); + scheduleReconnect(); + }); + ws.on("error", (err: Error) => console.error("[daemon] ws-fel:", err.message)); + } + + function scheduleReconnect(): void { + if (reconnectTimer || authFailed) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, 2000); + } + + pty.onData((data) => send({ type: "daemon:output", data })); + pty.onExit(({ exitCode }) => { + console.log(`[daemon] agenten avslutades (kod ${exitCode})`); + send({ type: "daemon:exit", code: exitCode }); + setTimeout(() => process.exit(exitCode), 200); + }); + + // Lokal hook-endpoint (endast 127.0.0.1). + const hookServer = http.createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/approve") { + res.writeHead(404); + res.end(); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + let parsed: { toolName?: string; toolInput?: unknown } = {}; + try { + parsed = JSON.parse(body); + } catch { + /* tom */ + } + const toolName = parsed.toolName ?? "okänt verktyg"; + const approvalId = randomUUID(); + const finish = (d: Decision) => { + if (res.writableEnded) return; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(d)); + }; + if (!ws || ws.readyState !== WebSocket.OPEN) { + finish({ decision: HOOK_DEFAULT }); + return; + } + const timer = setTimeout(() => { + if (pending.delete(approvalId)) finish({ decision: HOOK_DEFAULT, reason: "timeout" }); + }, HOOK_TIMEOUT); + pending.set(approvalId, (d) => { + clearTimeout(timer); + finish(d); + }); + send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput }); + }); + }); + hookServer.listen(HOOK_PORT, "127.0.0.1", () => { + console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`); + }); + + function shutdown(): void { + pty.kill(); + process.exit(0); + } + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + connect(); +} + +/* --------------------------------- hook-läge ------------------------------- */ function readStdin(): Promise { return new Promise((resolve) => { @@ -38,7 +315,7 @@ function readStdin(): Promise { process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => (data += c)); process.stdin.on("end", () => resolve(data)); - setTimeout(() => resolve(data), 1500); // fortsätt även om stdin aldrig stängs + setTimeout(() => resolve(data), 1500); }); } @@ -50,12 +327,9 @@ async function runHookMode(): Promise { } catch { payload = {}; } - // Geminis hook-payload kan variera mellan versioner — plocka defensivt. const p = payload as Record; - const toolName: string = - p.tool_name ?? p.toolName ?? p.tool?.name ?? p.name ?? "okänt verktyg"; - const toolInput: unknown = - p.tool_input ?? p.toolInput ?? p.tool?.input ?? p.arguments ?? p.args ?? payload; + const toolName: string = p.tool_name ?? p.toolName ?? p.tool?.name ?? p.name ?? "okänt verktyg"; + const toolInput: unknown = p.tool_input ?? p.toolInput ?? p.tool?.input ?? p.arguments ?? p.args ?? payload; const decision = await askDaemon(toolName, toolInput); if (decision.decision === "deny") { @@ -89,7 +363,6 @@ function askDaemon(toolName: string, toolInput: unknown): Promise { }); }, ); - // Ingen daemon igång / fel -> tillåt (graceful: agenten funkar som utan hook). req.on("error", () => resolve({ decision: "allow" })); req.setTimeout(180000, () => { req.destroy(); @@ -99,140 +372,3 @@ function askDaemon(toolName: string, toolInput: unknown): Promise { req.end(); }); } - -/* -------------------------------- normal daemon ----------------------------- */ - -function runDaemon(): void { - const TOKEN = process.env.AGENT_HELM_TOKEN ?? ""; - const SERVER_URL = process.env.SERVER_URL ?? "ws://localhost:8787"; - const AGENT_CMD = process.env.AGENT_CMD ?? "gemini"; - const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : []; - const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd(); - const TITLE = process.env.AGENT_TITLE ?? `${AGENT_CMD} @ ${os.hostname()}`; - const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow"; - const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000); - const COLS = 100; - const ROWS = 30; - - if (!TOKEN) { - console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön."); - process.exit(1); - } - - const sessionId = randomUUID(); - - const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, { - name: "xterm-256color", - cols: COLS, - rows: ROWS, - cwd: AGENT_CWD, - // Skicka hook-porten vidare så att `agent-helm hook` (som gemini spawnar) hittar oss. - env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record, - }); - console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`); - - let ws: WebSocket | null = null; - let reconnectTimer: ReturnType | null = null; - const pending = new Map void>(); - - function send(msg: DaemonMessage): void { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); - } - - function connect(): void { - console.log(`[daemon] ansluter till ${SERVER_URL} ...`); - ws = new WebSocket(SERVER_URL); - ws.on("open", () => { - console.log("[daemon] ansluten — registrerar session"); - send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS }); - }); - ws.on("message", (raw) => { - const msg = parseMessage(raw.toString()); - if (!msg) return; - if (msg.type === "server:input") { - pty.write(msg.data); - } else if (msg.type === "server:approval-decision") { - const resolve = pending.get(msg.approvalId); - if (resolve) { - pending.delete(msg.approvalId); - resolve({ decision: msg.decision, reason: msg.reason }); - } - } - }); - ws.on("close", () => { - console.log("[daemon] anslutning stängd — återansluter om 2s"); - scheduleReconnect(); - }); - ws.on("error", (err: Error) => console.error("[daemon] ws-fel:", err.message)); - } - - function scheduleReconnect(): void { - if (reconnectTimer) return; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - connect(); - }, 2000); - } - - pty.onData((data) => send({ type: "daemon:output", data })); - pty.onExit(({ exitCode }) => { - console.log(`[daemon] agenten avslutades (kod ${exitCode})`); - send({ type: "daemon:exit", code: exitCode }); - setTimeout(() => process.exit(exitCode), 200); - }); - - // Lokal hook-endpoint (endast 127.0.0.1 — aldrig nåbar utifrån). - const hookServer = http.createServer((req, res) => { - if (req.method !== "POST" || req.url !== "/approve") { - res.writeHead(404); - res.end(); - return; - } - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => { - let parsed: { toolName?: string; toolInput?: unknown } = {}; - try { - parsed = JSON.parse(body); - } catch { - /* tom */ - } - const toolName = parsed.toolName ?? "okänt verktyg"; - const approvalId = randomUUID(); - - const finish = (d: Decision) => { - if (res.writableEnded) return; - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify(d)); - }; - - // Ingen server ansluten -> svara med default direkt (graceful degradation). - if (!ws || ws.readyState !== WebSocket.OPEN) { - finish({ decision: HOOK_DEFAULT }); - return; - } - - const timer = setTimeout(() => { - if (pending.delete(approvalId)) finish({ decision: HOOK_DEFAULT, reason: "timeout" }); - }, HOOK_TIMEOUT); - pending.set(approvalId, (d) => { - clearTimeout(timer); - finish(d); - }); - - send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput }); - }); - }); - hookServer.listen(HOOK_PORT, "127.0.0.1", () => { - console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`); - }); - - function shutdown(): void { - pty.kill(); - process.exit(0); - } - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - connect(); -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0bc56f4..745817d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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(); const webClients = new Set(); const roles = new WeakMap(); +const pendingCookieUser = new WeakMap(); -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(); +const devicesByUserCode = new Map(); + +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(); +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 { + const out: Record = {}; + 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 { + 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 { + 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(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 = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", @@ -234,16 +617,12 @@ const MIME: Record = { ".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}`); }); diff --git a/packages/server/src/oidc.ts b/packages/server/src/oidc.ts new file mode 100644 index 0000000..a97384d --- /dev/null +++ b/packages/server/src/oidc.ts @@ -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(); + +async function discover(issuer: string): Promise { + 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 { + 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 { + 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; + + // 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, + }; +} diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts new file mode 100644 index 0000000..12037a5 --- /dev/null +++ b/packages/server/src/store.ts @@ -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; + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c1687e1..c1e168b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,15 +13,19 @@ export interface SessionMeta { cols: number; rows: number; exitCode?: number; + /** Visningsnamn för ägaren (sätts av servern utifrån klient-tokenet). */ + user?: string; } /* ----------------------------- Daemon -> Server ---------------------------- */ export interface DaemonHello { type: "daemon:hello"; + /** Klient-token från device-flödet (identifierar användaren), INTE en delad token. */ token: string; sessionId: string; - title: string; + /** Människovänligt namn på klienten/sessionen (visas i UI). */ + clientName: string; cols: number; rows: number; } @@ -69,7 +73,7 @@ export type ServerToDaemon = ServerInput | ServerApprovalDecision; export interface WebHello { type: "web:hello"; - token: string; + /** Ingen token här — webben autentiseras via sin inloggnings-cookie på WS-handskaket. */ } export interface WebSubscribe { @@ -158,6 +162,63 @@ export type ServerToWeb = | ServerApproval | ServerApprovalResolved; +/* ------------------------------- HTTP-API (REST) --------------------------- */ +/* Device Authorization Grant (RFC 8628-liknande) + auth-status. Delas så att + * daemon, server och web håller samma format. */ + +export type AuthMode = "local" | "oidc"; + +/** GET /api/config — publik info som inloggningsvyn behöver (ingen auth). */ +export interface PublicConfig { + authMode: AuthMode; + /** Knapptext för OIDC-login, t.ex. "Logga in med Authentik". */ + oidcLabel?: string; + /** Har servern fått sitt första admin-konto? */ + setupComplete: boolean; +} + +/** GET /api/me — vem är inloggad (via cookie). 401 om ingen. */ +export interface MeResponse { + userId: string; + username: string; + displayName: string; + isAdmin: boolean; +} + +/** POST /api/device/code — daemon startar device-flödet (ingen auth). */ +export interface DeviceCodeRequest { + clientName: string; +} +export interface DeviceCodeResponse { + deviceCode: string; + userCode: string; + /** Sida att öppna i webbläsaren. */ + verificationUri: string; + /** Samma sida med koden förifylld. */ + verificationUriComplete: string; + /** Sekunder mellan pollningar. */ + interval: number; + /** Sekunder tills koden går ut. */ + expiresIn: number; +} + +/** GET /api/device/info?user_code=… — webben hämtar info inför consent (cookie-auth). */ +export interface DeviceInfoResponse { + clientName: string; + status: "pending" | "approved" | "denied" | "expired"; +} + +/** POST /api/device/token — daemon pollar (ingen auth; device_code ÄR hemligheten). */ +export type DeviceTokenStatus = "pending" | "slow_down" | "denied" | "expired" | "ready"; +export interface DeviceTokenResponse { + status: DeviceTokenStatus; + /** Endast när status === "ready": */ + token?: string; + sessionId?: string; + clientName?: string; + user?: string; +} + /* --------------------------------- Helpers --------------------------------- */ export function parseMessage(raw: string): T | null { diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index b87f956..ebcae46 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -1,24 +1,47 @@