Change structure

This commit is contained in:
Joseph Garrone
2024-12-13 12:16:41 +01:00
parent 92b505dd56
commit 8d59fe7b67
12 changed files with 2704 additions and 2088 deletions

View File

@ -0,0 +1,123 @@
import { z } from "zod";
import { assert, type Equals } from "tsafe/assert";
import { is } from "tsafe/is";
import { id } from "tsafe/id";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type ParsedRealmJson = {
name: string;
users: {
id: string;
email: string;
username: string;
attributes: Record<string, unknown>;
credentials: {
type: string /* "password" or something else */;
}[];
clientRoles: Record<string, string[]>;
}[];
roles: {
client: {
name: string;
containerId: string; // client id
}[];
};
clients: {
id: string;
clientId: string; // example: realm-management
baseUrl?: string;
redirectUris?: string[];
webOrigins?: string[];
attributes?: {
"post.logout.redirect.uris"?: string;
};
protocol?: string;
protocolMappers?: unknown[];
}[];
};
export function readRealmJsonFile(params: {
realmJsonFilePath: string;
}): ParsedRealmJson {
const { realmJsonFilePath } = params;
const parsedRealmJson = JSON.parse(
fs.readFileSync(realmJsonFilePath).toString("utf8")
) as unknown;
const zParsedRealmJson = (() => {
type TargetType = ParsedRealmJson;
const zTargetType = z.object({
name: z.string(),
users: z.array(
z.object({
id: z.string(),
email: z.string(),
username: z.string(),
attributes: z.record(z.unknown()),
credentials: z.array(
z.object({
type: z.string()
})
),
clientRoles: z.record(z.array(z.string()))
})
),
roles: z.object({
client: z.array(
z.object({
name: z.string(),
containerId: z.string()
})
)
}),
clients: z.array(
z.object({
id: z.string(),
clientId: z.string(),
baseUrl: z.string().optional(),
redirectUris: z.array(z.string()).optional(),
webOrigins: z.array(z.string()).optional(),
attributes: z
.object({
"post.logout.redirect.uris": z.string().optional()
})
.optional(),
protocol: z.string().optional(),
protocolMappers: z.array(z.unknown()).optional()
})
)
});
type InferredType = z.infer<typeof zTargetType>;
assert<Equals<TargetType, InferredType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
zParsedRealmJson.parse(parsedRealmJson);
assert(is<ParsedRealmJson>(parsedRealmJson));
return parsedRealmJson;
}
export function getDefaultConfig(params: {
keycloakMajorVersionNumber: number;
}): ParsedRealmJson {
const { keycloakMajorVersionNumber } = params;
const realmJsonFilePath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
);
return readRealmJsonFile({ realmJsonFilePath });
}

View File

@ -0,0 +1,167 @@
import { runPrettier, getIsPrettierAvailable } from "../../tools/runPrettier";
import { CONTAINER_NAME } from "../../shared/constants";
import child_process from "child_process";
import { join as pathJoin } from "path";
import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert, is } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
export type BuildContextLike = {
cacheDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function dumpContainerConfig(params: {
realmName: string;
keycloakMajorVersionNumber: number;
targetRealmConfigJsonFilePath: string;
buildContext: BuildContextLike;
}) {
const {
realmName,
keycloakMajorVersionNumber,
targetRealmConfigJsonFilePath,
buildContext
} = params;
{
// https://github.com/keycloak/keycloak/issues/33800
const doesUseLockedH2Database = keycloakMajorVersionNumber >= 26;
if (doesUseLockedH2Database) {
child_process.execSync(
`docker exec ${CONTAINER_NAME} sh -c "cp -rp /opt/keycloak/data/h2 /tmp"`
);
}
const dCompleted = new Deferred<void>();
const child = child_process.spawn(
"docker",
[
...["exec", CONTAINER_NAME],
...["/opt/keycloak/bin/kc.sh", "export"],
...["--dir", "/tmp"],
...["--realm", realmName],
...["--users", "realm_file"],
...(!doesUseLockedH2Database
? []
: [
...["--db", "dev-file"],
...[
"--db-url",
"'jdbc:h2:file:/tmp/h2/keycloakdb;NON_KEYWORDS=VALUE'"
]
])
],
{ shell: true }
);
let output = "";
const onExit = (code: number | null) => {
dCompleted.reject(new Error(`Exited with code ${code}`));
};
child.once("exit", onExit);
child.stdout.on("data", data => {
const outputStr = data.toString("utf8");
if (outputStr.includes("Export finished successfully")) {
child.removeListener("exit", onExit);
// NOTE: On older Keycloak versions the process keeps running after the export is done.
const timer = setTimeout(() => {
child.removeListener("exit", onExit2);
child.kill();
dCompleted.resolve();
}, 1500);
const onExit2 = () => {
clearTimeout(timer);
dCompleted.resolve();
};
child.once("exit", onExit2);
}
output += outputStr;
});
child.stderr.on("data", data => (output += chalk.red(data.toString("utf8"))));
try {
await dCompleted.pr;
} catch (error) {
assert(is<Error>(error));
console.log(chalk.red(error.message));
console.log(output);
process.exit(1);
}
if (doesUseLockedH2Database) {
const dCompleted = new Deferred<void>();
child_process.exec(
`docker exec ${CONTAINER_NAME} sh -c "rm -rf /tmp/h2"`,
error => {
if (error !== null) {
dCompleted.reject(error);
return;
}
dCompleted.resolve();
}
);
await dCompleted.pr;
}
}
const targetRealmConfigJsonFilePath_tmp = pathJoin(
buildContext.cacheDirPath,
"realm.json"
);
{
const dCompleted = new Deferred<void>();
child_process.exec(
`docker cp ${CONTAINER_NAME}:/tmp/${realmName}-realm.json ${targetRealmConfigJsonFilePath_tmp}`,
error => {
if (error !== null) {
dCompleted.reject(error);
return;
}
dCompleted.resolve();
}
);
await dCompleted.pr;
}
let sourceCode = (await fs.readFile(targetRealmConfigJsonFilePath_tmp)).toString(
"utf8"
);
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
sourceCode = await runPrettier({
filePath: targetRealmConfigJsonFilePath,
sourceCode: sourceCode
});
}
await fs.writeFile(targetRealmConfigJsonFilePath, Buffer.from(sourceCode, "utf8"));
}

View File

@ -0,0 +1,271 @@
import { assert } from "tsafe/assert";
import { getDefaultConfig, type ParsedRealmJson } from "./ParsedRealmJson";
export function prepareRealmConfig(params: {
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;
}): {
realmName: string;
clientName: string;
username: string;
} {
const { parsedRealmJson, keycloakMajorVersionNumber } = params;
const { username } = addOrEditTestUser({
parsedRealmJson,
keycloakMajorVersionNumber
});
const { clientId } = addOrEditClient({
parsedRealmJson,
keycloakMajorVersionNumber
});
editAccountConsoleAndSecurityAdminConsole({ parsedRealmJson });
return {
realmName: parsedRealmJson.name,
clientName: clientId,
username
};
}
function addOrEditTestUser(params: {
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;
}): { username: string } {
const { parsedRealmJson, keycloakMajorVersionNumber } = params;
const parsedRealmJson_default = getDefaultConfig({ keycloakMajorVersionNumber });
const [defaultUser_default] = parsedRealmJson_default.users;
assert(defaultUser_default !== undefined);
const defaultUser_preexisting = parsedRealmJson.users.find(
user => user.username === defaultUser_default.username
);
const newUser = structuredClone(
defaultUser_preexisting ??
(() => {
const firstUser = parsedRealmJson.users[0];
if (firstUser === undefined) {
return undefined;
}
const firstUserCopy = structuredClone(firstUser);
firstUserCopy.id = defaultUser_default.id;
return firstUserCopy;
})() ??
defaultUser_default
);
newUser.username = defaultUser_default.username;
newUser.email = defaultUser_default.email;
delete_existing_password_credential_if_any: {
const i = newUser.credentials.findIndex(
credential => credential.type === "password"
);
if (i === -1) {
break delete_existing_password_credential_if_any;
}
newUser.credentials.splice(i, 1);
}
{
const credential = defaultUser_default.credentials.find(
credential => credential.type === "password"
);
assert(credential !== undefined);
newUser.credentials.push(credential);
}
{
const nameByClientId = Object.fromEntries(
parsedRealmJson.clients.map(client => [client.id, client.clientId] as const)
);
newUser.clientRoles = {};
for (const clientRole of parsedRealmJson.roles.client) {
const clientName = nameByClientId[clientRole.containerId];
assert(clientName !== undefined);
(newUser.clientRoles[clientName] ??= []).push(clientRole.name);
}
}
if (defaultUser_preexisting === undefined) {
parsedRealmJson.users.push(newUser);
} else {
const i = parsedRealmJson.users.indexOf(defaultUser_preexisting);
assert(i !== -1);
parsedRealmJson.users[i] = newUser;
}
return { username: newUser.username };
}
const TEST_APP_URL = "https://my-theme.keycloakify.dev";
function addOrEditClient(params: {
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;
}): { clientId: string } {
const { parsedRealmJson, keycloakMajorVersionNumber } = params;
const parsedRealmJson_default = getDefaultConfig({ keycloakMajorVersionNumber });
const testClient_default = (() => {
const clients = parsedRealmJson_default.clients.filter(client => {
return JSON.stringify(client).includes(TEST_APP_URL);
});
assert(clients.length === 1);
return clients[0];
})();
const clientIds_builtIn = parsedRealmJson_default.clients
.map(client => client.clientId)
.filter(clientId => clientId !== testClient_default.clientId);
const testClient_preexisting = (() => {
const clients = parsedRealmJson.clients
.filter(client => !clientIds_builtIn.includes(client.clientId))
.filter(client => client.protocol === "openid-connect");
{
const client = clients.find(
client => client.clientId === testClient_default.clientId
);
if (client !== undefined) {
return client;
}
}
{
const client = clients.find(
client =>
client.redirectUris?.find(redirectUri =>
redirectUri.startsWith(TEST_APP_URL)
) !== undefined
);
if (client !== undefined) {
return client;
}
}
const [client] = clients;
if (client === undefined) {
return undefined;
}
return client;
})();
let testClient: typeof testClient_default;
if (testClient_preexisting !== undefined) {
testClient = testClient_preexisting;
} else {
testClient = structuredClone(testClient_default);
delete testClient.protocolMappers;
parsedRealmJson.clients.push(testClient);
}
{
for (const redirectUri of [
`${TEST_APP_URL}/*`,
"http://localhost*",
"http://127.0.0.1*"
]) {
for (const propertyName of ["webOrigins", "redirectUris"] as const) {
const arr = (testClient[propertyName] ??= []);
if (arr.includes(redirectUri)) {
continue;
}
arr.push(redirectUri);
}
{
if (testClient.attributes === undefined) {
testClient.attributes = {};
}
const arr = (testClient.attributes["post.logout.redirect.uris"] ?? "")
.split("##")
.map(s => s.trim());
if (!arr.includes(redirectUri)) {
arr.push(redirectUri);
testClient.attributes["post.logout.redirect.uris"] = arr.join("##");
}
}
}
}
return { clientId: testClient.clientId };
}
function editAccountConsoleAndSecurityAdminConsole(params: {
parsedRealmJson: ParsedRealmJson;
}) {
const { parsedRealmJson } = params;
for (const clientId of ["account-console", "security-admin-console"]) {
const client = parsedRealmJson.clients.find(
client => client.clientId === clientId
);
assert(client !== undefined);
{
for (const redirectUri of [
`${TEST_APP_URL}/*`,
"http://localhost*",
"http://127.0.0.1*"
]) {
for (const propertyName of ["webOrigins", "redirectUris"] as const) {
const arr = (client[propertyName] ??= []);
if (arr.includes(redirectUri)) {
continue;
}
arr.push(redirectUri);
}
{
if (client.attributes === undefined) {
client.attributes = {};
}
const arr = (client.attributes["post.logout.redirect.uris"] ?? "")
.split("##")
.map(s => s.trim());
if (!arr.includes(redirectUri)) {
arr.push(redirectUri);
client.attributes["post.logout.redirect.uris"] = arr.join("##");
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff