Files
mesh-catalog/modules/keycloak/tools/index.ts
T
jschoubben 4d98da18a0 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.
2026-09-04 02:30:40 +02:00

379 lines
17 KiB
TypeScript

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