lagora-cli 1.1.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/README.md +138 -0
- package/dist/help.txt +70 -0
- package/dist/lagora.js +342 -0
- package/dist/report-help.txt +5 -0
- package/dist/scripts/agora_playground_harness.py +263 -0
- package/dist/scripts/announce.js +41 -0
- package/dist/scripts/check-kernel-submission.py +90 -0
- package/dist/scripts/chunk-2EAJVB5D.js +100 -0
- package/dist/scripts/chunk-2KTLCUFI.js +29 -0
- package/dist/scripts/chunk-AZ3EEBVD.js +137 -0
- package/dist/scripts/chunk-NBJMYAOA.js +2128 -0
- package/dist/scripts/chunk-NCJMUBTG.js +125 -0
- package/dist/scripts/chunk-QJPQHKIO.js +23 -0
- package/dist/scripts/chunk-RIR5KGHC.js +33 -0
- package/dist/scripts/chunk-TJZVQYBL.js +8 -0
- package/dist/scripts/chunk-UHJXD4TG.js +18 -0
- package/dist/scripts/chunk-UQ6I6VTY.js +117 -0
- package/dist/scripts/cli-auth.js +348 -0
- package/dist/scripts/cli-config-IA7EOSYD.js +7 -0
- package/dist/scripts/install-skill.js +199 -0
- package/dist/scripts/issue-local-client-DZUXZOKY.js +22 -0
- package/dist/scripts/issue-search.js +1823 -0
- package/dist/scripts/issue.js +386 -0
- package/dist/scripts/keycloak-provision.js +986 -0
- package/dist/scripts/legato-fsim-runner.py +126 -0
- package/dist/scripts/legato-lowering-runner.py +156 -0
- package/dist/scripts/legato_runner_annotations.py +235 -0
- package/dist/scripts/legato_runner_env.py +91 -0
- package/dist/scripts/legato_runner_launchers.py +287 -0
- package/dist/scripts/legato_runner_script_wrapper.py +193 -0
- package/dist/scripts/notifications-EU43SIEV.js +624 -0
- package/dist/scripts/playground.js +408 -0
- package/dist/scripts/report-bundle-sync-3U7QTP4Z.js +215 -0
- package/dist/scripts/report.js +104 -0
- package/dist/scripts/resolve-sdk-package-version.py +151 -0
- package/dist/scripts/sdk-runtime-JE6H2PB2.js +992 -0
- package/dist/scripts/sdk-runtime-kubernetes-job-KOWL4ITV.js +479 -0
- package/dist/scripts/sdk-runtime-smoke.py +168 -0
- package/dist/scripts/sdk.js +256 -0
- package/dist/scripts/site-feedback-CAPE5MPX.js +136 -0
- package/dist/scripts/site-feedback-rate-limit-5BU2WSFE.js +86 -0
- package/dist/scripts/site-feedback.js +117 -0
- package/dist/scripts/storage-234FBH54.js +67 -0
- package/dist/scripts/submit-issue.sh +489 -0
- package/dist/scripts/verification-3QCY66QW.js +772 -0
- package/dist/scripts/verify-issue.js +144 -0
- package/dist/skills/legato-agora-cli/SKILL.md +556 -0
- package/dist/skills/legato-agora-cli/agents/openai.yaml +7 -0
- package/dist/skills/legato-agora-cli/reference/kernel-with-golden.py +84 -0
- package/dist/skills/legato-site-feedback/SKILL.md +49 -0
- package/dist/skills/legato-site-feedback/agents/openai.yaml +7 -0
- package/package.json +16 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseVendorId
|
|
3
|
+
} from "./chunk-QJPQHKIO.js";
|
|
4
|
+
|
|
5
|
+
// scripts/keycloak-provision.ts
|
|
6
|
+
import { pathToFileURL as packageFileUrl } from "node:url";
|
|
7
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
// scripts/keycloak-provision-state.ts
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
// lib/server/managed-tenant-groups.ts
|
|
14
|
+
var managedTenantGroupDefinitions = [
|
|
15
|
+
{ path: "/tenants/admin", name: "admin", role: "admin" },
|
|
16
|
+
{ path: "/tenants/hyperaccel", name: "hyperaccel", role: "hyperaccel" },
|
|
17
|
+
{ path: "/tenants/dnotitia", name: "dnotitia", role: "vendor", vendorId: "dnotitia" },
|
|
18
|
+
{ path: "/tenants/nota", name: "nota", role: "vendor", vendorId: "nota" },
|
|
19
|
+
{ path: "/tenants/anybridge", name: "anybridge", role: "vendor", vendorId: "anybridge" }
|
|
20
|
+
];
|
|
21
|
+
var managedTenantGroupPaths = managedTenantGroupDefinitions.map((group) => group.path);
|
|
22
|
+
var allManagedTenantGroupPaths = ["/tenants", ...managedTenantGroupPaths];
|
|
23
|
+
|
|
24
|
+
// lib/server/tenant-principal.ts
|
|
25
|
+
var managedTenantGroups = new Set(managedTenantGroupPaths);
|
|
26
|
+
function parseTenantPrincipalClaims(claims) {
|
|
27
|
+
const role = parseTenantRole(claims.role);
|
|
28
|
+
if (!role) return void 0;
|
|
29
|
+
const subject = parseSubject(claims.sub);
|
|
30
|
+
if (!subject) return void 0;
|
|
31
|
+
const managedGroup = parseExactlyOneManagedGroup(claims.agora_groups);
|
|
32
|
+
if (!managedGroup) return void 0;
|
|
33
|
+
if (role === "admin" && managedGroup !== "/tenants/admin") return void 0;
|
|
34
|
+
if (role === "hyperaccel" && managedGroup !== "/tenants/hyperaccel") return void 0;
|
|
35
|
+
if (role === "vendor") {
|
|
36
|
+
const vendorId = parseVendorId(claims.vendor_id);
|
|
37
|
+
if (!vendorId || managedGroup !== `/tenants/${vendorId}`) return void 0;
|
|
38
|
+
return { role, vendorId, subject };
|
|
39
|
+
}
|
|
40
|
+
if (Object.hasOwn(claims, "vendor_id")) return void 0;
|
|
41
|
+
return { role, subject };
|
|
42
|
+
}
|
|
43
|
+
function parseExactlyOneManagedGroup(value) {
|
|
44
|
+
if (!Array.isArray(value)) return void 0;
|
|
45
|
+
const managed = value.filter((item) => typeof item === "string" && managedTenantGroups.has(item));
|
|
46
|
+
return managed.length === 1 ? managed[0] : void 0;
|
|
47
|
+
}
|
|
48
|
+
function parseTenantRole(value) {
|
|
49
|
+
if (value !== "admin" && value !== "hyperaccel" && value !== "vendor") return void 0;
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function parseSubject(value) {
|
|
53
|
+
if (typeof value !== "string") return void 0;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed || void 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// scripts/keycloak-provision-state.ts
|
|
59
|
+
var DEFAULT_KEYCLOAK_SERVER = "https://keycloak.hyperaccel.net";
|
|
60
|
+
var DEFAULT_KEYCLOAK_REALM = "hyperaccel-account-system";
|
|
61
|
+
var DEFAULT_APP_CLIENT_ID = "legato-dev-agora";
|
|
62
|
+
var DEFAULT_APP_BASE_URL = "https://legato-dev-agora.hyperaccel.net/legato-dev-agora";
|
|
63
|
+
var DEFAULT_CALLBACK_PATH = "/api/auth/keycloak/callback";
|
|
64
|
+
var DEFAULT_INTERNAL_ORIGIN = "https://legato-dev-agora.hyperaccel.net";
|
|
65
|
+
var DEFAULT_PUBLIC_CALLBACK = "https://public.hyperaccel.net/legato-dev-agora/oauth2/callback";
|
|
66
|
+
var DEFAULT_PUBLIC_ORIGIN = "https://public.hyperaccel.net";
|
|
67
|
+
var DEFAULT_MANAGED_GROUPS = allManagedTenantGroupPaths;
|
|
68
|
+
var MAX_PLAN_TTL_MS = 15 * 60 * 1e3;
|
|
69
|
+
var PLAN_CLOCK_SKEW_MS = 60 * 1e3;
|
|
70
|
+
var managedGroupDefinitions = [
|
|
71
|
+
{ path: "/tenants", name: "tenants", attributes: {} },
|
|
72
|
+
...managedTenantGroupDefinitions.map((group) => ({
|
|
73
|
+
path: group.path,
|
|
74
|
+
name: group.name,
|
|
75
|
+
role: group.role,
|
|
76
|
+
attributes: group.role === "vendor" ? { agora_role: [group.role], vendor_id: [group.vendorId] } : { agora_role: [group.role] }
|
|
77
|
+
}))
|
|
78
|
+
];
|
|
79
|
+
function desiredKeycloakState(input = {}) {
|
|
80
|
+
const serverUrl = trimTrailingSlash(input.serverUrl ?? DEFAULT_KEYCLOAK_SERVER);
|
|
81
|
+
const realm = input.realm ?? DEFAULT_KEYCLOAK_REALM;
|
|
82
|
+
const appClientId = input.appClientId ?? DEFAULT_APP_CLIENT_ID;
|
|
83
|
+
const appBaseUrl = trimTrailingSlash(input.appBaseUrl ?? DEFAULT_APP_BASE_URL);
|
|
84
|
+
const redirectUri = `${appBaseUrl}${DEFAULT_CALLBACK_PATH}`;
|
|
85
|
+
const postLogoutRedirectUri = `${appBaseUrl}/login`;
|
|
86
|
+
return {
|
|
87
|
+
serverUrl,
|
|
88
|
+
realm,
|
|
89
|
+
appClientId,
|
|
90
|
+
appBaseUrl,
|
|
91
|
+
redirectUri,
|
|
92
|
+
webOrigin: DEFAULT_INTERNAL_ORIGIN,
|
|
93
|
+
postLogoutRedirectUri,
|
|
94
|
+
client: {
|
|
95
|
+
clientId: appClientId,
|
|
96
|
+
enabled: true,
|
|
97
|
+
protocol: "openid-connect",
|
|
98
|
+
publicClient: false,
|
|
99
|
+
bearerOnly: false,
|
|
100
|
+
standardFlowEnabled: true,
|
|
101
|
+
serviceAccountsEnabled: false,
|
|
102
|
+
directAccessGrantsEnabled: false,
|
|
103
|
+
implicitFlowEnabled: false,
|
|
104
|
+
redirectUris: [redirectUri, DEFAULT_PUBLIC_CALLBACK],
|
|
105
|
+
webOrigins: [DEFAULT_INTERNAL_ORIGIN, DEFAULT_PUBLIC_ORIGIN],
|
|
106
|
+
attributes: {
|
|
107
|
+
"access.token.lifespan": "7200",
|
|
108
|
+
"client_credentials.use_refresh_token": "false",
|
|
109
|
+
"oauth2.device.authorization.grant.enabled": "false",
|
|
110
|
+
"oidc.ciba.grant.enabled": "false",
|
|
111
|
+
"post.logout.redirect.uris": postLogoutRedirectUri,
|
|
112
|
+
"pkce.code.challenge.method": "S256"
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
roles: ["admin", "hyperaccel", "vendor"],
|
|
116
|
+
groups: managedGroupDefinitions,
|
|
117
|
+
protocolMappers: [
|
|
118
|
+
{
|
|
119
|
+
name: "legato-dev-agora-audience",
|
|
120
|
+
protocol: "openid-connect",
|
|
121
|
+
protocolMapper: "oidc-audience-mapper",
|
|
122
|
+
config: {
|
|
123
|
+
"access.token.claim": "true",
|
|
124
|
+
"id.token.claim": "true",
|
|
125
|
+
"included.client.audience": appClientId
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "legato-dev-agora-role",
|
|
130
|
+
protocol: "openid-connect",
|
|
131
|
+
protocolMapper: "oidc-usermodel-attribute-mapper",
|
|
132
|
+
config: {
|
|
133
|
+
"access.token.claim": "true",
|
|
134
|
+
"aggregate.attrs": "true",
|
|
135
|
+
"claim.name": "role",
|
|
136
|
+
"id.token.claim": "true",
|
|
137
|
+
"jsonType.label": "String",
|
|
138
|
+
"multivalued": "false",
|
|
139
|
+
"user.attribute": "agora_role",
|
|
140
|
+
"userinfo.token.claim": "true"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "legato-dev-agora-vendor-id",
|
|
145
|
+
protocol: "openid-connect",
|
|
146
|
+
protocolMapper: "oidc-usermodel-attribute-mapper",
|
|
147
|
+
config: {
|
|
148
|
+
"access.token.claim": "true",
|
|
149
|
+
"aggregate.attrs": "true",
|
|
150
|
+
"claim.name": "vendor_id",
|
|
151
|
+
"id.token.claim": "true",
|
|
152
|
+
"jsonType.label": "String",
|
|
153
|
+
"multivalued": "false",
|
|
154
|
+
"user.attribute": "vendor_id",
|
|
155
|
+
"userinfo.token.claim": "true"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "legato-dev-agora-group-paths",
|
|
160
|
+
protocol: "openid-connect",
|
|
161
|
+
protocolMapper: "oidc-group-membership-mapper",
|
|
162
|
+
config: {
|
|
163
|
+
"access.token.claim": "true",
|
|
164
|
+
"claim.name": "agora_groups",
|
|
165
|
+
"full.path": "true",
|
|
166
|
+
"id.token.claim": "true",
|
|
167
|
+
"multivalued": "true",
|
|
168
|
+
"userinfo.token.claim": "true"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function createProvisionPlan(state, desired = desiredKeycloakState(), options) {
|
|
175
|
+
const operations = [];
|
|
176
|
+
const groupAttributeUpdates = [];
|
|
177
|
+
const groupRoleMappings = [];
|
|
178
|
+
const mapperOperations = [];
|
|
179
|
+
const issues = [];
|
|
180
|
+
const client = findClient(state, desired.appClientId);
|
|
181
|
+
if (!client) {
|
|
182
|
+
operations.push({ kind: "create-client", target: desired.appClientId, description: `Create confidential OIDC client ${desired.appClientId}`, destructive: false, payload: desired.client });
|
|
183
|
+
} else {
|
|
184
|
+
const updatePayload = clientUpdatePayload(client, desired.client);
|
|
185
|
+
if (updatePayload) {
|
|
186
|
+
operations.push({ kind: "update-client", target: desired.appClientId, description: `Update managed settings for client ${desired.appClientId}`, destructive: false, payload: updatePayload });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const clientKey = client?.id ?? client?.clientId ?? desired.appClientId;
|
|
190
|
+
for (const role of desired.roles) {
|
|
191
|
+
if (!state.clientRoles[clientKey]?.some((item) => item.name === role)) {
|
|
192
|
+
operations.push({ kind: "create-role", target: role, description: `Create app client role ${role}`, destructive: false, payload: { name: role } });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const group of desired.groups) {
|
|
196
|
+
const actualGroup = findGroupByPath(state, group.path);
|
|
197
|
+
if (!actualGroup) {
|
|
198
|
+
operations.push({ kind: "create-group", target: group.path, description: `Create managed group ${group.path}`, destructive: false, payload: group });
|
|
199
|
+
} else {
|
|
200
|
+
const groupIssue = validateManagedGroupAttributes(actualGroup, group);
|
|
201
|
+
if (groupIssue) issues.push(groupIssue);
|
|
202
|
+
if (!managedAttributesEqual(normalizeAttributes(actualGroup.attributes), group.attributes)) {
|
|
203
|
+
groupAttributeUpdates.push({ kind: "update-group-attributes", target: group.path, description: `Update managed group attributes for ${group.path}`, destructive: false, payload: { attributes: group.attributes } });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!group.role) continue;
|
|
207
|
+
const mappings = actualGroup ? state.groupClientRoleMappings[actualGroup.id] ?? state.groupClientRoleMappings[group.path] ?? [] : [];
|
|
208
|
+
const managedMappedRoles = mappings.filter((mapping) => desired.roles.includes(mapping.name)).toSorted(compareByNameThenId);
|
|
209
|
+
if (managedMappedRoles.some((mapping) => mapping.name !== group.role)) {
|
|
210
|
+
issues.push({ severity: "error", code: "conflicting-managed-role-mapping", message: `${group.path} has extra managed app-role mapping(s): ${managedMappedRoles.map((mapping) => mapping.name).join(", ")}` });
|
|
211
|
+
}
|
|
212
|
+
if (!mappings.some((mapping) => mapping.name === group.role)) {
|
|
213
|
+
groupRoleMappings.push({ kind: "create-group-role-mapping", target: `${group.path}:${group.role}`, description: `Map ${group.path} to app role ${group.role}`, destructive: false, payload: { groupPath: group.path, role: group.role } });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
operations.push(...groupAttributeUpdates, ...groupRoleMappings);
|
|
217
|
+
for (const conflict of findManagedMembershipConflicts(state, desired.groups)) {
|
|
218
|
+
issues.push({ severity: "error", code: "conflicting-managed-group-membership", message: `Managed Agora group membership is not exclusive for member fingerprint ${conflict.fingerprint} across ${conflict.groupPaths.join(", ")}` });
|
|
219
|
+
}
|
|
220
|
+
const mappers = state.protocolMappers[clientKey] ?? state.protocolMappers[desired.appClientId] ?? [];
|
|
221
|
+
for (const mapper of desired.protocolMappers) {
|
|
222
|
+
const actual = mappers.find((item) => item.name === mapper.name);
|
|
223
|
+
if (!actual) {
|
|
224
|
+
mapperOperations.push({ kind: "create-protocol-mapper", target: mapper.name, description: `Create protocol mapper ${mapper.name}`, destructive: false, payload: mapper });
|
|
225
|
+
} else if (!protocolMapperEqual(actual, mapper)) {
|
|
226
|
+
mapperOperations.push({ kind: "update-protocol-mapper", target: mapper.name, description: `Update protocol mapper ${mapper.name}`, destructive: false, payload: protocolMapperUpdatePayload(actual, mapper) });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
operations.push(...mapperOperations);
|
|
230
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
231
|
+
const ttlMs = Math.min(options.ttlMs ?? MAX_PLAN_TTL_MS, MAX_PLAN_TTL_MS);
|
|
232
|
+
const expiresAt = new Date(now.getTime() + ttlMs);
|
|
233
|
+
const stateHash = hashRealmState(state);
|
|
234
|
+
const desiredSummary = desiredSummaryFrom(desired);
|
|
235
|
+
const handoff = { ...desiredSummary, appClientSecretHandoff: "Vault -> Kubernetes Secret/legato-dev-agora-oidc for direct app OIDC; inactive oauth2-proxy may use oauth2-proxy-legato-dev-agora later" };
|
|
236
|
+
const planBody = { desired: desiredSummary, stateHash, operations, issues, handoff, mode: options.mode, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() };
|
|
237
|
+
const id = `kcplan-${hashJson(planBody).slice(0, 16)}`;
|
|
238
|
+
return redactSecrets({
|
|
239
|
+
schemaVersion: 1,
|
|
240
|
+
id,
|
|
241
|
+
createdAt: now.toISOString(),
|
|
242
|
+
expiresAt: expiresAt.toISOString(),
|
|
243
|
+
mode: options.mode,
|
|
244
|
+
desired: desiredSummary,
|
|
245
|
+
stateHash,
|
|
246
|
+
operations,
|
|
247
|
+
issues,
|
|
248
|
+
handoff
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function assertPlanApplicable(plan, currentState, approval, now = /* @__PURE__ */ new Date()) {
|
|
252
|
+
assertSerializedPlanIntegrity(plan);
|
|
253
|
+
if (approval !== plan.id) throw new Error(`Approval mismatch: expected --approve ${plan.id}`);
|
|
254
|
+
if (Date.parse(plan.expiresAt) <= now.getTime()) throw new Error(`Plan ${plan.id} is stale; create a fresh plan`);
|
|
255
|
+
if (plan.issues.some((issue) => issue.severity === "error")) throw new Error(`Plan ${plan.id} contains blocking issues`);
|
|
256
|
+
const currentHash = hashRealmState(currentState);
|
|
257
|
+
if (currentHash !== plan.stateHash) throw new Error(`Plan ${plan.id} is stale; live state changed`);
|
|
258
|
+
for (const operation of plan.operations) {
|
|
259
|
+
if (operation.destructive !== false) throw new Error(`Plan ${plan.id} contains a destructive operation and is not supported`);
|
|
260
|
+
if (!isOperationKind(operation.kind)) throw new Error(`Plan ${plan.id} contains unsupported operation kind`);
|
|
261
|
+
}
|
|
262
|
+
const createdAt = new Date(plan.createdAt);
|
|
263
|
+
const ttlMs = Date.parse(plan.expiresAt) - createdAt.getTime();
|
|
264
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_PLAN_TTL_MS) throw new Error(`Plan ${plan.id} has invalid timestamps`);
|
|
265
|
+
if (createdAt.getTime() - now.getTime() > PLAN_CLOCK_SKEW_MS) throw new Error(`Plan ${plan.id} was created in the future`);
|
|
266
|
+
const expected = createProvisionPlan(currentState, desiredKeycloakState({ serverUrl: plan.desired.serverUrl, realm: plan.desired.realm, appClientId: plan.desired.clientId }), { mode: "live", now: createdAt, ttlMs });
|
|
267
|
+
if (expected.id !== plan.id || !canonicalEqual(planComparable(expected), planComparable(plan))) {
|
|
268
|
+
throw new Error(`Plan ${plan.id} contents do not match regenerated live plan`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function verifyClaimFixtures(fixtures) {
|
|
272
|
+
if (!fixtures || typeof fixtures !== "object" || Array.isArray(fixtures)) {
|
|
273
|
+
return [{ severity: "error", code: "claims-fixture-shape", message: "Claims fixture must be an object keyed by case name" }];
|
|
274
|
+
}
|
|
275
|
+
const issues = [];
|
|
276
|
+
for (const [name, value] of Object.entries(fixtures)) {
|
|
277
|
+
const expected = typeof value === "object" && value && !Array.isArray(value) ? Reflect.get(value, "expected") : void 0;
|
|
278
|
+
const claims = typeof value === "object" && value && !Array.isArray(value) ? Reflect.get(value, "claims") : void 0;
|
|
279
|
+
const parsed = parseTenantPrincipalClaims(claims ?? value);
|
|
280
|
+
if (expected === "fail") {
|
|
281
|
+
if (parsed) issues.push({ severity: "error", code: "claim-expected-fail", message: `${name} unexpectedly parsed` });
|
|
282
|
+
} else if (!parsed) {
|
|
283
|
+
issues.push({ severity: "error", code: "claim-expected-pass", message: `${name} did not parse` });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return issues;
|
|
287
|
+
}
|
|
288
|
+
function redactSecrets(value) {
|
|
289
|
+
if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
|
|
290
|
+
if (!value || typeof value !== "object") return value;
|
|
291
|
+
const output = {};
|
|
292
|
+
for (const [key, item] of Object.entries(value)) {
|
|
293
|
+
if (isSecretKey(key)) {
|
|
294
|
+
output[key] = "[REDACTED]";
|
|
295
|
+
} else {
|
|
296
|
+
output[key] = redactSecrets(item);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return output;
|
|
300
|
+
}
|
|
301
|
+
function planHasBlockingIssues(plan) {
|
|
302
|
+
return plan.issues.some((issue) => issue.severity === "error");
|
|
303
|
+
}
|
|
304
|
+
function findClient(state, clientId) {
|
|
305
|
+
return state.clients.find((client) => client.clientId === clientId);
|
|
306
|
+
}
|
|
307
|
+
function findGroupByPath(state, path2) {
|
|
308
|
+
return state.groups.find((group) => group.path === path2);
|
|
309
|
+
}
|
|
310
|
+
function emptyRealmState() {
|
|
311
|
+
return { clients: [], clientRoles: {}, groups: [], groupClientRoleMappings: {}, protocolMappers: {} };
|
|
312
|
+
}
|
|
313
|
+
function hashRealmState(state) {
|
|
314
|
+
return hashJson(canonicalize(canonicalRealmState(redactSecrets(state))));
|
|
315
|
+
}
|
|
316
|
+
function clientUpdatePayload(actual, desired) {
|
|
317
|
+
const desiredAttrs = desired.attributes;
|
|
318
|
+
const actualAttrs = actual.attributes ?? {};
|
|
319
|
+
const mergedAttrs = { ...actual.attributes ?? {}, ...desiredAttrs };
|
|
320
|
+
const candidate = { ...desired, id: actual.id, attributes: mergedAttrs };
|
|
321
|
+
return clientManagedEqual(actual, desired, actualAttrs) ? void 0 : candidate;
|
|
322
|
+
}
|
|
323
|
+
function clientManagedEqual(actual, desired, actualAttrs) {
|
|
324
|
+
return actual.enabled === desired.enabled && actual.protocol === desired.protocol && actual.publicClient === desired.publicClient && actual.bearerOnly === desired.bearerOnly && actual.standardFlowEnabled === desired.standardFlowEnabled && actual.serviceAccountsEnabled === desired.serviceAccountsEnabled && actual.directAccessGrantsEnabled === desired.directAccessGrantsEnabled && actual.implicitFlowEnabled === desired.implicitFlowEnabled && arraysEqual(actual.redirectUris, desired.redirectUris) && arraysEqual(actual.webOrigins, desired.webOrigins) && Object.entries(desired.attributes).every(([key, value]) => actualAttrs[key] === value);
|
|
325
|
+
}
|
|
326
|
+
function validateManagedGroupAttributes(actual, desired) {
|
|
327
|
+
const attributes = normalizeAttributes(actual.attributes);
|
|
328
|
+
const role = attributes.agora_role ?? [];
|
|
329
|
+
const vendor = attributes.vendor_id ?? [];
|
|
330
|
+
const expectedRole = desired.attributes.agora_role ?? [];
|
|
331
|
+
if (!arraysEqual(role, expectedRole)) {
|
|
332
|
+
return { severity: "error", code: "malformed-managed-group-attributes", message: `${desired.path} must have agora_role=${expectedRole.join(",")}` };
|
|
333
|
+
}
|
|
334
|
+
const expectedVendor = desired.attributes.vendor_id ?? [];
|
|
335
|
+
if (!arraysEqual(vendor, expectedVendor)) {
|
|
336
|
+
return { severity: "error", code: "malformed-managed-group-attributes", message: `${desired.path} has invalid vendor_id attribute` };
|
|
337
|
+
}
|
|
338
|
+
return void 0;
|
|
339
|
+
}
|
|
340
|
+
function normalizeAttributes(input) {
|
|
341
|
+
const output = {};
|
|
342
|
+
for (const [key, value] of Object.entries(input ?? {})) {
|
|
343
|
+
output[key] = Array.isArray(value) ? value.map(String) : [String(value)];
|
|
344
|
+
}
|
|
345
|
+
return output;
|
|
346
|
+
}
|
|
347
|
+
function managedAttributesEqual(actual, desired) {
|
|
348
|
+
for (const key of ["agora_role", "vendor_id"]) {
|
|
349
|
+
if (!arraysEqual(actual[key] ?? [], desired[key] ?? [])) return false;
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
function protocolMapperEqual(actual, desired) {
|
|
354
|
+
return actual.protocol === desired.protocol && actual.protocolMapper === desired.protocolMapper && Object.entries(desired.config).every(([key, value]) => actual.config[key] === value);
|
|
355
|
+
}
|
|
356
|
+
function protocolMapperUpdatePayload(actual, desired) {
|
|
357
|
+
return { ...desired, id: actual.id, config: { ...actual.config, ...desired.config } };
|
|
358
|
+
}
|
|
359
|
+
function canonicalRealmState(state) {
|
|
360
|
+
return {
|
|
361
|
+
clients: state.clients.toSorted(compareClients),
|
|
362
|
+
clientRoles: sortRecordArrays(state.clientRoles, compareByNameThenId),
|
|
363
|
+
groups: state.groups.map((group) => ({ ...group, attributes: sortGroupAttributes(group.attributes), memberIds: group.memberIds?.toSorted() })).toSorted(compareGroups),
|
|
364
|
+
groupClientRoleMappings: sortRecordArrays(state.groupClientRoleMappings, compareByNameThenId),
|
|
365
|
+
protocolMappers: sortRecordArrays(state.protocolMappers, compareProtocolMappers)
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function sortRecordArrays(record, compare) {
|
|
369
|
+
return Object.fromEntries(Object.entries(record).toSorted(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, value.toSorted(compare)]));
|
|
370
|
+
}
|
|
371
|
+
function sortGroupAttributes(input) {
|
|
372
|
+
if (!input) return input;
|
|
373
|
+
return Object.fromEntries(Object.entries(input).toSorted(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, Array.isArray(value) ? value.map(String).toSorted() : String(value)]));
|
|
374
|
+
}
|
|
375
|
+
function compareClients(left, right) {
|
|
376
|
+
return compareStrings(left.clientId, right.clientId) || compareStrings(left.id ?? "", right.id ?? "");
|
|
377
|
+
}
|
|
378
|
+
function compareGroups(left, right) {
|
|
379
|
+
return compareStrings(left.path, right.path) || compareStrings(left.id, right.id);
|
|
380
|
+
}
|
|
381
|
+
function compareProtocolMappers(left, right) {
|
|
382
|
+
return compareStrings(left.name, right.name) || compareStrings(left.id ?? "", right.id ?? "");
|
|
383
|
+
}
|
|
384
|
+
function compareByNameThenId(left, right) {
|
|
385
|
+
return compareStrings(left.name, right.name) || compareStrings(left.id ?? "", right.id ?? "");
|
|
386
|
+
}
|
|
387
|
+
function compareStrings(left, right) {
|
|
388
|
+
return left.localeCompare(right);
|
|
389
|
+
}
|
|
390
|
+
function arraysEqual(left, right) {
|
|
391
|
+
return Array.isArray(left) && left.length === right.length && left.every((value, index) => value === right[index]);
|
|
392
|
+
}
|
|
393
|
+
function findManagedMembershipConflicts(state, desiredGroups) {
|
|
394
|
+
const memberships = /* @__PURE__ */ new Map();
|
|
395
|
+
for (const desiredGroup of desiredGroups) {
|
|
396
|
+
if (!desiredGroup.role) continue;
|
|
397
|
+
const group = findGroupByPath(state, desiredGroup.path);
|
|
398
|
+
for (const memberId of group?.memberIds ?? []) {
|
|
399
|
+
const paths = memberships.get(memberId) ?? [];
|
|
400
|
+
paths.push(desiredGroup.path);
|
|
401
|
+
memberships.set(memberId, paths);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return [...memberships.entries()].filter(([, paths]) => paths.length > 1).map(([memberId, paths]) => ({ fingerprint: hashJson(memberId).slice(0, 16), groupPaths: paths.toSorted() }));
|
|
405
|
+
}
|
|
406
|
+
function desiredSummaryFrom(desired) {
|
|
407
|
+
return {
|
|
408
|
+
serverUrl: desired.serverUrl,
|
|
409
|
+
realm: desired.realm,
|
|
410
|
+
clientId: desired.appClientId,
|
|
411
|
+
issuer: `${desired.serverUrl}/realms/${encodeURIComponent(desired.realm)}`,
|
|
412
|
+
redirectUri: desired.redirectUri,
|
|
413
|
+
webOrigin: desired.webOrigin,
|
|
414
|
+
postLogoutRedirectUri: desired.postLogoutRedirectUri,
|
|
415
|
+
requiredClaims: ["sub", "scalar role", "scalar vendor_id only for vendor", "multivalued full-path agora_groups"],
|
|
416
|
+
managedGroups: desired.groups.map((group) => group.path)
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function assertProvisionPlanSchema(value) {
|
|
420
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Plan schema is invalid");
|
|
421
|
+
if (Reflect.get(value, "schemaVersion") !== 1) throw new Error("Plan schema is invalid");
|
|
422
|
+
if (typeof Reflect.get(value, "id") !== "string") throw new Error("Plan schema is invalid");
|
|
423
|
+
if (Reflect.get(value, "mode") !== "live" && Reflect.get(value, "mode") !== "fixture") throw new Error("Plan schema is invalid");
|
|
424
|
+
const desired = Reflect.get(value, "desired");
|
|
425
|
+
if (!desired || typeof desired !== "object" || Array.isArray(desired)) throw new Error("Plan schema is invalid");
|
|
426
|
+
if (typeof Reflect.get(desired, "serverUrl") !== "string" || typeof Reflect.get(desired, "realm") !== "string" || typeof Reflect.get(desired, "clientId") !== "string") throw new Error("Plan schema is invalid");
|
|
427
|
+
if (typeof Reflect.get(value, "createdAt") !== "string" || typeof Reflect.get(value, "expiresAt") !== "string" || typeof Reflect.get(value, "stateHash") !== "string") throw new Error("Plan schema is invalid");
|
|
428
|
+
if (!Array.isArray(Reflect.get(value, "operations")) || !Array.isArray(Reflect.get(value, "issues"))) throw new Error("Plan schema is invalid");
|
|
429
|
+
}
|
|
430
|
+
function assertSerializedPlanIntegrity(value) {
|
|
431
|
+
assertProvisionPlanSchema(value);
|
|
432
|
+
if (value.mode !== "live") throw new Error(`Plan ${value.id} is ${value.mode}; apply requires a live plan`);
|
|
433
|
+
for (const operation of value.operations) {
|
|
434
|
+
if (operation.destructive !== false) throw new Error(`Plan ${value.id} contains a destructive operation and is not supported`);
|
|
435
|
+
if (!isOperationKind(operation.kind)) throw new Error(`Plan ${value.id} contains unsupported operation kind`);
|
|
436
|
+
}
|
|
437
|
+
const planBody = { desired: value.desired, stateHash: value.stateHash, operations: value.operations, issues: value.issues, handoff: value.handoff, mode: value.mode, createdAt: value.createdAt, expiresAt: value.expiresAt };
|
|
438
|
+
const expectedId = `kcplan-${hashJson(planBody).slice(0, 16)}`;
|
|
439
|
+
if (value.id !== expectedId) throw new Error(`Plan ${value.id} contents do not match its approval id`);
|
|
440
|
+
}
|
|
441
|
+
function isOperationKind(value) {
|
|
442
|
+
return value === "create-client" || value === "update-client" || value === "create-role" || value === "create-group" || value === "update-group-attributes" || value === "create-group-role-mapping" || value === "create-protocol-mapper" || value === "update-protocol-mapper";
|
|
443
|
+
}
|
|
444
|
+
function planComparable(plan) {
|
|
445
|
+
return {
|
|
446
|
+
schemaVersion: plan.schemaVersion,
|
|
447
|
+
id: plan.id,
|
|
448
|
+
createdAt: plan.createdAt,
|
|
449
|
+
expiresAt: plan.expiresAt,
|
|
450
|
+
mode: plan.mode,
|
|
451
|
+
desired: plan.desired,
|
|
452
|
+
stateHash: plan.stateHash,
|
|
453
|
+
operations: plan.operations,
|
|
454
|
+
issues: plan.issues,
|
|
455
|
+
handoff: plan.handoff
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function canonicalEqual(left, right) {
|
|
459
|
+
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
460
|
+
}
|
|
461
|
+
function hashJson(value) {
|
|
462
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
463
|
+
}
|
|
464
|
+
function canonicalize(value) {
|
|
465
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
466
|
+
if (!value || typeof value !== "object") return value;
|
|
467
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalize(item)]));
|
|
468
|
+
}
|
|
469
|
+
function isSecretKey(key) {
|
|
470
|
+
const normalized = key.toLowerCase();
|
|
471
|
+
if (normalized === "appclientsecrethandoff") return false;
|
|
472
|
+
if (normalized === "authorization" || normalized.endsWith("authorization")) return true;
|
|
473
|
+
if (normalized === "token" || normalized === "access_token" || normalized === "refresh_token" || normalized === "id_token") return true;
|
|
474
|
+
if (normalized === "password" || normalized.endsWith("password")) return true;
|
|
475
|
+
if (normalized === "credential" || normalized.endsWith("credential")) return true;
|
|
476
|
+
return normalized === "secret" || normalized.endsWith("secret") || normalized.endsWith("clientsecret") || normalized.endsWith("client_secret");
|
|
477
|
+
}
|
|
478
|
+
function trimTrailingSlash(value) {
|
|
479
|
+
return value.replace(/\/+$/, "");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// scripts/keycloak-admin-api.ts
|
|
483
|
+
var KeycloakAdminError = class extends Error {
|
|
484
|
+
constructor(message, status) {
|
|
485
|
+
super(message);
|
|
486
|
+
this.name = "KeycloakAdminError";
|
|
487
|
+
this.status = status;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
var KeycloakAdminApi = class {
|
|
491
|
+
constructor(options) {
|
|
492
|
+
this.discoveryValidated = false;
|
|
493
|
+
this.serverUrl = options.serverUrl.replace(/\/+$/, "");
|
|
494
|
+
this.realm = options.realm;
|
|
495
|
+
this.credentials = options.credentials;
|
|
496
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
497
|
+
}
|
|
498
|
+
async readRealmState(appClientId) {
|
|
499
|
+
await this.validateDiscovery();
|
|
500
|
+
const clients = await this.lookupClients(appClientId);
|
|
501
|
+
const client = clients.find((item) => item.clientId === appClientId);
|
|
502
|
+
const groups = await this.listKnownGroupsWithMembers();
|
|
503
|
+
if (!client?.id) return { clients, clientRoles: {}, groups, groupClientRoleMappings: {}, protocolMappers: {} };
|
|
504
|
+
const roles = await this.listClientRoles(client.id);
|
|
505
|
+
const roleMappings = {};
|
|
506
|
+
for (const group of groups) {
|
|
507
|
+
roleMappings[group.id] = await this.listGroupClientRoleMappings(group.id, client.id);
|
|
508
|
+
}
|
|
509
|
+
const protocolMappers = await this.listProtocolMappers(client.id);
|
|
510
|
+
return {
|
|
511
|
+
clients,
|
|
512
|
+
clientRoles: { [client.id]: roles, [client.clientId]: roles },
|
|
513
|
+
groups,
|
|
514
|
+
groupClientRoleMappings: roleMappings,
|
|
515
|
+
protocolMappers: { [client.id]: protocolMappers, [client.clientId]: protocolMappers }
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async applyOperation(operation, desired) {
|
|
519
|
+
const client = (await this.lookupClients(desired.appClientId)).find((item) => item.clientId === desired.appClientId);
|
|
520
|
+
if (operation.kind === "create-client") {
|
|
521
|
+
await this.createClient(desired.client);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (!client?.id) throw new KeycloakAdminError(`Client ${desired.appClientId} is required before ${operation.kind}`);
|
|
525
|
+
if (operation.kind === "update-client") {
|
|
526
|
+
await this.updateClient(client.id, operation.payload);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (operation.kind === "create-role") {
|
|
530
|
+
await this.createClientRole(client.id, { name: String(Reflect.get(operation.payload, "name")) });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (operation.kind === "create-group") {
|
|
534
|
+
await this.createGroup(operation.payload);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (operation.kind === "update-group-attributes") {
|
|
538
|
+
const group = await this.requireGroup(operation.target);
|
|
539
|
+
const attributes = Reflect.get(operation.payload, "attributes");
|
|
540
|
+
await this.updateGroup(group.id, { ...group, attributes: { ...group.attributes ?? {}, ...attributes } }, operation.target, attributes);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (operation.kind === "create-group-role-mapping") {
|
|
544
|
+
const payload = operation.payload;
|
|
545
|
+
const group = await this.requireGroup(payload.groupPath);
|
|
546
|
+
const role = await this.requireClientRole(client.id, payload.role);
|
|
547
|
+
await this.addGroupClientRoleMapping(group.id, client.id, role);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (operation.kind === "create-protocol-mapper") {
|
|
551
|
+
await this.createProtocolMapper(client.id, operation.payload);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (operation.kind === "update-protocol-mapper") {
|
|
555
|
+
const mapper = operation.payload;
|
|
556
|
+
if (!mapper.id) throw new KeycloakAdminError(`Mapper ${mapper.name} is missing an id`);
|
|
557
|
+
await this.updateProtocolMapper(client.id, mapper.id, mapper);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async validateDiscovery() {
|
|
562
|
+
if (this.discoveryValidated) return;
|
|
563
|
+
const expectedIssuer = `${this.serverUrl}/realms/${encodeURIComponent(this.realm)}`;
|
|
564
|
+
const expectedTokenEndpoint = `${expectedIssuer}/protocol/openid-connect/token`;
|
|
565
|
+
const response = await this.fetchImpl(`${expectedIssuer}/.well-known/openid-configuration`, { method: "GET", redirect: "manual" });
|
|
566
|
+
if (!response.ok) throw await this.errorFromResponse(response);
|
|
567
|
+
const metadata = await response.json();
|
|
568
|
+
const issuer = metadata.issuer;
|
|
569
|
+
const tokenEndpoint = metadata.token_endpoint;
|
|
570
|
+
const grants = metadata.grant_types_supported;
|
|
571
|
+
const pkce = metadata.code_challenge_methods_supported;
|
|
572
|
+
if (issuer !== expectedIssuer) throw new KeycloakAdminError("Keycloak discovery issuer does not match configured realm");
|
|
573
|
+
if (tokenEndpoint !== expectedTokenEndpoint) throw new KeycloakAdminError("Keycloak discovery token endpoint does not match configured realm");
|
|
574
|
+
if (!Array.isArray(grants) || !grants.includes("client_credentials") || !grants.includes("authorization_code")) throw new KeycloakAdminError("Keycloak discovery is missing required grant support");
|
|
575
|
+
if (!Array.isArray(pkce) || !pkce.includes("S256")) throw new KeycloakAdminError("Keycloak discovery is missing S256 PKCE support");
|
|
576
|
+
this.discoveryValidated = true;
|
|
577
|
+
}
|
|
578
|
+
async lookupClients(clientId) {
|
|
579
|
+
return readArray(await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/clients?clientId=${encodeURIComponent(clientId)}`, "GET"), "clients", isClient);
|
|
580
|
+
}
|
|
581
|
+
async createClient(client) {
|
|
582
|
+
try {
|
|
583
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/clients`, "POST", client);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (!(error instanceof KeycloakAdminError) || error.status !== 409) throw error;
|
|
586
|
+
if ((await this.lookupClients(client.clientId)).some((item) => clientManagedConverged(item, client))) return;
|
|
587
|
+
throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "client create conflict did not converge", clientId: client.clientId }))}`, 409);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async updateClient(id, client) {
|
|
591
|
+
try {
|
|
592
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(id)}`, "PUT", client);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (!(error instanceof KeycloakAdminError) || error.status !== 409) throw error;
|
|
595
|
+
if ((await this.lookupClients(client.clientId)).some((item) => clientManagedConverged(item, client))) return;
|
|
596
|
+
throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "client update conflict did not converge", clientId: client.clientId }))}`, 409);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async listClientRoles(clientUuid) {
|
|
600
|
+
return readArray(await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(clientUuid)}/roles`, "GET"), "client roles", isRole);
|
|
601
|
+
}
|
|
602
|
+
async createClientRole(clientUuid, role) {
|
|
603
|
+
try {
|
|
604
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(clientUuid)}/roles`, "POST", role);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error instanceof KeycloakAdminError && error.status === 409 && (await this.listClientRoles(clientUuid)).some((item) => item.name === role.name)) return;
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async requireClientRole(clientUuid, name) {
|
|
611
|
+
const role = await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(name)}`, "GET");
|
|
612
|
+
if (!isRole(role) || !role.id) throw new KeycloakAdminError(`Client role ${name} response is missing required id/name`);
|
|
613
|
+
return role;
|
|
614
|
+
}
|
|
615
|
+
async groupByPath(path2) {
|
|
616
|
+
try {
|
|
617
|
+
const group = await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/group-by-path/${encodeURIComponent(path2)}`, "GET");
|
|
618
|
+
if (!isGroup(group)) throw new KeycloakAdminError(`Group ${path2} response is missing required id/name/path`);
|
|
619
|
+
return group;
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (error instanceof KeycloakAdminError && error.status === 404) return void 0;
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async createGroup(group) {
|
|
626
|
+
const existing = await this.groupByPath(group.path);
|
|
627
|
+
if (existing) {
|
|
628
|
+
if (groupAttributesConverged(existing, group)) return;
|
|
629
|
+
throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "group create conflict did not converge", path: group.path }))}`, 409);
|
|
630
|
+
}
|
|
631
|
+
const parentPath = group.path.split("/").slice(0, -1).join("/") || "/";
|
|
632
|
+
const parent = parentPath === "/" ? void 0 : await this.groupByPath(parentPath);
|
|
633
|
+
if (parentPath !== "/" && !parent) throw new KeycloakAdminError(`Parent group ${parentPath} is required before creating ${group.path}`);
|
|
634
|
+
const endpoint = parent ? `/admin/realms/${encodeURIComponent(this.realm)}/groups/${encodeURIComponent(parent.id)}/children` : `/admin/realms/${encodeURIComponent(this.realm)}/groups`;
|
|
635
|
+
try {
|
|
636
|
+
await this.requestNoContent(endpoint, "POST", { name: group.name, attributes: group.attributes });
|
|
637
|
+
} catch (error) {
|
|
638
|
+
const converged = error instanceof KeycloakAdminError && error.status === 409 ? await this.groupByPath(group.path) : void 0;
|
|
639
|
+
if (converged && groupAttributesConverged(converged, group)) return;
|
|
640
|
+
if (error instanceof KeycloakAdminError && error.status === 409) throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "group create conflict did not converge", path: group.path }))}`, 409);
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async updateGroup(groupId, group, path2 = group.path, desiredAttributes = normalizeGroupAttributes(group.attributes)) {
|
|
645
|
+
try {
|
|
646
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/groups/${encodeURIComponent(groupId)}`, "PUT", group);
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (!(error instanceof KeycloakAdminError) || error.status !== 409) throw error;
|
|
649
|
+
const converged = await this.groupByPath(path2);
|
|
650
|
+
if (converged && groupAttributesConverged(converged, { path: path2, name: group.name, attributes: desiredAttributes })) return;
|
|
651
|
+
throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "group update conflict did not converge", path: path2 }))}`, 409);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async listGroupClientRoleMappings(groupId, clientUuid) {
|
|
655
|
+
return readArray(await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/groups/${encodeURIComponent(groupId)}/role-mappings/clients/${encodeURIComponent(clientUuid)}`, "GET"), "group client role mappings", isRole);
|
|
656
|
+
}
|
|
657
|
+
async addGroupClientRoleMapping(groupId, clientUuid, role) {
|
|
658
|
+
try {
|
|
659
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/groups/${encodeURIComponent(groupId)}/role-mappings/clients/${encodeURIComponent(clientUuid)}`, "POST", [role]);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if (error instanceof KeycloakAdminError && error.status === 409 && (await this.listGroupClientRoleMappings(groupId, clientUuid)).some((item) => item.name === role.name)) return;
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async listProtocolMappers(clientUuid) {
|
|
666
|
+
return readArray(await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(clientUuid)}/protocol-mappers/models`, "GET"), "protocol mappers", isProtocolMapper);
|
|
667
|
+
}
|
|
668
|
+
async listGroupMemberIds(groupId) {
|
|
669
|
+
const memberIds = [];
|
|
670
|
+
for (let first = 0; ; first += 100) {
|
|
671
|
+
const members = readArray(await this.requestJson(`/admin/realms/${encodeURIComponent(this.realm)}/groups/${encodeURIComponent(groupId)}/members?first=${first}&max=100`, "GET"), "group members", isUserSummary);
|
|
672
|
+
memberIds.push(...members.map((member) => member.id));
|
|
673
|
+
if (members.length < 100) return memberIds;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
async createProtocolMapper(clientUuid, mapper) {
|
|
677
|
+
try {
|
|
678
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(clientUuid)}/protocol-mappers/models`, "POST", mapper);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
if (error instanceof KeycloakAdminError && error.status === 409 && (await this.listProtocolMappers(clientUuid)).some((item) => protocolMapperConverged(item, mapper))) return;
|
|
681
|
+
if (error instanceof KeycloakAdminError && error.status === 409) throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "protocol mapper create conflict did not converge", name: mapper.name }))}`, 409);
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
async updateProtocolMapper(clientUuid, mapperId, mapper) {
|
|
686
|
+
try {
|
|
687
|
+
await this.requestNoContent(`/admin/realms/${encodeURIComponent(this.realm)}/clients/${encodeURIComponent(clientUuid)}/protocol-mappers/models/${encodeURIComponent(mapperId)}`, "PUT", mapper);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (!(error instanceof KeycloakAdminError) || error.status !== 409) throw error;
|
|
690
|
+
if ((await this.listProtocolMappers(clientUuid)).some((item) => protocolMapperConverged(item, mapper))) return;
|
|
691
|
+
throw new KeycloakAdminError(`Keycloak Admin API HTTP 409: ${JSON.stringify(redactSecrets({ message: "protocol mapper update conflict did not converge", name: mapper.name }))}`, 409);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async listKnownGroupsWithMembers() {
|
|
695
|
+
const groups = [];
|
|
696
|
+
for (const path2 of DEFAULT_MANAGED_GROUPS) {
|
|
697
|
+
const group = await this.groupByPath(path2);
|
|
698
|
+
if (group) groups.push({ ...group, memberIds: path2 === "/tenants" ? [] : await this.listGroupMemberIds(group.id) });
|
|
699
|
+
}
|
|
700
|
+
return groups;
|
|
701
|
+
}
|
|
702
|
+
async requireGroup(path2) {
|
|
703
|
+
const group = await this.groupByPath(path2);
|
|
704
|
+
if (!group) throw new KeycloakAdminError(`Group ${path2} is required before mapping`);
|
|
705
|
+
return group;
|
|
706
|
+
}
|
|
707
|
+
async requestJson(path2, method, body) {
|
|
708
|
+
const response = await this.request(path2, method, body);
|
|
709
|
+
if (response.status === 204) return void 0;
|
|
710
|
+
return await response.json();
|
|
711
|
+
}
|
|
712
|
+
async requestNoContent(path2, method, body) {
|
|
713
|
+
const response = await this.request(path2, method, body);
|
|
714
|
+
if (response.status >= 200 && response.status < 300) return;
|
|
715
|
+
throw await this.errorFromResponse(response);
|
|
716
|
+
}
|
|
717
|
+
async request(path2, method, body) {
|
|
718
|
+
const token = await this.accessToken();
|
|
719
|
+
const response = await this.fetchImpl(`${this.serverUrl}${path2}`, {
|
|
720
|
+
method,
|
|
721
|
+
redirect: "manual",
|
|
722
|
+
headers: {
|
|
723
|
+
Authorization: `Bearer ${token}`,
|
|
724
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
725
|
+
},
|
|
726
|
+
body: body === void 0 ? void 0 : JSON.stringify(assertNoSecretPayload(body))
|
|
727
|
+
});
|
|
728
|
+
if (response.ok || response.status === 409) return response;
|
|
729
|
+
throw await this.errorFromResponse(response);
|
|
730
|
+
}
|
|
731
|
+
async accessToken() {
|
|
732
|
+
if (this.token) return this.token;
|
|
733
|
+
const body = new URLSearchParams({ grant_type: "client_credentials", client_id: this.credentials.clientId, client_secret: this.credentials.clientSecret });
|
|
734
|
+
const response = await this.fetchImpl(`${this.serverUrl}/realms/${encodeURIComponent(this.realm)}/protocol/openid-connect/token`, {
|
|
735
|
+
method: "POST",
|
|
736
|
+
redirect: "manual",
|
|
737
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
738
|
+
body
|
|
739
|
+
});
|
|
740
|
+
if (!response.ok) throw await this.errorFromResponse(response);
|
|
741
|
+
const payload = await response.json();
|
|
742
|
+
if (typeof payload.access_token !== "string" || !payload.access_token) throw new KeycloakAdminError("Token response did not include an access token");
|
|
743
|
+
this.token = payload.access_token;
|
|
744
|
+
return this.token;
|
|
745
|
+
}
|
|
746
|
+
async errorFromResponse(response) {
|
|
747
|
+
const text = await response.text().catch(() => "");
|
|
748
|
+
const redactedBody = redactErrorText(text).slice(0, 500);
|
|
749
|
+
const redacted = JSON.stringify(redactSecrets({ message: redactedBody, authorization: response.headers.get("authorization") }));
|
|
750
|
+
return new KeycloakAdminError(`Keycloak Admin API HTTP ${response.status}: ${redacted}`, response.status);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
function readProvisionerCredentials(env = process.env) {
|
|
754
|
+
const clientId = env.KEYCLOAK_PROVISION_CLIENT_ID?.trim();
|
|
755
|
+
const clientSecret = env.KEYCLOAK_PROVISION_CLIENT_SECRET?.trim();
|
|
756
|
+
if (!clientId || !clientSecret) throw new Error("KEYCLOAK_PROVISION_CLIENT_ID and KEYCLOAK_PROVISION_CLIENT_SECRET are required for live/apply");
|
|
757
|
+
return { clientId, clientSecret };
|
|
758
|
+
}
|
|
759
|
+
function redactErrorText(text) {
|
|
760
|
+
try {
|
|
761
|
+
return JSON.stringify(redactSecrets(JSON.parse(text)));
|
|
762
|
+
} catch {
|
|
763
|
+
return text.replace(/(secret|token|authorization|credential|password)[^\s,;}]{0,80}/gi, "$1=[REDACTED]");
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function assertNoSecretPayload(payload) {
|
|
767
|
+
if (containsSecretKey(payload)) throw new KeycloakAdminError("Managed Keycloak payload contains a secret-shaped field");
|
|
768
|
+
return payload;
|
|
769
|
+
}
|
|
770
|
+
function readArray(value, label, guard) {
|
|
771
|
+
if (!Array.isArray(value)) throw new KeycloakAdminError(`Keycloak ${label} response was not an array`);
|
|
772
|
+
if (!value.every(guard)) throw new KeycloakAdminError(`Keycloak ${label} response has an unsupported shape`);
|
|
773
|
+
return value;
|
|
774
|
+
}
|
|
775
|
+
function isClient(value) {
|
|
776
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof Reflect.get(value, "clientId") === "string";
|
|
777
|
+
}
|
|
778
|
+
function isRole(value) {
|
|
779
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof Reflect.get(value, "name") === "string";
|
|
780
|
+
}
|
|
781
|
+
function isGroup(value) {
|
|
782
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof Reflect.get(value, "id") === "string" && typeof Reflect.get(value, "name") === "string" && typeof Reflect.get(value, "path") === "string";
|
|
783
|
+
}
|
|
784
|
+
function isProtocolMapper(value) {
|
|
785
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof Reflect.get(value, "name") === "string" && typeof Reflect.get(value, "protocol") === "string" && typeof Reflect.get(value, "protocolMapper") === "string" && !!Reflect.get(value, "config") && typeof Reflect.get(value, "config") === "object";
|
|
786
|
+
}
|
|
787
|
+
function isUserSummary(value) {
|
|
788
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof Reflect.get(value, "id") === "string";
|
|
789
|
+
}
|
|
790
|
+
function clientManagedConverged(actual, desired) {
|
|
791
|
+
const desiredAttrs = desired.attributes ?? {};
|
|
792
|
+
const actualAttrs = actual.attributes ?? {};
|
|
793
|
+
return actual.clientId === desired.clientId && actual.enabled === desired.enabled && actual.protocol === desired.protocol && actual.publicClient === desired.publicClient && actual.bearerOnly === desired.bearerOnly && actual.standardFlowEnabled === desired.standardFlowEnabled && actual.serviceAccountsEnabled === desired.serviceAccountsEnabled && actual.directAccessGrantsEnabled === desired.directAccessGrantsEnabled && actual.implicitFlowEnabled === desired.implicitFlowEnabled && stringArraysEqual(actual.redirectUris, desired.redirectUris ?? []) && stringArraysEqual(actual.webOrigins, desired.webOrigins ?? []) && Object.entries(desiredAttrs).every(([key, value]) => actualAttrs[key] === value);
|
|
794
|
+
}
|
|
795
|
+
function groupAttributesConverged(actual, desired) {
|
|
796
|
+
return managedRecordsOfStringArraysEqual(normalizeGroupAttributes(actual.attributes), desired.attributes);
|
|
797
|
+
}
|
|
798
|
+
function protocolMapperConverged(actual, desired) {
|
|
799
|
+
return actual.name === desired.name && actual.protocol === desired.protocol && actual.protocolMapper === desired.protocolMapper && stringRecordsEqual(actual.config, desired.config);
|
|
800
|
+
}
|
|
801
|
+
function normalizeGroupAttributes(input) {
|
|
802
|
+
return Object.fromEntries(Object.entries(input ?? {}).map(([key, value]) => [key, Array.isArray(value) ? value.map(String) : [String(value)]]));
|
|
803
|
+
}
|
|
804
|
+
function managedRecordsOfStringArraysEqual(left, right) {
|
|
805
|
+
for (const key of ["agora_role", "vendor_id"]) {
|
|
806
|
+
if (!stringArraysEqual(left[key] ?? [], right[key] ?? [])) return false;
|
|
807
|
+
}
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
function stringArraysEqual(left, right) {
|
|
811
|
+
return Array.isArray(left) && left.length === right.length && left.every((value, index) => value === right[index]);
|
|
812
|
+
}
|
|
813
|
+
function stringRecordsEqual(left, right) {
|
|
814
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
815
|
+
for (const key of keys) {
|
|
816
|
+
if (left[key] !== right[key]) return false;
|
|
817
|
+
}
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
function containsSecretKey(value) {
|
|
821
|
+
if (Array.isArray(value)) return value.some(containsSecretKey);
|
|
822
|
+
if (!value || typeof value !== "object") return false;
|
|
823
|
+
for (const [key, item] of Object.entries(value)) {
|
|
824
|
+
if (isOutboundSecretKey(key) || containsSecretKey(item)) return true;
|
|
825
|
+
}
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
function isOutboundSecretKey(key) {
|
|
829
|
+
const normalized = key.toLowerCase();
|
|
830
|
+
return normalized === "secret" || normalized.endsWith("secret") || normalized.endsWith("clientsecret") || normalized.endsWith("client_secret") || normalized === "password" || normalized.endsWith("password") || normalized === "credential" || normalized.endsWith("credential") || normalized === "authorization";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// scripts/keycloak-provision.ts
|
|
834
|
+
function parseArgs(argv) {
|
|
835
|
+
const args = { command: argv[0] };
|
|
836
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
837
|
+
const token = argv[index] ?? "";
|
|
838
|
+
if (token === "--live") {
|
|
839
|
+
args.live = true;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (!token.startsWith("--")) throw new Error(`Unexpected positional argument: ${token}`);
|
|
843
|
+
const value = argv[index + 1];
|
|
844
|
+
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
|
|
845
|
+
index += 1;
|
|
846
|
+
const key = {
|
|
847
|
+
"--state-fixture": "stateFixture",
|
|
848
|
+
"--claims-fixture": "claimsFixture",
|
|
849
|
+
"--out": "out",
|
|
850
|
+
"--plan": "plan",
|
|
851
|
+
"--approve": "approve",
|
|
852
|
+
"--server": "server",
|
|
853
|
+
"--realm": "realm"
|
|
854
|
+
}[token];
|
|
855
|
+
if (!key) throw new Error(`Unsupported option: ${token}`);
|
|
856
|
+
args[key] = value;
|
|
857
|
+
}
|
|
858
|
+
return args;
|
|
859
|
+
}
|
|
860
|
+
async function runKeycloakProvision(argv, env = process.env) {
|
|
861
|
+
const args = parseArgs(argv);
|
|
862
|
+
if (args.command === "plan") return planCommand(args, env);
|
|
863
|
+
if (args.command === "apply") return applyCommand(args, env);
|
|
864
|
+
if (args.command === "verify") return verifyCommand(args);
|
|
865
|
+
throw new Error("Usage: keycloak-provision plan|apply|verify [--state-fixture path|--live] [--out path] [--plan path --approve id] [--claims-fixture path] [--server url --realm realm]");
|
|
866
|
+
}
|
|
867
|
+
async function planCommand(args, env) {
|
|
868
|
+
const mode = args.live ? "live" : "fixture";
|
|
869
|
+
const desired = args.live ? desiredKeycloakState({ serverUrl: normalizeTrustedServerUrl(args.server ?? DEFAULT_KEYCLOAK_SERVER), realm: args.realm }) : desiredKeycloakState({ serverUrl: args.server, realm: args.realm });
|
|
870
|
+
const state = args.live ? await liveState(desired, env) : await fixtureState(args.stateFixture);
|
|
871
|
+
const plan = createProvisionPlan(state, desired, { mode });
|
|
872
|
+
const serialized = `${JSON.stringify(redactSecrets(plan), null, 2)}
|
|
873
|
+
`;
|
|
874
|
+
if (args.out) {
|
|
875
|
+
await writeFile(path.resolve(args.out), serialized, "utf8");
|
|
876
|
+
console.log(`Wrote Keycloak plan ${plan.id} to ${path.resolve(args.out)}`);
|
|
877
|
+
} else {
|
|
878
|
+
console.log(serialized.trimEnd());
|
|
879
|
+
}
|
|
880
|
+
if (planHasBlockingIssues(plan)) process.exitCode = 2;
|
|
881
|
+
}
|
|
882
|
+
async function applyCommand(args, env) {
|
|
883
|
+
const planPath = requireArg(args.plan, "--plan");
|
|
884
|
+
const approval = requireArg(args.approve, "--approve");
|
|
885
|
+
const plan = JSON.parse(await readFile(path.resolve(planPath), "utf8"));
|
|
886
|
+
assertSerializedPlanIntegrity(plan);
|
|
887
|
+
assertPlanPreflight(plan, approval);
|
|
888
|
+
const trustedServerUrl = normalizeTrustedServerUrl(args.server ?? DEFAULT_KEYCLOAK_SERVER);
|
|
889
|
+
const trustedRealm = args.realm ?? DEFAULT_KEYCLOAK_REALM;
|
|
890
|
+
if (plan.desired.serverUrl !== trustedServerUrl) throw new Error("Plan server does not match trusted --server/default destination");
|
|
891
|
+
if (plan.desired.realm !== trustedRealm) throw new Error("Plan realm does not match trusted --realm/default destination");
|
|
892
|
+
const desired = desiredKeycloakState({ serverUrl: trustedServerUrl, realm: trustedRealm, appClientId: plan.desired.clientId });
|
|
893
|
+
const credentials = readProvisionerCredentials(env);
|
|
894
|
+
const api = new KeycloakAdminApi({ serverUrl: desired.serverUrl, realm: desired.realm, credentials });
|
|
895
|
+
await api.validateDiscovery();
|
|
896
|
+
const state = await api.readRealmState(desired.appClientId);
|
|
897
|
+
assertPlanApplicable(plan, state, approval);
|
|
898
|
+
for (const operation of plan.operations) {
|
|
899
|
+
await api.applyOperation(operation, desired);
|
|
900
|
+
}
|
|
901
|
+
console.log(`Applied Keycloak plan ${plan.id}: ${plan.operations.length} operation(s)`);
|
|
902
|
+
}
|
|
903
|
+
function assertPlanPreflight(plan, approval, now = /* @__PURE__ */ new Date()) {
|
|
904
|
+
if (approval !== plan.id) throw new Error(`Approval mismatch: expected --approve ${plan.id}`);
|
|
905
|
+
if (Date.parse(plan.expiresAt) <= now.getTime()) throw new Error(`Plan ${plan.id} is stale; create a fresh plan`);
|
|
906
|
+
if (plan.issues.some((issue) => issue.severity === "error")) throw new Error(`Plan ${plan.id} contains blocking issues`);
|
|
907
|
+
const createdAt = new Date(plan.createdAt);
|
|
908
|
+
const ttlMs = Date.parse(plan.expiresAt) - createdAt.getTime();
|
|
909
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_PLAN_TTL_MS2) throw new Error(`Plan ${plan.id} has invalid timestamps`);
|
|
910
|
+
if (createdAt.getTime() - now.getTime() > PLAN_CLOCK_SKEW_MS2) throw new Error(`Plan ${plan.id} was created in the future`);
|
|
911
|
+
}
|
|
912
|
+
var MAX_PLAN_TTL_MS2 = 15 * 60 * 1e3;
|
|
913
|
+
var PLAN_CLOCK_SKEW_MS2 = 60 * 1e3;
|
|
914
|
+
var LIVE_VERIFY_TIMEOUT_MS = 1e4;
|
|
915
|
+
function normalizeTrustedServerUrl(value) {
|
|
916
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
917
|
+
const url = new URL(normalized);
|
|
918
|
+
if (url.username || url.password || url.search || url.hash) throw new Error("Trusted Keycloak server URL must not include credentials, query, or fragment");
|
|
919
|
+
if (url.pathname !== "/") throw new Error("Trusted Keycloak server URL must not include a non-root path");
|
|
920
|
+
if (url.protocol === "https:" && isDefaultKeycloakOrigin(url)) return url.origin;
|
|
921
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.origin;
|
|
922
|
+
throw new Error("Trusted Keycloak server URL must be the configured default HTTPS origin or loopback HTTP");
|
|
923
|
+
}
|
|
924
|
+
function isDefaultKeycloakOrigin(url) {
|
|
925
|
+
const defaultUrl = new URL(DEFAULT_KEYCLOAK_SERVER);
|
|
926
|
+
return url.protocol === defaultUrl.protocol && url.hostname === defaultUrl.hostname && url.port === defaultUrl.port;
|
|
927
|
+
}
|
|
928
|
+
function isLoopbackHost(hostname) {
|
|
929
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1";
|
|
930
|
+
}
|
|
931
|
+
async function verifyCommand(args) {
|
|
932
|
+
if (args.claimsFixture) {
|
|
933
|
+
const fixture = JSON.parse(await readFile(path.resolve(args.claimsFixture), "utf8"));
|
|
934
|
+
const issues = verifyClaimFixtures(fixture);
|
|
935
|
+
if (issues.length > 0) {
|
|
936
|
+
console.log(JSON.stringify({ ok: false, issues }, null, 2));
|
|
937
|
+
process.exitCode = 2;
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
console.log("Claim fixture verification passed");
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (args.live) {
|
|
944
|
+
const desired = desiredKeycloakState({ serverUrl: normalizeTrustedServerUrl(args.server ?? DEFAULT_KEYCLOAK_SERVER), realm: args.realm });
|
|
945
|
+
await verifyLivePostLogoutRedirect(desired);
|
|
946
|
+
console.log("Live Keycloak post-logout redirect verification passed");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const state = await fixtureState(args.stateFixture);
|
|
950
|
+
const plan = createProvisionPlan(state, desiredKeycloakState({ serverUrl: args.server, realm: args.realm }), { mode: "fixture" });
|
|
951
|
+
console.log(JSON.stringify({ ok: !planHasBlockingIssues(plan), planId: plan.id, operationCount: plan.operations.length, issues: plan.issues }, null, 2));
|
|
952
|
+
if (planHasBlockingIssues(plan)) process.exitCode = 2;
|
|
953
|
+
}
|
|
954
|
+
async function verifyLivePostLogoutRedirect(desired) {
|
|
955
|
+
const url = new URL(`${desired.serverUrl}/realms/${encodeURIComponent(desired.realm)}/protocol/openid-connect/logout`);
|
|
956
|
+
url.searchParams.set("client_id", desired.appClientId);
|
|
957
|
+
url.searchParams.set("post_logout_redirect_uri", desired.postLogoutRedirectUri);
|
|
958
|
+
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(LIVE_VERIFY_TIMEOUT_MS) });
|
|
959
|
+
if (response.status >= 400) {
|
|
960
|
+
throw new Error(`Keycloak post-logout redirect validation failed with HTTP ${response.status} for Keycloak client ${desired.appClientId}. Ensure the client post.logout.redirect.uris setting includes ${desired.postLogoutRedirectUri}.`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function liveState(desired, env) {
|
|
964
|
+
const api = new KeycloakAdminApi({ serverUrl: desired.serverUrl, realm: desired.realm, credentials: readProvisionerCredentials(env) });
|
|
965
|
+
await api.validateDiscovery();
|
|
966
|
+
return await api.readRealmState(desired.appClientId);
|
|
967
|
+
}
|
|
968
|
+
async function fixtureState(file) {
|
|
969
|
+
if (!file) return emptyRealmState();
|
|
970
|
+
return JSON.parse(await readFile(path.resolve(file), "utf8"));
|
|
971
|
+
}
|
|
972
|
+
function requireArg(value, name) {
|
|
973
|
+
const trimmed = value?.trim();
|
|
974
|
+
if (!trimmed) throw new Error(`${name} is required`);
|
|
975
|
+
return trimmed;
|
|
976
|
+
}
|
|
977
|
+
if (import.meta.url === packageFileUrl(process.argv[1]).href) {
|
|
978
|
+
runKeycloakProvision(process.argv.slice(2)).catch((error) => {
|
|
979
|
+
console.error(error instanceof Error ? error.message : error);
|
|
980
|
+
process.exit(1);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
export {
|
|
984
|
+
parseArgs,
|
|
985
|
+
runKeycloakProvision
|
|
986
|
+
};
|