230 lines
9.8 KiB
TypeScript
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" });
|
|
}
|
|
}
|