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 * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Felix Fontein * 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 * Update plugins/modules/identity/keycloak/keycloak_client.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_clienttemplate.py Co-authored-by: Felix Fontein * 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 * Update plugins/modules/identity/keycloak/keycloak_group.py Co-authored-by: Felix Fontein * 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 * Update changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml Co-authored-by: Amin Vakil * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Amin Vakil * Update plugins/modules/identity/keycloak/keycloak_client.py Co-authored-by: Amin Vakil * Update plugins/modules/identity/keycloak/keycloak_client.py Co-authored-by: Amin Vakil * Update plugins/modules/identity/keycloak/keycloak_clienttemplate.py Co-authored-by: Amin Vakil * Update changelogs/fragments/2250-allow-keycloak-modules-to-take-token-as-param.yml Co-authored-by: Amin Vakil * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Amin Vakil * Update plugins/modules/identity/keycloak/keycloak_clienttemplate.py Co-authored-by: Amin Vakil * Update plugins/modules/identity/keycloak/keycloak_group.py Co-authored-by: Amin Vakil * Update plugins/modules/identity/keycloak/keycloak_group.py Co-authored-by: Amin Vakil * 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 Co-authored-by: Amin Vakil --- ...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'