From c6189bfc51f4e40650d5e376c3093e77c9a9a360 Mon Sep 17 00:00:00 2001 From: pamenon Date: Thu, 23 Apr 2026 12:49:32 +0100 Subject: [PATCH] Add keycloak_client_scope and keycloak_authentication_flow modules with example playbooks The collection was missing modules for managing client scopes and authentication flows, forcing users to write raw uri calls against the Keycloak Admin REST API. This adds two new modules that leverage the existing KeycloakAPI helper methods: - keycloak_client_scope: create/update/delete client scopes with protocol mappers (supports check_mode and diff) - keycloak_authentication_flow: create/delete authentication flows with execution steps, or copy existing flows (supports check_mode and diff) Also adds three example playbooks using the new modules: - keycloak_client_scope.yml - keycloak_authentication_flow.yml - keycloak_realm_client.yml Made-with: Cursor --- playbooks/keycloak_authentication_flow.yml | 27 ++ playbooks/keycloak_client_scope.yml | 48 +++ playbooks/keycloak_realm_client.yml | 39 +++ .../modules/keycloak_authentication_flow.py | 296 ++++++++++++++++ plugins/modules/keycloak_client_scope.py | 324 ++++++++++++++++++ 5 files changed, 734 insertions(+) create mode 100644 playbooks/keycloak_authentication_flow.yml create mode 100644 playbooks/keycloak_client_scope.yml create mode 100644 playbooks/keycloak_realm_client.yml create mode 100644 plugins/modules/keycloak_authentication_flow.py create mode 100644 plugins/modules/keycloak_client_scope.py diff --git a/playbooks/keycloak_authentication_flow.yml b/playbooks/keycloak_authentication_flow.yml new file mode 100644 index 0000000..38878b5 --- /dev/null +++ b/playbooks/keycloak_authentication_flow.yml @@ -0,0 +1,27 @@ +--- +- name: Playbook for Keycloak Authentication Flow Configuration + hosts: all + vars: + keycloak_admin_user: admin + keycloak_admin_password: "remembertochangeme" + keycloak_url: "http://localhost:8080" + keycloak_realm: TestRealm + tasks: + - name: Create authentication flow with executions + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: "{{ keycloak_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + alias: my-browser-flow + description: "Custom browser authentication flow" + provider_id: basic-flow + executions: + - provider_id: auth-cookie + requirement: ALTERNATIVE + - provider_id: auth-password + requirement: REQUIRED + - provider_id: auth-otp-form + requirement: ALTERNATIVE + state: present diff --git a/playbooks/keycloak_client_scope.yml b/playbooks/keycloak_client_scope.yml new file mode 100644 index 0000000..aa5ed3d --- /dev/null +++ b/playbooks/keycloak_client_scope.yml @@ -0,0 +1,48 @@ +--- +- name: Playbook for Keycloak Client Scope Configuration + hosts: all + vars: + keycloak_admin_user: admin + keycloak_admin_password: "remembertochangeme" + keycloak_url: "http://localhost:8080" + keycloak_realm: TestRealm + tasks: + - name: Create client scope with protocol mappers + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: "{{ keycloak_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + name: TestClientScope + description: "Client scope created via Ansible" + protocol: openid-connect + protocol_mappers: + - name: email + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: email + claim.name: email + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + - name: firstName + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: firstName + claim.name: given_name + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + - name: username + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: username + claim.name: preferred_username + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + state: present diff --git a/playbooks/keycloak_realm_client.yml b/playbooks/keycloak_realm_client.yml new file mode 100644 index 0000000..ddefd88 --- /dev/null +++ b/playbooks/keycloak_realm_client.yml @@ -0,0 +1,39 @@ +--- +- name: Playbook for Keycloak Realm and Client Configuration + hosts: all + tasks: + - name: Keycloak Realm Role + ansible.builtin.include_role: + name: middleware_automation.keycloak.keycloak_realm + vars: + keycloak_admin_password: "remembertochangeme" + keycloak_realm: TestRealm + keycloak_client_default_roles: + - TestRoleAdmin + - TestRoleUser + keycloak_client_users: + - username: TestUser + password: password + client_roles: + - client: TestClient1 + role: TestRoleUser + realm: TestRealm + - username: TestAdmin + password: password + client_roles: + - client: TestClient1 + role: TestRoleUser + realm: TestRealm + - client: TestClient1 + role: TestRoleAdmin + realm: TestRealm + keycloak_clients: + - name: TestClient1 + client_id: TestClient1 + roles: "{{ keycloak_client_default_roles }}" + realm: TestRealm + public_client: true + web_origins: + - http://testclient1origin/application + - http://testclient1origin/other + users: "{{ keycloak_client_users }}" diff --git a/plugins/modules/keycloak_authentication_flow.py b/plugins/modules/keycloak_authentication_flow.py new file mode 100644 index 0000000..c6ae5e3 --- /dev/null +++ b/plugins/modules/keycloak_authentication_flow.py @@ -0,0 +1,296 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Contributors to the middleware_automation.keycloak collection +# 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 absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_authentication_flow + +short_description: Allows administration of Keycloak authentication flows via Keycloak API + +description: + - This module allows you to add, remove or modify Keycloak authentication flows via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - This module supports creating new top-level authentication flows, copying existing flows, + and adding execution steps to a flow. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the authentication flow. + - On V(present), the flow will be created if it does not yet exist. + - On V(absent), the flow will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + alias: + type: str + required: true + description: + - Alias (name) of the authentication flow. + + description: + type: str + description: + - Description of the authentication flow. + default: '' + + realm: + type: str + description: + - The Keycloak realm under which this authentication flow resides. + default: 'master' + + provider_id: + type: str + description: + - The provider ID for the flow. + default: 'basic-flow' + aliases: + - providerId + + copy_from: + type: str + description: + - If set, the new flow is created as a copy of the flow with this alias. + - Cannot be used together with O(executions). + aliases: + - copyFrom + + executions: + type: list + elements: dict + description: + - A list of executions (authenticator steps) to add to the flow. + - Each execution is a dict with keys C(provider_id) (or C(providerId)) and C(requirement). + - Executions are only added when the flow is first created. + default: [] + suboptions: + provider_id: + type: str + required: true + description: + - The authenticator provider ID (e.g. V(auth-cookie), V(auth-password), V(auth-otp-form)). + aliases: + - providerId + requirement: + type: str + required: true + description: + - The requirement level for this execution. + choices: + - REQUIRED + - ALTERNATIVE + - DISABLED + - CONDITIONAL + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Paulo Menon (@paulomenon) +''' + +EXAMPLES = ''' +- name: Create an authentication flow with executions + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + alias: my-browser-flow + description: "Custom browser flow" + provider_id: basic-flow + executions: + - provider_id: auth-cookie + requirement: ALTERNATIVE + - provider_id: auth-password + requirement: REQUIRED + - provider_id: auth-otp-form + requirement: ALTERNATIVE + state: present + delegate_to: localhost + +- name: Create an authentication flow by copying an existing one + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + alias: my-copy-of-browser + copy_from: browser + state: present + delegate_to: localhost + +- name: Create a flow using token authentication + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + token: MY_TOKEN + realm: TestRealm + alias: my-flow + state: present + delegate_to: localhost + +- name: Delete an authentication flow + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + alias: my-browser-flow + state: absent + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Authentication flow my-browser-flow has been created" + +end_state: + description: Representation of the authentication flow after module execution. + returned: on success + type: dict + sample: { + "id": "uuid-here", + "alias": "my-browser-flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + argument_spec = keycloak_argument_spec() + + execution_spec = dict( + provider_id=dict(type='str', required=True, aliases=['providerId']), + requirement=dict(type='str', required=True, choices=['REQUIRED', 'ALTERNATIVE', 'DISABLED', 'CONDITIONAL']), + ) + + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + alias=dict(type='str', required=True), + description=dict(type='str', default=''), + realm=dict(type='str', default='master'), + provider_id=dict(type='str', default='basic-flow', aliases=['providerId']), + copy_from=dict(type='str', aliases=['copyFrom']), + executions=dict(type='list', default=[], options=execution_spec, elements='dict'), + ) + + 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']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']]), + mutually_exclusive=[['copy_from', 'executions']]) + + result = dict(changed=False, msg='', diff={}, end_state={}) + + 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') + alias = module.params.get('alias') + state = module.params.get('state') + description = module.params.get('description') + provider_id = module.params.get('provider_id') + copy_from = module.params.get('copy_from') + executions = module.params.get('executions') + + before_flow = kc.get_authentication_flow_by_alias(alias, realm=realm) + flow_exists = bool(before_flow) + + if state == 'absent': + if flow_exists: + result['changed'] = True + if module._diff: + result['diff'] = dict(before=before_flow, after='') + if module.check_mode: + module.exit_json(**result) + kc.delete_authentication_flow_by_id(before_flow['id'], realm=realm) + result['msg'] = "Authentication flow {alias} has been deleted".format(alias=alias) + else: + result['msg'] = "Authentication flow {alias} does not exist, doing nothing".format(alias=alias) + result['end_state'] = {} + module.exit_json(**result) + + if flow_exists: + result['changed'] = False + result['end_state'] = before_flow + result['msg'] = "Authentication flow {alias} already exists".format(alias=alias) + module.exit_json(**result) + + result['changed'] = True + + flow_config = { + 'alias': alias, + 'description': description, + 'providerId': provider_id, + } + + if module._diff: + result['diff'] = dict(before='', after=flow_config) + + if module.check_mode: + module.exit_json(**result) + + if copy_from: + flow_config['copyFrom'] = copy_from + after_flow = kc.copy_auth_flow(flow_config, realm=realm) + result['msg'] = "Authentication flow {alias} has been created (copied from {src})".format(alias=alias, src=copy_from) + else: + after_flow = kc.create_empty_auth_flow(flow_config, realm=realm) + + if executions: + for execution in executions: + exec_rep = { + 'providerId': execution['provider_id'], + 'requirement': execution['requirement'], + } + kc.create_execution(exec_rep, alias, realm=realm) + + result['msg'] = "Authentication flow {alias} has been created".format(alias=alias) + + after_flow = kc.get_authentication_flow_by_alias(alias, realm=realm) + result['end_state'] = after_flow + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_client_scope.py b/plugins/modules/keycloak_client_scope.py new file mode 100644 index 0000000..cf5f6ab --- /dev/null +++ b/plugins/modules/keycloak_client_scope.py @@ -0,0 +1,324 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Contributors to the middleware_automation.keycloak collection +# 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 absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_client_scope + +short_description: Allows administration of Keycloak client scopes via Keycloak API + +description: + - This module allows you to add, remove or modify Keycloak client scopes via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - This module also supports managing protocol mappers within a client scope. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the client scope. + - On V(present), the client scope will be created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the client scope will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + required: true + description: + - Name of the client scope. + + description: + type: str + description: + - Description of the client scope. + + realm: + type: str + description: + - The Keycloak realm under which this client scope resides. + default: 'master' + + protocol: + type: str + description: + - The protocol associated with the client scope. + default: 'openid-connect' + choices: + - openid-connect + - saml + + attributes: + type: dict + description: + - A dict of key/value pairs to set as attributes for the client scope. + + protocol_mappers: + type: list + elements: dict + description: + - A list of protocol mappers to associate with the client scope. + - Each mapper is a dict with the keys C(name), C(protocol), C(protocolMapper), and C(config). + default: [] + suboptions: + name: + type: str + required: true + description: + - Name of the protocol mapper. + protocol: + type: str + description: + - Protocol for the mapper. + default: 'openid-connect' + protocolMapper: + type: str + required: true + description: + - The mapper type (e.g. V(oidc-usermodel-attribute-mapper), V(oidc-audience-mapper)). + aliases: + - protocol_mapper_type + config: + type: dict + required: true + description: + - Configuration for the protocol mapper. + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Paulo Menon (@paulomenon) +''' + +EXAMPLES = ''' +- name: Create a client scope with protocol mappers + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + name: my-client-scope + description: "A custom client scope" + protocol: openid-connect + protocol_mappers: + - name: email + protocol: openid-connect + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: email + claim.name: email + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + state: present + delegate_to: localhost + +- name: Create a client scope using token authentication + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + token: MY_TOKEN + realm: TestRealm + name: my-scope + state: present + delegate_to: localhost + +- name: Delete a client scope + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + name: my-client-scope + state: absent + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client scope my-scope has been created" + +end_state: + description: Representation of the client scope after module execution. + returned: on success + type: dict + sample: { + "id": "uuid-here", + "name": "my-scope", + "protocol": "openid-connect", + "description": "A custom scope" + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +import copy + + +def main(): + argument_spec = keycloak_argument_spec() + + mapper_spec = dict( + name=dict(type='str', required=True), + protocol=dict(type='str', default='openid-connect'), + protocolMapper=dict(type='str', required=True, aliases=['protocol_mapper_type']), + config=dict(type='dict', required=True), + ) + + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + name=dict(type='str', required=True), + description=dict(type='str', default=''), + realm=dict(type='str', default='master'), + protocol=dict(type='str', default='openid-connect', choices=['openid-connect', 'saml']), + attributes=dict(type='dict'), + protocol_mappers=dict(type='list', default=[], options=mapper_spec, elements='dict'), + ) + + 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']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, end_state={}) + + 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') + name = module.params.get('name') + state = module.params.get('state') + protocol = module.params.get('protocol') + description = module.params.get('description') + attributes = module.params.get('attributes') + protocol_mappers = module.params.get('protocol_mappers') + + before_scope = kc.get_clientscope_by_name(name, realm=realm) + + if state == 'absent': + if before_scope: + result['changed'] = True + if module._diff: + result['diff'] = dict(before=before_scope, after='') + if module.check_mode: + module.exit_json(**result) + kc.delete_clientscope(cid=before_scope['id'], realm=realm) + result['msg'] = "Client scope {name} has been deleted".format(name=name) + else: + result['msg'] = "Client scope {name} does not exist, doing nothing".format(name=name) + result['end_state'] = {} + module.exit_json(**result) + + scope_rep = { + 'name': name, + 'protocol': protocol, + 'description': description, + } + if attributes: + scope_rep['attributes'] = attributes + + if not before_scope: + result['changed'] = True + if module._diff: + result['diff'] = dict(before='', after=scope_rep) + if module.check_mode: + module.exit_json(**result) + + kc.create_clientscope(scope_rep, realm=realm) + after_scope = kc.get_clientscope_by_name(name, realm=realm) + + if protocol_mappers: + for mapper in protocol_mappers: + mapper_rep = { + 'name': mapper['name'], + 'protocol': mapper.get('protocol', protocol), + 'protocolMapper': mapper['protocolMapper'], + 'config': mapper['config'], + } + kc.create_clientscope_protocolmapper(after_scope['id'], mapper_rep, realm=realm) + after_scope = kc.get_clientscope_by_name(name, realm=realm) + + result['end_state'] = after_scope + result['msg'] = "Client scope {name} has been created".format(name=name) + module.exit_json(**result) + + else: + changed = False + for key in ('protocol', 'description'): + if scope_rep.get(key) and scope_rep[key] != before_scope.get(key): + changed = True + break + + if attributes and attributes != before_scope.get('attributes', {}): + changed = True + + if changed: + result['changed'] = True + scope_rep['id'] = before_scope['id'] + if module._diff: + result['diff'] = dict(before=before_scope, after=scope_rep) + if module.check_mode: + module.exit_json(**result) + kc.update_clientscope(scope_rep, realm=realm) + + if protocol_mappers: + existing_mappers = kc.get_clientscope_protocolmappers(before_scope['id'], realm=realm) + existing_mapper_names = {m['name'] for m in existing_mappers} + + for mapper in protocol_mappers: + if mapper['name'] not in existing_mapper_names: + result['changed'] = True + if not module.check_mode: + mapper_rep = { + 'name': mapper['name'], + 'protocol': mapper.get('protocol', protocol), + 'protocolMapper': mapper['protocolMapper'], + 'config': mapper['config'], + } + kc.create_clientscope_protocolmapper(before_scope['id'], mapper_rep, realm=realm) + + after_scope = kc.get_clientscope_by_name(name, realm=realm) + result['end_state'] = after_scope + + if result['changed']: + result['msg'] = "Client scope {name} has been updated".format(name=name) + else: + result['msg'] = "No changes required to client scope {name}".format(name=name) + module.exit_json(**result) + + +if __name__ == '__main__': + main()