import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { isRole, type Role } from "./authorization"; export type KuberUser = { username: string; passwordHash: string; roles: Role[]; authVersion: number; disabled?: boolean; }; export type SessionRecord = { tokenHash: string; username: string; authVersion: number; expiresAt: string; }; export type SessionInput = | SessionRecord | { tokenHash: string; username: string; roles: string[]; expiresAt: string; }; export type NewKuberUser = Omit & { authVersion?: number; }; export type UserUpdate = Partial< Pick >; export interface AuthStore { getUser(username: string): Promise; listUsers(): Promise; putUser(user: NewKuberUser | KuberUser): Promise; createUser(user: NewKuberUser): Promise; updateUser( username: string, update: UserUpdate, ): Promise; deleteUser(username: string): Promise; getSession(tokenHash: string): Promise; putSession(session: SessionInput): Promise; deleteSession(tokenHash: string): Promise; revokeUserSessions(username: string): Promise; listExpiredSessions(now?: number): Promise; deleteExpiredSessions(now?: number): Promise; } export function normalizeUser(user: NewKuberUser | KuberUser): KuberUser { if (!user.username || user.username !== user.username.trim()) throw new Error("Username must be a non-empty trimmed string"); if (!user.passwordHash) throw new Error("Password hash is required"); if ( !Array.isArray(user.roles) || user.roles.length === 0 || new Set(user.roles).size !== user.roles.length || !user.roles.every(isRole) ) { throw new Error("At least one unique valid role is required"); } const authVersion = user.authVersion ?? 1; if (!Number.isSafeInteger(authVersion) || authVersion < 1) throw new Error("Auth version must be a positive integer"); return { ...user, roles: [...user.roles], authVersion }; } export function normalizeSession(session: SessionRecord): SessionRecord { if (!/^[a-f0-9]{64}$/.test(session.tokenHash)) throw new Error("Session token hash must be a SHA-256 hex digest"); if (!session.username) throw new Error("Session username is required"); if (!Number.isSafeInteger(session.authVersion) || session.authVersion < 1) throw new Error("Session auth version must be a positive integer"); const expiresAt = new Date(session.expiresAt); if ( !Number.isFinite(expiresAt.getTime()) || expiresAt.toISOString() !== session.expiresAt ) { throw new Error("Session expiration must be an ISO timestamp"); } return { ...session }; } export function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } export function createToken(): string { return randomBytes(32).toString("base64url"); } export function tokenHashesEqual(left: string, right: string): boolean { if (!/^[a-f0-9]{64}$/.test(left) || !/^[a-f0-9]{64}$/.test(right)) return false; const leftBuffer = Buffer.from(left, "hex"); const rightBuffer = Buffer.from(right, "hex"); return ( leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) ); } export class MemoryAuthStore implements AuthStore { readonly users = new Map(); readonly sessions = new Map(); async getUser(username: string): Promise { return this.users.get(username); } async listUsers(): Promise { return [...this.users.values()].sort((a, b) => a.username.localeCompare(b.username), ); } async putUser(user: NewKuberUser | KuberUser): Promise { const normalized = normalizeUser(user); this.users.set(normalized.username, normalized); } async createUser(user: NewKuberUser): Promise { if (this.users.has(user.username)) throw new Error("User already exists"); const normalized = normalizeUser(user); this.users.set(normalized.username, normalized); return normalized; } async updateUser( username: string, update: UserUpdate, ): Promise { const existing = this.users.get(username); if (!existing) return; const updated = normalizeUser({ ...existing, ...update, username, authVersion: existing.authVersion + 1, }); this.users.set(username, updated); return updated; } async deleteUser(username: string): Promise { await this.revokeUserSessions(username); return this.users.delete(username); } async getSession(tokenHash: string): Promise { const session = this.sessions.get(tokenHash); if (!session) return; const user = this.users.get(session.username); if (!user || user.disabled || user.authVersion !== session.authVersion) return; return session; } async putSession(session: SessionInput): Promise { const user = this.users.get(session.username); if (!user || user.disabled) throw new Error("Session user is not active"); const authVersion = "authVersion" in session ? session.authVersion : user.authVersion; if (authVersion !== user.authVersion) throw new Error("Session auth version is stale"); const normalized = normalizeSession({ tokenHash: session.tokenHash, username: session.username, authVersion, expiresAt: session.expiresAt, }); this.sessions.set(normalized.tokenHash, normalized); } async deleteSession(tokenHash: string): Promise { this.sessions.delete(tokenHash); } async revokeUserSessions(username: string): Promise { let deleted = 0; for (const [tokenHash, session] of this.sessions) { if (session.username !== username) continue; this.sessions.delete(tokenHash); deleted += 1; } return deleted; } async listExpiredSessions(now = Date.now()): Promise { return [...this.sessions.values()].filter( (session) => Date.parse(session.expiresAt) <= now, ); } async deleteExpiredSessions(now = Date.now()): Promise { const expired = await this.listExpiredSessions(now); for (const session of expired) this.sessions.delete(session.tokenHash); return expired.length; } }