From 9d2fd5faa0a591ceea8c2ed0251360c6d72ffc6f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 30 Apr 2026 16:31:59 -0700 Subject: [PATCH] feat(keycloak): add Incus OIDC client --- aws/keycloak/README.md | 23 +++++++++++---- aws/keycloak/locals.tf | 2 +- .../templates/keycloak/lab-realm.json.tftpl | 27 +++++++++++++++++ .../scripts/run-keycloak-config.sh.tftpl | 29 ++++++++++--------- .../glab-keycloak-config.service.tftpl | 1 - aws/keycloak/tests/main.tftest.hcl | 20 +++++++++++++ 6 files changed, 81 insertions(+), 21 deletions(-) diff --git a/aws/keycloak/README.md b/aws/keycloak/README.md index d38ccd3..f641dd9 100644 --- a/aws/keycloak/README.md +++ b/aws/keycloak/README.md @@ -13,9 +13,9 @@ This stack creates: - a GitHub token broker Lambda, sourced from `meigma/github-token-broker`, for short-lived `GilmanLab/secrets` access - Keycloak instance-role permission to invoke the token broker - Keycloak instance-role permission to decrypt only SOPS secrets with `Repo=GilmanLab/secrets` and `Scope=keycloak` -- Ignition-managed systemd units for Postgres, Keycloak, Traefik, bootstrap secret fetches, and first-boot realm configuration +- Ignition-managed systemd units for Postgres, Keycloak, Traefik, bootstrap secret fetches, and realm configuration -This stack configures only the initial `lab` realm and one local admin account with password plus WebAuthn/YubiKey enrollment. It does **not** configure GitHub OIDC, service clients, backups, Synology sync, Kubernetes OIDC, Argo CD, Grafana, or IAM Identity Center federation. +This stack configures the `lab` realm, one local admin account with password plus WebAuthn/YubiKey enrollment, and a minimal public OIDC client for Incus. It does **not** configure GitHub OIDC, OpenFGA, backups, Synology sync, Kubernetes OIDC, Argo CD, Grafana, or IAM Identity Center federation. Traefik obtains the `id.glab.lol` certificate from Let's Encrypt through DNS-01. Cloudflare delegates `_acme-challenge.id.glab.lol` to the public Route 53 `acme.glab.lol` zone, and the Keycloak instance role may mutate only the delegated TXT record for this hostname. @@ -36,13 +36,24 @@ secrets get services/keycloak/bootstrap.sops.yaml \ Plaintext bootstrap material is written only under `/run/glab/keycloak`. The persistent data volume stores Postgres state in `/var/lib/keycloak/postgres` and ACME state in `/var/lib/keycloak/acme`; the Postgres directory is owned by the container's Postgres UID. -`glab-keycloak-config.service` runs once after Keycloak is healthy. It fetches +`glab-keycloak-config.service` runs after Keycloak is healthy. It renders the +realm config, compares its SHA-256 hash with +`/var/lib/keycloak/config/lab-realm.sha256`, and skips when the stored hash +matches. When the hash changes, it fetches `services/keycloak/admin.sops.yaml` through the same broker-backed `labctl` path, writes `/run/glab/keycloak/admin.env`, then runs pinned `keycloak-config-cli` to create the `lab` realm, local admin user, and -touch-only WebAuthn policy. The service writes -`/var/lib/keycloak/config/lab-realm-imported` after a successful import so it -does not run again on reboot. +touch-only WebAuthn policy. It also creates the public `incus` OIDC client with +OAuth 2.0 Device Authorization Grant enabled. The service writes the new +realm-config hash only after a successful import. + +The follow-up Incus-side OIDC values are: + +- `oidc.issuer = https://id.glab.lol/realms/lab` +- `oidc.client.id = incus` +- `oidc.scopes = openid,email,profile` +- `oidc.claim = preferred_username` if validation confirms that Keycloak emits + it in the Incus access token After enrolling and validating the admin YubiKey in the browser, disable the temporary master bootstrap admin manually: diff --git a/aws/keycloak/locals.tf b/aws/keycloak/locals.tf index 14e6afe..bea2629 100644 --- a/aws/keycloak/locals.tf +++ b/aws/keycloak/locals.tf @@ -14,7 +14,7 @@ locals { keycloak_env_path = "${var.bootstrap_runtime_dir}/keycloak.env" helper_script_dir = "${var.runtime_dir}/bin" keycloak_config_dir = "${var.data_dir}/config" - keycloak_config_marker_path = "${local.keycloak_config_dir}/lab-realm-imported" + keycloak_config_marker_path = "${local.keycloak_config_dir}/lab-realm.sha256" keycloak_realm_config_path = "${var.bootstrap_runtime_dir}/lab-realm.json" data_volume_device_path = "/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${replace(aws_ebs_volume.keycloak_data.id, "-", "")}" diff --git a/aws/keycloak/templates/keycloak/lab-realm.json.tftpl b/aws/keycloak/templates/keycloak/lab-realm.json.tftpl index b05f79d..6c50a4c 100644 --- a/aws/keycloak/templates/keycloak/lab-realm.json.tftpl +++ b/aws/keycloak/templates/keycloak/lab-realm.json.tftpl @@ -110,6 +110,33 @@ "config": {} } ], + "clients": [ + { + "clientId": "incus", + "name": "Incus", + "description": "Public OIDC client for Incus human authentication.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "bearerOnly": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "frontchannelLogout": false, + "attributes": { + "oauth2.device.authorization.grant.enabled": "true" + }, + "defaultClientScopes": [ + "profile", + "email" + ], + "optionalClientScopes": [ + "offline_access" + ] + } + ], "users": [ { "username": "$(env:KEYCLOAK_LOCAL_ADMIN_USERNAME)", diff --git a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl index 7a3ac68..acfc68e 100644 --- a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl +++ b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl @@ -3,15 +3,24 @@ set -eu . '${bootstrap_output_path}' -if [ -f '${keycloak_config_marker_path}' ]; then - echo 'Keycloak lab realm configuration already imported.' - exit 0 -fi - mkdir -p '${bootstrap_runtime_dir}' '${keycloak_config_dir}' chmod 0700 '${bootstrap_runtime_dir}' rm -f '${config_output_path}' '${keycloak_realm_config_path}' '${keycloak_realm_config_path}.gz.b64' +cat >'${keycloak_realm_config_path}.gz.b64' <<'EOF_REALM_CONFIG' +${lab_realm_config_gzip_base64} +EOF_REALM_CONFIG +/usr/bin/base64 -d '${keycloak_realm_config_path}.gz.b64' | /usr/bin/gzip -d >'${keycloak_realm_config_path}' +chmod 0600 '${keycloak_realm_config_path}' +rm -f '${keycloak_realm_config_path}.gz.b64' + +desired_hash="$(/usr/bin/sha256sum '${keycloak_realm_config_path}' | /usr/bin/awk '{print $1}')" +if [ -f '${keycloak_config_marker_path}' ] && [ "$(cat '${keycloak_config_marker_path}')" = "$desired_hash" ]; then + echo "Keycloak lab realm configuration already at $desired_hash." + rm -f '${keycloak_realm_config_path}' + exit 0 +fi + /usr/bin/docker run --rm \ --network host \ --user 0:0 \ @@ -25,13 +34,6 @@ rm -f '${config_output_path}' '${keycloak_realm_config_path}' '${keycloak_realm_ --broker-function '${github_token_broker_function_name}' chmod 0600 '${config_output_path}' -cat >'${keycloak_realm_config_path}.gz.b64' <<'EOF_REALM_CONFIG' -${lab_realm_config_gzip_base64} -EOF_REALM_CONFIG -/usr/bin/base64 -d '${keycloak_realm_config_path}.gz.b64' | /usr/bin/gzip -d >'${keycloak_realm_config_path}' -chmod 0600 '${keycloak_realm_config_path}' -rm -f '${keycloak_realm_config_path}.gz.b64' - deadline=$(( $(date +%s) + 300 )) until /usr/bin/curl -fsS http://127.0.0.1:9000/health/ready >/dev/null; do if [ "$(date +%s)" -ge "$deadline" ]; then @@ -59,5 +61,6 @@ done -v '${keycloak_realm_config_path}:/config/lab-realm.json:ro' \ '${keycloak_config_cli_image}' -touch '${keycloak_config_marker_path}' +printf '%s\n' "$desired_hash" >'${keycloak_config_marker_path}' chmod 0600 '${keycloak_config_marker_path}' +rm -f '${keycloak_realm_config_path}' diff --git a/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl index e7e67a6..0a6ac55 100644 --- a/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl +++ b/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl @@ -2,7 +2,6 @@ Description=Configure Keycloak lab realm Requires=docker.service ${data_unit_name} ${network_unit_name} ${bootstrap_unit_name} ${keycloak_unit_name} After=docker.service ${data_unit_name} ${network_unit_name} ${bootstrap_unit_name} ${keycloak_unit_name} -ConditionPathExists=!${keycloak_config_marker_path} [Service] Type=oneshot diff --git a/aws/keycloak/tests/main.tftest.hcl b/aws/keycloak/tests/main.tftest.hcl index 4a0bf9a..bfd2c6c 100644 --- a/aws/keycloak/tests/main.tftest.hcl +++ b/aws/keycloak/tests/main.tftest.hcl @@ -136,6 +136,16 @@ run "plan_defaults" { error_message = "The config script should fetch only the Keycloak admin_env field into /run." } + assert { + condition = strcontains(local.run_config_script, "sha256sum '/run/glab/keycloak/lab-realm.json'") && strcontains(local.run_config_script, "cat '/var/lib/keycloak/config/lab-realm.sha256'") && strcontains(local.run_config_script, "printf '%s\\n' \"$desired_hash\" >'/var/lib/keycloak/config/lab-realm.sha256'") + error_message = "The config script should use a rendered realm hash marker instead of a permanent first-import marker." + } + + assert { + condition = !strcontains(local.config_unit, "ConditionPathExists") + error_message = "The config unit should run on boot and let the config script decide whether the realm config changed." + } + assert { condition = strcontains(local.run_config_script, "quay.io/adorsys/keycloak-config-cli@sha256:2d2a0663cf324379d9ffab896db8d00293cd0326151968b319cf166f6eec8fca") error_message = "The config script should use the pinned keycloak-config-cli image digest." @@ -156,6 +166,16 @@ run "plan_defaults" { error_message = "The realm config should create the local admin from runtime env and require WebAuthn registration." } + assert { + condition = strcontains(local.lab_realm_config, "\"clientId\": \"incus\"") && strcontains(local.lab_realm_config, "\"publicClient\": true") && strcontains(local.lab_realm_config, "\"oauth2.device.authorization.grant.enabled\": \"true\"") + error_message = "The realm config should define a public Incus OIDC client with device authorization enabled." + } + + assert { + condition = strcontains(local.lab_realm_config, "\"standardFlowEnabled\": false") && strcontains(local.lab_realm_config, "\"implicitFlowEnabled\": false") && strcontains(local.lab_realm_config, "\"directAccessGrantsEnabled\": false") && strcontains(local.lab_realm_config, "\"serviceAccountsEnabled\": false") && strcontains(local.lab_realm_config, "\"authorizationServicesEnabled\": false") + error_message = "The Incus client should keep unused OIDC grants and authorization services disabled." + } + assert { condition = strcontains(local.prepare_data_script, "/var/lib/keycloak/postgres") && strcontains(local.prepare_data_script, "/var/lib/keycloak/acme") && strcontains(local.prepare_data_script, "chown 999:999") error_message = "The data preparation script should place Postgres and ACME state on the data volume."