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
257 lines
8.1 KiB
TypeScript
257 lines
8.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|