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:
2026-09-04 02:30:40 +02:00
parent 1498a22c94
commit 4d98da18a0
6 changed files with 677 additions and 1 deletions
+219
View File
@@ -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" });
}
}
+44
View File
@@ -0,0 +1,44 @@
// keycloak's events. Keycloak's worth to the mesh is in what it changes — an identity created, a
// client registered, a password reset — so its events are emitted from the admin actions themselves
// (novox/hq ADR 0046/0047), not scraped back by polling. This module is the single vocabulary for
// them: every keycloak event goes through one of the helpers here, and the tools call them at the
// point the change succeeds.
//
// Emits:
// module.keycloak.user.created / .deleted — an identity appeared or was removed
// module.keycloak.password.reset — a user's credential was reset (no secret in the body)
// module.keycloak.client.created — an OIDC client was registered
// module.keycloak.group.created — a group was created
// module.keycloak.role.created — a realm role was created
// Consumes:
// nothing — Keycloak is upstream of the things that authenticate against it; it reacts to none of
// their events. There is no honest `on(...)` to write, so there is none.
import { emit } from "@novox/mesh-sdk/events";
// A completed admin action must not be undone by a flaky broker: the change already happened in
// Keycloak, so a failed emit is logged and swallowed rather than thrown back through the tool.
async function announce(type: string, body: Record<string, unknown>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[keycloak] emit ${type} failed: ${err}`);
}
}
export const events = {
userCreated: (realm: string, username: string, email?: string) =>
announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }),
userDeleted: (realm: string, userId: string) =>
announce("module.keycloak.user.deleted", { realm, userId }),
passwordReset: (realm: string, userId: string) =>
announce("module.keycloak.password.reset", { realm, userId }),
clientCreated: (realm: string, clientId: string, name?: string) =>
announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }),
groupCreated: (realm: string, name: string) =>
announce("module.keycloak.group.created", { realm, name }),
roleCreated: (realm: string, name: string) =>
announce("module.keycloak.role.created", { realm, name }),
};
console.log("[keycloak] event surface ready — identity, client, group and role changes are announced");
+10 -1
View File
@@ -18,6 +18,14 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.keycloak.user.created",
"module.keycloak.user.deleted",
"module.keycloak.password.reset",
"module.keycloak.client.created",
"module.keycloak.group.created",
"module.keycloak.role.created"
],
"listens": [
{
"port": 8080,
@@ -27,7 +35,8 @@
}
],
"own-secrets": {
"admin": "/var/lib/keycloak/admin.secret"
"admin": "/var/lib/keycloak/admin.secret",
"broker": "/var/lib/keycloak/broker"
},
"resources": [
{
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-keycloak",
"version": "0.1.0",
"description": "keycloak — identity and access. Its admin API client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+378
View File
@@ -0,0 +1,378 @@
// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0044), importing keycloak's own
// client. They return structured data (not the hal MCP `{content:[...]}` shape); the mesh serves
// them through the sdk's tool harness. Write actions announce themselves through the module's event
// surface at the point they succeed.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { KeycloakClient } from "../client.js";
import { events } from "../index.js";
export function getKeycloakTools(kc: KeycloakClient): ToolDefinition[] {
// Almost every tool is realm-scoped; an omitted realm falls back to the one the module resolved
// from its environment, so the common single-realm case needs no argument.
const realmOf = (args: Readonly<Record<string, unknown>>): string =>
args.realm ? String(args.realm) : kc.defaultRealm;
return [
// Realms & sessions
{
name: "keycloak_list_realms",
description: "List all Keycloak realms.",
input: {},
run: async () => {
const realms = await kc.listRealms();
return { realms: realms.map((r) => ({ id: r.id, realm: r.realm, displayName: r.displayName, enabled: r.enabled })) };
},
},
{
name: "keycloak_list_sessions",
description: "List active sessions for a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
},
run: async (args) => ({ sessions: await kc.getUserSessions(realmOf(args), String(args.user_id)) }),
},
// Users
{
name: "keycloak_list_users",
description: "List users in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
search: { type: "string", description: "search by username, email, first/last name" },
max: { type: "number", description: "maximum number of results" },
},
run: async (args) => ({
users: await kc.listUsers(realmOf(args), {
search: args.search ? String(args.search) : undefined,
max: args.max ? Number(args.max) : undefined,
}),
}),
},
{
name: "keycloak_create_user",
description: "Create a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
username: { type: "string", description: "username" },
email: { type: "string", description: "email address" },
password: { type: "string", description: "initial password" },
temporary_password: { type: "boolean", description: "require a password change on first login (default true)" },
},
run: async (args) => {
const realm = realmOf(args);
const username = String(args.username);
const email = args.email ? String(args.email) : undefined;
const credentials = args.password
? [{ type: "password", value: String(args.password), temporary: args.temporary_password !== false }]
: undefined;
await kc.createUser(realm, { username, email, credentials });
await events.userCreated(realm, username, email);
return { created: { realm, username, email } };
},
},
{
name: "keycloak_delete_user",
description: "Delete a user from a Keycloak realm (requires confirm).",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
confirm: { type: "boolean", description: "must be true to confirm deletion" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
if (args.confirm !== true) return { aborted: "confirm must be true to delete a user" };
await kc.deleteUser(realm, userId);
await events.userDeleted(realm, userId);
return { deleted: { realm, userId } };
},
},
{
name: "keycloak_update_user",
description: "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
enabled: { type: "boolean", description: "enable or disable the user" },
email: { type: "string", description: "new email address" },
firstName: { type: "string", description: "new first name" },
lastName: { type: "string", description: "new last name" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
const updates: Record<string, unknown> = {};
if (args.enabled !== undefined) updates.enabled = args.enabled === true;
if (args.email !== undefined) updates.email = String(args.email);
if (args.firstName !== undefined) updates.firstName = String(args.firstName);
if (args.lastName !== undefined) updates.lastName = String(args.lastName);
if (Object.keys(updates).length === 0) return { aborted: "no updates provided" };
await kc.updateUser(realm, userId, updates);
return { updated: { realm, userId, fields: Object.keys(updates) } };
},
},
{
name: "keycloak_reset_password",
description: "Reset a user's password in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
password: { type: "string", description: "new password" },
temporary: { type: "boolean", description: "require a password change on next login (default false)" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
await kc.resetPassword(realm, userId, String(args.password), args.temporary === true);
await events.passwordReset(realm, userId);
return { reset: { realm, userId } };
},
},
// Clients
{
name: "keycloak_list_clients",
description: "List OIDC clients in a Keycloak realm.",
input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } },
run: async (args) => {
const clients = (await kc.listClients(realmOf(args))) as Array<Record<string, unknown>>;
return {
clients: clients.map((c) => ({
id: c.id, clientId: c.clientId, name: c.name, enabled: c.enabled,
protocol: c.protocol, publicClient: c.publicClient, rootUrl: c.rootUrl,
})),
};
},
},
{
name: "keycloak_create_client",
description: "Create an OIDC client in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID (e.g. 'my-app')" },
name: { type: "string", description: "display name" },
root_url: { type: "string", description: "root URL of the application" },
redirect_uris: { type: "array", description: "allowed redirect URIs" },
public_client: { type: "boolean", description: "public client, no client secret (default true)" },
},
run: async (args) => {
const realm = realmOf(args);
const clientId = String(args.client_id);
const name = args.name ? String(args.name) : undefined;
await kc.createClient(realm, {
clientId,
name,
rootUrl: args.root_url ? String(args.root_url) : undefined,
redirectUris: Array.isArray(args.redirect_uris) ? args.redirect_uris.map(String) : undefined,
publicClient: args.public_client !== false,
});
await events.clientCreated(realm, clientId, name);
return { created: { realm, clientId, name } };
},
},
{
name: "keycloak_delete_client",
description: "Delete an OIDC client from a Keycloak realm (requires confirm).",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID (e.g. 'my-app')" },
confirm: { type: "boolean", description: "must be true to confirm deletion" },
},
run: async (args) => {
const realm = realmOf(args);
const clientId = String(args.client_id);
if (args.confirm !== true) return { aborted: "confirm must be true to delete a client" };
await kc.deleteClient(realm, clientId);
return { deleted: { realm, clientId } };
},
},
{
name: "keycloak_get_client_secret",
description: "Get the client secret for a confidential OIDC client.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID" },
},
run: async (args) => ({ secret: await kc.getClientSecret(realmOf(args), String(args.client_id)) }),
},
{
name: "keycloak_add_protocol_mapper",
description:
"Add a protocol mapper to an OIDC client. Common types: oidc-usermodel-realm-role-mapper " +
"(realm roles), oidc-usermodel-attribute-mapper (user attributes), oidc-audience-mapper.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID (e.g. 'grafana')" },
name: { type: "string", description: "mapper name (e.g. 'realm roles')" },
mapper_type: { type: "string", description: "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')" },
claim_name: { type: "string", description: "token claim name (e.g. 'realm_access.roles')" },
claim_type: { type: "string", description: "JSON type: String, long, int, boolean (default String)" },
multivalued: { type: "boolean", description: "whether the claim has multiple values (default false)" },
id_token: { type: "boolean", description: "include in ID token (default true)" },
access_token: { type: "boolean", description: "include in access token (default true)" },
userinfo: { type: "boolean", description: "include in userinfo response (default true)" },
},
run: async (args) => {
const realm = realmOf(args);
const clientId = String(args.client_id);
const name = String(args.name);
await kc.addProtocolMapper(realm, clientId, {
name,
protocolMapper: String(args.mapper_type),
config: {
"claim.name": String(args.claim_name),
"jsonType.label": args.claim_type ? String(args.claim_type) : "String",
"multivalued": String(args.multivalued === true),
"id.token.claim": String(args.id_token !== false),
"access.token.claim": String(args.access_token !== false),
"userinfo.token.claim": String(args.userinfo !== false),
},
});
return { added: { realm, clientId, mapper: name } };
},
},
// Groups
{
name: "keycloak_list_groups",
description: "List groups in a Keycloak realm.",
input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } },
run: async (args) => ({ groups: await kc.listGroups(realmOf(args)) }),
},
{
name: "keycloak_create_group",
description: "Create a group in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
name: { type: "string", description: "group name" },
},
run: async (args) => {
const realm = realmOf(args);
const name = String(args.name);
await kc.createGroup(realm, name);
await events.groupCreated(realm, name);
return { created: { realm, group: name } };
},
},
{
name: "keycloak_get_user_groups",
description: "List the groups a user belongs to in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
},
run: async (args) => ({ groups: await kc.getUserGroups(realmOf(args), String(args.user_id)) }),
},
{
name: "keycloak_add_user_to_group",
description: "Add a user to a group in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
group_id: { type: "string", description: "group ID (UUID)" },
},
run: async (args) => {
const realm = realmOf(args);
await kc.addUserToGroup(realm, String(args.user_id), String(args.group_id));
return { added: { realm, userId: String(args.user_id), groupId: String(args.group_id) } };
},
},
{
name: "keycloak_remove_user_from_group",
description: "Remove a user from a group in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
group_id: { type: "string", description: "group ID (UUID)" },
},
run: async (args) => {
const realm = realmOf(args);
await kc.removeUserFromGroup(realm, String(args.user_id), String(args.group_id));
return { removed: { realm, userId: String(args.user_id), groupId: String(args.group_id) } };
},
},
// Roles
{
name: "keycloak_get_user_roles",
description: "List the realm roles assigned to a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
},
run: async (args) => ({ roles: await kc.getUserRealmRoles(realmOf(args), String(args.user_id)) }),
},
{
name: "keycloak_create_role",
description: "Create a realm role in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
role_name: { type: "string", description: "role name" },
description: { type: "string", description: "role description" },
},
run: async (args) => {
const realm = realmOf(args);
const name = String(args.role_name);
await kc.createRealmRole(realm, { name, description: args.description ? String(args.description) : undefined });
await events.roleCreated(realm, name);
return { created: { realm, role: name } };
},
},
{
name: "keycloak_assign_user_role",
description: "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
role_name: { type: "string", description: "role name to assign" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
const roleName = String(args.role_name);
// The mapping API needs the role's UUID, which only the "available" list carries; if the
// role is neither available nor already assigned it does not exist in this realm.
const available = await kc.getAvailableRealmRoles(realm, userId);
const role = available.find((r) => r.name === roleName);
if (!role) {
const assigned = await kc.getUserRealmRoles(realm, userId);
if (assigned.find((r) => r.name === roleName)) return { alreadyAssigned: { realm, userId, role: roleName } };
return { notFound: { realm, role: roleName } };
}
await kc.assignRealmRoles(realm, userId, [{ id: role.id, name: role.name }]);
return { assigned: { realm, userId, role: roleName } };
},
},
{
name: "keycloak_remove_user_role",
description: "Remove a realm role from a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
role_name: { type: "string", description: "role name to remove" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
const roleName = String(args.role_name);
const assigned = await kc.getUserRealmRoles(realm, userId);
const role = assigned.find((r) => r.name === roleName);
if (!role) return { notAssigned: { realm, userId, role: roleName } };
await kc.removeRealmRoles(realm, userId, [{ id: role.id, name: role.name }]);
return { removed: { realm, userId, role: roleName } };
},
},
];
}
// The tools exist only when the client can be configured; without an admin password, keycloak
// contributes none rather than failing the whole runtime.
registerModuleTools("keycloak", (env) => {
try {
return getKeycloakTools(KeycloakClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}