keycloak-model-context-protocol 0.0.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +188 -101
- package/dist/config.js +178 -0
- package/dist/config.js.map +1 -0
- package/dist/guards.js +47 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.js +36 -174
- package/dist/index.js.map +1 -0
- package/dist/keycloak/errors.js +86 -0
- package/dist/keycloak/errors.js.map +1 -0
- package/dist/keycloak/gateway.js +131 -0
- package/dist/keycloak/gateway.js.map +1 -0
- package/dist/logging.js +49 -0
- package/dist/logging.js.map +1 -0
- package/dist/prompts/audit-realm-security.js +26 -0
- package/dist/prompts/audit-realm-security.js.map +1 -0
- package/dist/prompts/index.js +12 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/investigate-failed-logins.js +34 -0
- package/dist/prompts/investigate-failed-logins.js.map +1 -0
- package/dist/prompts/offboard-employee.js +31 -0
- package/dist/prompts/offboard-employee.js.map +1 -0
- package/dist/prompts/onboard-employee.js +50 -0
- package/dist/prompts/onboard-employee.js.map +1 -0
- package/dist/prompts/shared.js +46 -0
- package/dist/prompts/shared.js.map +1 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/realms.js +96 -0
- package/dist/resources/realms.js.map +1 -0
- package/dist/resources/shared.js +44 -0
- package/dist/resources/shared.js.map +1 -0
- package/dist/resources/users.js +26 -0
- package/dist/resources/users.js.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/clients.js +137 -0
- package/dist/tools/clients.js.map +1 -0
- package/dist/tools/events.js +161 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/groups.js +135 -0
- package/dist/tools/groups.js.map +1 -0
- package/dist/tools/index.js +31 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/realms.js +99 -0
- package/dist/tools/realms.js.map +1 -0
- package/dist/tools/roles.js +270 -0
- package/dist/tools/roles.js.map +1 -0
- package/dist/tools/security.js +58 -0
- package/dist/tools/security.js.map +1 -0
- package/dist/tools/sessions.js +68 -0
- package/dist/tools/sessions.js.map +1 -0
- package/dist/tools/shared.js +144 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools/users.js +272 -0
- package/dist/tools/users.js.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +46 -11
package/dist/index.js
CHANGED
|
@@ -1,178 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
capabilities: {
|
|
11
|
-
tools: {},
|
|
12
|
-
},
|
|
13
|
-
});
|
|
14
|
-
// Initialize Keycloak client
|
|
15
|
-
const kcAdminClient = new KcAdminClient({
|
|
16
|
-
baseUrl: process.env.KEYCLOAK_URL || 'http://localhost:8080',
|
|
17
|
-
realmName: 'master'
|
|
18
|
-
});
|
|
19
|
-
// Tool schemas
|
|
20
|
-
const CreateUserSchema = z.object({
|
|
21
|
-
realm: z.string(),
|
|
22
|
-
username: z.string(),
|
|
23
|
-
email: z.string().email(),
|
|
24
|
-
firstName: z.string(),
|
|
25
|
-
lastName: z.string()
|
|
26
|
-
});
|
|
27
|
-
const DeleteUserSchema = z.object({
|
|
28
|
-
realm: z.string(),
|
|
29
|
-
userId: z.string()
|
|
30
|
-
});
|
|
31
|
-
const ListUsersSchema = z.object({
|
|
32
|
-
realm: z.string()
|
|
33
|
-
});
|
|
34
|
-
// List available tools
|
|
35
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
36
|
-
return {
|
|
37
|
-
tools: [
|
|
38
|
-
{
|
|
39
|
-
name: "create-user",
|
|
40
|
-
description: "Create a new user in a specific realm",
|
|
41
|
-
inputSchema: {
|
|
42
|
-
type: "object",
|
|
43
|
-
properties: {
|
|
44
|
-
realm: { type: "string" },
|
|
45
|
-
username: { type: "string" },
|
|
46
|
-
email: { type: "string", format: "email" },
|
|
47
|
-
firstName: { type: "string" },
|
|
48
|
-
lastName: { type: "string" }
|
|
49
|
-
},
|
|
50
|
-
required: ["realm", "username", "email", "firstName", "lastName"]
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
name: "delete-user",
|
|
55
|
-
description: "Delete a user from a specific realm",
|
|
56
|
-
inputSchema: {
|
|
57
|
-
type: "object",
|
|
58
|
-
properties: {
|
|
59
|
-
realm: { type: "string" },
|
|
60
|
-
userId: { type: "string" }
|
|
61
|
-
},
|
|
62
|
-
required: ["realm", "userId"]
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: "list-realms",
|
|
67
|
-
description: "List all available realms",
|
|
68
|
-
inputSchema: {
|
|
69
|
-
type: "object",
|
|
70
|
-
properties: {},
|
|
71
|
-
required: []
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
name: "list-users",
|
|
76
|
-
description: "List users in a specific realm",
|
|
77
|
-
inputSchema: {
|
|
78
|
-
type: "object",
|
|
79
|
-
properties: {
|
|
80
|
-
realm: { type: "string" }
|
|
81
|
-
},
|
|
82
|
-
required: ["realm"]
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
]
|
|
86
|
-
};
|
|
87
|
-
});
|
|
88
|
-
// Handle tool calls
|
|
89
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
90
|
-
// Authenticate before each request
|
|
91
|
-
await kcAdminClient.auth({
|
|
92
|
-
username: process.env.KEYCLOAK_ADMIN || 'admin',
|
|
93
|
-
password: process.env.KEYCLOAK_ADMIN_PASSWORD || 'admin',
|
|
94
|
-
grantType: 'password',
|
|
95
|
-
clientId: 'admin-cli',
|
|
96
|
-
});
|
|
97
|
-
const { name, arguments: args } = request.params;
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { ConfigError, collectSecrets, loadConfig } from "./config.js";
|
|
4
|
+
import { KeycloakAdminGateway } from "./keycloak/gateway.js";
|
|
5
|
+
import { createLogger } from "./logging.js";
|
|
6
|
+
import { createServer } from "./server.js";
|
|
7
|
+
import { VERSION } from "./version.js";
|
|
8
|
+
function main() {
|
|
9
|
+
let config;
|
|
98
10
|
try {
|
|
99
|
-
|
|
100
|
-
case "create-user": {
|
|
101
|
-
const { realm, username, email, firstName, lastName } = CreateUserSchema.parse(args);
|
|
102
|
-
kcAdminClient.setConfig({
|
|
103
|
-
realmName: realm
|
|
104
|
-
});
|
|
105
|
-
const user = await kcAdminClient.users.create({
|
|
106
|
-
realm,
|
|
107
|
-
username,
|
|
108
|
-
email,
|
|
109
|
-
firstName,
|
|
110
|
-
lastName,
|
|
111
|
-
enabled: true
|
|
112
|
-
});
|
|
113
|
-
return {
|
|
114
|
-
content: [{
|
|
115
|
-
type: "text",
|
|
116
|
-
text: `User created successfully. User ID: ${user.id}`
|
|
117
|
-
}]
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
case "delete-user": {
|
|
121
|
-
const { realm, userId } = DeleteUserSchema.parse(args);
|
|
122
|
-
kcAdminClient.setConfig({
|
|
123
|
-
realmName: realm
|
|
124
|
-
});
|
|
125
|
-
await kcAdminClient.users.del({
|
|
126
|
-
id: userId,
|
|
127
|
-
realm
|
|
128
|
-
});
|
|
129
|
-
return {
|
|
130
|
-
content: [{
|
|
131
|
-
type: "text",
|
|
132
|
-
text: `User ${userId} deleted successfully from realm ${realm}`
|
|
133
|
-
}]
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
case "list-realms": {
|
|
137
|
-
const realms = await kcAdminClient.realms.find();
|
|
138
|
-
return {
|
|
139
|
-
content: [{
|
|
140
|
-
type: "text",
|
|
141
|
-
text: `Available realms:\n${realms.map(r => `- ${r.realm}`).join('\n')}`
|
|
142
|
-
}]
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
case "list-users": {
|
|
146
|
-
const { realm } = ListUsersSchema.parse(args);
|
|
147
|
-
kcAdminClient.setConfig({
|
|
148
|
-
realmName: realm
|
|
149
|
-
});
|
|
150
|
-
const users = await kcAdminClient.users.find();
|
|
151
|
-
return {
|
|
152
|
-
content: [{
|
|
153
|
-
type: "text",
|
|
154
|
-
text: `Users in realm ${realm}:\n${users.map(u => `- ${u.username} (${u.id})`).join('\n')}`
|
|
155
|
-
}]
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
default:
|
|
159
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
160
|
-
}
|
|
11
|
+
config = loadConfig();
|
|
161
12
|
}
|
|
162
13
|
catch (error) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
isError: true,
|
|
166
|
-
content: [{
|
|
167
|
-
type: "text",
|
|
168
|
-
text: `Invalid arguments: ${error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(", ")}`
|
|
169
|
-
}]
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
throw error;
|
|
14
|
+
process.stderr.write(`${error instanceof ConfigError ? error.message : "Failed to load configuration."}\n`);
|
|
15
|
+
process.exit(1);
|
|
173
16
|
}
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
17
|
+
const logger = createLogger({ level: config.logLevel, secrets: collectSecrets(config) });
|
|
18
|
+
for (const warning of config.warnings)
|
|
19
|
+
logger.warn(warning);
|
|
20
|
+
// Login happens lazily on the first tool call, so startup needs no reachable Keycloak.
|
|
21
|
+
const gateway = new KeycloakAdminGateway(config, { logger });
|
|
22
|
+
const handle = serveStdio(() => createServer({ config, gateway, logger }), {
|
|
23
|
+
onerror: (error) => logger.error("Transport error", { error: error.message }),
|
|
24
|
+
});
|
|
25
|
+
logger.info("keycloak-model-context-protocol started", {
|
|
26
|
+
version: VERSION,
|
|
27
|
+
url: config.url,
|
|
28
|
+
authRealm: config.authRealm,
|
|
29
|
+
grantType: config.credentials.grantType,
|
|
30
|
+
readOnly: config.readOnly,
|
|
31
|
+
toolsets: [...config.toolsets],
|
|
32
|
+
});
|
|
33
|
+
const shutdown = () => {
|
|
34
|
+
handle.close().finally(() => process.exit(0));
|
|
35
|
+
};
|
|
36
|
+
process.on("SIGINT", shutdown);
|
|
37
|
+
process.on("SIGTERM", shutdown);
|
|
38
|
+
}
|
|
39
|
+
main();
|
|
40
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,SAAS,IAAI;IACX,IAAI,MAAqC,CAAC;IAC1C,IAAI,CAAC;QACH,MAAM,GAAG,UAAU,EAAE,CAAC;IACxB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,GAAG,KAAK,YAAY,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,+BAA+B,IAAI,CACtF,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,MAAM,GAAG,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACzF,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,QAAQ;QAAE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAE5D,uFAAuF;IACvF,MAAM,OAAO,GAAG,IAAI,oBAAoB,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7D,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE;QACzE,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,iBAAiB,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC;KAC9E,CAAC,CAAC;IACH,MAAM,CAAC,IAAI,CAAC,yCAAyC,EAAE;QACrD,OAAO,EAAE,OAAO;QAChB,GAAG,EAAE,MAAM,CAAC,GAAG;QACf,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,SAAS,EAAE,MAAM,CAAC,WAAW,CAAC,SAAS;QACvC,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC;KAC/B,CAAC,CAAC;IAEH,MAAM,QAAQ,GAAG,GAAS,EAAE;QAC1B,MAAM,CAAC,KAAK,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IAChD,CAAC,CAAC;IACF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { NetworkError } from "@keycloak/keycloak-admin-client";
|
|
2
|
+
/** An error whose message is safe and useful to show to the model/user. */
|
|
3
|
+
export class ToolError extends Error {
|
|
4
|
+
kind;
|
|
5
|
+
status;
|
|
6
|
+
constructor(kind, message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ToolError";
|
|
9
|
+
this.kind = kind;
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function detailFrom(data) {
|
|
14
|
+
if (typeof data === "string" && data.trim() !== "")
|
|
15
|
+
return data.trim().slice(0, 300);
|
|
16
|
+
if (data && typeof data === "object") {
|
|
17
|
+
const record = data;
|
|
18
|
+
for (const key of ["errorMessage", "error_description", "error", "message"]) {
|
|
19
|
+
const value = record[key];
|
|
20
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
21
|
+
return value.trim().slice(0, 300);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function withDetail(message, detail) {
|
|
27
|
+
return detail ? `${message} Keycloak says: ${detail}` : message;
|
|
28
|
+
}
|
|
29
|
+
export function httpStatusOf(error) {
|
|
30
|
+
return error instanceof NetworkError ? error.response.status : undefined;
|
|
31
|
+
}
|
|
32
|
+
function isTimeout(error) {
|
|
33
|
+
return (error instanceof Error &&
|
|
34
|
+
(error.name === "TimeoutError" ||
|
|
35
|
+
error.name === "AbortError" ||
|
|
36
|
+
(error.cause instanceof Error &&
|
|
37
|
+
(error.cause.name === "TimeoutError" || error.cause.name === "AbortError"))));
|
|
38
|
+
}
|
|
39
|
+
function mapHttp(error, ctx) {
|
|
40
|
+
const status = error.response.status;
|
|
41
|
+
const detail = detailFrom(error.responseData);
|
|
42
|
+
if (ctx.phase === "login") {
|
|
43
|
+
const realm = ctx.authRealm ?? "master";
|
|
44
|
+
if (status === 400 || status === 401) {
|
|
45
|
+
return new ToolError("auth", withDetail(`Authentication against Keycloak realm '${realm}' failed (HTTP ${status}). Check KEYCLOAK_CLIENT_ID/KEYCLOAK_CLIENT_SECRET or KEYCLOAK_USERNAME/KEYCLOAK_PASSWORD and KEYCLOAK_AUTH_REALM.`, detail), status);
|
|
46
|
+
}
|
|
47
|
+
if (status === 404) {
|
|
48
|
+
return new ToolError("auth", `Authentication realm '${realm}' or the token endpoint was not found (HTTP 404). Check KEYCLOAK_URL and KEYCLOAK_AUTH_REALM.`, status);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 401:
|
|
53
|
+
return new ToolError("auth", "Keycloak rejected the access token (HTTP 401). The credentials may be invalid or the token expired.", status);
|
|
54
|
+
case 403:
|
|
55
|
+
return new ToolError("forbidden", withDetail("Keycloak denied the operation (HTTP 403). The configured account lacks the required admin permissions (e.g. realm-management roles such as view-users, manage-users, view-realm) in the target realm.", detail), status);
|
|
56
|
+
case 404:
|
|
57
|
+
return new ToolError("not_found", withDetail("Not found (HTTP 404). The realm or the requested resource does not exist.", detail), status);
|
|
58
|
+
case 409:
|
|
59
|
+
return new ToolError("conflict", withDetail("Conflict (HTTP 409). The resource already exists or is in conflict.", detail), status);
|
|
60
|
+
case 400:
|
|
61
|
+
case 422:
|
|
62
|
+
return new ToolError("bad_request", withDetail(`Keycloak rejected the request as invalid (HTTP ${status}).`, detail), status);
|
|
63
|
+
default:
|
|
64
|
+
if (status >= 500) {
|
|
65
|
+
return new ToolError("server", withDetail(`Keycloak reported a server error (HTTP ${status}).`, detail), status);
|
|
66
|
+
}
|
|
67
|
+
return new ToolError("unexpected", withDetail(`Unexpected response from Keycloak (HTTP ${status}).`, detail), status);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Idempotent: ToolErrors pass through unchanged. Never leaks stack traces or secrets. */
|
|
71
|
+
export function mapKeycloakError(error, ctx = {}) {
|
|
72
|
+
if (error instanceof ToolError)
|
|
73
|
+
return error;
|
|
74
|
+
if (error instanceof NetworkError)
|
|
75
|
+
return mapHttp(error, ctx);
|
|
76
|
+
if (isTimeout(error)) {
|
|
77
|
+
return new ToolError("timeout", `The request to Keycloak timed out${ctx.timeoutMs ? ` after ${ctx.timeoutMs} ms` : ""}. Increase KEYCLOAK_MCP_TIMEOUT_MS or check the server.`);
|
|
78
|
+
}
|
|
79
|
+
if (error instanceof TypeError ||
|
|
80
|
+
(error instanceof Error &&
|
|
81
|
+
/fetch failed|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|certificate/i.test(`${error.message} ${String(error.cause ?? "")}`))) {
|
|
82
|
+
return new ToolError("network", `Cannot reach Keycloak${ctx.baseUrl ? ` at ${ctx.baseUrl}` : ""}. Check KEYCLOAK_URL, network connectivity and TLS settings.`);
|
|
83
|
+
}
|
|
84
|
+
return new ToolError("unexpected", "An unexpected error occurred while talking to Keycloak.");
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/keycloak/errors.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import KcAdminClient from "@keycloak/keycloak-admin-client";
|
|
2
|
+
import { silentLogger } from "../logging.js";
|
|
3
|
+
import { httpStatusOf, mapKeycloakError } from "./errors.js";
|
|
4
|
+
const DEFAULT_SKEW_MS = 30_000;
|
|
5
|
+
const FALLBACK_LIFETIME_MS = 60_000;
|
|
6
|
+
function tokenExpiryMs(token) {
|
|
7
|
+
if (!token)
|
|
8
|
+
return undefined;
|
|
9
|
+
const payload = token.split(".")[1];
|
|
10
|
+
if (!payload)
|
|
11
|
+
return undefined;
|
|
12
|
+
try {
|
|
13
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
14
|
+
return typeof decoded.exp === "number" ? decoded.exp * 1000 : undefined;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Owns the admin client and the token lifecycle. The client's realm is the
|
|
22
|
+
* auth realm for its whole life; target realms travel only in request payloads
|
|
23
|
+
* (never via setConfig), so tokens can never be requested from a target realm.
|
|
24
|
+
*/
|
|
25
|
+
export class KeycloakAdminGateway {
|
|
26
|
+
kc;
|
|
27
|
+
config;
|
|
28
|
+
now;
|
|
29
|
+
logger;
|
|
30
|
+
skewMs;
|
|
31
|
+
expiresAtMs = 0;
|
|
32
|
+
inflight;
|
|
33
|
+
constructor(config, options = {}) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.now = options.now ?? Date.now;
|
|
36
|
+
this.logger = options.logger ?? silentLogger;
|
|
37
|
+
this.skewMs = options.refreshSkewMs ?? DEFAULT_SKEW_MS;
|
|
38
|
+
this.kc =
|
|
39
|
+
options.client ??
|
|
40
|
+
new KcAdminClient({
|
|
41
|
+
baseUrl: config.url,
|
|
42
|
+
realmName: config.authRealm,
|
|
43
|
+
timeout: config.timeoutMs,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
get errorContext() {
|
|
47
|
+
return {
|
|
48
|
+
baseUrl: this.config.url,
|
|
49
|
+
authRealm: this.config.authRealm,
|
|
50
|
+
timeoutMs: this.config.timeoutMs,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async run(fn) {
|
|
54
|
+
await this.ensureToken();
|
|
55
|
+
try {
|
|
56
|
+
return await fn(this.kc);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (httpStatusOf(error) !== 401)
|
|
60
|
+
throw mapKeycloakError(error, this.errorContext);
|
|
61
|
+
// Token revoked or expired server-side: re-authenticate once and retry.
|
|
62
|
+
this.logger.debug("Keycloak returned 401; re-authenticating once");
|
|
63
|
+
this.expiresAtMs = 0;
|
|
64
|
+
await this.ensureToken();
|
|
65
|
+
try {
|
|
66
|
+
return await fn(this.kc);
|
|
67
|
+
}
|
|
68
|
+
catch (retryError) {
|
|
69
|
+
throw mapKeycloakError(retryError, this.errorContext);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
tokenIsFresh() {
|
|
74
|
+
return this.kc.accessToken !== undefined && this.now() < this.expiresAtMs - this.skewMs;
|
|
75
|
+
}
|
|
76
|
+
/** Single-flight: concurrent callers share one login/refresh. */
|
|
77
|
+
async ensureToken() {
|
|
78
|
+
if (this.tokenIsFresh())
|
|
79
|
+
return;
|
|
80
|
+
this.inflight ??= this.authenticate().finally(() => {
|
|
81
|
+
this.inflight = undefined;
|
|
82
|
+
});
|
|
83
|
+
await this.inflight;
|
|
84
|
+
}
|
|
85
|
+
async authenticate() {
|
|
86
|
+
const { credentials } = this.config;
|
|
87
|
+
try {
|
|
88
|
+
if (this.kc.refreshToken && this.kc.accessToken) {
|
|
89
|
+
try {
|
|
90
|
+
await this.kc.auth({
|
|
91
|
+
grantType: "refresh_token",
|
|
92
|
+
clientId: credentials.clientId,
|
|
93
|
+
...(credentials.clientSecret !== undefined && {
|
|
94
|
+
clientSecret: credentials.clientSecret,
|
|
95
|
+
}),
|
|
96
|
+
refreshToken: this.kc.refreshToken,
|
|
97
|
+
});
|
|
98
|
+
this.recordExpiry();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.logger.debug("Token refresh failed; falling back to full login");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await this.kc.auth(credentials.grantType === "client_credentials"
|
|
106
|
+
? {
|
|
107
|
+
grantType: "client_credentials",
|
|
108
|
+
clientId: credentials.clientId,
|
|
109
|
+
clientSecret: credentials.clientSecret,
|
|
110
|
+
}
|
|
111
|
+
: {
|
|
112
|
+
grantType: "password",
|
|
113
|
+
clientId: credentials.clientId,
|
|
114
|
+
...(credentials.clientSecret !== undefined && {
|
|
115
|
+
clientSecret: credentials.clientSecret,
|
|
116
|
+
}),
|
|
117
|
+
username: credentials.username,
|
|
118
|
+
password: credentials.password,
|
|
119
|
+
});
|
|
120
|
+
this.recordExpiry();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
this.expiresAtMs = 0;
|
|
124
|
+
throw mapKeycloakError(error, { ...this.errorContext, phase: "login" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
recordExpiry() {
|
|
128
|
+
this.expiresAtMs = tokenExpiryMs(this.kc.accessToken) ?? this.now() + FALLBACK_LIFETIME_MS;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=gateway.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.js","sourceRoot":"","sources":["../../src/keycloak/gateway.ts"],"names":[],"mappings":"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"}
|
package/dist/logging.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
2
|
+
const SENSITIVE_KEY = /secret|password|token|authorization|credential/i;
|
|
3
|
+
const MASK = "***";
|
|
4
|
+
function maskSecrets(text, secrets) {
|
|
5
|
+
let result = text;
|
|
6
|
+
for (const secret of secrets) {
|
|
7
|
+
if (secret.length > 0)
|
|
8
|
+
result = result.split(secret).join(MASK);
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
function serializeFields(fields) {
|
|
13
|
+
if (!fields)
|
|
14
|
+
return "";
|
|
15
|
+
const safe = {};
|
|
16
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
17
|
+
safe[key] = SENSITIVE_KEY.test(key) ? "[REDACTED]" : value;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return ` ${JSON.stringify(safe)}`;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return " [unserializable fields]";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function createLogger(options) {
|
|
27
|
+
const threshold = LOG_LEVELS.indexOf(options.level);
|
|
28
|
+
const secrets = options.secrets ?? [];
|
|
29
|
+
const write = options.write ?? ((line) => process.stderr.write(`${line}\n`));
|
|
30
|
+
const emit = (level, message, fields) => {
|
|
31
|
+
if (LOG_LEVELS.indexOf(level) < threshold)
|
|
32
|
+
return;
|
|
33
|
+
const line = `${new Date().toISOString()} ${level.toUpperCase()} ${message}${serializeFields(fields)}`;
|
|
34
|
+
write(maskSecrets(line, secrets));
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
debug: (message, fields) => emit("debug", message, fields),
|
|
38
|
+
info: (message, fields) => emit("info", message, fields),
|
|
39
|
+
warn: (message, fields) => emit("warn", message, fields),
|
|
40
|
+
error: (message, fields) => emit("error", message, fields),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export const silentLogger = {
|
|
44
|
+
debug: () => { },
|
|
45
|
+
info: () => { },
|
|
46
|
+
warn: () => { },
|
|
47
|
+
error: () => { },
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=logging.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logging.js","sourceRoot":"","sources":["../src/logging.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DATA_NOTICE, guardRealm, missingToolHint, promptResult, q, realmArg } from "./shared.js";
|
|
3
|
+
export function registerAuditRealmSecurity(server, ctx) {
|
|
4
|
+
server.registerPrompt("audit_realm_security", {
|
|
5
|
+
title: "Audit realm security",
|
|
6
|
+
description: "Read-only security review of a realm (policies, admin roles, clients, events) that produces a prioritized list of findings.",
|
|
7
|
+
argsSchema: z.object({ realm: realmArg(ctx) }),
|
|
8
|
+
}, ({ realm }) => {
|
|
9
|
+
guardRealm(ctx, realm, false);
|
|
10
|
+
const text = `Perform a read-only security audit of the Keycloak realm ${q(realm)}. Do not change anything.
|
|
11
|
+
|
|
12
|
+
${DATA_NOTICE}
|
|
13
|
+
${missingToolHint("any tool named below", "core, roles, clients, events")} Report which checks you could not perform because of this.
|
|
14
|
+
|
|
15
|
+
Always pass realm ${q(realm)} explicitly. Collect evidence with:
|
|
16
|
+
|
|
17
|
+
1. realm_get: sslRequired (should be 'all' or at least 'external'), bruteForceProtected, passwordPolicy (length, complexity, history), registrationAllowed, verifyEmail, token and session lifespans (long access-token or SSO lifespans), eventsEnabled and adminEventsEnabled.
|
|
18
|
+
2. role_list and user_roles_list: who holds administrative or realm-management roles; flag many or unexpected admins and disabled-but-privileged accounts.
|
|
19
|
+
3. client_list (page through all results): public clients, clients with directAccessGrantsEnabled, bearer-only or service-account clients with broad access, suspicious redirect or wildcard configuration (use client_get for details of risky ones). Never request or reveal client secrets.
|
|
20
|
+
4. event_list and admin_event_list: recent LOGIN_ERROR spikes and unusual admin changes (if events are disabled, that itself is a finding).
|
|
21
|
+
|
|
22
|
+
Present the result as a prioritized findings list: for each finding give severity (critical, high, medium, low, info), evidence (tool and values), risk, and a concrete recommendation. End with a short summary of what is in good shape. Suggest fixes but do not apply them.`;
|
|
23
|
+
return promptResult(`Security audit of realm ${realm}`, text);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=audit-realm-security.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-realm-security.js","sourceRoot":"","sources":["../../src/prompts/audit-realm-security.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAElG,MAAM,UAAU,0BAA0B,CAAC,MAAiB,EAAE,GAAgB;IAC5E,MAAM,CAAC,cAAc,CACnB,sBAAsB,EACtB;QACE,KAAK,EAAE,sBAAsB;QAC7B,WAAW,EACT,6HAA6H;QAC/H,UAAU,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;KAC/C,EACD,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE;QACZ,UAAU,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;QAC9B,MAAM,IAAI,GAAG,4DAA4D,CAAC,CAAC,KAAK,CAAC;;EAErF,WAAW;EACX,eAAe,CAAC,sBAAsB,EAAE,8BAA8B,CAAC;;oBAErD,CAAC,CAAC,KAAK,CAAC;;;;;;;gRAOoP,CAAC;QAC3Q,OAAO,YAAY,CAAC,2BAA2B,KAAK,EAAE,EAAE,IAAI,CAAC,CAAC;IAChE,CAAC,CACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { registerAuditRealmSecurity } from "./audit-realm-security.js";
|
|
2
|
+
import { registerInvestigateFailedLogins } from "./investigate-failed-logins.js";
|
|
3
|
+
import { registerOffboardEmployee } from "./offboard-employee.js";
|
|
4
|
+
import { registerOnboardEmployee } from "./onboard-employee.js";
|
|
5
|
+
/** Registers MCP prompts (guided multi-step workflows built on the existing tools). */
|
|
6
|
+
export function registerPrompts(server, ctx) {
|
|
7
|
+
registerOnboardEmployee(server, ctx);
|
|
8
|
+
registerOffboardEmployee(server, ctx);
|
|
9
|
+
registerAuditRealmSecurity(server, ctx);
|
|
10
|
+
registerInvestigateFailedLogins(server, ctx);
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/prompts/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,0BAA0B,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAE,+BAA+B,EAAE,MAAM,gCAAgC,CAAC;AACjF,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,uFAAuF;AACvF,MAAM,UAAU,eAAe,CAAC,MAAiB,EAAE,GAAgB;IACjE,uBAAuB,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACrC,wBAAwB,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACtC,0BAA0B,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACxC,+BAA+B,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAC/C,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DATA_NOTICE, guardRealm, missingToolHint, promptResult, q, realmArg } from "./shared.js";
|
|
3
|
+
export const DEFAULT_DAYS = 7;
|
|
4
|
+
export function registerInvestigateFailedLogins(server, ctx) {
|
|
5
|
+
server.registerPrompt("investigate_failed_logins", {
|
|
6
|
+
title: "Investigate failed logins",
|
|
7
|
+
description: "Analyze failed login events in a realm (optionally for one user), check lockout status and summarize patterns without changing anything.",
|
|
8
|
+
argsSchema: z.object({
|
|
9
|
+
realm: realmArg(ctx),
|
|
10
|
+
username: z.string().optional().describe("Limit the analysis to this username."),
|
|
11
|
+
days: z.string().optional().describe(`Look-back window in days (default ${DEFAULT_DAYS}).`),
|
|
12
|
+
}),
|
|
13
|
+
}, ({ realm, username, days }) => {
|
|
14
|
+
guardRealm(ctx, realm, false);
|
|
15
|
+
const parsed = Number.parseInt(days ?? "", 10);
|
|
16
|
+
const window = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 365) : DEFAULT_DAYS;
|
|
17
|
+
const text = `Investigate failed logins in the Keycloak realm ${q(realm)} over the last ${window} day(s)${username ? ` for the user ${q(username)}` : ""}.
|
|
18
|
+
|
|
19
|
+
${DATA_NOTICE}
|
|
20
|
+
${missingToolHint("event_list or user_brute_force_status", "events, security")} Also note that login events must be enabled in the realm (check realm_get eventsEnabled).
|
|
21
|
+
|
|
22
|
+
Always pass realm ${q(realm)} explicitly. Steps:
|
|
23
|
+
|
|
24
|
+
1. Compute dateFrom as today minus ${window} day(s) (format yyyy-MM-dd).${username ? ` Resolve the user ID with user_search (exact username ${q(username)}).` : ""}
|
|
25
|
+
2. Call event_list with types ['LOGIN_ERROR'] (also 'CODE_TO_TOKEN_ERROR' and 'CLIENT_LOGIN_ERROR' if relevant), dateFrom${username ? ", and the user filter" : ""}. Page through results.
|
|
26
|
+
3. Summarize patterns: count by error reason, by username, by source IP address and by client; time clustering (bursts, off-hours); many users from one IP (spraying) versus many attempts on one user (brute force).
|
|
27
|
+
4. For the most affected users call user_brute_force_status and report whether they are currently locked out.
|
|
28
|
+
5. Conclude with likely causes (forgotten password, misconfigured client, attack) and recommendations.
|
|
29
|
+
|
|
30
|
+
This is an investigation only. Do NOT unlock users or change any setting automatically; if unlocking (user_brute_force_unlock) seems appropriate, propose it and wait for explicit confirmation from the human.`;
|
|
31
|
+
return promptResult(`Failed-login investigation for realm ${realm}`, text);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=investigate-failed-logins.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"investigate-failed-logins.js","sourceRoot":"","sources":["../../src/prompts/investigate-failed-logins.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAElG,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,CAAC;AAE9B,MAAM,UAAU,+BAA+B,CAAC,MAAiB,EAAE,GAAgB;IACjF,MAAM,CAAC,cAAc,CACnB,2BAA2B,EAC3B;QACE,KAAK,EAAE,2BAA2B;QAClC,WAAW,EACT,0IAA0I;QAC5I,UAAU,EAAE,CAAC,CAAC,MAAM,CAAC;YACnB,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC;YACpB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sCAAsC,CAAC;YAChF,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,YAAY,IAAI,CAAC;SAC5F,CAAC;KACH,EACD,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE;QAC5B,UAAU,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;QAC9B,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;QAC5F,MAAM,IAAI,GAAG,mDAAmD,CAAC,CAAC,KAAK,CAAC,kBAAkB,MAAM,UAAU,QAAQ,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE;;EAE5J,WAAW;EACX,eAAe,CAAC,uCAAuC,EAAE,kBAAkB,CAAC;;oBAE1D,CAAC,CAAC,KAAK,CAAC;;qCAES,MAAM,+BAA+B,QAAQ,CAAC,CAAC,CAAC,yDAAyD,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;2HACvC,QAAQ,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,EAAE;;;;;gNAK8C,CAAC;QAC3M,OAAO,YAAY,CAAC,wCAAwC,KAAK,EAAE,EAAE,IAAI,CAAC,CAAC;IAC7E,CAAC,CACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DATA_NOTICE, guardRealm, missingToolHint, promptResult, q, realmArg } from "./shared.js";
|
|
3
|
+
export function registerOffboardEmployee(server, ctx) {
|
|
4
|
+
server.registerPrompt("offboard_employee", {
|
|
5
|
+
title: "Offboard employee",
|
|
6
|
+
description: "Guided workflow to safely deactivate a departing employee: review the account, end sessions and disable it (without deleting).",
|
|
7
|
+
argsSchema: z.object({
|
|
8
|
+
realm: realmArg(ctx),
|
|
9
|
+
username: z.string().min(1).describe("Username of the departing employee."),
|
|
10
|
+
}),
|
|
11
|
+
}, ({ realm, username }) => {
|
|
12
|
+
guardRealm(ctx, realm, true);
|
|
13
|
+
const text = `You are offboarding the user ${q(username)} in the Keycloak realm ${q(realm)}.
|
|
14
|
+
|
|
15
|
+
${DATA_NOTICE}
|
|
16
|
+
${missingToolHint("a tool named below", "core, sessions (user_sessions_list, user_logout)")}
|
|
17
|
+
|
|
18
|
+
Always pass realm ${q(realm)} explicitly. Steps:
|
|
19
|
+
|
|
20
|
+
1. Find the user with user_search (exact username). If there is no match or more than one, stop and ask the user.
|
|
21
|
+
2. Call user_get with the ID and show the user the identity (username, name, e-mail, enabled state) so they can confirm it is the right person.
|
|
22
|
+
3. Call user_sessions_list to show active sessions and the clients involved.
|
|
23
|
+
4. Ask for explicit confirmation, then end all sessions with user_logout (this is destructive; it needs confirm: true only after the human approved).
|
|
24
|
+
5. Disable the account with user_update and enabled=false. Summarize before doing it.
|
|
25
|
+
6. Verify with user_get that enabled is false and report the result.
|
|
26
|
+
|
|
27
|
+
Do NOT delete the user (no user_delete) unless the human explicitly asks for deletion in this conversation; disabling preserves audit trails and allows rollback. Do not change group or role memberships unless asked.`;
|
|
28
|
+
return promptResult(`Offboard ${username} in realm ${realm}`, text);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=offboard-employee.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offboard-employee.js","sourceRoot":"","sources":["../../src/prompts/offboard-employee.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAElG,MAAM,UAAU,wBAAwB,CAAC,MAAiB,EAAE,GAAgB;IAC1E,MAAM,CAAC,cAAc,CACnB,mBAAmB,EACnB;QACE,KAAK,EAAE,mBAAmB;QAC1B,WAAW,EACT,gIAAgI;QAClI,UAAU,EAAE,CAAC,CAAC,MAAM,CAAC;YACnB,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC;YACpB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,qCAAqC,CAAC;SAC5E,CAAC;KACH,EACD,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE;QACtB,UAAU,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;QAC7B,MAAM,IAAI,GAAG,gCAAgC,CAAC,CAAC,QAAQ,CAAC,0BAA0B,CAAC,CAAC,KAAK,CAAC;;EAE9F,WAAW;EACX,eAAe,CAAC,oBAAoB,EAAE,kDAAkD,CAAC;;oBAEvE,CAAC,CAAC,KAAK,CAAC;;;;;;;;;wNAS4L,CAAC;QACnN,OAAO,YAAY,CAAC,YAAY,QAAQ,aAAa,KAAK,EAAE,EAAE,IAAI,CAAC,CAAC;IACtE,CAAC,CACF,CAAC;AACJ,CAAC"}
|