/** * 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; } }