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" });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user