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:
256
packages/server/src/store.ts
Normal file
256
packages/server/src/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user