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:
79
packages/web/src/lib/api.ts
Normal file
79
packages/web/src/lib/api.ts
Normal 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));
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user