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

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

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

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

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

View File

@@ -0,0 +1,79 @@
/** Tunn fetch-wrapper mot serverns REST-API. Allt same-origin → cookien följer med. */
import type {
DeviceInfoResponse,
MeResponse,
PublicConfig,
} from "@agent-helm/shared";
async function jsonOrThrow<T>(r: Response): Promise<T> {
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
msg = (await r.json()).error || msg;
} catch {
/* tom */
}
throw new Error(msg);
}
return r.json() as Promise<T>;
}
const post = (path: string, body?: unknown) =>
fetch(path, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
export const getConfig = () => fetch("/api/config", { credentials: "include" }).then((r) => jsonOrThrow<PublicConfig>(r));
export async function getMe(): Promise<MeResponse | null> {
const r = await fetch("/api/me", { credentials: "include" });
return r.ok ? ((await r.json()) as MeResponse) : null;
}
export const login = (username: string, password: string) =>
post("/api/login", { username, password }).then((r) => jsonOrThrow<MeResponse>(r));
export const setup = (username: string, password: string, displayName?: string) =>
post("/api/setup", { username, password, displayName }).then((r) => jsonOrThrow<MeResponse>(r));
export const logout = () => post("/api/logout");
export const deviceInfo = (userCode: string) =>
fetch(`/api/device/info?user_code=${encodeURIComponent(userCode)}`, { credentials: "include" }).then((r) =>
jsonOrThrow<DeviceInfoResponse>(r),
);
export const deviceApprove = (userCode: string, decision: "allow" | "deny") =>
post("/api/device/approve", { userCode, decision }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export interface ClientCredDto {
id: string;
name: string;
createdAt: number;
lastSeenAt: number;
revoked: boolean;
}
export const listClients = () =>
fetch("/api/clients", { credentials: "include" }).then((r) => jsonOrThrow<{ clients: ClientCredDto[] }>(r));
export const revokeClient = (id: string) =>
fetch(`/api/clients/${id}`, { method: "DELETE", credentials: "include" }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
/* ---- admin ---- */
export const adminListUsers = () =>
fetch("/api/admin/users", { credentials: "include" }).then((r) => jsonOrThrow<{ users: MeResponse[] }>(r));
export const adminCreateUser = (username: string, password: string, displayName: string, isAdmin: boolean) =>
post("/api/admin/users", { username, password, displayName, isAdmin }).then((r) => jsonOrThrow<MeResponse>(r));
export const adminDeleteUser = (id: string) =>
fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export const adminSetOidc = (cfg: {
issuer: string;
clientId: string;
clientSecret: string;
label?: string;
allowedGroup?: string;
}) => post("/api/admin/oidc", cfg).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export const adminSetAuthMode = (mode: "local" | "oidc") =>
post("/api/admin/authmode", { mode }).then((r) => jsonOrThrow<{ ok: boolean }>(r));

View File

@@ -30,12 +30,13 @@ export function createHelmClient() {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(url: string, token: string): void {
function connect(url: string): void {
error.value = null;
connecting.value = true;
ws = new WebSocket(url);
ws.onopen = () => send({ type: "web:hello", token });
// Ingen token — vi autentiseras via inloggnings-cookien på WS-handskaket.
ws.onopen = () => send({ type: "web:hello" });
ws.onmessage = (ev) => {
let msg: ServerToWeb;