From 1cc04de630add9186462b6af4c3140502ddef4ea Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 25 Sep 2026 11:52:29 +0200 Subject: [PATCH 1/3] feat(k8s): optionally auto-fill the medewerker OTP step for the public demo (refs #177) A `big-demo` Keycloak login theme (keycloak.v2 + one script) is always mounted and set as default. With demo.otpAutofill (repo variable OTP_AUTOFILL=true) the script computes the code from the committed fixture secret and submits it, so the demo shows MFA enforced without an authenticator. Off by default: the script is empty and the login is plain keycloak.v2. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/deploy.yaml | 5 +- docs/runbooks/kubernetes-talos.md | 5 ++ .../big-reference/templates/deployments.yaml | 5 ++ .../templates/keycloak-theme.yaml | 60 +++++++++++++++++++ infra/helm/big-reference/values.yaml | 13 +++- 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 infra/helm/big-reference/templates/keycloak-theme.yaml diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 1f9dfae..5c53711 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -31,6 +31,9 @@ jobs: # origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing # through the labs Caddy"). KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }} + # `true` fills in the medewerker OTP step for the public demo (chart value + # demo.otpAutofill). The fixture secret is committed: demo only. + OTP_AUTOFILL: ${{ vars.OTP_AUTOFILL }} steps: - uses: https://github.com/actions/checkout@v4 @@ -97,7 +100,7 @@ jobs: make k8s-reseed \ TALOS_HOST=${TALOS_HOST:-localhost} \ K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \ - K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL}" + K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL} --set demo.otpAutofill=${OTP_AUTOFILL:-false}" # `dev` is a mutable tag and helm sees an unchanged pod template, so the # new images only land on a restart (pullPolicy is already Always). diff --git a/docs/runbooks/kubernetes-talos.md b/docs/runbooks/kubernetes-talos.md index 75b3c2b..e7abb4a 100644 --- a/docs/runbooks/kubernetes-talos.md +++ b/docs/runbooks/kubernetes-talos.md @@ -430,6 +430,11 @@ make k8s-up TALOS_HOST=localhost K8S_REGISTRY=:30500 \ For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value. 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 +`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): the `big-demo` login theme then +fills in and submits the code from the fixture secret, so the step is visible but needs no +authenticator. Keycloak restarts when the value flips. Demo only — the secret is committed. + One-time setup: 1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo diff --git a/infra/helm/big-reference/templates/deployments.yaml b/infra/helm/big-reference/templates/deployments.yaml index 64fd8fb..0bcea09 100644 --- a/infra/helm/big-reference/templates/deployments.yaml +++ b/infra/helm/big-reference/templates/deployments.yaml @@ -30,6 +30,11 @@ spec: annotations: checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }} {{- end }} + {{- /* subPath mounts never refresh, so Keycloak restarts when the toggle flips. */}} + {{- if eq .configMap "kc-theme-js" }} + annotations: + checksum/otp-autofill: {{ $.Values.demo.otpAutofill | toString | sha256sum }} + {{- end }} {{- end }} labels: {{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }} diff --git a/infra/helm/big-reference/templates/keycloak-theme.yaml b/infra/helm/big-reference/templates/keycloak-theme.yaml new file mode 100644 index 0000000..2f249aa --- /dev/null +++ b/infra/helm/big-reference/templates/keycloak-theme.yaml @@ -0,0 +1,60 @@ +{{- /* +Keycloak login theme `big-demo`: keycloak.v2 plus one 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. + +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: + theme.properties: | + parent=keycloak.v2 + import=common/keycloak + scripts=js/otp-autofill.js +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: kc-theme-js + labels: +{{- include "big.labels" (dict "root" $ "name" "kc-theme-js") | nindent 4 }} +data: + 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 }} diff --git a/infra/helm/big-reference/values.yaml b/infra/helm/big-reference/values.yaml index 20e7361..2f68b43 100644 --- a/infra/helm/big-reference/values.yaml +++ b/infra/helm/big-reference/values.yaml @@ -30,6 +30,12 @@ host: 192.168.122.100 # portals' authority (runbook, "Publishing through the labs Caddy"). keycloakUrl: "" +demo: + # 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 only: the secret is committed. + otpAutofill: false + # Set when pulling from a private registry (e.g. the Gitea Container Registry). imagePullSecrets: [] @@ -275,11 +281,16 @@ workloads: # this issuer back, which is what browser tokens carry (infra/host-browser.yml). KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}' KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" + # keycloak.v2 plus the demo.otpAutofill script (templates/keycloak-theme.yaml). + KC_SPI_THEME_DEFAULT: big-demo ports: [{ name: http, port: 8080 }] # 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. probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 } - files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }] + files: + - { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import } + - { configMap: kc-theme, mountPath: /opt/keycloak/themes/big-demo/login/theme.properties, subPath: theme.properties } + - { configMap: kc-theme-js, mountPath: /opt/keycloak/themes/big-demo/login/resources/js/otp-autofill.js, subPath: otp-autofill.js } # ── Flowable (S-03) ───────────────────────────────────────────────────────── flowable-db: -- 2.54.0 From 6cd2268be9f372c2c600baca24fa61c64efe92ed Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 25 Sep 2026 12:02:46 +0200 Subject: [PATCH 2/3] fix(k8s): declare every theme type in big-demo so the account console loads (refs #177) KC_SPI_THEME_DEFAULT applies to all theme types and Keycloak does not fall back for one the theme lacks: the account console returned 500 (NPE on a null theme). The theme is now one ConfigMap mounted as a directory with login, account, admin and email types; the podspec gains `items` for that. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../helm/big-reference/templates/_helpers.tpl | 4 ++++ .../big-reference/templates/deployments.yaml | 4 ++-- .../templates/keycloak-theme.yaml | 22 ++++++++++--------- infra/helm/big-reference/values.yaml | 10 +++++++-- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/infra/helm/big-reference/templates/_helpers.tpl b/infra/helm/big-reference/templates/_helpers.tpl index 24bb55d..588535d 100644 --- a/infra/helm/big-reference/templates/_helpers.tpl +++ b/infra/helm/big-reference/templates/_helpers.tpl @@ -94,6 +94,10 @@ volumes: {{- with .defaultMode }} defaultMode: {{ . }} {{- end }} + {{- with .items }} + items: +{{- toYaml . | nindent 8 }} + {{- end }} {{- end }} {{- with $w.data }} - name: data diff --git a/infra/helm/big-reference/templates/deployments.yaml b/infra/helm/big-reference/templates/deployments.yaml index 0bcea09..df9ead7 100644 --- a/infra/helm/big-reference/templates/deployments.yaml +++ b/infra/helm/big-reference/templates/deployments.yaml @@ -30,8 +30,8 @@ spec: annotations: checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }} {{- end }} - {{- /* subPath mounts never refresh, so Keycloak restarts when the toggle flips. */}} - {{- if eq .configMap "kc-theme-js" }} + {{- /* 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 }} diff --git a/infra/helm/big-reference/templates/keycloak-theme.yaml b/infra/helm/big-reference/templates/keycloak-theme.yaml index 2f249aa..d1502f2 100644 --- a/infra/helm/big-reference/templates/keycloak-theme.yaml +++ b/infra/helm/big-reference/templates/keycloak-theme.yaml @@ -1,9 +1,13 @@ {{- /* -Keycloak login theme `big-demo`: keycloak.v2 plus one script. It is always +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 @@ -17,18 +21,16 @@ metadata: labels: {{- include "big.labels" (dict "root" $ "name" "kc-theme") | nindent 4 }} data: - theme.properties: | + login.properties: | parent=keycloak.v2 import=common/keycloak scripts=js/otp-autofill.js ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: kc-theme-js - labels: -{{- include "big.labels" (dict "root" $ "name" "kc-theme-js") | nindent 4 }} -data: + 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 diff --git a/infra/helm/big-reference/values.yaml b/infra/helm/big-reference/values.yaml index 2f68b43..213762d 100644 --- a/infra/helm/big-reference/values.yaml +++ b/infra/helm/big-reference/values.yaml @@ -289,8 +289,14 @@ workloads: probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 } files: - { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import } - - { configMap: kc-theme, mountPath: /opt/keycloak/themes/big-demo/login/theme.properties, subPath: theme.properties } - - { configMap: kc-theme-js, mountPath: /opt/keycloak/themes/big-demo/login/resources/js/otp-autofill.js, subPath: otp-autofill.js } + - configMap: kc-theme + mountPath: /opt/keycloak/themes/big-demo + items: + - { key: login.properties, path: login/theme.properties } + - { key: otp-autofill.js, path: login/resources/js/otp-autofill.js } + - { key: account.properties, path: account/theme.properties } + - { key: admin.properties, path: admin/theme.properties } + - { key: email.properties, path: email/theme.properties } # ── Flowable (S-03) ───────────────────────────────────────────────────────── flowable-db: -- 2.54.0 From 0ea562db526a1b64d5bdd4c0f51dd649851fceb2 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 25 Sep 2026 12:15:35 +0200 Subject: [PATCH 3/3] refactor(k8s): keep the big-demo theme as real files under infra/keycloak/themes (refs #177) 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) --- docs/runbooks/kubernetes-talos.md | 13 +++- .../helm/big-reference/templates/_helpers.tpl | 8 ++- .../big-reference/templates/deployments.yaml | 5 -- .../templates/keycloak-theme.yaml | 62 ------------------- infra/helm/big-reference/values.yaml | 12 ++-- infra/helm/seed-configmaps.sh | 9 +++ .../themes/big-demo/account/theme.properties | 4 ++ .../themes/big-demo/admin/theme.properties | 4 ++ .../themes/big-demo/email/theme.properties | 4 ++ .../login/resources/js/otp-autofill.js | 24 +++++++ .../themes/big-demo/login/theme.properties | 11 ++++ 11 files changed, 79 insertions(+), 77 deletions(-) delete mode 100644 infra/helm/big-reference/templates/keycloak-theme.yaml create mode 100644 infra/keycloak/themes/big-demo/account/theme.properties create mode 100644 infra/keycloak/themes/big-demo/admin/theme.properties create mode 100644 infra/keycloak/themes/big-demo/email/theme.properties create mode 100644 infra/keycloak/themes/big-demo/login/resources/js/otp-autofill.js create mode 100644 infra/keycloak/themes/big-demo/login/theme.properties diff --git a/docs/runbooks/kubernetes-talos.md b/docs/runbooks/kubernetes-talos.md index e7abb4a..d35a8f4 100644 --- a/docs/runbooks/kubernetes-talos.md +++ b/docs/runbooks/kubernetes-talos.md @@ -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. 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 -fills in and submits the code from the fixture secret, so the step is visible but needs no -authenticator. Keycloak restarts when the value flips. Demo only — the secret is committed. +`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): Keycloak then uses the `big-demo` +theme, which fills in and submits the code from the fixture secret, so the step is visible +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: diff --git a/infra/helm/big-reference/templates/_helpers.tpl b/infra/helm/big-reference/templates/_helpers.tpl index 588535d..7efbf86 100644 --- a/infra/helm/big-reference/templates/_helpers.tpl +++ b/infra/helm/big-reference/templates/_helpers.tpl @@ -129,13 +129,17 @@ volumes: {{/* 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 -({{ .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" -}} {{- $root := index . 0 -}} {{- range $k, $v := index . 1 }} +{{- $val := tpl (toString $v) $root }} +{{- if $val }} - name: {{ $k }} - value: {{ tpl (toString $v) $root | quote }} + value: {{ $val | quote }} +{{- end }} {{- end }} {{- end -}} diff --git a/infra/helm/big-reference/templates/deployments.yaml b/infra/helm/big-reference/templates/deployments.yaml index df9ead7..64fd8fb 100644 --- a/infra/helm/big-reference/templates/deployments.yaml +++ b/infra/helm/big-reference/templates/deployments.yaml @@ -30,11 +30,6 @@ spec: annotations: checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }} {{- 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 }} labels: {{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }} diff --git a/infra/helm/big-reference/templates/keycloak-theme.yaml b/infra/helm/big-reference/templates/keycloak-theme.yaml deleted file mode 100644 index d1502f2..0000000 --- a/infra/helm/big-reference/templates/keycloak-theme.yaml +++ /dev/null @@ -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 }} diff --git a/infra/helm/big-reference/values.yaml b/infra/helm/big-reference/values.yaml index 213762d..8b9c92e 100644 --- a/infra/helm/big-reference/values.yaml +++ b/infra/helm/big-reference/values.yaml @@ -32,8 +32,8 @@ keycloakUrl: "" demo: # 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 only: the secret is committed. + # demo shows MFA enforced without an authenticator: makes the big-demo theme + # (infra/keycloak/themes/big-demo) Keycloak's default. Demo only: the secret is committed. otpAutofill: false # 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). KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}' KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" - # keycloak.v2 plus the demo.otpAutofill script (templates/keycloak-theme.yaml). - KC_SPI_THEME_DEFAULT: big-demo + # Only rendered with demo.otpAutofill (big.env skips empty values); off, Keycloak + # 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 }] # 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. probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 } files: - { 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 items: - { key: login.properties, path: login/theme.properties } diff --git a/infra/helm/seed-configmaps.sh b/infra/helm/seed-configmaps.sh index 9dd9961..9d8f05e 100755 --- a/infra/helm/seed-configmaps.sh +++ b/infra/helm/seed-configmaps.sh @@ -30,6 +30,15 @@ seed() { # name seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/" seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/" 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-objecten-config --from-file="$repo/infra/objecten/setup_configuration/" # register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped: diff --git a/infra/keycloak/themes/big-demo/account/theme.properties b/infra/keycloak/themes/big-demo/account/theme.properties new file mode 100644 index 0000000..50cacd7 --- /dev/null +++ b/infra/keycloak/themes/big-demo/account/theme.properties @@ -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 diff --git a/infra/keycloak/themes/big-demo/admin/theme.properties b/infra/keycloak/themes/big-demo/admin/theme.properties new file mode 100644 index 0000000..3430eff --- /dev/null +++ b/infra/keycloak/themes/big-demo/admin/theme.properties @@ -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 diff --git a/infra/keycloak/themes/big-demo/email/theme.properties b/infra/keycloak/themes/big-demo/email/theme.properties new file mode 100644 index 0000000..4abe495 --- /dev/null +++ b/infra/keycloak/themes/big-demo/email/theme.properties @@ -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 diff --git a/infra/keycloak/themes/big-demo/login/resources/js/otp-autofill.js b/infra/keycloak/themes/big-demo/login/resources/js/otp-autofill.js new file mode 100644 index 0000000..e46ed1b --- /dev/null +++ b/infra/keycloak/themes/big-demo/login/resources/js/otp-autofill.js @@ -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(); +}); diff --git a/infra/keycloak/themes/big-demo/login/theme.properties b/infra/keycloak/themes/big-demo/login/theme.properties new file mode 100644 index 0000000..bfd5838 --- /dev/null +++ b/infra/keycloak/themes/big-demo/login/theme.properties @@ -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 -- 2.54.0