Files
mesh-catalog/modules/keycloak/client.ts
T
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00

230 lines
9.8 KiB
TypeScript

// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0044).
// 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 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<string> {
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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const doRequest = async (token: string): Promise<Response> =>
fetch(`${this.baseUrl}/admin/realms${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(options.headers as Record<string, string>),
},
});
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<T>;
}
// Realms
async listRealms(): Promise<Array<{ id: string; realm: string; displayName?: string; enabled: boolean }>> {
return this.request("/");
}
// Users
async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise<unknown[]> {
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<void> {
await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) });
}
async updateUser(realm: string, userId: string, data: Record<string, unknown>): Promise<void> {
await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) });
}
async deleteUser(realm: string, userId: string): Promise<void> {
await this.request(`/${realm}/users/${userId}`, { method: "DELETE" });
}
async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise<void> {
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<unknown[]> {
return this.request(`/${realm}/users/${userId}/sessions`);
}
// Clients
async listClients(realm: string): Promise<unknown[]> {
return this.request(`/${realm}/clients`);
}
async createClient(realm: string, data: {
clientId: string;
name?: string;
rootUrl?: string;
redirectUris?: string[];
publicClient?: boolean;
protocol?: string;
}): Promise<void> {
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<string> {
const clients = (await this.listClients(realm)) as Array<Record<string, unknown>>;
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<void> {
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
}
async getClientSecret(realm: string, clientId: string): Promise<string> {
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<string, string>;
}): Promise<void> {
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<Array<{ id: string; name: string; description?: string; composite: boolean }>> {
return this.request(`/${realm}/roles`);
}
async createRealmRole(realm: string, data: { name: string; description?: string }): Promise<void> {
await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) });
}
async getUserRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
return this.request(`/${realm}/users/${userId}/role-mappings/realm`);
}
async getAvailableRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`);
}
async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
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<void> {
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) });
}
// Groups
async listGroups(realm: string): Promise<Array<{ id: string; name: string; path: string; subGroupCount?: number }>> {
return this.request(`/${realm}/groups`);
}
async createGroup(realm: string, name: string): Promise<void> {
await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) });
}
async getUserGroups(realm: string, userId: string): Promise<Array<{ id: string; name: string; path: string }>> {
return this.request(`/${realm}/users/${userId}/groups`);
}
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" });
}
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" });
}
}