From 6ab9b05da39344475b93238b3fc59fdbab62e73f Mon Sep 17 00:00:00 2001
From: Gaetan2907 <48204380+Gaetan2907@users.noreply.github.com>
Date: Tue, 20 Apr 2021 13:20:46 +0200
Subject: [PATCH] Allow keycloak modules to take token as parameter for the
 auth.  (#2250)

* Allow keycloak_group.py to take token as parameter for the authentification

* Fix some pep8 issues

* Add changelog fragment

* Refactor get_token to pass module.params + Documentation

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Fix unit test and add new one for token as param

* Fix identation

* Check base_url format also if token is given

* Update plugins/doc_fragments/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/identity/keycloak/keycloak_client.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/identity/keycloak/keycloak_clienttemplate.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Allow keycloak_group.py to take token as parameter for the authentification

* Refactor get_token to pass module.params + Documentation

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/identity/keycloak/keycloak_group.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Check if base_url is None before to check format

* Fix unit test: rename base_url parameter to auth_keycloak_url

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/modules/identity/keycloak/keycloak_client.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/modules/identity/keycloak/keycloak_client.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/modules/identity/keycloak/keycloak_clienttemplate.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/modules/identity/keycloak/keycloak_clienttemplate.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/modules/identity/keycloak/keycloak_group.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Update plugins/modules/identity/keycloak/keycloak_group.py

Co-authored-by: Amin Vakil <info@aminvakil.com>

* Switch to modern syntax for the documentation (e.g. community.general.keycloak_client)

* Add check either creds or token as argument of all keyloak_* modules

* Update plugins/modules/identity/keycloak/keycloak_client.py

Co-authored-by: Felix Fontein <felix@fontein.de>
Co-authored-by: Amin Vakil <info@aminvakil.com>
---
 ...eycloak-modules-to-take-token-as-param.yml |  5 ++
 plugins/doc_fragments/keycloak.py             |  9 +-
 .../identity/keycloak/keycloak.py             | 88 +++++++++++--------
 .../identity/keycloak/keycloak_client.py      | 42 +++++----
 .../keycloak/keycloak_clienttemplate.py       | 38 ++++----
 .../identity/keycloak/keycloak_group.py       | 29 +++---
 .../keycloak/test_keycloak_connect.py         | 66 ++++++--------
 7 files changed, 155 insertions(+), 122 deletions(-)
 create mode 100644 changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml

diff --git a/changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml b/changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml
new file mode 100644
index 0000000000..5b8deb2a03
--- /dev/null
+++ b/changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml
@@ -0,0 +1,5 @@
+---
+minor_changes:
+  - keycloak_* modules - allow the keycloak modules to use a token for the
+    authentication, the modules can take either a token or the credentials
+    (https://github.com/ansible-collections/community.general/pull/2250).
diff --git a/plugins/doc_fragments/keycloak.py b/plugins/doc_fragments/keycloak.py
index e664d7ec89..72e0b71d50 100644
--- a/plugins/doc_fragments/keycloak.py
+++ b/plugins/doc_fragments/keycloak.py
@@ -30,7 +30,6 @@ options:
         description:
             - Keycloak realm name to authenticate to for API access.
         type: str
-        required: true
 
     auth_client_secret:
         description:
@@ -41,7 +40,6 @@ options:
         description:
             - Username to authenticate for API access with.
         type: str
-        required: true
         aliases:
           - username
 
@@ -49,10 +47,15 @@ options:
         description:
             - Password to authenticate for API access with.
         type: str
-        required: true
         aliases:
           - password
 
+    token:
+        description:
+            - Authentication token for Keycloak API.
+        type: str
+        version_added: 3.0.0
+
     validate_certs:
         description:
             - Verify TLS certificates (do not disable this in production).
diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py
index 58a39645e4..0f73b729cc 100644
--- a/plugins/module_utils/identity/keycloak/keycloak.py
+++ b/plugins/module_utils/identity/keycloak/keycloak.py
@@ -57,11 +57,12 @@ def keycloak_argument_spec():
     return dict(
         auth_keycloak_url=dict(type='str', aliases=['url'], required=True, no_log=False),
         auth_client_id=dict(type='str', default='admin-cli'),
-        auth_realm=dict(type='str', required=True),
+        auth_realm=dict(type='str'),
         auth_client_secret=dict(type='str', default=None, no_log=True),
-        auth_username=dict(type='str', aliases=['username'], required=True),
-        auth_password=dict(type='str', aliases=['password'], required=True, no_log=True),
-        validate_certs=dict(type='bool', default=True)
+        auth_username=dict(type='str', aliases=['username']),
+        auth_password=dict(type='str', aliases=['password'], no_log=True),
+        validate_certs=dict(type='bool', default=True),
+        token=dict(type='str', no_log=True),
     )
 
 
@@ -73,41 +74,58 @@ class KeycloakError(Exception):
     pass
 
 
-def get_token(base_url, validate_certs, auth_realm, client_id,
-              auth_username, auth_password, client_secret):
+def get_token(module_params):
+    """ Obtains connection header with token for the authentication,
+        token already given or obtained from credentials
+        :param module_params: parameters of the module
+        :return: connection header
+    """
+    token = module_params.get('token')
+    base_url = module_params.get('auth_keycloak_url')
+
     if not base_url.lower().startswith(('http', 'https')):
         raise KeycloakError("auth_url '%s' should either start with 'http' or 'https'." % base_url)
-    auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm)
-    temp_payload = {
-        'grant_type': 'password',
-        'client_id': client_id,
-        'client_secret': client_secret,
-        'username': auth_username,
-        'password': auth_password,
-    }
-    # Remove empty items, for instance missing client_secret
-    payload = dict(
-        (k, v) for k, v in temp_payload.items() if v is not None)
-    try:
-        r = json.loads(to_native(open_url(auth_url, method='POST',
-                                          validate_certs=validate_certs,
-                                          data=urlencode(payload)).read()))
-    except ValueError as e:
-        raise KeycloakError(
-            'API returned invalid JSON when trying to obtain access token from %s: %s'
-            % (auth_url, str(e)))
-    except Exception as e:
-        raise KeycloakError('Could not obtain access token from %s: %s'
-                            % (auth_url, str(e)))
 
-    try:
-        return {
-            'Authorization': 'Bearer ' + r['access_token'],
-            'Content-Type': 'application/json'
+    if token is None:
+        base_url = module_params.get('auth_keycloak_url')
+        validate_certs = module_params.get('validate_certs')
+        auth_realm = module_params.get('auth_realm')
+        client_id = module_params.get('auth_client_id')
+        auth_username = module_params.get('auth_username')
+        auth_password = module_params.get('auth_password')
+        client_secret = module_params.get('auth_client_secret')
+        auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm)
+        temp_payload = {
+            'grant_type': 'password',
+            'client_id': client_id,
+            'client_secret': client_secret,
+            'username': auth_username,
+            'password': auth_password,
         }
-    except KeyError:
-        raise KeycloakError(
-            'Could not obtain access token from %s' % auth_url)
+        # Remove empty items, for instance missing client_secret
+        payload = dict(
+            (k, v) for k, v in temp_payload.items() if v is not None)
+        try:
+            r = json.loads(to_native(open_url(auth_url, method='POST',
+                                              validate_certs=validate_certs,
+                                              data=urlencode(payload)).read()))
+        except ValueError as e:
+            raise KeycloakError(
+                'API returned invalid JSON when trying to obtain access token from %s: %s'
+                % (auth_url, str(e)))
+        except Exception as e:
+            raise KeycloakError('Could not obtain access token from %s: %s'
+                                % (auth_url, str(e)))
+
+        try:
+            token = r['access_token']
+        except KeyError:
+            raise KeycloakError(
+                'Could not obtain access token from %s' % auth_url)
+    return {
+        'Authorization': 'Bearer ' + token,
+        'Content-Type': 'application/json'
+    }
 
 
 class KeycloakAPI(object):
diff --git a/plugins/modules/identity/keycloak/keycloak_client.py b/plugins/modules/identity/keycloak/keycloak_client.py
index e49edcf1d2..e3e39fc173 100644
--- a/plugins/modules/identity/keycloak/keycloak_client.py
+++ b/plugins/modules/identity/keycloak/keycloak_client.py
@@ -511,20 +511,30 @@ author:
 '''
 
 EXAMPLES = '''
-- name: Create or update Keycloak client (minimal example)
-  local_action:
-    module: keycloak_client
-    auth_client_id: admin-cli
+- name: Create or update Keycloak client (minimal example), authentication with credentials
+  community.general.keycloak_client:
     auth_keycloak_url: https://auth.example.com/auth
     auth_realm: master
     auth_username: USERNAME
     auth_password: PASSWORD
     client_id: test
     state: present
+  delegate_to: localhost
+
+
+- name: Create or update Keycloak client (minimal example), authentication with token
+  community.general.keycloak_client:
+    auth_client_id: admin-cli
+    auth_keycloak_url: https://auth.example.com/auth
+    auth_realm: master
+    token: TOKEN
+    client_id: test
+    state: present
+  delegate_to: localhost
+
 
 - name: Delete a Keycloak client
-  local_action:
-    module: keycloak_client
+  community.general.keycloak_client:
     auth_client_id: admin-cli
     auth_keycloak_url: https://auth.example.com/auth
     auth_realm: master
@@ -532,10 +542,11 @@ EXAMPLES = '''
     auth_password: PASSWORD
     client_id: test
     state: absent
+  delegate_to: localhost
+
 
 - name: Create or update a Keycloak client (with all the bells and whistles)
-  local_action:
-    module: keycloak_client
+  community.general.keycloak_client:
     auth_client_id: admin-cli
     auth_keycloak_url: https://auth.example.com/auth
     auth_realm: master
@@ -619,6 +630,7 @@ EXAMPLES = '''
       use.jwks.url: true
       jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT
       jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH
+  delegate_to: localhost
 '''
 
 RETURN = '''
@@ -740,21 +752,15 @@ def main():
 
     module = AnsibleModule(argument_spec=argument_spec,
                            supports_check_mode=True,
-                           required_one_of=([['client_id', 'id']]))
+                           required_one_of=([['client_id', 'id'],
+                                             ['token', 'auth_realm', 'auth_username', 'auth_password']]),
+                           required_together=([['auth_realm', 'auth_username', 'auth_password']]))
 
     result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})
 
     # Obtain access token, initialize API
     try:
-        connection_header = get_token(
-            base_url=module.params.get('auth_keycloak_url'),
-            validate_certs=module.params.get('validate_certs'),
-            auth_realm=module.params.get('auth_realm'),
-            client_id=module.params.get('auth_client_id'),
-            auth_username=module.params.get('auth_username'),
-            auth_password=module.params.get('auth_password'),
-            client_secret=module.params.get('auth_client_secret'),
-        )
+        connection_header = get_token(module.params)
     except KeycloakError as e:
         module.fail_json(msg=str(e))
 
diff --git a/plugins/modules/identity/keycloak/keycloak_clienttemplate.py b/plugins/modules/identity/keycloak/keycloak_clienttemplate.py
index d68198d570..82991aea85 100644
--- a/plugins/modules/identity/keycloak/keycloak_clienttemplate.py
+++ b/plugins/modules/identity/keycloak/keycloak_clienttemplate.py
@@ -169,9 +169,8 @@ author:
 '''
 
 EXAMPLES = '''
-- name: Create or update Keycloak client template (minimal)
-  local_action:
-    module: keycloak_clienttemplate
+- name: Create or update Keycloak client template (minimal), authentication with credentials
+  community.general.keycloak_client:
     auth_client_id: admin-cli
     auth_keycloak_url: https://auth.example.com/auth
     auth_realm: master
@@ -179,10 +178,20 @@ EXAMPLES = '''
     auth_password: PASSWORD
     realm: master
     name: this_is_a_test
+  delegate_to: localhost
+
+- name: Create or update Keycloak client template (minimal), authentication with token
+  community.general.keycloak_clienttemplate:
+    auth_client_id: admin-cli
+    auth_keycloak_url: https://auth.example.com/auth
+    auth_realm: master
+    token: TOKEN
+    realm: master
+    name: this_is_a_test
+  delegate_to: localhost
 
 - name: Delete Keycloak client template
-  local_action:
-    module: keycloak_clienttemplate
+  community.general.keycloak_client:
     auth_client_id: admin-cli
     auth_keycloak_url: https://auth.example.com/auth
     auth_realm: master
@@ -191,10 +200,10 @@ EXAMPLES = '''
     realm: master
     state: absent
     name: test01
+  delegate_to: localhost
 
 - name: Create or update Keycloak client template (with a protocol mapper)
-  local_action:
-    module: keycloak_clienttemplate
+  community.general.keycloak_client:
     auth_client_id: admin-cli
     auth_keycloak_url: https://auth.example.com/auth
     auth_realm: master
@@ -217,6 +226,7 @@ EXAMPLES = '''
         protocolMapper: oidc-usermodel-property-mapper
     full_scope_allowed: false
     id: bce6f5e9-d7d3-4955-817e-c5b7f8d65b3f
+  delegate_to: localhost
 '''
 
 RETURN = '''
@@ -296,21 +306,15 @@ def main():
 
     module = AnsibleModule(argument_spec=argument_spec,
                            supports_check_mode=True,
-                           required_one_of=([['id', 'name']]))
+                           required_one_of=([['id', 'name'],
+                                             ['token', 'auth_realm', 'auth_username', 'auth_password']]),
+                           required_together=([['auth_realm', 'auth_username', 'auth_password']]))
 
     result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})
 
     # Obtain access token, initialize API
     try:
-        connection_header = get_token(
-            base_url=module.params.get('auth_keycloak_url'),
-            validate_certs=module.params.get('validate_certs'),
-            auth_realm=module.params.get('auth_realm'),
-            client_id=module.params.get('auth_client_id'),
-            auth_username=module.params.get('auth_username'),
-            auth_password=module.params.get('auth_password'),
-            client_secret=module.params.get('auth_client_secret'),
-        )
+        connection_header = get_token(module.params)
     except KeycloakError as e:
         module.fail_json(msg=str(e))
     kc = KeycloakAPI(module, connection_header)
diff --git a/plugins/modules/identity/keycloak/keycloak_group.py b/plugins/modules/identity/keycloak/keycloak_group.py
index 45b5c2905b..56e72fcb94 100644
--- a/plugins/modules/identity/keycloak/keycloak_group.py
+++ b/plugins/modules/identity/keycloak/keycloak_group.py
@@ -81,7 +81,7 @@ author:
 '''
 
 EXAMPLES = '''
-- name: Create a Keycloak group
+- name: Create a Keycloak group, authentication with credentials
   community.general.keycloak_group:
     name: my-new-kc-group
     realm: MyCustomRealm
@@ -93,6 +93,16 @@ EXAMPLES = '''
     auth_password: PASSWORD
   delegate_to: localhost
 
+- name: Create a Keycloak group, authentication with token
+  community.general.keycloak_group:
+    name: my-new-kc-group
+    realm: MyCustomRealm
+    state: present
+    auth_client_id: admin-cli
+    auth_keycloak_url: https://auth.example.com/auth
+    token: TOKEN
+  delegate_to: localhost
+
 - name: Delete a keycloak group
   community.general.keycloak_group:
     id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
@@ -217,30 +227,25 @@ def main():
         realm=dict(default='master'),
         id=dict(type='str'),
         name=dict(type='str'),
-        attributes=dict(type='dict')
+        attributes=dict(type='dict'),
     )
 
     argument_spec.update(meta_args)
 
     module = AnsibleModule(argument_spec=argument_spec,
                            supports_check_mode=True,
-                           required_one_of=([['id', 'name']]))
+                           required_one_of=([['id', 'name'],
+                                             ['token', 'auth_realm', 'auth_username', 'auth_password']]),
+                           required_together=([['auth_realm', 'auth_username', 'auth_password']]))
 
     result = dict(changed=False, msg='', diff={}, group='')
 
     # Obtain access token, initialize API
     try:
-        connection_header = get_token(
-            base_url=module.params.get('auth_keycloak_url'),
-            validate_certs=module.params.get('validate_certs'),
-            auth_realm=module.params.get('auth_realm'),
-            client_id=module.params.get('auth_client_id'),
-            auth_username=module.params.get('auth_username'),
-            auth_password=module.params.get('auth_password'),
-            client_secret=module.params.get('auth_client_secret'),
-        )
+        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')
diff --git a/tests/unit/plugins/module_utils/identity/keycloak/test_keycloak_connect.py b/tests/unit/plugins/module_utils/identity/keycloak/test_keycloak_connect.py
index a929382abb..49692a412e 100644
--- a/tests/unit/plugins/module_utils/identity/keycloak/test_keycloak_connect.py
+++ b/tests/unit/plugins/module_utils/identity/keycloak/test_keycloak_connect.py
@@ -11,6 +11,16 @@ from ansible_collections.community.general.plugins.module_utils.identity.keycloa
 from ansible.module_utils.six import StringIO
 from ansible.module_utils.six.moves.urllib.error import HTTPError
 
+module_params_creds = {
+    'auth_keycloak_url': 'http://keycloak.url/auth',
+    'validate_certs': True,
+    'auth_realm': 'master',
+    'client_id': 'admin-cli',
+    'auth_username': 'admin',
+    'auth_password': 'admin',
+    'client_secret': None,
+}
+
 
 def build_mocked_request(get_id_user_count, response_dict):
     def _mocked_requests(*args, **kwargs):
@@ -58,16 +68,22 @@ def mock_good_connection(mocker):
     )
 
 
-def test_connect_to_keycloak(mock_good_connection):
-    keycloak_header = get_token(
-        base_url='http://keycloak.url/auth',
-        validate_certs=True,
-        auth_realm='master',
-        client_id='admin-cli',
-        auth_username='admin',
-        auth_password='admin',
-        client_secret=None
-    )
+def test_connect_to_keycloak_with_creds(mock_good_connection):
+    keycloak_header = get_token(module_params_creds)
+    assert keycloak_header == {
+        'Authorization': 'Bearer alongtoken',
+        'Content-Type': 'application/json'
+    }
+
+
+def test_connect_to_keycloak_with_token(mock_good_connection):
+    module_params_token = {
+        'auth_keycloak_url': 'http://keycloak.url/auth',
+        'validate_certs': True,
+        'client_id': 'admin-cli',
+        'token': "alongtoken"
+    }
+    keycloak_header = get_token(module_params_token)
     assert keycloak_header == {
         'Authorization': 'Bearer alongtoken',
         'Content-Type': 'application/json'
@@ -87,15 +103,7 @@ def mock_bad_json_returned(mocker):
 
 def test_bad_json_returned(mock_bad_json_returned):
     with pytest.raises(KeycloakError) as raised_error:
-        get_token(
-            base_url='http://keycloak.url/auth',
-            validate_certs=True,
-            auth_realm='master',
-            client_id='admin-cli',
-            auth_username='admin',
-            auth_password='admin',
-            client_secret=None
-        )
+        get_token(module_params_creds)
     # cannot check all the message, different errors message for the value
     # error in python 2.6, 2.7 and 3.*.
     assert (
@@ -125,15 +133,7 @@ def mock_401_returned(mocker):
 
 def test_error_returned(mock_401_returned):
     with pytest.raises(KeycloakError) as raised_error:
-        get_token(
-            base_url='http://keycloak.url/auth',
-            validate_certs=True,
-            auth_realm='master',
-            client_id='admin-cli',
-            auth_username='notadminuser',
-            auth_password='notadminpassword',
-            client_secret=None
-        )
+        get_token(module_params_creds)
     assert str(raised_error.value) == (
         'Could not obtain access token from http://keycloak.url'
         '/auth/realms/master/protocol/openid-connect/token: '
@@ -154,15 +154,7 @@ def mock_json_without_token_returned(mocker):
 
 def test_json_without_token_returned(mock_json_without_token_returned):
     with pytest.raises(KeycloakError) as raised_error:
-        get_token(
-            base_url='http://keycloak.url/auth',
-            validate_certs=True,
-            auth_realm='master',
-            client_id='admin-cli',
-            auth_username='admin',
-            auth_password='admin',
-            client_secret=None
-        )
+        get_token(module_params_creds)
     assert str(raised_error.value) == (
         'Could not obtain access token from http://keycloak.url'
         '/auth/realms/master/protocol/openid-connect/token'