1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-03-21 20:59:10 +00:00

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 <felix@fontein.de>

* 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 <felix@fontein.de>
Co-authored-by: Thomas Bargetz <thomas.bargetz@rise-world.com>
This commit is contained in:
thomasbargetz 2026-03-12 22:04:08 +01:00 committed by GitHub
parent 0e4783dcc3
commit a69f7e60b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1765 additions and 0 deletions

2
.github/BOTMETA.yml vendored
View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1,10 @@
<!--
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
-->
# Running keycloak_authentication_v2 module integration test
Run integration tests:
ansible-test integration -v keycloak_authentication_v2 --allow-unsupported --docker fedora42 --docker-network host

View file

@ -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

View file

@ -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

View file

@ -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 <Integration Test Flow> 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

View file

@ -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 <Integration Test Flow> 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

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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 <Integration Test 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 <Integration Test 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 <Integration Test Flow> 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') }}"

View file

@ -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 <Integration Test Flow>
ansible.builtin.include_tasks:
file: ../actions/flow_v1.yml
- name: Delete the flow <Integration Test 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

View file

@ -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

View file

@ -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 <Integration Test Flow>
ansible.builtin.include_tasks:
file: ../actions/flow_v1.yml
- name: Modify the flow <Integration Test 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 <Integration Test Flow>
ansible.builtin.include_tasks:
file: ../assertions/assertion_flow_v2.yml
# Revert the modifications from the previous step
- name: Revert the modifications the flow <Integration Test 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 <Integration Test Flow> after reverting the modifications
ansible.builtin.include_tasks:
file: ../assertions/assertion_flow_v1.yml

View file

@ -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 <Integration Test Flow>
ansible.builtin.include_tasks:
file: ../actions/flow_v1.yml
- name: Assign the flow <Integration Test 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 <Integration Test 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 <Integration Test 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 <Integration Test 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
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 <Integration Test Flow>
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 <Integration Test Flow> 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 <Integration Test 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"

View file

@ -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\\.:]+"