keycloak: full nox module — client, tools and events (ADR 0044/0046)
Identity provider. 22 admin tools (realms, users, clients + secrets, groups, roles) over the admin API, moved out of the shared sdk. Emits user created/ deleted, password reset, client/group/role created — from the write tools themselves, since Keycloak's value is the changes it makes, not pollable state. Consumes nothing: it is upstream of everything that authenticates against it. Typechecks; manifest parses.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
// 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.
|
||||
|
||||
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 url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
|
||||
const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
|
||||
const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
|
||||
if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD");
|
||||
const 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" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user