refactor(k8s): keep the big-demo theme as real files under infra/keycloak/themes (refs #177)
CI / lint (pull_request) Successful in 1m55s
CI / k8s (pull_request) Successful in 9s
CI / build (pull_request) Successful in 1m27s
CI / unit (pull_request) Successful in 1m51s
CI / frontend (pull_request) Successful in 3m24s
CI / mutation (pull_request) Successful in 6m40s
CI / verify-stack (pull_request) Failing after 1m1s

The theme was inline text in a Helm template. It now lives next to the realms
and is seeded as rr-kc-theme by seed-configmaps.sh, like every other file
input, so it can be edited as a normal Keycloak theme. demo.otpAutofill now
only decides whether KC_SPI_THEME_DEFAULT is set (big.env skips values that
render empty); off, Keycloak keeps its stock theme.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
not
2026-09-25 12:15:35 +02:00
co-authored by Claude Opus 5.5
parent 6cd2268be9
commit 0ea562db52
11 changed files with 79 additions and 77 deletions
+10 -3
View File
@@ -431,9 +431,16 @@ For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same valu
With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string. With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string.
Staff logins still hit the enforced OTP step. For a demo, set the repository variable Staff logins still hit the enforced OTP step. For a demo, set the repository variable
`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): the `big-demo` login theme then `OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): Keycloak then uses the `big-demo`
fills in and submits the code from the fixture secret, so the step is visible but needs no theme, which fills in and submits the code from the fixture secret, so the step is visible
authenticator. Keycloak restarts when the value flips. Demo only — the secret is committed. but needs no authenticator. Keycloak restarts when the value flips. Demo only — the secret
is committed.
The theme lives in `infra/keycloak/themes/big-demo/` and is seeded as the `rr-kc-theme`
ConfigMap by `infra/helm/seed-configmaps.sh` on every deploy. Keycloak runs `start-dev`,
which doesn't cache themes, so an edit shows up about a minute after the ConfigMap changes.
A *new* theme file also needs a key in the seed script and a path in the keycloak `files`
in `values.yaml`.
One-time setup: One-time setup:
@@ -129,13 +129,17 @@ volumes:
{{/* {{/*
Env list from a map. Every value is run through `tpl`, so values.yaml can name Env list from a map. Every value is run through `tpl`, so values.yaml can name
cluster-internal hosts ({{ .Release.Namespace }}) and the node address cluster-internal hosts ({{ .Release.Namespace }}) and the node address
({{ .Values.host }}) without the chart hard-coding either. ({{ .Values.host }}) without the chart hard-coding either. A value that renders
empty is left out, which is how a setting is made conditional on a chart value.
*/}} */}}
{{- define "big.env" -}} {{- define "big.env" -}}
{{- $root := index . 0 -}} {{- $root := index . 0 -}}
{{- range $k, $v := index . 1 }} {{- range $k, $v := index . 1 }}
{{- $val := tpl (toString $v) $root }}
{{- if $val }}
- name: {{ $k }} - name: {{ $k }}
value: {{ tpl (toString $v) $root | quote }} value: {{ $val | quote }}
{{- end }}
{{- end }} {{- end }}
{{- end -}} {{- end -}}
@@ -30,11 +30,6 @@ spec:
annotations: annotations:
checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }} checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }}
{{- end }} {{- end }}
{{- /* Keycloak reads the theme at startup, so it restarts when the toggle flips. */}}
{{- if eq .configMap "kc-theme" }}
annotations:
checksum/otp-autofill: {{ $.Values.demo.otpAutofill | toString | sha256sum }}
{{- end }}
{{- end }} {{- end }}
labels: labels:
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }} {{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }}
@@ -1,62 +0,0 @@
{{- /*
Keycloak theme `big-demo`: keycloak.v2 plus one login script. It is always
mounted and always the default theme (KC_SPI_THEME_DEFAULT), so the only thing
`demo.otpAutofill` switches is what that script does. Off, it is empty and the
login is exactly keycloak.v2.
KC_SPI_THEME_DEFAULT covers every theme type, and Keycloak does not fall back
for a type the theme lacks (the account console 500s), so account, admin and
email are declared too, each a plain child of Keycloak 26's own default.
On, the medewerker OTP step computes the code from the realm fixture secret
(docs/runbooks/keycloak.md) and submits it: the demo still shows MFA being
enforced without anyone needing an authenticator. The secret is committed and
shared, so this is a demo convenience only — never enable it anywhere real.
*/ -}}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kc-theme
labels:
{{- include "big.labels" (dict "root" $ "name" "kc-theme") | nindent 4 }}
data:
login.properties: |
parent=keycloak.v2
import=common/keycloak
scripts=js/otp-autofill.js
account.properties: |
parent=keycloak.v3
admin.properties: |
parent=keycloak.v2
email.properties: |
parent=keycloak
otp-autofill.js: |
{{- if .Values.demo.otpAutofill }}
// RFC 6238 with Keycloak's default policy (HmacSHA1, 6 digits, 30 s) over the
// raw bytes of the medewerker fixture secret — same as tests/e2e/keycloak-login.ts.
document.addEventListener('DOMContentLoaded', async () => {
const input = document.querySelector('input[name="otp"]');
if (!input || !input.form) return;
const key = await crypto.subtle.importKey('raw',
new TextEncoder().encode('BIGMEDEWERKEROTPSEED'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
// A code is single-use, so a second login in the same window spends the next
// counter (Keycloak's look-ahead accepts it). Past that, fill but don't submit,
// so a rejected code can't turn into a submit loop.
const now = Math.floor(Date.now() / 30000);
let last = -1;
try { last = Number(sessionStorage.getItem('big-otp-counter')) || -1; } catch {}
const counter = Math.max(now, last + 1);
const msg = new DataView(new ArrayBuffer(8));
msg.setBigUint64(0, BigInt(counter));
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg.buffer));
const o = mac[19] & 0x0f;
const n = ((mac[o] & 0x7f) << 24 | mac[o + 1] << 16 | mac[o + 2] << 8 | mac[o + 3]) % 1e6;
input.value = String(n).padStart(6, '0');
if (counter > now + 1) return;
try { sessionStorage.setItem('big-otp-counter', String(counter)); } catch {}
input.form.requestSubmit();
});
{{- else }}
// demo.otpAutofill is off: the OTP step is entered by hand.
{{- end }}
+7 -5
View File
@@ -32,8 +32,8 @@ keycloakUrl: ""
demo: demo:
# Fill in and submit the medewerker OTP step from the fixture secret, so a public # Fill in and submit the medewerker OTP step from the fixture secret, so a public
# demo shows MFA enforced without an authenticator (templates/keycloak-theme.yaml). # demo shows MFA enforced without an authenticator: makes the big-demo theme
# Demo only: the secret is committed. # (infra/keycloak/themes/big-demo) Keycloak's default. Demo only: the secret is committed.
otpAutofill: false otpAutofill: false
# Set when pulling from a private registry (e.g. the Gitea Container Registry). # Set when pulling from a private registry (e.g. the Gitea Container Registry).
@@ -281,15 +281,17 @@ workloads:
# this issuer back, which is what browser tokens carry (infra/host-browser.yml). # this issuer back, which is what browser tokens carry (infra/host-browser.yml).
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}' KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
# keycloak.v2 plus the demo.otpAutofill script (templates/keycloak-theme.yaml). # Only rendered with demo.otpAutofill (big.env skips empty values); off, Keycloak
KC_SPI_THEME_DEFAULT: big-demo # keeps its stock theme and the mounted big-demo theme is unused.
KC_SPI_THEME_DEFAULT: '{{ if .Values.demo.otpAutofill }}big-demo{{ end }}'
ports: [{ name: http, port: 8080 }] ports: [{ name: http, port: 8080 }]
# TCP, not /health/ready on the management port: nothing here gates on realm # TCP, not /health/ready on the management port: nothing here gates on realm
# import, and a wrong health path would leave the Service with no endpoints. # import, and a wrong health path would leave the Service with no endpoints.
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 } probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
files: files:
- { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import } - { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }
- configMap: kc-theme # infra/keycloak/themes/big-demo, seeded by infra/helm/seed-configmaps.sh.
- configMap: rr-kc-theme
mountPath: /opt/keycloak/themes/big-demo mountPath: /opt/keycloak/themes/big-demo
items: items:
- { key: login.properties, path: login/theme.properties } - { key: login.properties, path: login/theme.properties }
+9
View File
@@ -30,6 +30,15 @@ seed() { # name <kubectl --from-file args...>
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/" seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/" seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/" seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
# The big-demo login theme (demo.otpAutofill). ConfigMap keys are flat, so each
# file gets a key here and its path back in the keycloak `files` in values.yaml.
theme="$repo/infra/keycloak/themes/big-demo"
seed rr-kc-theme \
--from-file=login.properties="$theme/login/theme.properties" \
--from-file=otp-autofill.js="$theme/login/resources/js/otp-autofill.js" \
--from-file=account.properties="$theme/account/theme.properties" \
--from-file=admin.properties="$theme/admin/theme.properties" \
--from-file=email.properties="$theme/email/theme.properties"
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/" seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/" seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped: # register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the account page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v3
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the admin page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v2
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the email page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak
@@ -0,0 +1,24 @@
// RFC 6238 with Keycloak's default policy (HmacSHA1, 6 digits, 30 s) over the
// raw bytes of the medewerker fixture secret — same as tests/e2e/keycloak-login.ts.
document.addEventListener('DOMContentLoaded', async () => {
const input = document.querySelector('input[name="otp"]');
if (!input || !input.form) return;
const key = await crypto.subtle.importKey('raw',
new TextEncoder().encode('BIGMEDEWERKEROTPSEED'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
// A code is single-use, so a second login in the same window spends the next
// counter (Keycloak's look-ahead accepts it). Past that, fill but don't submit,
// so a rejected code can't turn into a submit loop.
const now = Math.floor(Date.now() / 30000);
let last = -1;
try { last = Number(sessionStorage.getItem('big-otp-counter')) || -1; } catch {}
const counter = Math.max(now, last + 1);
const msg = new DataView(new ArrayBuffer(8));
msg.setBigUint64(0, BigInt(counter));
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg.buffer));
const o = mac[19] & 0x0f;
const n = ((mac[o] & 0x7f) << 24 | mac[o + 1] << 16 | mac[o + 2] << 8 | mac[o + 3]) % 1e6;
input.value = String(n).padStart(6, '0');
if (counter > now + 1) return;
try { sessionStorage.setItem('big-otp-counter', String(counter)); } catch {}
input.form.requestSubmit();
});
@@ -0,0 +1,11 @@
# Demo login theme for the public Talos deployment: keycloak.v2 plus a script that
# fills in and submits the medewerker OTP step from the committed fixture secret
# (docs/runbooks/keycloak.md). Only used when the chart's demo.otpAutofill is on —
# it then becomes Keycloak's default theme. Never enable it anywhere real.
#
# Add styles, messages or template overrides here as in any Keycloak theme
# (https://www.keycloak.org/ui-customization/themes); new files must also be
# listed in infra/helm/seed-configmaps.sh and the keycloak `files` in values.yaml.
parent=keycloak.v2
import=common/keycloak
scripts=js/otp-autofill.js