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,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;
}
}