From bfe18dd83c88d2f79e342caed52bcf12f402b1e4 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 17 Aug 2025 11:30:33 +0200 Subject: [PATCH] Refactor Keycloak role: - Replace KEYCLOAK_KCADM_PATH with KEYCLOAK_EXEC_KCADM consistently - Externalize client.json to separate Jinja2 template and include it in realm.json - Simplify LDAP bind update to use explicit KEYCLOAK_LDAP_* vars - Add async/poll support for long-running kcadm updates - Restructure vars/main.yml: clearer grouping (General, Docker, Server, Update, LDAP, API) - Compute redirectUris/webOrigins centrally in vars - Align post.logout.redirect.uris handling with playbook Conversation: https://chatgpt.com/share/68a1a11f-f8ac-800f-bada-cdc99a4fa1bf --- .../tasks/02_update_client_redirects.yml | 44 ++++-------- .../tasks/03_update-ldap-bind.yml | 29 +++----- .../tasks/04_ssh_public_key.yml | 10 +-- .../templates/import/client.json.j2 | 62 +++++++++++++++++ .../templates/import/realm.json.j2 | 67 +------------------ roles/web-app-keycloak/vars/main.yml | 44 +++++++++--- 6 files changed, 125 insertions(+), 131 deletions(-) create mode 100644 roles/web-app-keycloak/templates/import/client.json.j2 diff --git a/roles/web-app-keycloak/tasks/02_update_client_redirects.yml b/roles/web-app-keycloak/tasks/02_update_client_redirects.yml index 822a7a67..e395adcd 100644 --- a/roles/web-app-keycloak/tasks/02_update_client_redirects.yml +++ b/roles/web-app-keycloak/tasks/02_update_client_redirects.yml @@ -6,7 +6,7 @@ # - KEYCLOAK_REALM target realm name # - KEYCLOAK_SERVER_HOST_URL e.g. "http://127.0.0.1:8080" # - KEYCLOAK_SERVER_INTERNAL_URL e.g. "http://127.0.0.1:8080" -# - KEYCLOAK_KCADM_PATH e.g. "docker exec -i keycloak /opt/keycloak/bin/kcadm.sh" +# - KEYCLOAK_EXEC_KCADM e.g. "docker exec -i keycloak /opt/keycloak/bin/kcadm.sh" # - KEYCLOAK_MASTER_API_USER_NAME # - KEYCLOAK_MASTER_API_USER_PASSWORD # - KEYCLOAK_CLIENT_ID clientId to update (e.g. same as realm or an app client) @@ -20,7 +20,7 @@ - KEYCLOAK_REALM is defined - KEYCLOAK_SERVER_HOST_URL is defined - KEYCLOAK_SERVER_INTERNAL_URL is defined - - KEYCLOAK_KCADM_PATH is defined + - KEYCLOAK_EXEC_KCADM is defined - KEYCLOAK_MASTER_API_USER_NAME is defined - KEYCLOAK_MASTER_API_USER_PASSWORD is defined - KEYCLOAK_CLIENT_ID is defined @@ -32,32 +32,16 @@ - name: "kcadm login" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" shell: > - {{ KEYCLOAK_KCADM_PATH }} config credentials + {{ KEYCLOAK_EXEC_KCADM }} config credentials --server {{ KEYCLOAK_SERVER_INTERNAL_URL }} --realm master --user {{ KEYCLOAK_MASTER_API_USER_NAME }} --password {{ KEYCLOAK_MASTER_API_USER_PASSWORD }} changed_when: false -# 1) Build desired sets (NO defaults) -- name: "Build desired redirect URIs from config via filter" - set_fact: - kc_redirect_uris: >- - {{ domains | redirect_uris(applications, WEB_PROTOCOL, '/*', KEYCLOAK_REDIRECT_FEATURES, True) }} - -- name: Build desired web origins (scheme://host[:port]) - set_fact: - kc_web_origins: >- - {{ kc_redirect_uris - | map('regex_replace','/\\*$','') - | map('regex_search','^(https?://[^/]+)') - | select('string') - | list | unique }} - -# 2) Resolve client id (strictly by provided clientId, no fallback) - name: "Resolve client internal id for {{ KEYCLOAK_CLIENT_ID }}" shell: > - {{ KEYCLOAK_KCADM_PATH }} get clients + {{ KEYCLOAK_EXEC_KCADM }} get clients -r {{ KEYCLOAK_REALM }} --query 'clientId={{ KEYCLOAK_CLIENT_ID }}' --fields id --format json | jq -r '.[0].id' @@ -69,10 +53,9 @@ that: kc_client.stdout is match('^[0-9a-f-]+$') fail_msg: "Client '{{ KEYCLOAK_CLIENT_ID }}' not found in realm '{{ KEYCLOAK_REALM }}'." -# 3) Read current config (assume keys exist; we don't use defaults) - name: "Read current client configuration" shell: > - {{ KEYCLOAK_KCADM_PATH }} get clients/{{ kc_client.stdout }} + {{ KEYCLOAK_EXEC_KCADM }} get clients/{{ kc_client.stdout }} -r {{ KEYCLOAK_REALM }} --format json register: kc_client_obj changed_when: false @@ -93,13 +76,11 @@ | reject('equalto','') | list | sort }} - kc_desired_redirect_uris: "{{ kc_redirect_uris | sort }}" - kc_desired_web_origins: "{{ kc_web_origins | sort }}" - kc_desired_post_logout_uris: "+" - kc_desired_post_logout_uris_list: >- + kc_desired_redirect_uris: "{{ KEYCLOAK_REDIRECT_URIS | sort }}" + kc_desired_web_origins: "{{ KEYCLOAK_WEB_ORIGINS | sort }}" + KEYCLOAK_POST_LOGOUT_URIS_list: >- {{ "+" | split('\n') | reject('equalto','') | list | sort }} -# after "Read current client configuration" - name: "Extract current frontchannel logout url" set_fact: kc_current_frontchannel_logout_url: >- @@ -111,16 +92,17 @@ ) }} -# 4) Update only when changed - name: "Update client with redirectUris, webOrigins, frontchannelLogout" shell: > - {{ KEYCLOAK_KCADM_PATH }} update clients/{{ kc_client.stdout }} + {{ KEYCLOAK_EXEC_KCADM }} update clients/{{ kc_client.stdout }} -r {{ KEYCLOAK_REALM }} - -s 'redirectUris={{ kc_redirect_uris | to_json }}' - -s 'webOrigins={{ kc_web_origins | to_json }}' + -s 'redirectUris={{ KEYCLOAK_REDIRECT_URIS | to_json }}' + -s 'webOrigins={{ KEYCLOAK_WEB_ORIGINS | to_json }}' -s 'frontchannelLogout=true' -s 'attributes."frontchannel.logout.url"={{ KEYCLOAK_FRONTCHANNEL_LOGOUT_URL | to_json }}' when: kc_current_redirect_uris != kc_desired_redirect_uris or kc_current_web_origins != kc_desired_web_origins or kc_current_frontchannel_logout_url != KEYCLOAK_FRONTCHANNEL_LOGOUT_URL + async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" diff --git a/roles/web-app-keycloak/tasks/03_update-ldap-bind.yml b/roles/web-app-keycloak/tasks/03_update-ldap-bind.yml index 2594318e..226a4e32 100644 --- a/roles/web-app-keycloak/tasks/03_update-ldap-bind.yml +++ b/roles/web-app-keycloak/tasks/03_update-ldap-bind.yml @@ -79,21 +79,13 @@ kc_ldap_current_bind_pw: "{{ ((kc_ldap_list.stdout | from_json)[0].config['bindCredential'] | default(['']))[0] }}" kc_ldap_current_connection_url: "{{ ((kc_ldap_list.stdout | from_json)[0].config['connectionUrl'] | default(['']))[0] }}" -# Desired values come STRICTLY from ldap.* -- name: "Set desired LDAP values (strict from ldap.*)" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - set_fact: - kc_desired_bind_dn: "{{ ldap.dn.administrator.data }}" - kc_desired_bind_pw: "{{ ldap.bind_credential }}" - kc_desired_connection_url: "{{ ldap.server.uri }}" - - name: "Determine if update is required" set_fact: kc_needs_update: >- {{ - (kc_ldap_current_bind_dn != kc_desired_bind_dn) - or (kc_ldap_current_bind_pw != kc_desired_bind_pw) - or (kc_ldap_current_connection_url != kc_desired_connection_url) + (kc_ldap_current_bind_dn != KEYCLOAK_LDAP_BIND_DN) + or (kc_ldap_current_bind_pw != KEYCLOAK_LDAP_BIND_PW) + or (kc_ldap_current_connection_url != KEYCLOAK_LDAP_URL) }} # Pass each -s as a single argv token with valid JSON (arrays), zero shell quoting issues. @@ -103,16 +95,11 @@ argv: "{{ kcadm_argv_base + ['update', 'components/' ~ kc_ldap_component_id, '-r', KEYCLOAK_REALM, - '-s', 'config.bindDn=' ~ ([kc_desired_bind_dn] | to_json), - '-s', 'config.bindCredential=' ~ ([kc_desired_bind_pw] | to_json), - '-s', 'config.connectionUrl=' ~ ([kc_desired_connection_url] | to_json) + '-s', 'config.bindDn=' ~ ([KEYCLOAK_LDAP_BIND_DN] | to_json), + '-s', 'config.bindCredential=' ~ ([KEYCLOAK_LDAP_BIND_PW] | to_json), + '-s', 'config.connectionUrl=' ~ ([KEYCLOAK_LDAP_URL] | to_json) ] }}" when: kc_needs_update | bool register: kc_bind_update - -- name: "LDAP provider updated" - debug: - msg: "LDAP bindDn/bindCredential/connectionUrl updated on component {{ KEYCLOAK_LDAP_CMP_NAME }}." - when: - - kc_bind_update is defined - - kc_bind_update.rc == 0 + async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" diff --git a/roles/web-app-keycloak/tasks/04_ssh_public_key.yml b/roles/web-app-keycloak/tasks/04_ssh_public_key.yml index b4232035..5af7b19e 100644 --- a/roles/web-app-keycloak/tasks/04_ssh_public_key.yml +++ b/roles/web-app-keycloak/tasks/04_ssh_public_key.yml @@ -1,7 +1,7 @@ # Configure Credentials - name: Ensure Keycloak CLI credentials are configured shell: | - {{ KEYCLOAK_KCADM_PATH }} config credentials \ + {{ KEYCLOAK_EXEC_KCADM }} config credentials \ --server {{ KEYCLOAK_SERVER_INTERNAL_URL }} \ --realm master \ --user {{ KEYCLOAK_MASTER_API_USER_NAME }} \ @@ -10,7 +10,7 @@ # LDAP Source - name: Get ID of LDAP storage provider shell: | - {{ KEYCLOAK_KCADM_PATH }} get components \ + {{ KEYCLOAK_EXEC_KCADM }} get components \ -r {{ KEYCLOAK_REALM }} \ --query 'providerId=ldap' \ --fields id,name \ @@ -47,12 +47,12 @@ - name: Enable user profile in realm shell: > - {{ KEYCLOAK_KCADM_PATH }} update realms/{{ KEYCLOAK_REALM }} + {{ KEYCLOAK_EXEC_KCADM }} update realms/{{ KEYCLOAK_REALM }} -s 'attributes.userProfileEnabled=true' - name: Re-authenticate to Keycloak after enabling user profile shell: | - {{ KEYCLOAK_KCADM_PATH }} config credentials \ + {{ KEYCLOAK_EXEC_KCADM }} config credentials \ --server {{ KEYCLOAK_SERVER_INTERNAL_URL }} \ --realm master \ --user {{ KEYCLOAK_MASTER_API_USER_NAME }} \ @@ -69,3 +69,5 @@ shell: | docker exec -i {{ KEYCLOAK_CONTAINER }} \ /opt/keycloak/bin/kcadm.sh update realms/{{ KEYCLOAK_REALM }} -f {{ KEYCLOAK_DOCKER_IMPORT_DIR }}user-profile.json + async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" \ No newline at end of file diff --git a/roles/web-app-keycloak/templates/import/client.json.j2 b/roles/web-app-keycloak/templates/import/client.json.j2 new file mode 100644 index 00000000..fcd59876 --- /dev/null +++ b/roles/web-app-keycloak/templates/import/client.json.j2 @@ -0,0 +1,62 @@ +{ + "id": "7b5f97e3-7fa8-4d86-b1e9-80aac996da26", + "clientId": "{{ KEYCLOAK_CLIENT_ID }}", + "name": "", + "description": "", + "rootUrl": "{{ KEYCLOAK_REALM_URL }}", + "adminUrl": "{{ KEYCLOAK_REALM_URL }}", + "baseUrl": "{{ KEYCLOAK_REALM_URL }}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "desktop-secret", + "secret": "{{ OIDC.CLIENT.SECRET }}", + {# The following line should be covered by 02_update_client_redirects.yml #} + "redirectUris": {{ KEYCLOAK_REDIRECT_URIS | to_json }}, + "webOrigins": {{ KEYCLOAK_WEB_ORIGINS | to_json }}, + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "frontchannel.logout.url": {{ KEYCLOAK_FRONTCHANNEL_LOGOUT_URL | tojson }}, + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "0", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": {{ KEYCLOAK_POST_LOGOUT_URIS | tojson }}, + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-app-origins", + "service_account", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt", + "{{ applications | get_app_conf(application_id, 'scopes.rbac_roles', True) }}", + "{{ applications | get_app_conf(application_id, 'scopes.nextcloud', True) }}" + + ] +} \ No newline at end of file diff --git a/roles/web-app-keycloak/templates/import/realm.json.j2 b/roles/web-app-keycloak/templates/import/realm.json.j2 index 8dd7b161..436bf12f 100644 --- a/roles/web-app-keycloak/templates/import/realm.json.j2 +++ b/roles/web-app-keycloak/templates/import/realm.json.j2 @@ -527,10 +527,9 @@ "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "publicClient": true, - "frontchannelLogout": true, + "frontchannelLogout": false, "protocol": "openid-connect", "attributes": { - "frontchannel.logout.url": "{{ KEYCLOAK_FRONTCHANNEL_LOGOUT_URL }}", "realm_client": "false", "oidc.ciba.grant.enabled": "false", "backchannel.logout.session.required": "true", @@ -821,69 +820,7 @@ "microprofile-jwt" ] }, - { - "id": "7b5f97e3-7fa8-4d86-b1e9-80aac996da26", - "clientId": "{{ KEYCLOAK_REALM }}", - "name": "", - "description": "", - "rootUrl": "{{ WEB_PROTOCOL }}://{{ KEYCLOAK_REALM }}/", - "adminUrl": "{{ WEB_PROTOCOL }}://{{ KEYCLOAK_REALM }}/", - "baseUrl": "{{ WEB_PROTOCOL }}://{{ KEYCLOAK_REALM }}/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "desktop-secret", - "secret": "{{ OIDC.CLIENT.SECRET }}", - {# The following line should be covered by 02_update_client_redirects.yml #} - "redirectUris": {{ domains | redirect_uris(applications, WEB_PROTOCOL) | tojson }}, - "webOrigins": [ - "{{ WEB_PROTOCOL }}://*.{{ PRIMARY_DOMAIN }}" - ], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": true, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": true, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "false", - "oidc.ciba.grant.enabled": "false", - "client.secret.creation.time": "0", - "backchannel.logout.session.required": "true", - "post.logout.redirect.uris": "{{ WEB_PROTOCOL }}://{{ PRIMARY_DOMAIN }}/*##+", - "frontchannel.logout.session.required": "true", - "oauth2.device.authorization.grant.enabled": "false", - "display.on.consent.screen": "false", - "use.jwks.url": "false", - "backchannel.logout.revoke.offline.tokens": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-app-origins", - "service_account", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "organization", - "offline_access", - "microprofile-jwt", - "{{ applications | get_app_conf(application_id, 'scopes.rbac_roles', True) }}", - "{{ applications | get_app_conf(application_id, 'scopes.nextcloud', True) }}" - - ] - } + {% include "client.json.j2" %} ], "clientScopes": [ { diff --git a/roles/web-app-keycloak/vars/main.yml b/roles/web-app-keycloak/vars/main.yml index 8bdd4bb2..f0d2e59a 100644 --- a/roles/web-app-keycloak/vars/main.yml +++ b/roles/web-app-keycloak/vars/main.yml @@ -3,22 +3,46 @@ application_id: "web-app-keycloak" database_type: "postgres" # Database which will be used # Keycloak + +## General +KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}" # This is the name of the default realm which is used by the applications +KEYCLOAK_REALM_URL: "{{ WEB_PROTOCOL }}://{{ KEYCLOAK_REALM }}" +KEYCLOAK_DEBUG_ENABLED: "{{ MODE_DEBUG }}" +KEYCLOAK_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" + +## Docker KEYCLOAK_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name') }}" # Name of the keycloak docker container KEYCLOAK_DOCKER_IMPORT_DIR: "/opt/keycloak/data/import/" # Directory in which keycloak import files are placed in the running docker container -KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}" # This is the name of the default realm which is used by the applications -KEYCLOAK_MASTER_API_USER: "{{ applications | get_app_conf(application_id, 'users.administrator') }}" # Master Administrator -KEYCLOAK_MASTER_API_USER_NAME: "{{ KEYCLOAK_MASTER_API_USER.username }}" # Master Administrator Username -KEYCLOAK_MASTER_API_USER_PASSWORD: "{{ KEYCLOAK_MASTER_API_USER.password }}" # Master Administrator Password -KEYCLOAK_KCADM_PATH: "docker exec -i {{ KEYCLOAK_CONTAINER }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak +KEYCLOAK_EXEC_KCADM: "docker exec -i {{ KEYCLOAK_CONTAINER }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak +KEYCLOAK_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.image') }}" # Keycloak docker image +KEYCLOAK_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version') }}" # Keycloak docker version + +## Server KEYCLOAK_SERVER_INTERNAL_URL: "http://127.0.0.1:8080" KEYCLOAK_SERVER_HOST: "127.0.0.1:{{ ports.localhost.http[application_id] }}" KEYCLOAK_SERVER_HOST_URL: "http://{{ KEYCLOAK_SERVER_HOST }}" -KEYCLOAK_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.image') }}" # Keycloak docker image -KEYCLOAK_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version') }}" # Keycloak docker version -KEYCLOAK_DEBUG_ENABLED: "{{ MODE_DEBUG }}" + +## Update KEYCLOAK_REDIRECT_FEATURES: ["features.oauth2","features.oidc"] -KEYCLOAK_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" -KEYCLOAK_LDAP_CMP_NAME: "{{ ldap.server.domain }}" # Name of the LDAP User Federation component in Keycloak (as shown in UI) KEYCLOAK_IMPORT_REALM_ENABLED: "{{ applications | get_app_conf(application_id, 'actions.import_realm') }}" # Activate realm import KEYCLOAK_UPDATE_LDAP_BIND: "{{ applications | get_app_conf(application_id, 'actions.update_ldap_bind') }}" # Toggle the LDAP bind update step KEYCLOAK_FRONTCHANNEL_LOGOUT_URL: "{{ domains | get_url('web-svc-logout', WEB_PROTOCOL) }}/" +KEYCLOAK_REDIRECT_URIS: "{{ domains | redirect_uris(applications, WEB_PROTOCOL, '/*', KEYCLOAK_REDIRECT_FEATURES) }}" +KEYCLOAK_WEB_ORIGINS: >- + {{ KEYCLOAK_REDIRECT_URIS + | map('regex_replace','/\\*$','') + | map('regex_search','^(https?://[^/]+)') + | select('string') + | list | unique }} +KEYCLOAK_POST_LOGOUT_URIS: "+" + +## LDAP +KEYCLOAK_LDAP_CMP_NAME: "{{ ldap.server.domain }}" # Name of the LDAP User Federation component in Keycloak (as shown in UI) +KEYCLOAK_LDAP_BIND_DN: "{{ ldap.dn.administrator.data }}" +KEYCLOAK_LDAP_BIND_PW: "{{ ldap.bind_credential }}" +KEYCLOAK_LDAP_URL: "{{ ldap.server.uri }}" + +## API +KEYCLOAK_MASTER_API_USER: "{{ applications | get_app_conf(application_id, 'users.administrator') }}" # Master Administrator +KEYCLOAK_MASTER_API_USER_NAME: "{{ KEYCLOAK_MASTER_API_USER.username }}" # Master Administrator Username +KEYCLOAK_MASTER_API_USER_PASSWORD: "{{ KEYCLOAK_MASTER_API_USER.password }}" # Master Administrator Password \ No newline at end of file