auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
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:
@@ -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
112
packages/server/src/oidc.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
256
packages/server/src/store.ts
Normal file
256
packages/server/src/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user