diff --git a/.github/workflows/keycloak-drift.yml b/.github/workflows/keycloak-drift.yml index 7b5bd9c..cd9c591 100644 --- a/.github/workflows/keycloak-drift.yml +++ b/.github/workflows/keycloak-drift.yml @@ -25,6 +25,8 @@ on: # The check reads keycloakify's bundled message set, so an upgrade of it changes the answer. - 'theme/ui/package.json' - 'theme/ui/yarn.lock' + - 'theme/ui/dev-server/**' + - 'theme/build.gradle' pull_request: paths: @@ -38,6 +40,8 @@ on: - '.github/workflows/keycloak-drift.yml' - 'theme/ui/package.json' - 'theme/ui/yarn.lock' + - 'theme/ui/dev-server/**' + - 'theme/build.gradle' workflow_dispatch: @@ -79,3 +83,30 @@ jobs: - name: Check against Keycloak working-directory: theme/ui run: node scripts/check-keycloak-drift.mjs + + # The image build skips downloadStockKeycloak so a release cannot fail on Maven Central being + # unreachable, which leaves nothing exercising the download or the renderer behind the dev + # server's stock pages. This job is that, and it belongs here for the same reason: this + # workflow is already the one allowed to depend on Maven Central. + stock-renderer: + name: Dev server stock renderer + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 and gradle cache + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + - name: Download Keycloak's themes + run: ./gradlew downloadStockKeycloak + + # Covered nowhere else: the image build skips the download this needs. The script is + # runnable by hand as well, which is the point of it being a script. + - name: Render a stock page + run: theme/ui/dev-server/check-stock-renderer.sh diff --git a/.github/workflows/keycloak.yml b/.github/workflows/keycloak.yml index 1fc4f5a..f9a19d8 100644 --- a/.github/workflows/keycloak.yml +++ b/.github/workflows/keycloak.yml @@ -63,8 +63,11 @@ jobs: with: node-version: '22' + # downloadStockKeycloak fetches Keycloak's own themes, FreeMarker and Jackson so the local dev + # server can render stock pages. None of it goes in the image, so CI skips it rather than + # downloading it on every run and failing a release if Maven Central is unreachable. - name: Build with Gradle - run: ./gradlew installDist + run: ./gradlew installDist -x downloadStockKeycloak - name: Install QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/AGENTS.md b/AGENTS.md index 0f39ad6..e0ecf4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,11 @@ Work from the link you were given: node ids change when the file is reorganized, ## Gradle - The jar is assembled from two resource roots, so anything under `src/main/resources/theme/openremote/login` collides with the generated theme. -- The Dockerfile's `ARG VERSION` is the Keycloak the image runs; the version catalog's `keycloak` is what the event listener compiles against. +- The Dockerfile's `ARG VERSION` is the Keycloak the image runs; the version catalog's `keycloak` is what the event listener compiles against. `downloadStockKeycloak` reads the Dockerfile. + +## Dev server + +- `dev-server/StockRenderer.java` renders Keycloak's own templates from Keycloakify's mocks. `keycloakBehavior()` adapts the mock data to what `FreeMarkerLoginFormsProvider` provides; add a rule only for a template that fails to render, and never one that names a page. ## Verifying diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ed6b2d..387504e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] autoService = "1.1.1" byteBuddy = "1.17.5" +jackson = "2.22.2" jbossLogging = "3.6.1.Final" keycloak = "26.6.4" lombok = "1.18.38" @@ -10,6 +11,7 @@ slf4j = "2.0.17" autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } byteBuddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" } byteBuddyAgent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" } +jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jbossLogging = { module = "org.jboss.logging:jboss-logging", version.ref = "jbossLogging" } keycloakCore = { module = "org.keycloak:keycloak-core", version.ref = "keycloak" } keycloakServerSpi = { module = "org.keycloak:keycloak-server-spi", version.ref = "keycloak" } diff --git a/theme/build.gradle b/theme/build.gradle index 4ee3bb5..ff080e6 100644 --- a/theme/build.gradle +++ b/theme/build.gradle @@ -147,7 +147,99 @@ sourceSets.main.resources.srcDir unpackedThemeDir processResources.dependsOn 'unpackLoginTheme' +// --------------------------------------------------------------------------- +// Stock Keycloak themes, for the dev server only +// +// ui/dev-server renders Keycloak's own login templates from the dev harness's mock data, so a +// stock page can be shown beside ours, and an unimplemented page previewed, without running +// Keycloak. This downloads those templates, and the FreeMarker that renders them, into +// ui/.keycloak (gitignored). Nothing here reaches the jar. +// --------------------------------------------------------------------------- + +repositories { + mavenCentral() +} + +/* + * The Keycloak the image actually runs, taken from the Dockerfile's ARG VERSION, because the + * pages to compare against are the ones that version serves. The version catalog's keycloak + * entry is the SPI this repo compiles against, and is not necessarily the same. + */ +def keycloakImageVersion = providers.fileContents(rootProject.layout.projectDirectory.file('Dockerfile')) + .asText + .map { dockerfile -> + def match = dockerfile =~ /(?m)^ARG VERSION=(\S+)\s*$/ + if (!match.find()) { + throw new GradleException('No `ARG VERSION=` line in the Dockerfile') + } + match.group(1) + } + .get() + +configurations { + stockKeycloakThemes { canBeConsumed = false; transitive = false } + stockKeycloakVendor { canBeConsumed = false; transitive = false } + stockRenderer { canBeConsumed = false } +} + +dependencies { + stockKeycloakThemes "org.keycloak:keycloak-themes:${keycloakImageVersion}" + // PatternFly and the other assets the themes import live in a jar of their own. + stockKeycloakVendor "org.keycloak:keycloak-themes-vendor:${keycloakImageVersion}" + // Keycloak's parent pom pins the FreeMarker it renders with, so this follows it on upgrade. + stockRenderer platform("org.keycloak:keycloak-parent:${keycloakImageVersion}") + stockRenderer 'org.freemarker:freemarker' + // Only to read the dev server's own protocol, so it follows this repo rather than Keycloak. + stockRenderer libs.jacksonDatabind +} + +def stockKeycloakManifest = layout.buildDirectory.file('stock-keycloak/stock.json') + +tasks.register('writeStockKeycloakManifest') { + description = 'Records which Keycloak the downloaded stock themes belong to' + group = 'build' + + def version = keycloakImageVersion + def manifest = stockKeycloakManifest + + inputs.property('keycloakVersion', version) + outputs.file(manifest) + + doLast { + manifest.get().asFile.text = groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson([keycloakVersion: version])) + } +} + +tasks.register('downloadStockKeycloak', Sync) { + description = "Downloads Keycloak's own login themes for the dev server's stock pages" + group = 'build' + + into file('ui/.keycloak') + + // The two themes worth rendering: keycloak.v2, what a fresh realm uses, and keycloak, what + // the built theme's parent= points at for the pages this theme does not implement, plus + // base, which both extend. + from(zipTree(configurations.stockKeycloakThemes.elements.map { it.first().asFile })) { + include 'theme/base/login/**', 'theme/keycloak/login/**', 'theme/keycloak.v2/login/**', 'theme/keycloak/common/**' + } + from(zipTree(configurations.stockKeycloakVendor.elements.map { it.first().asFile })) { + include 'theme/keycloak/common/**' + } + // The renderer's classpath, as a directory: it is passed to java as `lib/*`, so which jars are + // in it is the resolved graph's business and nothing here has to name them. + from(configurations.stockRenderer) { + into 'lib' + } + from(tasks.named('writeStockKeycloakManifest')) + + includeEmptyDirs = false +} + tasks.register('installDist', Copy) { + // Not part of the image: only so the dev server's stock pages work after the usual build. + dependsOn 'downloadStockKeycloak' + into "${rootDir}/build/image" from jar.outputs } diff --git a/theme/ui/.gitignore b/theme/ui/.gitignore index 144007a..28a5ebe 100644 --- a/theme/ui/.gitignore +++ b/theme/ui/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ build_keycloak/ src/kc.gen.tsx +# Keycloak's own themes and the renderer's jars, downloaded by `./gradlew installDist`. +.keycloak/ # Keycloak's themes jar and its contents, downloaded by `yarn check-keycloak`. .keycloak-cache/ diff --git a/theme/ui/README.md b/theme/ui/README.md index bfa6bcf..3643168 100644 --- a/theme/ui/README.md +++ b/theme/ui/README.md @@ -15,17 +15,9 @@ yarn start # http://localhost:5173 No Keycloak, no container. Page data comes from Keycloakify's `getKcContextMock`, so every page has realistic values, and rspack live-reloads on save. -A rail down the left lists **all 38 login pages** in two sections: the ones with an -implementation, then the ones a built theme leaves to Keycloak's own (clicking those shows a -placeholder). It overlays rather than displacing the page, so the layout you are judging is the -real one; below 940px it collapses to a tab at the left edge. It also carries brand color and -logo overrides, for checking a custom project's branding without a manager running. - -Switching pages **does not reload the document** — the click is handled in place, the URL is -updated with `pushState` and Lit re-renders. A real navigation re-parsed the bundle and, since -rspack injects CSS through JS in development, repainted an unstyled frame each time, which is -what made switching flash. Back/forward work via `popstate`, and `index.html` resolves the color -scheme inline before first paint so a dark page never flashes light on hard reload. +Live reload covers `src/` only. `rspack.config.mjs` and the modules it imports — everything under `dev-server/` — are read once at startup, so a change there needs the dev server restarted. Nothing there is part of the built theme, so it never needs a Gradle build. + +A menu down the left side lists **every login page** in two sections: the ones with an implementation, then the ones a built theme leaves to Keycloak's own (clicking those renders Keycloak's page from the same mock data). It overlays rather than displacing the page, so the layout you are judging is the real one; below 940px it collapses to a tab at the left edge. It also carries brand color and logo overrides, for checking a custom project's branding without a manager running. Switching pages **does not reload the document** — the click is handled in place, the URL is updated with `pushState` and Lit re-renders. A real navigation re-parsed the bundle and, since rspack injects CSS through JS in development, repainted an unstyled frame each time, which is what made switching flash. Back/forward work via `popstate`, and `index.html` resolves the color scheme inline before first paint so a dark page never flashes light on hard reload. @@ -41,17 +33,40 @@ Pages, settings and color scheme are all addressable directly, `settings` being ``` http://localhost:5173/?page=login-config-totp.ftl +http://localhost:5173/?page=login-config-totp.ftl&settings=totp.manual +http://localhost:5173/?page=login.ftl&settings=realm.rememberMe,realm.registrationAllowed +http://localhost:5173/?page=login.ftl&settings=social.providers,message.error +http://localhost:5173/?page=login.ftl&lang=de http://localhost:5173/?page=login.ftl&theme=dark http://localhost:5173/?page=login.ftl&theme=light ``` -`theme` is an explicit override in **both** directions; with no `theme` parameter the page -follows `prefers-color-scheme`. +**Links and buttons on the pages go where Keycloak would go.** In production every one of them returns to Keycloak, and its authentication flow on the server decides what comes next — so without a server, every submit used to post to a URL the dev server does not serve, and the language dropdown opened a GitHub gist. `src/dev/flows.ts` replaces each navigation URL in the mock with a marker naming its field, and resolves it, together with the submit button's name and value, against a table of Keycloak's transitions: "Review profile" goes to `idp-review-user-profile`, "Forgot password" back to login with the email-sent message, "Try Another Way" to `select-authenticator`, the language dropdown to the same page in that language. + +Where Keycloak's answer depends on the realm or the data — does "Sign In" succeed, does the user have 2FA, can the realm send email — a small panel offers each outcome instead of picking one, and a step that ends the login flow says so rather than navigating nowhere. That table is the one place the harness has to know Keycloak's flow, because the mocks do not carry it; every destination in it is checked against the mocks' page ids, and a control with no entry says so on screen instead of silently doing nothing. + +`theme` is an explicit override in **both** directions. With no `theme` parameter the page is light: following `prefers-color-scheme` is switched off in `src/render.ts` until the manager has a dark mode of its own. + +**Nothing in that nav is hardcoded.** Both halves are derived: every pageId comes from `kcContextMocks`, the same data `getKcContextMock` serves, so a Keycloakify upgrade that adds or removes a page updates the list automatically; and the implemented set comes from `src/page-registry.ts`, which scans `src/pages` with rspack's `import.meta.webpackContext`. + +## Comparing against stock Keycloak + +**Show stock Keycloak** in the menu splits the window: this theme on the left, Keycloak's own page on the right. Useful for deciding whether a page is worth implementing, and more so now that the theme uses Keycloak's own wording — the copy on the left is supposed to be the copy on the right. + +No Keycloak, no container. The dev server renders Keycloak's own FreeMarker templates itself, from the same mock data the left side is using — so **the realm settings in the menu apply to both sides**, and every page Keycloakify has a mock for is one click away. It needs two things: + +- `./gradlew installDist` to have run once. It downloads the templates for the Keycloak version in the Dockerfile, plus the renderer's classpath — the FreeMarker that Keycloak release uses, and Jackson — into `.keycloak/` (gitignored). `./gradlew :theme:downloadStockKeycloak` does just that part. +- `java` on the PATH — the JDK Gradle already needs. The renderer is one file, `dev-server/StockRenderer.java`, run by the JDK's single-file launcher, so there is nothing to build. If either is missing the pane says which. + +The right side is Keycloak's `keycloak.v2` theme, which is what a fresh realm uses. Its links and buttons go through the same dev flows as ours (`src/dev/flows.ts`), so clicking "Forgot Password?" on either side moves both on. + +**Pages this theme does not implement render the same way, in place of a placeholder**, in the theme production actually serves them from. The dev server does not name that theme: it reads `parent=` out of the descriptor the build ships, so the preview cannot drift from the image. That is `keycloak.v2` by default, the same theme as the compare pane; `./gradlew installDist -PloginParentTheme=keycloak` switches the inherited pages to Keycloak's older theme, and the preview follows. + +The settings list is the union of what changes our page and what changes either stock page, so a setting only Keycloak reacts to is not hidden while you compare. Half of that answer arrives from the dev server a moment after the click, so until it does the list keeps whatever the page offered last time rather than collapsing to what the browser alone can work out and reopening. + +What this is not: a running server. The pages are Keycloak's markup with mock data, so behavior that only exists server-side — real validation, a real redirect — is not there. Two templates need more than their mocks provide (`webauthn-register`, `login-recovery-authn-code-config`); they say so rather than rendering blank. -**Nothing in that nav is hardcoded.** Both halves are derived: every pageId comes from -`kcContextMocks`, the same data `getKcContextMock` serves, so a Keycloakify upgrade that adds or -removes a page updates the list automatically; and the implemented set comes from -`src/page-registry.ts`, which scans `src/pages` with rspack's `import.meta.webpackContext`. +Keycloakify's mocks follow Keycloak's template data closely but not exactly, so the renderer fills the gap the way Keycloak's own FreeMarker provider does — see `keycloakBehavior()` in `StockRenderer.java`. Each rule there was found as a template failing to render, and none names a page, so an upgrade that changes one shows up as a render error rather than a wrong page. ## Adding a page @@ -102,7 +117,7 @@ Four further failures are about the check rather than the theme. It reads upstre What it watches is our pages' upstream counterparts closed over `<#import>`, not just the page files: much of what a page renders lives in a macro it imports, which can change while the page file does not. -It needs `unzip` or a JDK, and downloads Keycloak's themes jar once per version into `.keycloak-cache/`, checked against the checksum Maven Central publishes beside it. +It needs `unzip` or a JDK, and downloads Keycloak's themes jar once per version into `.keycloak-cache/`, checked against the checksum Maven Central publishes beside it. When `./gradlew installDist` has already unpacked the same version into `.keycloak/`, with every theme the check reads, it reads that instead. Which of Keycloak's own themes our pages are compared against is the one they would fall through to: `loginParentTheme` from `theme/build.gradle`, which is `keycloak.v2` unless the build is given another, plus `base` for every file that theme does not override. To check against a different one, set `ORG_GRADLE_PROJECT_loginParentTheme`, the environment variable Gradle also reads for that property. A `-PloginParentTheme` on a Gradle command line, or an entry in a `gradle.properties`, is not visible to the check. diff --git a/theme/ui/dev-server/StockRenderer.java b/theme/ui/dev-server/StockRenderer.java new file mode 100644 index 0000000..9482179 --- /dev/null +++ b/theme/ui/dev-server/StockRenderer.java @@ -0,0 +1,371 @@ +import com.fasterxml.jackson.databind.ObjectMapper; + +import freemarker.cache.FileTemplateLoader; +import freemarker.cache.MultiTemplateLoader; +import freemarker.cache.TemplateLoader; +import freemarker.core.HTMLOutputFormat; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapper; +import freemarker.template.Template; +import freemarker.template.TemplateBooleanModel; +import freemarker.template.TemplateException; +import freemarker.template.TemplateExceptionHandler; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.TemplateNumberModel; +import freemarker.template.TemplateScalarModel; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.Reader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; + +/* + * Dev-only: renders Keycloak's own login templates outside Keycloak, from the dev server's mock data. + * + * This is what lets the dev server show a stock Keycloak page next to ours (and a page this theme + * does not implement in place of a placeholder) without running Keycloak. The templates are + * Keycloak's, extracted from its keycloak-themes jar by `./gradlew installDist`, so nothing about + * them is reproduced here. What is reproduced is the small part of Keycloak's FreeMarker provider + * that shapes the data a template sees; see keycloakBehavior() below. + * + * Run with the JDK's single-file source launcher, so there is no build step: + * + * java -cp "lib/*" StockRenderer.java + * + * It stays running and answers one JSON request per line on stdin with one JSON response per line + * on stdout. dev-server/stock.mjs is the other end: + * + * {"id":1,"theme":"keycloak.v2","template":"login.ftl","locale":"en","model":{...}} + * {"id":1,"html":"..."} or {"id":1,"error":"..."} + * + * The model is the mock as plain JSON. Everything this adds to it is in keycloakBehavior(), and + * it is deliberately small: Keycloak's templates distinguish an absent value from an empty one + * (`usernameHidden??`, `auth?has_content`), so a general "missing means empty" rule would change + * what pages render. Each entry there is a value Keycloak's provider always sets. + */ +public class StockRenderer { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final Path themes; + private final Map loaded = new HashMap<>(); + + StockRenderer(Path themes) { + this.themes = themes; + } + + public static void main(String[] args) throws IOException { + StockRenderer renderer = new StockRenderer(Path.of(args[0])); + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8); + + for (String line; (line = in.readLine()) != null; ) { + if (line.isBlank()) { + continue; + } + + Object id = null; + + try { + @SuppressWarnings("unchecked") + Map request = JSON.readValue(line, Map.class); + id = request.get("id"); + + @SuppressWarnings("unchecked") + String html = renderer.render( + (String) request.get("theme"), + (String) request.get("template"), + (String) request.getOrDefault("locale", "en"), + (Map) request.get("model")); + + respond(out, id, "html", html); + } catch (TemplateException e) { + respond(out, id, "error", describe(e)); + } catch (Exception e) { + respond(out, id, "error", e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + } + + /** One response per line, so the reader on the other end can split on newlines. */ + private static void respond(PrintStream out, Object id, String key, String value) throws IOException { + Map response = new LinkedHashMap<>(); + response.put("id", id); + response.put(key, value); + out.println(JSON.writeValueAsString(response)); + } + + /** One line a developer can act on, rather than FreeMarker's full report. */ + private static String describe(TemplateException e) { + String expression = e.getBlamedExpressionString(); + String where = e.getTemplateSourceName() + " line " + e.getLineNumber(); + + return expression != null + ? "Keycloak's " + where + " needs `" + expression + "`, which this page's mock data does not provide." + : where + ": " + e.getMessageWithoutStackTop().lines().findFirst().orElse(""); + } + + String render(String themeName, String template, String locale, Map model) throws IOException, TemplateException { + Theme theme = loaded.computeIfAbsent(themeName, this::load); + keycloakBehavior(model, theme, locale); + + Template t = theme.configuration.getTemplate(template); + StringWriter html = new StringWriter(); + t.process(model, html); + return html.toString(); + } + + /* + * Members Keycloak's beans always have and Keycloakify's mocks leave out when they are falsey, + * which makes a template reading one fail outright. Listed as data because that is all they are; + * `attributesByName.*` is applied to each attribute of a user profile. + */ + private static final Map> ALWAYS_SET = Map.of( + "", Map.of("passwordPolicies", Map.of()), + "auth", Map.of("showUsername", false, "showResetCredentials", false, "showTryAnotherWayLink", false), + "locale", Map.of("rtl", false), + "profile", Map.of("html5DataAnnotations", Map.of()), + "attributesByName.*", Map.of( + "multivalued", false, "readOnly", false, "required", false, + "annotations", Map.of(), "validators", Map.of(), + "html5DataAnnotations", Map.of(), "values", List.of())); + + /* + * The parts of Keycloak's FreeMarkerLoginFormsProvider (and the beans it hands templates) that a + * mock cannot carry. Keycloakify's mocks mirror Keycloak's template data closely (they are built + * from it) but not exactly, and every rule below was found as a template failing to render. + * They describe how Keycloak builds the data, not any one page, so none of them names a page. + */ + @SuppressWarnings("unchecked") + private static void keycloakBehavior(Map model, Theme theme, String locale) { + /* + * Keycloak only sets a top-level flag when it is on, and its templates test for presence - + * `usernameHidden??`, `isAppInitiatedAction??`, `enableWebAuthnConditionalUI?has_content`. + * Keycloakify writes the same flags as an explicit false, which FreeMarker counts as present. + * Nested bean properties such as realm.rememberMe are read directly and keep their value. + */ + model.values().removeIf(Boolean.FALSE::equals); + + model.put("properties", theme.properties); + model.put("lang", locale); + model.putIfAbsent("darkMode", Boolean.parseBoolean(String.valueOf(theme.properties.getOrDefault("darkMode", "false")))); + + Properties messages = theme.messages(locale); + model.put("msg", (TemplateMethodModelEx) args -> format(messages, str(args.get(0)), args.subList(1, args.size()))); + model.put("advancedMsg", (TemplateMethodModelEx) args -> { + String key = str(args.get(0)); + if (key.startsWith("${") && key.endsWith("}")) { + key = key.substring(2, key.length() - 1); + } + return messages.containsKey(key) ? format(messages, key, args.subList(1, args.size())) : key; + }); + // Keycloak sanitizes admin-authored HTML here. Mock data is not user input. + model.put("kcSanitize", (TemplateMethodModelEx) args -> str(args.get(0))); + + /* + * MessagesPerFieldBean. The harness has no per-field errors, so these answer "none", but they + * have to exist, because the templates call them. + */ + model.put("messagesPerField", Map.of( + "existsError", (TemplateMethodModelEx) args -> false, + "exists", (TemplateMethodModelEx) args -> false, + "get", (TemplateMethodModelEx) args -> "", + "getFirstError", (TemplateMethodModelEx) args -> "", + "printIfExists", (TemplateMethodModelEx) args -> "")); + + alwaysSet(model, ""); + alwaysSet(model.get("auth"), "auth"); + alwaysSet(model.get("locale"), "locale"); + alwaysSet(model.get("profile"), "profile"); + + /* + * The two that are derivations rather than defaults: LocaleBean.current is the current + * language's display name, and ProfileBean carries its attributes as a list while the mocks + * keep only the by-name map. + */ + if (model.get("locale") instanceof Map localeBean) { + Map bean = (Map) localeBean; + Object tag = bean.get("currentLanguageTag"); + bean.putIfAbsent("current", label(bean.get("supported"), tag)); + } + + if (model.get("profile") instanceof Map profileBean + && profileBean.get("attributesByName") instanceof Map byName) { + ((Map) profileBean).putIfAbsent("attributes", new ArrayList<>(byName.values())); + byName.values().forEach(attribute -> alwaysSet(attribute, "attributesByName.*")); + } + } + + /** Adds the members Keycloak always sets on this bean, leaving anything the mock has alone. */ + @SuppressWarnings("unchecked") + private static void alwaysSet(Object bean, String path) { + if (bean instanceof Map map) { + ALWAYS_SET.getOrDefault(path, Map.of()).forEach(((Map) map)::putIfAbsent); + } + } + + /** The label `supported` gives for `tag`, or the tag itself. */ + private static String label(Object supported, Object tag) { + if (supported instanceof List languages) { + for (Object entry : languages) { + if (entry instanceof Map language && Objects.equals(language.get("languageTag"), tag)) { + return String.valueOf(language.get("label")); + } + } + } + return String.valueOf(tag); + } + + /** A theme and its parents, loaded the way Keycloak resolves them: `parent=` in theme.properties. */ + private Theme load(String name) { + try { + List chain = new ArrayList<>(); + for (String current = name; current != null; ) { + Path dir = themes.resolve(current).resolve("login"); + if (!Files.isDirectory(dir)) { + throw new IOException("No login theme `" + current + "` under " + themes); + } + chain.add(dir); + current = properties(dir.resolve("theme.properties")).getProperty("parent"); + } + return new Theme(chain); + } catch (IOException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + private static final class Theme { + /** Child first: keycloak.v2, then base. */ + final List chain; + final Map properties = new HashMap<>(); + final Configuration configuration = new Configuration(Configuration.VERSION_2_3_32); + private final Map messagesByLocale = new HashMap<>(); + + Theme(List chain) throws IOException { + this.chain = chain; + + // Parent first, so a child's value wins, the same merge Keycloak does. + for (int i = chain.size() - 1; i >= 0; i--) { + properties(chain.get(i).resolve("theme.properties")).forEach((k, v) -> properties.put((String) k, v)); + } + + List loaders = new ArrayList<>(); + for (Path dir : chain) { + loaders.add(new FileTemplateLoader(dir.toFile())); + } + configuration.setTemplateLoader(new MultiTemplateLoader(loaders.toArray(new TemplateLoader[0]))); + configuration.setObjectWrapper(new CallableWrapper()); + configuration.setOutputFormat(HTMLOutputFormat.INSTANCE); + configuration.setDefaultEncoding("UTF-8"); + configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + configuration.setLogTemplateExceptions(false); + } + + /** English underneath the requested language, so a key missing from a translation still resolves. */ + Properties messages(String locale) { + return messagesByLocale.computeIfAbsent(locale, tag -> { + Properties merged = new Properties(); + for (String bundle : tag.equals("en") ? List.of("en") : List.of("en", tag.replace('-', '_'))) { + for (int i = chain.size() - 1; i >= 0; i--) { + try { + merged.putAll(properties(chain.get(i).resolve("messages/messages_" + bundle + ".properties"))); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + return merged; + }); + } + } + + private static Properties properties(Path path) throws IOException { + Properties properties = new Properties(); + if (Files.exists(path)) { + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + properties.load(reader); + } + } + return properties; + } + + private static String str(Object value) throws TemplateModelException { + if (value instanceof TemplateScalarModel scalar) { + return scalar.getAsString(); + } + if (value instanceof TemplateNumberModel number) { + return number.getAsNumber().toString(); + } + return String.valueOf(value); + } + + /** Keycloak's messages are MessageFormat patterns, with '' for a literal apostrophe. */ + private static String format(Properties messages, String key, List args) throws TemplateModelException { + String pattern = messages.getProperty(key); + if (pattern == null) { + return key; + } + Object[] values = new Object[args.size()]; + for (int i = 0; i < values.length; i++) { + values[i] = str(args.get(i)); + } + return new MessageFormat(pattern.replace("'", "''").replace("''''", "''")).format(values); + } + + /* + * Keycloak's beans expose values both as properties and as methods (`auth.showUsername` and + * `auth.showUsername()` are both valid against the real AuthenticationContextBean) while JSON can + * only carry the value. Every scalar is therefore also callable with no arguments, returning itself. + */ + private static final class CallableWrapper extends DefaultObjectWrapper { + CallableWrapper() { + super(Configuration.VERSION_2_3_32); + } + + @Override + public TemplateModel wrap(Object value) throws TemplateModelException { + if (value instanceof Boolean b) { + return new CallableBoolean(b); + } + if (value instanceof String s) { + return new CallableString(s); + } + return super.wrap(value); + } + } + + private record CallableBoolean(boolean value) implements TemplateBooleanModel, TemplateMethodModelEx { + public boolean getAsBoolean() { + return value; + } + + public Object exec(@SuppressWarnings("rawtypes") List args) { + return this; + } + } + + private record CallableString(String value) implements TemplateScalarModel, TemplateMethodModelEx { + public String getAsString() { + return value; + } + + public Object exec(@SuppressWarnings("rawtypes") List args) { + return this; + } + } +} diff --git a/theme/ui/dev-server/check-stock-renderer.sh b/theme/ui/dev-server/check-stock-renderer.sh new file mode 100755 index 0000000..f3afb3e --- /dev/null +++ b/theme/ui/dev-server/check-stock-renderer.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Renders one of Keycloak's own templates through StockRenderer.java, the way the dev server does, +# and fails unless Keycloak's own markup comes back. +# +# What this covers is the toolchain the compare pane stands on: what `./gradlew +# downloadStockKeycloak` fetched, the classpath it assembled, the JDK running the renderer as a +# source file, and FreeMarker finding a template through the theme chain. It does not cover the +# dev server itself, nor keycloakBehavior(), which is what adapts a page's mock data. +# +# The model is the smallest error.ftl accepts, because the template is the instrument here, not +# the subject. Run it from anywhere: +# +# theme/ui/dev-server/check-stock-renderer.sh +set -euo pipefail + +ui=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +downloaded="$ui/.keycloak" + +if [ ! -d "$downloaded/lib" ]; then + echo "Keycloak's themes are not downloaded yet. Run \`./gradlew downloadStockKeycloak\` from the repository root." >&2 + exit 1 +fi + +# One JSON object per line is the renderer's protocol, so the newlines come back out. +request=$(cat <<'REQUEST' | tr -d '\n' +{"id": 1, "theme": "keycloak.v2", "template": "error.ftl", "locale": "en", "model": { + "pageId": "", "properties": {}, "scripts": [], + "realm": { "name": "master", "displayNameHtml": "master", "internationalizationEnabled": false }, + "url": { "resourcesPath": "/r", "resourcesCommonPath": "/rc", "loginAction": "/a", "ssoLoginInOtherTabsUrl": "/sso" }, + "message": { "type": "error", "summary": "Rendered by the stock renderer" }, + "client": { "baseUrl": "" }, + "auth": { "showTryAnotherWayLink": false } +}} +REQUEST +) + +response=$(printf '%s\n' "$request" | java -cp "$downloaded/lib/*" "$ui/dev-server/StockRenderer.java" "$downloaded/theme") + +# login-pf is Keycloak's own class, from its template.ftl, so it is there only if a template of +# theirs actually rendered. A failure answers with "error" instead and is printed in full. +if [[ $response != *login-pf* ]]; then + echo "The stock renderer did not render Keycloak's page:" >&2 + echo "$response" >&2 + exit 1 +fi + +echo "Rendered keycloak.v2/error.ftl, ${#response} bytes of response." diff --git a/theme/ui/dev-server/stock.mjs b/theme/ui/dev-server/stock.mjs new file mode 100644 index 0000000..fd77b78 --- /dev/null +++ b/theme/ui/dev-server/stock.mjs @@ -0,0 +1,364 @@ +/* + * Dev-only: stock Keycloak pages, rendered by the dev server rather than by a running Keycloak. + * + * `./gradlew installDist` downloads Keycloak's own themes and the FreeMarker version Keycloak uses + * into ui/.keycloak (gitignored). This module starts StockRenderer.java once, with the JDK's + * single-file launcher, and exposes it to the dev harness: + * + * GET /__stock/status what is installed, or why nothing is + * POST /__stock/render {theme, pageId, locale, model, variants?} -> {url, changed} + * GET /__stock/page/ a rendered page + * GET /__stock/resources//... that theme's resources, looked up through its parents + * GET /__stock/resources/common/... the resources every theme imports + * + * The model is the same shaped kcContext the harness renders our own page from, so the realm + * settings in the menu apply to both. Wired into rspack.config.mjs's devServer only; a production + * build never loads it. + */ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +/* + * The theme a fresh Keycloak realm uses for its login pages, and so the one to compare against. Not + * derivable from the jar: it is a server default (Keycloak 26's login v2 feature), which is also what + * the live-Keycloak pane shows. + */ +const COMPARE_THEME = "keycloak.v2"; + +const TYPES = { + ".css": "text/css", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject" +}; + +/** `parent=` and `import=` from a theme.properties, or nothing if the file is not there. */ +function themeProperties(file) { + if (!existsSync(file)) { + return {}; + } + + return Object.fromEntries( + readFileSync(file, "utf8") + .split(/\r?\n/) + .map(line => /^\s*([\w.-]+)\s*=\s*(.*?)\s*$/.exec(line)) + .filter(match => match !== null) + .map(match => [match[1], match[2]]) + ); +} + +/* + * Injected into every rendered page. Its links and forms carry the same #dev-flow: markers as our + * own page (they come from the same mock), but they would go nowhere inside a frame: a form posts + * to a URL the dev server does not serve. So both are handed to the harness, which resolves them + * through src/dev/flows.ts exactly as it does for ours and moves the whole preview on. + */ +const BRIDGE = ``; + +/** + * Puts the bridge at the end of the document. + * + * The closing tag is found from the end, not the start: `login-recovery-authn-code-config` builds a + * printable document inside a JavaScript string, so the first `` in the page is a literal + * three quarters of the way through a `