import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { isCapability, isRole, type Capability, 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 ApiKeyRecord = { id: string; tokenHash: string; username: string; capabilities: Capability[]; workspace?: string; expiresAt: string; disabled?: boolean; }; export type NewApiKey = ApiKeyRecord; 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; getApiKey(tokenHash: string): Promise; createApiKey(key: NewApiKey): Promise; listApiKeys(username: string): Promise; revokeApiKey(username: string, id: string): Promise; deleteExpiredApiKeys(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 normalizeApiKey(key: NewApiKey): ApiKeyRecord { if (!/^[a-zA-Z0-9_-]{16,128}$/.test(key.id)) throw new Error("API key ID is invalid"); if (!/^[a-f0-9]{64}$/.test(key.tokenHash)) throw new Error("API key token hash must be a SHA-256 hex digest"); if (!key.username || key.username !== key.username.trim()) throw new Error("API key username is required"); if ( !Array.isArray(key.capabilities) || key.capabilities.length === 0 || new Set(key.capabilities).size !== key.capabilities.length || !key.capabilities.every(isCapability) ) { throw new Error("API key requires unique valid capabilities"); } if ( key.workspace !== undefined && (!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(key.workspace) || key.workspace.length > 63) ) { throw new Error("API key workspace scope is invalid"); } const expiresAt = new Date(key.expiresAt); if ( !Number.isFinite(expiresAt.getTime()) || expiresAt.toISOString() !== key.expiresAt ) { throw new Error("API key expiration must be an ISO timestamp"); } return { ...key, capabilities: [...key.capabilities], disabled: Boolean(key.disabled), }; } 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(); readonly apiKeys = new Map(); readonly apiKeysByTokenHash = new Map(); private storeApiKey(key: ApiKeyRecord): void { this.apiKeys.set(key.id, key); this.apiKeysByTokenHash.set(key.tokenHash, key); } private deleteApiKey(id: string): void { const key = this.apiKeys.get(id); if (!key) return; this.apiKeys.delete(id); if (this.apiKeysByTokenHash.get(key.tokenHash) === key) this.apiKeysByTokenHash.delete(key.tokenHash); } 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); for (const [id, key] of this.apiKeys) if (key.username === username) this.deleteApiKey(id); 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; } async getApiKey(tokenHash: string): Promise { const key = this.apiKeysByTokenHash.get(tokenHash); if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return; const user = this.users.get(key.username); if (!user || user.disabled) return; return key; } async createApiKey(key: NewApiKey): Promise { const normalized = normalizeApiKey(key); const user = this.users.get(normalized.username); if (!user || user.disabled) throw new Error("API key user is not active"); if (this.apiKeys.has(normalized.id)) throw new Error("API key already exists"); this.storeApiKey(normalized); } async listApiKeys(username: string): Promise { return [...this.apiKeys.values()] .filter((key) => key.username === username) .sort((left, right) => left.id.localeCompare(right.id)); } async revokeApiKey(username: string, id: string): Promise { const key = this.apiKeys.get(id); if (!key || key.username !== username) return false; this.deleteApiKey(id); return true; } async deleteExpiredApiKeys(now = Date.now()): Promise { const expired = [...this.apiKeys.values()].filter( (key) => Date.parse(key.expiresAt) <= now, ); for (const key of expired) this.deleteApiKey(key.id); return expired.length; } }