Files

230 lines
9.8 KiB
TypeScript

// 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<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" });
}
}