import { KubeConfig, KubernetesObjectApi, PatchStrategy, type KubernetesObject, } from "@kubernetes/client-node"; import { createHash } from "node:crypto"; import { createKubernetesHttpLibrary } from "../lib/k8s-http"; import { normalizeSession, normalizeUser, normalizeApiKey, type ApiKeyRecord, type AuthStore, type KuberUser, type NewKuberUser, type SessionInput, type SessionRecord, type UserUpdate, } from "./auth"; import { isCapability, isRole, type Capability } from "./authorization"; const FIELD_MANAGER = "kuber-server"; export const KUBER_SYSTEM_NAMESPACE = "kuber-system"; type SecretObject = KubernetesObject & { data?: Record; type?: string; }; function objectName(prefix: string, value: string): string { const digest = createHash("sha256").update(value).digest("hex").slice(0, 48); return `${prefix}-${digest}`; } function decode(value: unknown): string | undefined { if ( typeof value !== "string" || value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( value, ) ) { return; } const decoded = Buffer.from(value, "base64"); if (decoded.toString("base64") !== value) return; try { return new TextDecoder("utf-8", { fatal: true }).decode(decoded); } catch { return; } } function hasOnlyKeys( data: Record, keys: readonly string[], ): boolean { const actual = Object.keys(data).sort(); const expected = [...keys].sort(); return ( actual.length === expected.length && actual.every((key, index) => key === expected[index]) ); } function parseRoles(value: unknown): KuberUser["roles"] | undefined { const decoded = decode(value); if (!decoded) return; try { const roles: unknown = JSON.parse(decoded); if ( !Array.isArray(roles) || roles.length === 0 || new Set(roles).size !== roles.length || !roles.every(isRole) ) return; return roles; } catch { return; } } function isSecret( secret: SecretObject, type: "user" | "session" | "api-key", ): boolean { return ( secret.apiVersion === "v1" && secret.kind === "Secret" && secret.type === "Opaque" && secret.metadata?.namespace === KUBER_SYSTEM_NAMESPACE && secret.metadata.labels?.["kuber.astrxl.dev/type"] === type && Boolean(secret.data) && typeof secret.data === "object" && !Array.isArray(secret.data) ); } function parseCapabilities(value: unknown): Capability[] | undefined { const decoded = decode(value); if (!decoded) return; try { const capabilities: unknown = JSON.parse(decoded); if ( !Array.isArray(capabilities) || capabilities.length === 0 || new Set(capabilities).size !== capabilities.length || !capabilities.every(isCapability) ) return; return capabilities; } catch { return; } } function parseUser( secret: SecretObject, expectedUsername?: string, ): KuberUser | undefined { if (!isSecret(secret, "user")) return; const userKeys = secret.data?.authVersion === undefined ? ["username", "passwordHash", "roles", "disabled"] : ["username", "passwordHash", "roles", "authVersion", "disabled"]; if (!secret.data || !hasOnlyKeys(secret.data, userKeys)) return; const username = decode(secret.data?.username); const passwordHash = decode(secret.data?.passwordHash); const roles = parseRoles(secret.data?.roles); const disabled = decode(secret.data?.disabled); const encodedAuthVersion = secret.data?.authVersion; const authVersion = encodedAuthVersion === undefined ? 1 : Number(decode(encodedAuthVersion)); if ( !username || username !== username.trim() || (expectedUsername !== undefined && username !== expectedUsername) || secret.metadata?.name !== objectName("user", username) || !passwordHash || !roles || (disabled !== "true" && disabled !== "false") || !Number.isSafeInteger(authVersion) || authVersion < 1 ) return; return { username, passwordHash, roles, disabled: disabled === "true", authVersion, }; } function parseSession(secret: SecretObject): SessionRecord | undefined { if (!isSecret(secret, "session")) return; const sessionKeys = secret.data?.authVersion === undefined ? ["tokenHash", "username", "roles", "expiresAt"] : ["tokenHash", "username", "authVersion", "expiresAt"]; if (!secret.data || !hasOnlyKeys(secret.data, sessionKeys)) return; const tokenHash = decode(secret.data?.tokenHash); const username = decode(secret.data?.username); const expiresAt = decode(secret.data?.expiresAt); const encodedAuthVersion = secret.data?.authVersion; const authVersion = encodedAuthVersion === undefined ? 1 : Number(decode(encodedAuthVersion)); if ( !tokenHash || !username || !expiresAt || secret.metadata?.name !== objectName("session", tokenHash) || (encodedAuthVersion === undefined && !parseRoles(secret.data?.roles)) ) return; try { return normalizeSession({ tokenHash, username, authVersion, expiresAt }); } catch { return; } } function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined { if (!isSecret(secret, "api-key")) return; const keys = [ "id", "tokenHash", "username", "capabilities", "workspace", "expiresAt", "disabled", ]; if (!secret.data || !hasOnlyKeys(secret.data, keys)) return; const id = decode(secret.data.id); const tokenHash = decode(secret.data.tokenHash); const username = decode(secret.data.username); const capabilities = parseCapabilities(secret.data.capabilities); const workspace = decode(secret.data.workspace); const expiresAt = decode(secret.data.expiresAt); const disabled = decode(secret.data.disabled); if ( !id || !tokenHash || !username || !capabilities || !expiresAt || (workspace !== "" && workspace === undefined) || (disabled !== "true" && disabled !== "false") || secret.metadata?.name !== objectName("api-key", tokenHash) ) return; try { return normalizeApiKey({ id, tokenHash, username, capabilities, ...(workspace && { workspace }), expiresAt, disabled: disabled === "true", }); } catch { return; } } function isNotFound(error: unknown): boolean { return Boolean( error && typeof error === "object" && "code" in error && error.code === 404, ); } function createObjectApi(): KubernetesObjectApi { const config = new KubeConfig(); if (process.env.KUBERNETES_SERVICE_HOST) config.loadFromCluster(); else config.loadFromDefault(); const makeApiClient = config.makeApiClient.bind(config); const httpLibrary = createKubernetesHttpLibrary({ maxConcurrent: 4, minIntervalMs: 0, }); config.makeApiClient = ((apiClientType) => { const client = makeApiClient(apiClientType) as unknown as { api?: { configuration?: { httpApi?: typeof httpLibrary } }; configuration?: { httpApi?: typeof httpLibrary }; }; if (client.api?.configuration) client.api.configuration.httpApi = httpLibrary; if (client.configuration) client.configuration.httpApi = httpLibrary; return client; }) as typeof config.makeApiClient; return KubernetesObjectApi.makeApiClient(config); } export class KubernetesAuthStore implements AuthStore { constructor(private readonly objects = createObjectApi()) {} private async readSecret(name: string): Promise { try { return (await this.objects.read({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE }, })) as SecretObject; } catch (error) { if (isNotFound(error)) return; throw error; } } private async applySecret( name: string, type: "user" | "session" | "api-key", stringData: Record, ): Promise { await this.objects.patch( { apiVersion: "v1", kind: "Secret", metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE, labels: { "kuber.astrxl.dev/type": type }, }, type: "Opaque", stringData, } as KubernetesObject, undefined, undefined, FIELD_MANAGER, true, PatchStrategy.ServerSideApply, ); } private async listSecrets( type: "user" | "session" | "api-key", ): Promise { const result = await this.objects.list( "v1", "Secret", KUBER_SYSTEM_NAMESPACE, undefined, undefined, undefined, undefined, `kuber.astrxl.dev/type=${type}`, ); return result.items.map((item) => ({ ...item, apiVersion: item.apiVersion ?? "v1", kind: item.kind ?? "Secret", })) as SecretObject[]; } private async deleteSecret(name: string): Promise { try { await this.objects.delete({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE }, }); return true; } catch (error) { if (isNotFound(error)) return false; throw error; } } async getUser(username: string): Promise { const secret = await this.readSecret(objectName("user", username)); return secret ? parseUser(secret, username) : undefined; } async listUsers(): Promise { return (await this.listSecrets("user")) .map((secret) => parseUser(secret)) .filter((user): user is KuberUser => Boolean(user)) .sort((a, b) => a.username.localeCompare(b.username)); } async putUser(user: NewKuberUser | KuberUser): Promise { const normalized = normalizeUser(user); await this.applySecret(objectName("user", normalized.username), "user", { username: normalized.username, passwordHash: normalized.passwordHash, roles: JSON.stringify(normalized.roles), authVersion: String(normalized.authVersion), disabled: String(Boolean(normalized.disabled)), }); } async createUser(user: NewKuberUser): Promise { if (await this.getUser(user.username)) throw new Error("User already exists"); const normalized = normalizeUser(user); await this.putUser(normalized); return normalized; } async updateUser( username: string, update: UserUpdate, ): Promise { const existing = await this.getUser(username); if (!existing) return; const updated = normalizeUser({ ...existing, ...update, username, authVersion: existing.authVersion + 1, }); await this.putUser(updated); return updated; } async deleteUser(username: string): Promise { await this.revokeUserSessions(username); for (const key of await this.listApiKeys(username)) await this.deleteSecret(objectName("api-key", key.tokenHash)); return this.deleteSecret(objectName("user", username)); } async getSession(tokenHash: string): Promise { const secret = await this.readSecret(objectName("session", tokenHash)); if (!secret) return; const session = parseSession(secret); if (!session || session.tokenHash !== tokenHash) return; const user = await this.getUser(session.username); if (!user || user.disabled || user.authVersion !== session.authVersion) return; return session; } async putSession(session: SessionInput): Promise { const user = await this.getUser(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, }); await this.applySecret( objectName("session", normalized.tokenHash), "session", { tokenHash: normalized.tokenHash, username: normalized.username, authVersion: String(normalized.authVersion), expiresAt: normalized.expiresAt, }, ); } async deleteSession(tokenHash: string): Promise { await this.deleteSecret(objectName("session", tokenHash)); } async revokeUserSessions(username: string): Promise { const sessions = (await this.listSecrets("session")) .map((secret) => parseSession(secret)) .filter( (session): session is SessionRecord => session?.username === username, ); for (const session of sessions) await this.deleteSession(session.tokenHash); return sessions.length; } async listExpiredSessions(now = Date.now()): Promise { return (await this.listSecrets("session")) .map((secret) => parseSession(secret)) .filter( (session): session is SessionRecord => session !== undefined && Date.parse(session.expiresAt) <= now, ); } async deleteExpiredSessions(now = Date.now()): Promise { const expired = await this.listExpiredSessions(now); for (const session of expired) { await this.deleteSession(session.tokenHash); } return expired.length; } async getApiKey(tokenHash: string): Promise { if (!/^[a-f0-9]{64}$/.test(tokenHash)) return; const key = (await this.listSecrets("api-key")) .map(parseApiKey) .find((item): item is ApiKeyRecord => item?.tokenHash === tokenHash); if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return; const user = await this.getUser(key.username); if (!user || user.disabled) return; return key; } async createApiKey(key: ApiKeyRecord): Promise { const normalized = normalizeApiKey(key); const user = await this.getUser(normalized.username); if (!user || user.disabled) throw new Error("API key user is not active"); await this.applySecret( objectName("api-key", normalized.tokenHash), "api-key", { id: normalized.id, tokenHash: normalized.tokenHash, username: normalized.username, capabilities: JSON.stringify(normalized.capabilities), workspace: normalized.workspace ?? "", expiresAt: normalized.expiresAt, disabled: String(Boolean(normalized.disabled)), }, ); } async listApiKeys(username: string): Promise { return (await this.listSecrets("api-key")) .map(parseApiKey) .filter((key): key is ApiKeyRecord => key?.username === username) .sort((left, right) => left.id.localeCompare(right.id)); } async revokeApiKey(username: string, id: string): Promise { const key = (await this.listApiKeys(username)).find( (item) => item.id === id, ); return key ? this.deleteSecret(objectName("api-key", key.tokenHash)) : false; } async deleteExpiredApiKeys(now = Date.now()): Promise { const expired = (await this.listSecrets("api-key")) .map(parseApiKey) .filter( (key): key is ApiKeyRecord => key !== undefined && Date.parse(key.expiresAt) <= now, ); for (const key of expired) await this.deleteSecret(objectName("api-key", key.tokenHash)); return expired.length; } }