// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0039). // Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here // it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and // nothing outside keycloak does. import { readFileSync } from "node:fs"; /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } export class KeycloakClient { readonly baseUrl: string; readonly defaultRealm: string; // The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh // password grant, and a 401 mid-flight refreshes it once rather than failing the request. private tokenCache: { token: string; expiresAt: number } | null = null; constructor( url: string, private readonly adminUser: string, private readonly adminPass: string, defaultRealm = "master", ) { this.baseUrl = url.replace(/\/+$/, ""); this.defaultRealm = defaultRealm; } /** * Build from the module's resolved environment. Admin URL, credentials and the fallback realm are * read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own * KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice. * Throws when no admin password can be found: without it the client can do nothing, so failing * here lets the tool runtime expose no keycloak tools rather than tools that always error. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE); const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD"); const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master"; return new KeycloakClient(url, adminUser, adminPass, realm); } private async getToken(): Promise { if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token; const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "password", client_id: "admin-cli", username: this.adminUser, password: this.adminPass, }), }); if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`); const data = (await res.json()) as { access_token: string; expires_in: number }; this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 }; return data.access_token; } private async request(path: string, options: RequestInit = {}): Promise { const doRequest = async (token: string): Promise => fetch(`${this.baseUrl}/admin/realms${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...(options.headers as Record), }, }); let res = await doRequest(await this.getToken()); // A cached token that expired against the server's clock reads as 401; drop it and retry once. if (res.status === 401) { this.tokenCache = null; res = await doRequest(await this.getToken()); } if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`); // 201/204 carry no body — the admin API's create/update/delete answer with an empty response. if (res.status === 201 || res.status === 204) return null as T; return res.json() as Promise; } // Realms async listRealms(): Promise> { return this.request("/"); } // Users async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise { const qs = new URLSearchParams(); if (params.search) qs.set("search", params.search); if (params.max) qs.set("max", String(params.max)); const query = qs.toString(); return this.request(`/${realm}/users${query ? `?${query}` : ""}`); } async createUser(realm: string, data: { username: string; email?: string; enabled?: boolean; credentials?: Array<{ type: string; value: string; temporary: boolean }>; }): Promise { await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) }); } async updateUser(realm: string, userId: string, data: Record): Promise { await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) }); } async deleteUser(realm: string, userId: string): Promise { await this.request(`/${realm}/users/${userId}`, { method: "DELETE" }); } async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise { await this.request(`/${realm}/users/${userId}/reset-password`, { method: "PUT", body: JSON.stringify({ type: "password", value: password, temporary }), }); } async getUserSessions(realm: string, userId: string): Promise { return this.request(`/${realm}/users/${userId}/sessions`); } // Clients async listClients(realm: string): Promise { return this.request(`/${realm}/clients`); } async createClient(realm: string, data: { clientId: string; name?: string; rootUrl?: string; redirectUris?: string[]; publicClient?: boolean; protocol?: string; }): Promise { await this.request(`/${realm}/clients`, { method: "POST", body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }), }); } // The admin API addresses a client by its internal UUID, not the human clientId a caller knows; // every client-scoped call resolves the one to the other first. private async resolveClientId(realm: string, clientId: string): Promise { const clients = (await this.listClients(realm)) as Array>; const client = clients.find((c) => c.clientId === clientId); if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`); return client.id as string; } async deleteClient(realm: string, clientId: string): Promise { await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" }); } async getClientSecret(realm: string, clientId: string): Promise { const id = await this.resolveClientId(realm, clientId); const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`); return result.value; } async addProtocolMapper(realm: string, clientId: string, mapper: { name: string; protocolMapper: string; config: Record; }): Promise { const id = await this.resolveClientId(realm, clientId); await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, { method: "POST", body: JSON.stringify({ protocol: "openid-connect", ...mapper }), }); } // Roles async listRealmRoles(realm: string): Promise> { return this.request(`/${realm}/roles`); } async createRealmRole(realm: string, data: { name: string; description?: string }): Promise { await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) }); } async getUserRealmRoles(realm: string, userId: string): Promise> { return this.request(`/${realm}/users/${userId}/role-mappings/realm`); } async getAvailableRealmRoles(realm: string, userId: string): Promise> { return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`); } async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) }); } async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) }); } // Groups async listGroups(realm: string): Promise> { return this.request(`/${realm}/groups`); } async createGroup(realm: string, name: string): Promise { await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) }); } async getUserGroups(realm: string, userId: string): Promise> { return this.request(`/${realm}/users/${userId}/groups`); } async addUserToGroup(realm: string, userId: string, groupId: string): Promise { await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" }); } async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise { await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" }); } }