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