From a69f7e60b42f17012ffc42a87efdd6f2c17ee288 Mon Sep 17 00:00:00 2001 From: thomasbargetz Date: Thu, 12 Mar 2026 22:04:08 +0100 Subject: [PATCH] add module keycloak_authentication_v2 (#11557) * add module keycloak_authentication_v2 * skip sanity checks, because the run into a recursion * 11556 fix documentation * 11556 limit the depth of nested flows to 4 * 11556 code cleanup * 11556 code cleanup - add type hints * 11556 add keycloak_authentication_v2 to meta/runtime.yml * 11556 code cleanup - remove custom type hints * 11556 code cleanup - none checks * Update plugins/modules/keycloak_authentication_v2.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Update plugins/modules/keycloak_authentication_v2.py Co-authored-by: Felix Fontein * 11556 code cleanup - remove document starts * 11556 cleanup * 11556 cleanup --------- Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Felix Fontein Co-authored-by: Thomas Bargetz --- .github/BOTMETA.yml | 2 + meta/runtime.yml | 1 + .../identity/keycloak/keycloak.py | 45 + plugins/modules/keycloak_authentication_v2.py | 980 ++++++++++++++++++ .../keycloak_authentication_v2/README.md | 10 + .../keycloak_authentication_v2/aliases | 5 + .../tasks/actions/fetch_access_token.yml | 24 + .../tasks/actions/flow_v1.yml | 41 + .../tasks/actions/flow_v2.yml | 47 + .../tasks/assertions/assertion_flow_v1.yml | 144 +++ .../tasks/assertions/assertion_flow_v2.yml | 151 +++ .../keycloak_authentication_v2/tasks/main.yml | 26 + .../tasks/tests/test_flow_creation.yml | 33 + .../tasks/tests/test_flow_deletion.yml | 44 + .../tasks/tests/test_setup.yml | 46 + .../tests/test_unused_flow_modification.yml | 41 + .../tests/test_used_flow_modification.yml | 111 ++ .../keycloak_authentication_v2/vars/main.yml | 14 + 18 files changed, 1765 insertions(+) create mode 100644 plugins/modules/keycloak_authentication_v2.py create mode 100644 tests/integration/targets/keycloak_authentication_v2/README.md create mode 100644 tests/integration/targets/keycloak_authentication_v2/aliases create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/actions/fetch_access_token.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v1.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v2.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v1.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v2.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/main.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_creation.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_deletion.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_setup.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_unused_flow_modification.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_used_flow_modification.yml create mode 100644 tests/integration/targets/keycloak_authentication_v2/vars/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index e36914d809..7fb8f6129a 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -834,6 +834,8 @@ files: maintainers: $team_keycloak $modules/keycloak_authentication.py: maintainers: elfelip Gaetan2907 + $modules/keycloak_authentication_v2.py: + maintainers: thomasbargetz $modules/keycloak_authentication_required_actions.py: maintainers: Skrekulko $modules/keycloak_authz_authorization_scope.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index a780e77eac..0844aa66fc 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -21,6 +21,7 @@ action_groups: keycloak: - keycloak_authentication - keycloak_authentication_required_actions + - keycloak_authentication_v2 - keycloak_authz_authorization_scope - keycloak_authz_custom_policy - keycloak_authz_permission diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 4c37e9debc..65f6dc7557 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -2274,6 +2274,35 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Unable get authentication flow {alias}: {e}") + def get_authentication_flow_by_id(self, id, realm: str = "master"): + """ + Get an authentication flow by its id + :param id: id of the authentication flow to get. + :param realm: Realm. + :return: Authentication flow representation. + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return json.load(self._request(flow_url, method="GET")) + except Exception as e: + self.fail_request(e, msg=f"Could not get authentication flow {id} in realm {realm}: {e}") + + def update_authentication_flow(self, id, config, realm: str = "master"): + """ + Updates an authentication flow + :param id: id of the authentication flow to update. + :param config: Authentication flow configuration. + :param realm: Realm. + :return: Authentication flow representation. + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(flow_url, method="PUT", data=json.dumps(config)) + except Exception as e: + self.fail_request(e, msg=f"Could not get authentication flow {id} in realm {realm}: {e}") + def delete_authentication_flow_by_id(self, id, realm: str = "master"): """ Delete an authentication flow from Keycloak @@ -2379,6 +2408,22 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Unable to add authenticationConfig {executionId}: {e}") + def update_authentication_config(self, configId, authenticationConfig, realm: str = "master"): + """ + Updates an authentication config + :param configId: id of the authentication config + :param authenticationConfig: The authentication config + :param realm: realm of authentication config + """ + try: + self._request( + URL_AUTHENTICATION_CONFIG.format(url=self.baseurl, realm=realm, id=configId), + method="PUT", + data=json.dumps(authenticationConfig), + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to update the authentication config {configId}: {e}") + def delete_authentication_config(self, configId, realm: str = "master"): """Delete authenticator config diff --git a/plugins/modules/keycloak_authentication_v2.py b/plugins/modules/keycloak_authentication_v2.py new file mode 100644 index 0000000000..c6ab3f4a7e --- /dev/null +++ b/plugins/modules/keycloak_authentication_v2.py @@ -0,0 +1,980 @@ +#!/usr/bin/python + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authentication_v2 + +short_description: Configure authentication flows in Keycloak in an idempotent and safe manner. +version_added: 12.5.0 +description: + - This module allows the creation, deletion, and modification of Keycloak authentication flows using the Keycloak REST API. + - Rather than modifying an existing flow in place, the module re-creates the flow using the B(Safe Swap) mechanism described below. + - B(Safe Swap mechanism) - When an authentication flow needs to be updated, the module never modifies the existing flow in place. + Instead it follows a multi-step swap procedure to ensure the flow is never left in an intermediate or unsafe state during the update. + This is especially important when the flow is actively bound to a realm binding or a client override, + because a partially-updated flow could inadvertently allow unauthorised access. + - The B(Safe Swap mechanism) is as follows. 1. A new flow is created under a temporary name (the original alias plus a configurable suffix, + for example C(myflow_tmp_for_swap)). + 2. All executions and their configurations are added to the new temporary flow. 3. If the existing flow is currently bound to a realm or a client, + all bindings are redirected to the new temporary flow. This ensures continuity and avoids any gap in active authentication coverage. + 4. The old flow is deleted. 5. The temporary flow is renamed to the original alias, restoring the expected name. + - B(Handling pre-existing temporary swap flows) - If a temporary swap flow already exists (for example, from a previously interrupted run), + the module can optionally delete it before proceeding. This behaviour is controlled by the O(force_temporary_swap_flow_deletion) option. + If the option is V(false) and a temporary flow already exists, the module will fail to prevent accidental data loss. + - B(Idempotency) - If the existing flow already matches the desired configuration, no changes are made. + The module compares a normalised representation of the existing flow against the desired state before deciding whether to trigger the Safe Swap procedure. + - A depth of 4 sub-flows is supported. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + realm: + description: + - The name of the realm in which the authentication flow resides. + required: true + type: str + alias: + description: + - The name of the authentication flow. + required: true + type: str + description: + description: + - A human-readable description of the flow. + type: str + providerId: + description: + - The C(providerId) for the new flow. + choices: [basic-flow, client-flow] + type: str + default: basic-flow + authenticationExecutions: + description: + - The desired execution configuration for the flow. + - Executions at root level. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 1. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 2. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 3. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 4 (last sub level). + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + required: true + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + state: + description: + - Whether the authentication flow should exist or not. + choices: [present, absent] + default: present + type: str + temporary_swap_flow_suffix: + description: + - The suffix appended to the alias of the temporary flow created during a Safe Swap update. + - The temporary flow exists only for the duration of the swap procedure and is renamed to + the original alias once all bindings have been successfully transferred. + type: str + default: _tmp_for_swap + force_temporary_swap_flow_deletion: + description: + - If C(true), any pre-existing temporary swap flow (identified by the original alias plus + O(temporary_swap_flow_suffix)) is deleted before the Safe Swap procedure begins. + - Set this to C(false) to cause the module to fail instead of silently removing a + pre-existing temporary flow, for example to avoid accidental data loss after an + interrupted run. + default: true + type: bool +extends_documentation_fragment: + - community.general.keycloak + - community.general.keycloak.actiongroup_keycloak + - community.general.attributes + +author: + - Thomas Bargetz (@thomasbargetz) +""" + +EXAMPLES = r""" +- name: Create or modify the 'My Login Flow'. + community.general.keycloak_authentication_v2: + auth_keycloak_url: http://localhost:8080/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: My Login Flow + authenticationExecutions: + - providerId: idp-review-profile + requirement: REQUIRED + authenticationConfig: + alias: My Login Flow - review profile config + config: + update.profile.on.first.login: "missing" + - subFlow: My Login Flow - User creation or linking + requirement: REQUIRED + authenticationExecutions: + - providerId: idp-create-user-if-unique + requirement: ALTERNATIVE + authenticationConfig: + alias: My Login Flow - create unique user config + config: + require.password.update.after.registration: "true" + - providerId: auth-cookie + requirement: REQUIRED + - subFlow: My Login Flow - Handle Existing Account + requirement: ALTERNATIVE + authenticationExecutions: + - providerId: idp-confirm-link + requirement: REQUIRED + - providerId: auth-cookie + requirement: DISABLED + state: present + +- name: Remove an authentication flow. + community.general.keycloak_authentication_v2: + auth_keycloak_url: http://localhost:8080/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: My Login Flow + state: absent +""" + +RETURN = r""" +end_state: + description: Representation of the authentication flow after module execution. + returned: on success + type: dict + sample: + { + "alias": "My Login Flow", + "builtIn": false, + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "id": "bc228863-5887-4297-b898-4d988f8eaa5c", + "providerId": "basic-flow", + "topLevel": true, + "authenticationExecutions": [ + { + "alias": "review profile config", + "authenticationConfig": { + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + }, + "id": "6f09e4fb-aad4-496a-b873-7fa9779df6d7" + }, + "configurable": true, + "displayName": "Review Profile", + "id": "8f77dab8-2008-416f-989e-88b09ccf0b4c", + "index": 0, + "level": 0, + "providerId": "idp-review-profile", + "requirement": "REQUIRED", + "requirementChoices": [ + "REQUIRED", + "ALTERNATIVE", + "DISABLED" + ] + } + ] + } +""" + +import copy +import traceback +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def rename_auth_flow(kc: KeycloakAPI, realm: str, flow_id: str, new_alias: str) -> None: + """Rename an existing authentication flow to a new alias. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param flow_id: the ID of the flow to rename. + :param new_alias: the new alias to assign to the flow. + """ + auth = kc.get_authentication_flow_by_id(flow_id, realm) + if auth is not None: + updated = copy.deepcopy(auth) + updated["alias"] = new_alias + # The authenticationExecutions key is not accepted by the update endpoint. + updated.pop("authenticationExecutions", None) + kc.update_authentication_flow(flow_id, config=updated, realm=realm) + + +def append_suffix_to_executions(executions: list, suffix: str) -> None: + """Recursively append a suffix to all sub-flow and authentication config aliases. + + :param executions: a list of execution dicts to process. + :param suffix: the suffix string to append. + """ + for execution in executions: + if execution.get("authenticationConfig") is not None: + execution["authenticationConfig"]["alias"] += suffix + if execution.get("subFlow") is not None: + execution["subFlow"] += suffix + if execution.get("authenticationExecutions") is not None: + append_suffix_to_executions(execution["authenticationExecutions"], suffix) + + +def append_suffix_to_flow_names(desired_auth: dict, suffix: str) -> None: + """Append a suffix to the top-level alias and all nested aliases in a flow definition. + + This is used during the Safe Swap procedure to give the temporary flow a distinct name. + + :param desired_auth: the desired authentication flow dict (mutated in place). + :param suffix: the suffix string to append. + """ + desired_auth["alias"] += suffix + append_suffix_to_executions(desired_auth["authenticationExecutions"], suffix) + + +def remove_suffix_from_flow_names(kc: KeycloakAPI, realm: str, auth: dict, suffix: str) -> None: + """Remove a previously-added suffix from the top-level flow alias, all sub-flow aliases, + and all authentication config aliases. + + This is the final step of the Safe Swap procedure, which restores the original alias after + the temporary flow has been bound and the old flow deleted. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param auth: the authentication flow dict (mutated in place to reflect the renamed alias). + :param suffix: the suffix to remove. + """ + new_alias = auth["alias"].removesuffix(suffix) + rename_auth_flow(kc, realm, auth["id"], new_alias) + auth["alias"] = new_alias + + executions = kc.get_executions_representation(config=auth, realm=realm) + for execution in executions: + if execution.get("authenticationFlow"): + new_sub_flow_alias = execution["displayName"].removesuffix(suffix) + rename_auth_flow(kc, realm, execution["flowId"], new_sub_flow_alias) + if execution.get("configurable"): + auth_config = execution.get("authenticationConfig") + if auth_config is not None: + auth_config["alias"] = auth_config["alias"].removesuffix(suffix) + kc.update_authentication_config( + configId=auth_config["id"], + authenticationConfig=auth_config, + realm=realm, + ) + + +def update_execution_requirement_and_config( + kc: KeycloakAPI, + realm: str, + top_level_auth: dict, + execution: dict, + parent_flow_alias: str, +) -> None: + """Update a newly-created execution to set its requirement and, if present, its configuration. + + Keycloak ignores the requirement value on execution creation and defaults all new executions + to DISABLED. A subsequent update is therefore required to apply the correct requirement. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param top_level_auth: the top-level authentication flow dict used to look up executions. + :param execution: the desired execution dict containing 'requirement' and optionally + 'authenticationConfig'. + :param parent_flow_alias: the alias of the flow or sub-flow that owns this execution. + """ + # The most recently added execution is always last in the list. + created_exec = kc.get_executions_representation(top_level_auth, realm=realm)[-1] + exec_update = { + "id": created_exec["id"], + "providerId": execution["providerId"], + "requirement": execution["requirement"], + "priority": created_exec["priority"], + } + kc.update_authentication_executions( + flowAlias=parent_flow_alias, + updatedExec=exec_update, + realm=realm, + ) + + if execution.get("authenticationConfig") is not None: + kc.add_authenticationConfig_to_execution( + created_exec["id"], + execution["authenticationConfig"], + realm=realm, + ) + + +def create_executions( + kc: KeycloakAPI, + realm: str, + top_level_auth: dict, + executions: list, + parent_flow_alias: str, +) -> None: + """Recursively create all executions and sub-flows under the given parent flow. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param top_level_auth: the top-level authentication flow dict, used when querying the + current execution list after each creation. + :param executions: a list of desired execution dicts to create. + :param parent_flow_alias: the alias of the flow or sub-flow that will own the executions. + """ + for desired_exec in executions: + sub_flow = desired_exec["subFlow"] + sub_flow_type = desired_exec["subFlowType"] + sub_flow_execs = desired_exec.get("authenticationExecutions") + + # Build the minimal payload accepted by the execution creation endpoint. + exec_payload = { + "providerId": desired_exec.get("providerId"), + "requirement": desired_exec["requirement"], + } + if desired_exec.get("authenticationConfig") is not None: + exec_payload["authenticationConfig"] = desired_exec["authenticationConfig"] + + if sub_flow is not None: + kc.create_subflow(sub_flow, parent_flow_alias, realm=realm, flowType=sub_flow_type) + update_execution_requirement_and_config(kc, realm, top_level_auth, exec_payload, parent_flow_alias) + if sub_flow_execs is not None: + create_executions(kc, realm, top_level_auth, sub_flow_execs, sub_flow) + else: + kc.create_execution(exec_payload, flowAlias=parent_flow_alias, realm=realm) + update_execution_requirement_and_config(kc, realm, top_level_auth, exec_payload, parent_flow_alias) + + +def create_empty_flow(kc: KeycloakAPI, realm: str, auth_flow_config: dict) -> dict: + """Create an empty authentication flow from the given configuration dict. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which to create the flow. + :param auth_flow_config: the flow configuration dict (must include at least 'alias'). + :returns: the newly-created flow dict as returned by the Keycloak API. + :raises RuntimeError: if the created flow cannot be retrieved immediately after creation. + """ + created_auth = kc.create_empty_auth_flow(config=auth_flow_config, realm=realm) + if created_auth is None: + raise RuntimeError(f"Could not retrieve the authentication flow that was just created: {auth_flow_config}") + + return created_auth + + +def desired_auth_to_diff_repr(desired_auth: dict) -> dict: + """Convert a desired authentication flow dict into the normalized representation used for + diff comparison. + + :param desired_auth: the desired flow dict as provided by the module parameters. + :returns: a normalized dict suitable for comparison with 'existing_auth_to_diff_repr'. + """ + desired_copy = copy.deepcopy(desired_auth) + desired_copy["topLevel"] = True + desired_copy["authenticationExecutions"] = desired_executions_to_diff_repr(desired_copy["authenticationExecutions"]) + return desired_copy + + +def desired_executions_to_diff_repr(desired_executions: list) -> list: + return desired_executions_to_diff_repr_rec(executions=desired_executions, level=0) + + +def desired_executions_to_diff_repr_rec(executions: list, level: int) -> list: + """Recursively flatten and normalize a nested execution list into the same flat structure + that the Keycloak API returns, so that the two representations can be compared directly. + + :param executions: a list of desired execution dicts (possibly nested). + :param level: the current nesting depth (0 for top-level executions). + :returns: a flat list of normalized execution dicts. + """ + converted: list = [] + for index, execution in enumerate(executions): + converted.append(execution) + execution["index"] = index + execution["priority"] = index + execution["level"] = level + + if execution.get("authenticationConfig") is None: + execution.pop("authenticationConfig", None) + + if execution.get("subFlow") is not None: + execution.pop("providerId", None) + execution["authenticationFlow"] = True + if execution.get("authenticationExecutions") is not None: + converted += desired_executions_to_diff_repr_rec(execution["authenticationExecutions"], level + 1) + + execution.pop("subFlow", None) + execution.pop("subFlowType", None) + execution.pop("authenticationExecutions", None) + + return converted + + +def existing_auth_to_diff_repr(kc: KeycloakAPI, realm: str, existing_auth: dict) -> dict: + """Build a normalized representation of an existing flow that can be compared with the + output of 'desired_auth_to_diff_repr'. + + Server-side fields that have no equivalent in the desired state (such as 'id', + 'builtIn', 'requirementChoices', and 'configurable') are stripped so that the + comparison is not skewed by fields the user cannot control. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param existing_auth: the existing flow dict as returned by the Keycloak API. + :returns: a normalized dict. + """ + existing_copy = copy.deepcopy(existing_auth) + existing_copy.pop("id", None) + existing_copy.pop("builtIn", None) + + executions = kc.get_executions_representation(config=existing_copy, realm=realm) + for execution in executions: + execution.pop("id", None) + execution.pop("requirementChoices", None) + execution.pop("configurable", None) + execution.pop("displayName", None) + execution.pop("description", None) + execution.pop("flowId", None) + + if execution.get("authenticationConfig") is not None: + execution["authenticationConfig"].pop("id", None) + # The alias is already stored inside the authenticationConfig object; the + # top-level alias field on the execution is redundant and is removed. + execution.pop("alias", None) + + existing_copy["authenticationExecutions"] = executions + # Normalize a missing description to None so that it compares equal to an unset desired value. + existing_copy["description"] = existing_copy.get("description") or None + return existing_copy + + +def is_auth_flow_in_use(kc: KeycloakAPI, realm: str, existing_auth: dict) -> bool: + """Determine whether the given flow is currently bound to a realm binding or a client + authentication flow override. + + :param kc: a KeycloakAPI instance. + :param realm: the realm to inspect. + :param existing_auth: the existing flow dict (must include 'id' and 'alias'). + :returns: True if the flow is bound anywhere, False otherwise. + """ + flow_id = existing_auth["id"] + flow_alias = existing_auth["alias"] + realm_data = kc.get_realm_by_id(realm) + if realm_data is None: + raise RuntimeError(f"realm '{realm}' does not exist") + + realm_binding_keys = [ + "browserFlow", + "registrationFlow", + "directGrantFlow", + "resetCredentialsFlow", + "clientAuthenticationFlow", + "dockerAuthenticationFlow", + "firstBrokerLoginFlow", + ] + for binding_key in realm_binding_keys: + if realm_data.get(binding_key) == flow_alias: + return True + + for client in kc.get_clients(realm=realm): + overrides = client.get("authenticationFlowBindingOverrides", {}) + if overrides.get("browser") == flow_id: + return True + if overrides.get("direct_grant") == flow_id: + return True + + return False + + +def rebind_auth_flow_bindings( + kc: KeycloakAPI, + realm: str, + from_id: str, + from_alias: str, + to_id: str, + to_alias: str, +) -> None: + """Re-point all realm bindings and client overrides that reference the source flow to the + target flow. + + This is the critical step in the Safe Swap procedure that transfers live bindings from the + old flow to the newly-created temporary flow without any gap in coverage. + + :param kc: a KeycloakAPI instance. + :param realm: the realm to update. + :param from_id: the ID of the flow to rebind away from. + :param from_alias: the alias of the flow to rebind away from. + :param to_id: the ID of the flow to rebind to. + :param to_alias: the alias of the flow to rebind to. + """ + realm_data = kc.get_realm_by_id(realm) + if realm_data is None: + raise RuntimeError(f"realm '{realm}' does not exist") + realm_changed = False + + realm_binding_keys = [ + "browserFlow", + "registrationFlow", + "directGrantFlow", + "resetCredentialsFlow", + "clientAuthenticationFlow", + "dockerAuthenticationFlow", + "firstBrokerLoginFlow", + ] + for binding_key in realm_binding_keys: + if realm_data.get(binding_key) == from_alias: + realm_data[binding_key] = to_alias + realm_changed = True + + if realm_changed: + kc.update_realm(realm_data, realm) + + for client in kc.get_clients(realm=realm): + overrides = client.get("authenticationFlowBindingOverrides", {}) + client_changed = False + + if overrides.get("browser") == from_id: + client["authenticationFlowBindingOverrides"]["browser"] = to_id + client_changed = True + if overrides.get("direct_grant") == from_id: + client["authenticationFlowBindingOverrides"]["direct_grant"] = to_id + client_changed = True + + if client_changed: + kc.update_client(id=client["id"], clientrep=client, realm=realm) + + +def delete_tmp_swap_flow_if_exists( + kc: KeycloakAPI, + realm: str, + tmp_swap_alias: str, + fallback_id: str, + fallback_alias: str, +) -> None: + """Delete a pre-existing temporary swap flow, rebinding any of its bindings back to the + fallback flow first to avoid orphaned bindings. + + :param kc: a KeycloakAPI instance. + :param realm: the realm to inspect. + :param tmp_swap_alias: the alias of the temporary swap flow to delete. + :param fallback_id: the ID of the flow to rebind to before deleting the temporary flow. + :param fallback_alias: the alias of the flow to rebind to before deleting the temporary flow. + """ + existing_tmp = kc.get_authentication_flow_by_alias(tmp_swap_alias, realm) + if existing_tmp is not None and len(existing_tmp) > 0: + rebind_auth_flow_bindings( + kc, + realm, + from_id=existing_tmp["id"], + from_alias=existing_tmp["alias"], + to_id=fallback_id, + to_alias=fallback_alias, + ) + kc.delete_authentication_flow_by_id(id=existing_tmp["id"], realm=realm) + + +def create_authentication_execution_spec_options(depth: int) -> dict[str, Any]: + options: dict[str, Any] = dict( + providerId=dict(type="str", required=depth == 0), + requirement=dict(type="str", required=True, choices=["REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL"]), + authenticationConfig=dict( + type="dict", + options=dict( + alias=dict(type="str", required=True), + config=dict(type="dict", required=True), + ), + ), + ) + if depth > 0: + options.update( + subFlow=dict(type="str"), + subFlowType=dict(type="str", choices=["basic-flow", "form-flow"], default="basic-flow"), + authenticationExecutions=dict( + type="list", + elements="dict", + options=create_authentication_execution_spec_options(depth - 1), + ), + ) + return options + + +def main() -> None: + """Module entry point.""" + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(type="str", required=True), + alias=dict(type="str", required=True), + providerId=dict(type="str", choices=["basic-flow", "client-flow"], default="basic-flow"), + description=dict(type="str"), + authenticationExecutions=dict( + type="list", + elements="dict", + options=create_authentication_execution_spec_options(4), + ), + state=dict(choices=["absent", "present"], default="present"), + force_temporary_swap_flow_deletion=dict(type="bool", default=True), + temporary_swap_flow_suffix=dict(type="str", default="_tmp_for_swap"), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", end_state={}) + + # Obtain an access token and initialize the API client. + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + force_swap_deletion = module.params.get("force_temporary_swap_flow_deletion") + tmp_swap_suffix = module.params.get("temporary_swap_flow_suffix") + + desired_auth = { + "alias": module.params.get("alias"), + "providerId": module.params.get("providerId"), + "authenticationExecutions": module.params.get("authenticationExecutions") or [], + "description": module.params.get("description") or None, + } + desired_auth_diff_repr = desired_auth_to_diff_repr(desired_auth) + + existing_auth = kc.get_authentication_flow_by_alias(alias=desired_auth["alias"], realm=realm) + existing_auth_diff_repr = None + if existing_auth: + existing_auth_diff_repr = existing_auth_to_diff_repr(kc, realm, existing_auth) + + try: + if not existing_auth: + if state == "absent": + # The flow does not exist and is not required; nothing to do. + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = f"'{desired_auth['alias']}' is already absent" + module.exit_json(**result) + + elif state == "present": + # The flow does not yet exist; create it. + if module.check_mode: + result["changed"] = True + result["diff"] = dict(before="", after=desired_auth_diff_repr) + module.exit_json(**result) + + created_auth = create_empty_flow(kc, realm, desired_auth) + result["changed"] = True + + create_executions( + kc=kc, + realm=realm, + top_level_auth=created_auth, + executions=desired_auth["authenticationExecutions"], + parent_flow_alias=desired_auth["alias"], + ) + + exec_repr = kc.get_executions_representation(config=desired_auth, realm=realm) + if exec_repr is not None: + created_auth["authenticationExecutions"] = exec_repr + + result["diff"] = dict(before="", after=created_auth) + result["end_state"] = created_auth + result["msg"] = f"Authentication flow '{created_auth['alias']}' with id: '{created_auth['id']}' created" + + else: + is_flow_in_use = is_auth_flow_in_use(kc, realm, existing_auth) + + if state == "present": + change_required = existing_auth_diff_repr != desired_auth_diff_repr + if change_required: + result["diff"] = dict(before=existing_auth_diff_repr, after=desired_auth_diff_repr) + + if module.check_mode: + result["changed"] = change_required + module.exit_json(**result) + + if not change_required: + # The existing flow already matches the desired state; nothing to do. + result["end_state"] = existing_auth_diff_repr + module.exit_json(**result) + + # The flow needs to be updated. Rather than modifying the existing flow in place, + # the Safe Swap procedure is used to guarantee that the flow is never left in an + # unsafe intermediate state. See the module documentation for a full description. + if is_flow_in_use: + tmp_swap_alias = desired_auth["alias"] + tmp_swap_suffix + + if force_swap_deletion: + # Remove any leftover temporary flow from a previous interrupted run, + # rebinding its bindings back to the current flow first. + delete_tmp_swap_flow_if_exists( + kc=kc, + realm=realm, + tmp_swap_alias=tmp_swap_alias, + fallback_id=existing_auth["id"], + fallback_alias=existing_auth["alias"], + ) + + # Build the new flow under a temporary name so that both flows coexist + # during the swap. + append_suffix_to_flow_names(desired_auth, tmp_swap_suffix) + else: + # The flow is not bound anywhere; it is safe to delete it immediately and + # recreate it under the original name. + kc.delete_authentication_flow_by_id(existing_auth["id"], realm=realm) + + created_auth = create_empty_flow(kc, realm, desired_auth) + result["changed"] = True + create_executions( + kc=kc, + realm=realm, + top_level_auth=created_auth, + executions=desired_auth["authenticationExecutions"], + parent_flow_alias=desired_auth["alias"], + ) + + if is_flow_in_use: + # Transfer all bindings from the old flow to the new temporary flow, then + # delete the old flow and strip the temporary suffix from all aliases. + rebind_auth_flow_bindings( + kc=kc, + realm=realm, + from_id=existing_auth["id"], + from_alias=existing_auth["alias"], + to_id=created_auth["id"], + to_alias=created_auth["alias"], + ) + kc.delete_authentication_flow_by_id(existing_auth["id"], realm=realm) + remove_suffix_from_flow_names(kc, realm, created_auth, tmp_swap_suffix) + + created_auth_diff_repr = existing_auth_to_diff_repr(kc, realm, created_auth) + result["diff"] = dict(before=existing_auth_diff_repr, after=created_auth_diff_repr) + result["end_state"] = created_auth_diff_repr + result["msg"] = f"Authentication flow: {created_auth['alias']} id: {created_auth['id']} updated" + + else: + if is_flow_in_use: + module.fail_json( + msg=f"Flow {existing_auth['alias']} with id {existing_auth['id']} is in use and therefore cannot be deleted in realm {realm}" + ) + + result["diff"] = dict(before=existing_auth_diff_repr, after="") + if module.check_mode: + result["changed"] = True + module.exit_json(**result) + + kc.delete_authentication_flow_by_id(id=existing_auth["id"], realm=realm) + result["changed"] = True + result["msg"] = f"Authentication flow: {desired_auth['alias']} id: {existing_auth['id']} is deleted" + except Exception as e: + module.fail_json( + msg=f"An unexpected error occurred: {e}", + exception=traceback.format_exc(), + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/keycloak_authentication_v2/README.md b/tests/integration/targets/keycloak_authentication_v2/README.md new file mode 100644 index 0000000000..a07b6a615b --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/README.md @@ -0,0 +1,10 @@ + +# Running keycloak_authentication_v2 module integration test + +Run integration tests: + + ansible-test integration -v keycloak_authentication_v2 --allow-unsupported --docker fedora42 --docker-network host \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/aliases b/tests/integration/targets/keycloak_authentication_v2/aliases new file mode 100644 index 0000000000..bd1f024441 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +unsupported diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/actions/fetch_access_token.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/actions/fetch_access_token.yml new file mode 100644 index 0000000000..5af1fe5c76 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/actions/fetch_access_token.yml @@ -0,0 +1,24 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Get access token + ansible.builtin.uri: + url: "{{ url }}/realms/{{ admin_realm }}/protocol/openid-connect/token" + method: POST + status_code: 200 + headers: + Accept: application/json + User-agent: Ansible + body_format: form-urlencoded + body: + grant_type: "password" + client_id: "admin-cli" + username: "{{ admin_user }}" + password: "{{ admin_password }}" + register: token_response + no_log: true + +- name: Extract access token + ansible.builtin.set_fact: + access_token: "{{ token_response.json['access_token'] }}" + no_log: true diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v1.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v1.yml new file mode 100644 index 0000000000..87ff128168 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v1.yml @@ -0,0 +1,41 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Flow Creation/Update Version 1 + community.general.keycloak_authentication_v2: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: Integration Test Flow + state: present + authenticationExecutions: + - providerId: idp-review-profile + requirement: REQUIRED + authenticationConfig: + alias: Integration Test Flow - review profile config + config: + update.profile.on.first.login: "missing" + - subFlow: Integration Test Flow - User creation or linking + requirement: REQUIRED + authenticationExecutions: + - providerId: idp-create-user-if-unique + requirement: ALTERNATIVE + authenticationConfig: + alias: Integration Test Flow - create unique user config + config: + require.password.update.after.registration: "false" + - subFlow: Integration Test Flow - Handle Existing Account + requirement: ALTERNATIVE + authenticationExecutions: + - providerId: idp-confirm-link + requirement: REQUIRED + - providerId: auth-cookie + requirement: REQUIRED + register: flow_v1_result + +- name: Expose flow_v1_result to parent scope + ansible.builtin.set_fact: + flow_v1_result: "{{ flow_v1_result }}" + no_log: true \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v2.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v2.yml new file mode 100644 index 0000000000..b18bddaebf --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/actions/flow_v2.yml @@ -0,0 +1,47 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# - adding a new execution inside the sub flow 'Integration Test Flow - User creation or linking' after the execution 'idp-create-user-if-unique' +# - disable auth-cookie at the end of the flow +# - change the config require.password.update.after.registration to "true" +- name: Flow Creation/Update Version 2 + community.general.keycloak_authentication_v2: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: Integration Test Flow + state: present + authenticationExecutions: + - providerId: idp-review-profile + requirement: REQUIRED + authenticationConfig: + alias: Integration Test Flow - review profile config + config: + update.profile.on.first.login: "missing" + - subFlow: Integration Test Flow - User creation or linking + requirement: REQUIRED + authenticationExecutions: + - providerId: idp-create-user-if-unique + requirement: ALTERNATIVE + authenticationConfig: + alias: Integration Test Flow - create unique user config + config: + require.password.update.after.registration: "true" + - providerId: auth-cookie + requirement: REQUIRED + - subFlow: Integration Test Flow - Handle Existing Account + requirement: ALTERNATIVE + authenticationExecutions: + - providerId: idp-confirm-link + requirement: REQUIRED + - providerId: auth-cookie + requirement: DISABLED + register: flow_v2_result + +- name: Expose flow_v2_result to parent scope + ansible.builtin.set_fact: + flow_v2_result: "{{ flow_v2_result }}" + no_log: true \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v1.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v1.yml new file mode 100644 index 0000000000..15e30cf068 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v1.yml @@ -0,0 +1,144 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Retrieve access token + ansible.builtin.include_tasks: + file: ../actions/fetch_access_token.yml + +- name: Fetch all authentication flows + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: keycloak_flows_response + no_log: true + +- name: Extract flow id for alias 'Integration Test Flow' + ansible.builtin.set_fact: + test_flow_id: >- + {{ keycloak_flows_response.json + | selectattr('alias', 'equalto', 'Integration Test Flow') + | map(attribute='id') + | first }} + no_log: true + +- name: Fetch basic flow data for 'Integration Test Flow' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows/{{ test_flow_id }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_top_level_data + no_log: true + +- name: Assert flow attributes + ansible.builtin.assert: + that: + - test_flow_top_level_data.json.alias == "Integration Test Flow" + - test_flow_top_level_data.json.providerId == "basic-flow" + - test_flow_top_level_data.json.topLevel == true + - test_flow_top_level_data.json.builtIn == false + +- name: Fetch flow executions for 'Integration Test Flow' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows/Integration%20Test%20Flow/executions" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_executions + no_log: true + +- name: Assert flow executions + ansible.builtin.assert: + that: + - test_flow_executions.json | length == 6 + - test_flow_executions.json[0].providerId == "idp-review-profile" + - test_flow_executions.json[0].requirement == "REQUIRED" + - test_flow_executions.json[0].index == 0 + - test_flow_executions.json[0].priority == 0 + - test_flow_executions.json[0].level == 0 + - test_flow_executions.json[0].authenticationConfig is not none + - test_flow_executions.json[0]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[1].displayName == "Integration Test Flow - User creation or linking" + - test_flow_executions.json[1].requirement == "REQUIRED" + - test_flow_executions.json[1].index == 1 + - test_flow_executions.json[1].priority == 1 + - test_flow_executions.json[1].level == 0 + - test_flow_executions.json[1]['authenticationConfig'] is not defined + - test_flow_executions.json[1].authenticationFlow == true + - test_flow_executions.json[2].providerId == "idp-create-user-if-unique" + - test_flow_executions.json[2].requirement == "ALTERNATIVE" + - test_flow_executions.json[2].index == 0 + - test_flow_executions.json[2].priority == 0 + - test_flow_executions.json[2].level == 1 + - test_flow_executions.json[2].authenticationConfig is not none + - test_flow_executions.json[2]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[3].displayName == "Integration Test Flow - Handle Existing Account" + - test_flow_executions.json[3].requirement == "ALTERNATIVE" + - test_flow_executions.json[3].index == 1 + - test_flow_executions.json[3].priority == 1 + - test_flow_executions.json[3].level == 1 + - test_flow_executions.json[3]['authenticationConfig'] is not defined + - test_flow_executions.json[3].authenticationFlow == true + - test_flow_executions.json[4].providerId == "idp-confirm-link" + - test_flow_executions.json[4].requirement == "REQUIRED" + - test_flow_executions.json[4].index == 0 + - test_flow_executions.json[4].priority == 0 + - test_flow_executions.json[4].level == 2 + - test_flow_executions.json[4]['authenticationConfig'] is not defined + - test_flow_executions.json[4]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[5].providerId == "auth-cookie" + - test_flow_executions.json[5].requirement == "REQUIRED" + - test_flow_executions.json[5].index == 2 + - test_flow_executions.json[5].priority == 2 + - test_flow_executions.json[5].level == 0 + - test_flow_executions.json[5]['authenticationConfig'] is not defined + - test_flow_executions.json[5]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + +- name: Fetch flow execution config for 'idp-review-profile' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/config/{{ test_flow_executions.json[0].authenticationConfig }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_execution_idp_review_profile_config + no_log: true + +- name: Assert flow execution config for 'idp-review-profile' + ansible.builtin.assert: + that: + - test_flow_execution_idp_review_profile_config.json.alias == "Integration Test Flow - review profile config" + - test_flow_execution_idp_review_profile_config.json.config is not none + - test_flow_execution_idp_review_profile_config.json.config['update.profile.on.first.login'] == "missing" + +- name: Fetch flow execution config for 'idp-create-user-if-unique' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/config/{{ test_flow_executions.json[2].authenticationConfig }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_execution_idp_create_user_if_unique + no_log: true + +- name: Assert flow execution config for 'idp-create-user-if-unique' + ansible.builtin.assert: + that: + - test_flow_execution_idp_create_user_if_unique.json.alias == "Integration Test Flow - create unique user config" + - test_flow_execution_idp_create_user_if_unique.json.config is not none + - test_flow_execution_idp_create_user_if_unique.json.config['require.password.update.after.registration'] == "false" \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v2.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v2.yml new file mode 100644 index 0000000000..c2961f7fec --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/assertions/assertion_flow_v2.yml @@ -0,0 +1,151 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Retrieve access token + ansible.builtin.include_tasks: + file: ../actions/fetch_access_token.yml + +- name: Fetch all authentication flows + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: keycloak_flows_response + no_log: true + +- name: Extract flow id for alias 'Integration Test Flow' + ansible.builtin.set_fact: + test_flow_id: >- + {{ keycloak_flows_response.json + | selectattr('alias', 'equalto', 'Integration Test Flow') + | map(attribute='id') + | first }} + no_log: true + +- name: Fetch basic flow data for 'Integration Test Flow' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows/{{ test_flow_id }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_top_level_data + no_log: true + +- name: Assert flow attributes + ansible.builtin.assert: + that: + - test_flow_top_level_data.json.alias == "Integration Test Flow" + - test_flow_top_level_data.json.providerId == "basic-flow" + - test_flow_top_level_data.json.topLevel == true + - test_flow_top_level_data.json.builtIn == false + +- name: Fetch flow executions for 'Integration Test Flow' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows/Integration%20Test%20Flow/executions" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_executions + no_log: true + +- name: Assert flow executions + ansible.builtin.assert: + that: + - test_flow_executions.json | length == 7 + - test_flow_executions.json[0].providerId == "idp-review-profile" + - test_flow_executions.json[0].requirement == "REQUIRED" + - test_flow_executions.json[0].index == 0 + - test_flow_executions.json[0].priority == 0 + - test_flow_executions.json[0].level == 0 + - test_flow_executions.json[0].authenticationConfig is not none + - test_flow_executions.json[0]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[1].displayName == "Integration Test Flow - User creation or linking" + - test_flow_executions.json[1].requirement == "REQUIRED" + - test_flow_executions.json[1].index == 1 + - test_flow_executions.json[1].priority == 1 + - test_flow_executions.json[1].level == 0 + - test_flow_executions.json[1]['authenticationConfig'] is not defined + - test_flow_executions.json[1].authenticationFlow == true + - test_flow_executions.json[2].providerId == "idp-create-user-if-unique" + - test_flow_executions.json[2].requirement == "ALTERNATIVE" + - test_flow_executions.json[2].index == 0 + - test_flow_executions.json[2].priority == 0 + - test_flow_executions.json[2].level == 1 + - test_flow_executions.json[2].authenticationConfig is not none + - test_flow_executions.json[2]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[3].providerId == "auth-cookie" + - test_flow_executions.json[3].requirement == "REQUIRED" + - test_flow_executions.json[3].index == 1 + - test_flow_executions.json[3].priority == 1 + - test_flow_executions.json[3].level == 1 + - test_flow_executions.json[3]['authenticationConfig'] is not defined + - test_flow_executions.json[3]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[4].displayName == "Integration Test Flow - Handle Existing Account" + - test_flow_executions.json[4].requirement == "ALTERNATIVE" + - test_flow_executions.json[4].index == 2 + - test_flow_executions.json[4].priority == 2 + - test_flow_executions.json[4].level == 1 + - test_flow_executions.json[4]['authenticationConfig'] is not defined + - test_flow_executions.json[4].authenticationFlow == true + - test_flow_executions.json[5].providerId == "idp-confirm-link" + - test_flow_executions.json[5].requirement == "REQUIRED" + - test_flow_executions.json[5].index == 0 + - test_flow_executions.json[5].priority == 0 + - test_flow_executions.json[5].level == 2 + - test_flow_executions.json[5]['authenticationConfig'] is not defined + - test_flow_executions.json[5]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + - test_flow_executions.json[6].providerId == "auth-cookie" + - test_flow_executions.json[6].requirement == "DISABLED" + - test_flow_executions.json[6].index == 2 + - test_flow_executions.json[6].priority == 2 + - test_flow_executions.json[6].level == 0 + - test_flow_executions.json[6]['authenticationConfig'] is not defined + - test_flow_executions.json[6]['authenticationFlow'] is not defined or test_flow_executions.json[0].authenticationFlow == false + +- name: Fetch flow execution config for 'idp-review-profile' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/config/{{ test_flow_executions.json[0].authenticationConfig }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_execution_idp_review_profile_config + no_log: true + +- name: Assert flow execution config for 'idp-review-profile' + ansible.builtin.assert: + that: + - test_flow_execution_idp_review_profile_config.json.alias == "Integration Test Flow - review profile config" + - test_flow_execution_idp_review_profile_config.json.config is not none + - test_flow_execution_idp_review_profile_config.json.config['update.profile.on.first.login'] == "missing" + +- name: Fetch flow execution config for 'idp-create-user-if-unique' + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/config/{{ test_flow_executions.json[2].authenticationConfig }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: test_flow_execution_idp_create_user_if_unique + no_log: true + +- name: Assert flow execution config for 'idp-create-user-if-unique' + ansible.builtin.assert: + that: + - test_flow_execution_idp_create_user_if_unique.json.alias == "Integration Test Flow - create unique user config" + - test_flow_execution_idp_create_user_if_unique.json.config is not none + - test_flow_execution_idp_create_user_if_unique.json.config['require.password.update.after.registration'] == "true" \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/main.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/main.yml new file mode 100644 index 0000000000..ed4b01ff3b --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/main.yml @@ -0,0 +1,26 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Install required packages + pip: + name: + - jmespath + - requests + register: result + until: result is success + +- name: Executing Flow Creation tests + ansible.builtin.include_tasks: + file: tests/test_flow_creation.yml + +- name: Executing unused Flow Modification tests + ansible.builtin.include_tasks: + file: tests/test_unused_flow_modification.yml + +- name: Executing used Flow Modification tests + ansible.builtin.include_tasks: + file: tests/test_used_flow_modification.yml + +- name: Executing Flow Deletion tests + ansible.builtin.include_tasks: + file: tests/test_flow_deletion.yml \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_creation.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_creation.yml new file mode 100644 index 0000000000..0b711df408 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_creation.yml @@ -0,0 +1,33 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Setup Test + ansible.builtin.include_tasks: + file: test_setup.yml + +- name: Create the new flow + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Assert the flow creation result + ansible.builtin.assert: + that: + - flow_v1_result is changed + - flow_v1_result.diff is defined + msg: "Expected changes and a diff but got: changed={{ flow_v1_result.changed }}, diff={{ flow_v1_result.get('diff') }}" + +- name: Assert the created flow + ansible.builtin.include_tasks: + file: ../assertions/assertion_flow_v1.yml + +# Check idempotency, by trying to create the existing flow again +- name: Trying to create the again, although nothing changed + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Assert that the task didn't re-create the flow, because the flow didn't change + ansible.builtin.assert: + that: + - flow_v1_result is not changed + - flow_v1_result.diff is not defined + msg: "Expected no changes and no diff but got: changed={{ flow_v1_result.changed }}, diff={{ flow_v1_result.get('diff') }}" \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_deletion.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_deletion.yml new file mode 100644 index 0000000000..bb80e3aee0 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_flow_deletion.yml @@ -0,0 +1,44 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Setup Test + ansible.builtin.include_tasks: + file: test_setup.yml + +- name: Create the new flow + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Delete the flow + community.general.keycloak_authentication_v2: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: Integration Test Flow + state: absent + register: flow_deletion_result + +- name: Assert that the flow deletion result + ansible.builtin.assert: + that: + - flow_deletion_result is changed + - flow_deletion_result.diff is defined + msg: "Expected changes and a diff but got: changed={{ flow_deletion_result.changed }}, diff={{ flow_deletion_result.get('diff') }}" + +- name: Retrieve access token + ansible.builtin.include_tasks: + file: ../actions/fetch_access_token.yml + +- name: Assert that the flow does not exist anymore + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows/Integratoin%20Test%20Flow/executions" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + status_code: 404 + register: flow_response diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_setup.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_setup.yml new file mode 100644 index 0000000000..06e9fcf347 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_setup.yml @@ -0,0 +1,46 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Start container + community.docker.docker_container: + name: mykeycloak + image: "quay.io/keycloak/keycloak:{{ keycloak_version }}" + command: start-dev + env: + KC_HTTP_RELATIVE_PATH: /auth + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: password + ports: + - "{{ keycloak_port }}:8080" + detach: true + auto_remove: true + memory: 2200M + +- name: Wait for Keycloak + uri: + url: "{{ url }}/admin/" + status_code: 200 + validate_certs: false + register: result + until: result.status == 200 + retries: 10 + delay: 10 + +- name: Delete realm if exists + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + state: absent + +- name: Create realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ realm }}" + realm: "{{ realm }}" + state: present \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_unused_flow_modification.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_unused_flow_modification.yml new file mode 100644 index 0000000000..943b16422d --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_unused_flow_modification.yml @@ -0,0 +1,41 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Setup Test + ansible.builtin.include_tasks: + file: test_setup.yml + +- name: Create the new flow + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Modify the flow ' + ansible.builtin.include_tasks: + file: ../actions/flow_v2.yml + +- name: Assert the flow creation modification result + ansible.builtin.assert: + that: + - flow_v2_result is changed + - flow_v2_result.diff is defined + msg: "Expected changes and a diff but got: changed={{ flow_v2_result.changed }}, diff={{ flow_v2_result.get('diff') }}" + +- name: Assert the modified flow + ansible.builtin.include_tasks: + file: ../assertions/assertion_flow_v2.yml + +# Revert the modifications from the previous step +- name: Revert the modifications the flow + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Assert the flow modification result + ansible.builtin.assert: + that: + - flow_v1_result is changed + - flow_v1_result.diff is defined + msg: "Expected changes and a diff but got: changed={{ flow_v1_result.changed }}, diff={{ flow_v1_result.get('diff') }}" + +- name: Assert the flow after reverting the modifications + ansible.builtin.include_tasks: + file: ../assertions/assertion_flow_v1.yml \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_used_flow_modification.yml b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_used_flow_modification.yml new file mode 100644 index 0000000000..1bd0d916e8 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/tasks/tests/test_used_flow_modification.yml @@ -0,0 +1,111 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Setup Test + ansible.builtin.include_tasks: + file: test_setup.yml + +- name: Create the new flow + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Assign the flow as default browser flow + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ realm }}" + realm: "{{ realm }}" + state: present + browser_flow: Integration Test Flow + +- name: Modify the flow although it is bound as realm browser flow + ansible.builtin.include_tasks: + file: ../actions/flow_v2.yml + +- name: Assert the flow modification result + ansible.builtin.assert: + that: + - flow_v2_result is changed + - flow_v2_result.diff is defined + msg: "Expected changes and a diff but got: changed={{ flow_v2_result.changed }}, diff={{ flow_v2_result.get('diff') }}" + +- name: Assert the flow after the modifications + ansible.builtin.include_tasks: + file: ../assertions/assertion_flow_v2.yml + +- name: Fetch realm data to validate if the browser flow is still set to + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ realm }}" + realm: "{{ realm }}" + state: present + register: realm_result + +- name: Assert realm browser flow binding + ansible.builtin.assert: + that: + - realm_result.end_state.browserFlow == "Integration Test Flow" + +- name: Create a client with browser override + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "test_client" + state: present + authentication_flow_binding_overrides: + browser_name: "Integration Test Flow" + +- name: Revert the flow modifications of although it is bound as realm browser and client override flow + ansible.builtin.include_tasks: + file: ../actions/flow_v1.yml + +- name: Assert the flow modification result + ansible.builtin.assert: + that: + - flow_v1_result is changed + - flow_v1_result.diff is defined + msg: "Expected changes and a diff but got: changed={{ flow_v1_result.changed }}, diff={{ flow_v1_result.get('diff') }}" + +- name: Assert the flow after the revert + ansible.builtin.include_tasks: + file: ../assertions/assertion_flow_v1.yml + +- name: Fetch client data + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "test_client" + state: present + register: client_result + +- name: Retrieve access token + ansible.builtin.include_tasks: + file: ../actions/fetch_access_token.yml + +- name: Fetch authentication flow + ansible.builtin.uri: + url: "{{ url }}/admin/realms/{{ realm }}/authentication/flows/{{ client_result.end_state.authenticationFlowBindingOverrides.browser }}" + method: GET + headers: + Accept: application/json + User-agent: Ansible + Authorization: "Bearer {{ access_token }}" + return_content: true + register: flow_response + +- name: Assert client flow override binding + ansible.builtin.assert: + that: + - flow_response.json is defined + - flow_response.json.alias == "Integration Test Flow" \ No newline at end of file diff --git a/tests/integration/targets/keycloak_authentication_v2/vars/main.yml b/tests/integration/targets/keycloak_authentication_v2/vars/main.yml new file mode 100644 index 0000000000..9cbc2f4d74 --- /dev/null +++ b/tests/integration/targets/keycloak_authentication_v2/vars/main.yml @@ -0,0 +1,14 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +keycloak_version: latest +keycloak_port: 8080 + +url: "http://localhost:{{ keycloak_port }}/auth" +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm + +keycloak_no_otp_required_pattern_orinale: "X-Forwarded-For: 10\\.[0-9\\.:]+" +keycloak_no_otp_required_pattern_modifed: "X-Original-Forwarded-For: 10\\.[0-9\\.:]+" \ No newline at end of file