379 lines
17 KiB
TypeScript
379 lines
17 KiB
TypeScript
// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0039), 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 [];
|
|
}
|
|
});
|