diff --git a/changelogs/fragments/keycloak-client-add-missing-fields.yml b/changelogs/fragments/keycloak-client-add-missing-fields.yml new file mode 100644 index 0000000000..08bf4654cb --- /dev/null +++ b/changelogs/fragments/keycloak-client-add-missing-fields.yml @@ -0,0 +1,7 @@ +minor_changes: + - keycloak_client - add ``valid_post_logout_redirect_uris`` option to configure + post logout redirect URIs for a client, and ``backchannel_logout_url`` option to configure the + backchannel logout URL for a client + (https://github.com/ansible-collections/community.general/issues/6812, + https://github.com/ansible-collections/community.general/issues/4892, + https://github.com/ansible-collections/community.general/pull/11473). diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py index f4f34406e8..82b1f6a5c6 100644 --- a/plugins/modules/keycloak_client.py +++ b/plugins/modules/keycloak_client.py @@ -151,6 +151,17 @@ options: type: list elements: str + valid_post_logout_redirect_uris: + description: + - Valid post logout redirect URIs for this client. + - This is stored as C(post.logout.redirect.uris) in the client attributes. + - Use V(+) as a single list element to allow all redirect URIs. + aliases: + - postLogoutRedirectUris + type: list + elements: str + version_added: "12.4.0" + not_before: description: - Revoke any tokens issued before this date for this client (this is a UNIX timestamp). This is C(notBefore) in the @@ -227,6 +238,15 @@ options: - frontchannelLogout type: bool + backchannel_logout_url: + description: + - URL that will cause the client to log itself out when a logout request is sent to this realm. + - This is stored as C(backchannel.logout.url) in the client attributes. + aliases: + - backchannelLogoutUrl + type: str + version_added: "12.4.0" + protocol: description: - Type of client. @@ -764,6 +784,21 @@ PROTOCOL_SAML = "saml" PROTOCOL_DOCKER_V2 = "docker-v2" CLIENT_META_DATA = ["authorizationServicesEnabled"] +# Parameters that map to client attributes rather than top-level API fields. +# Each entry maps the module parameter name to (attribute_key, transform_fn). +# transform_fn converts the module param value to the attribute string value. +# Use None for transform_fn when no transformation is needed (identity). +ATTRIBUTE_PARAMS = { + "valid_post_logout_redirect_uris": ( + "post.logout.redirect.uris", + "##".join, + ), + "backchannel_logout_url": ( + "backchannel.logout.url", + None, + ), +} + def normalise_scopes_for_behavior(desired_client, before_client, clientScopesBehavior): """ @@ -1222,6 +1257,7 @@ def main(): default_roles=dict(type="list", elements="str", aliases=["defaultRoles"]), redirect_uris=dict(type="list", elements="str", aliases=["redirectUris"]), web_origins=dict(type="list", elements="str", aliases=["webOrigins"]), + valid_post_logout_redirect_uris=dict(type="list", elements="str", aliases=["postLogoutRedirectUris"]), not_before=dict(type="int", aliases=["notBefore"]), bearer_only=dict(type="bool", aliases=["bearerOnly"]), consent_required=dict(type="bool", aliases=["consentRequired"]), @@ -1232,6 +1268,7 @@ def main(): authorization_services_enabled=dict(type="bool", aliases=["authorizationServicesEnabled"]), public_client=dict(type="bool", aliases=["publicClient"]), frontchannel_logout=dict(type="bool", aliases=["frontchannelLogout"]), + backchannel_logout_url=dict(type="str", aliases=["backchannelLogoutUrl"]), protocol=dict(type="str", choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), attributes=dict(type="dict"), full_scope_allowed=dict(type="bool", aliases=["fullScopeAllowed"]), @@ -1311,9 +1348,20 @@ def main(): # Build a proposed changeset from parameters given to this module changeset = {} + # Collect attribute-mapped parameters to inject into attributes later + attribute_overrides = {} + for param_name, (attr_key, transform_fn) in ATTRIBUTE_PARAMS.items(): + param_value = module.params.get(param_name) + if param_value is not None: + attribute_overrides[attr_key] = transform_fn(param_value) if transform_fn else param_value + for client_param in client_params: new_param_value = module.params.get(client_param) + # Skip attribute-mapped params; they are handled via attributes + if client_param in ATTRIBUTE_PARAMS: + continue + # Unfortunately, the ansible argument spec checker introduces variables with null values when # they are not specified if client_param == "protocol_mappers": @@ -1335,6 +1383,13 @@ def main(): changeset[camel(client_param)] = new_param_value + # Inject attribute-mapped parameters into the attributes dict + if attribute_overrides: + if "attributes" not in changeset: + changeset["attributes"] = copy.deepcopy(before_client.get("attributes", {})) + if isinstance(changeset["attributes"], dict): + changeset["attributes"].update(attribute_overrides) + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) desired_client = copy.deepcopy(before_client) desired_client.update(changeset) diff --git a/tests/integration/targets/keycloak_client/tasks/main.yml b/tests/integration/targets/keycloak_client/tasks/main.yml index 9d9d5a049f..8fb9059d6c 100644 --- a/tests/integration/targets/keycloak_client/tasks/main.yml +++ b/tests/integration/targets/keycloak_client/tasks/main.yml @@ -456,4 +456,74 @@ - end_state.attributes["backchannel.logout.session.required"] == 'false' - end_state.attributes["oauth2.device.authorization.grant.enabled"] == 'false' vars: - end_state: "{{ check_client_when_present_and_attributes_modified.end_state }}" \ No newline at end of file + end_state: "{{ check_client_when_present_and_attributes_modified.end_state }}" + +# ---- Tests for valid_post_logout_redirect_uris and backchannel_logout_url ---- + +- name: Create client with post logout redirect URIs and backchannel logout URL + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: logout-test-client + valid_post_logout_redirect_uris: "{{ post_logout_redirect_uris }}" + backchannel_logout_url: "{{ backchannel_logout_url }}" + state: present + register: result_create_logout_client + +- name: Assert logout client is created with correct attributes + assert: + that: + - result_create_logout_client is changed + - result_create_logout_client.end_state.attributes["post.logout.redirect.uris"] is defined + - result_create_logout_client.end_state.attributes["backchannel.logout.url"] == backchannel_logout_url + +- name: Re-create client with same logout fields (idempotency) + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: logout-test-client + valid_post_logout_redirect_uris: "{{ post_logout_redirect_uris }}" + backchannel_logout_url: "{{ backchannel_logout_url }}" + state: present + register: result_idempotent_logout_client + +- name: Assert logout client is idempotent + assert: + that: + - result_idempotent_logout_client is not changed + +- name: Update client logout fields + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: logout-test-client + valid_post_logout_redirect_uris: + - "https://example.com/new-logout" + backchannel_logout_url: "https://example.com/new-backchannel" + state: present + register: result_update_logout_client + +- name: Assert logout client fields are updated + assert: + that: + - result_update_logout_client is changed + - result_update_logout_client.end_state.attributes["backchannel.logout.url"] == "https://example.com/new-backchannel" + +- name: Delete logout test client + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: logout-test-client + state: absent \ No newline at end of file diff --git a/tests/integration/targets/keycloak_client/vars/main.yml b/tests/integration/targets/keycloak_client/vars/main.yml index ef61063c5c..417b80a6bc 100644 --- a/tests/integration/targets/keycloak_client/vars/main.yml +++ b/tests/integration/targets/keycloak_client/vars/main.yml @@ -20,6 +20,11 @@ auth_args: auth_username: "{{ admin_user }}" auth_password: "{{ admin_password }}" +post_logout_redirect_uris: + - "https://example.com/logout-callback" + - "https://example.com/signout" +backchannel_logout_url: "https://example.com/backchannel-logout" + redirect_uris1: - "http://example.c.com/" - "http://example.b.com/"