keycloak: add support for client_credentials authentication (#10231)
Some checks are pending
EOL CI / EOL Sanity (Ⓐ2.16) (push) Waiting to run
EOL CI / EOL Units (Ⓐ2.16+py2.7) (push) Waiting to run
EOL CI / EOL Units (Ⓐ2.16+py3.11) (push) Waiting to run
EOL CI / EOL Units (Ⓐ2.16+py3.6) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/1/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/2/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/3/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/1/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/2/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/3/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/1/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/2/) (push) Waiting to run
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/3/) (push) Waiting to run
nox / Run extra sanity tests (push) Waiting to run

* add client_credentials authentication for keycloak tasks incl. test case

* support client credentials in all keycloak modules

* Add changelog fragment

* fix typos in required list

* Update changelogs/fragments/10231-keycloak-add-client-credentials-authentication.yml

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

* revert keycloak url in test environment

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
divinity666 2025-06-18 07:40:46 +02:00 committed by GitHub
parent 74ed0fc438
commit f44ca23d7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 190 additions and 50 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- keycloak - add support for ``grant_type=client_credentials`` to all keycloak modules, so that specifying ``auth_client_id`` and ``auth_client_secret`` is sufficient for authentication (https://github.com/ansible-collections/community.general/pull/10231).

View file

@ -248,6 +248,29 @@ def _request_token_using_refresh_token(module_params):
return _token_request(module_params, payload)
def _request_token_using_client_credentials(module_params):
""" Obtains connection header with token for the authentication,
using the provided auth_client_id and auth_client_secret by grant_type
client_credentials. Ensure that the used client uses client authorization
with service account roles enabled and required service roles assigned.
:param module_params: parameters of the module. Must include 'auth_client_id'
and 'auth_client_secret'..
:return: connection header
"""
client_id = module_params.get('auth_client_id')
client_secret = module_params.get('auth_client_secret')
temp_payload = {
'grant_type': 'client_credentials',
'client_id': client_id,
'client_secret': client_secret,
}
# Remove empty items, for instance missing client_secret
payload = {k: v for k, v in temp_payload.items() if v is not None}
return _token_request(module_params, payload)
def get_token(module_params):
""" Obtains connection header with token for the authentication,
token already given or obtained from credentials
@ -257,7 +280,13 @@ def get_token(module_params):
token = module_params.get('token')
if token is None:
token = _request_token_using_credentials(module_params)
auth_client_id = module_params.get('auth_client_id')
auth_client_secret = module_params.get('auth_client_secret')
auth_username = module_params.get('auth_username')
if auth_client_id is not None and auth_client_secret is not None and auth_username is None:
token = _request_token_using_client_credentials(module_params)
else:
token = _request_token_using_credentials(module_params)
return {
'Authorization': 'Bearer ' + token,
@ -387,6 +416,21 @@ class KeycloakAPI(object):
r = make_request_catching_401()
if isinstance(r, Exception):
# Try to re-auth with client_id and client_secret, if available
auth_client_id = self.module.params.get('auth_client_id')
auth_client_secret = self.module.params.get('auth_client_secret')
if auth_client_id is not None and auth_client_secret is not None:
try:
token = _request_token_using_client_credentials(self.module.params)
self.restheaders['Authorization'] = 'Bearer ' + token
r = make_request_catching_401()
except KeycloakError as e:
# Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400
if e.authError is not None and e.authError.code != 400:
raise e
if isinstance(r, Exception):
# Either no re-auth options were available, or they all failed
raise r

View file

@ -367,8 +367,8 @@ def main():
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']]),
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'},
)

View file

@ -237,8 +237,8 @@ def main():
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']]),
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'},
)

View file

@ -153,8 +153,8 @@ def main():
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']]),
[['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'},
)

View file

@ -139,8 +139,8 @@ def main():
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']]),
[['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'},
)

View file

@ -253,8 +253,8 @@ def main():
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']]),
[['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'},
)

View file

@ -134,8 +134,8 @@ def main():
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']]),
[['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'},
)

View file

@ -941,8 +941,8 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['client_id', 'id'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
['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'},
)

View file

@ -268,8 +268,8 @@ def main():
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']]),
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'},
)

View file

@ -354,8 +354,8 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
['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'},
)

View file

@ -145,10 +145,10 @@ def keycloak_clientscope_type_module():
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([
['token', 'auth_realm', 'auth_username', 'auth_password'],
['token', 'auth_realm', 'auth_username', 'auth_password', 'auth_client_id', 'auth_client_secret'],
['default_clientscopes', 'optional_clientscopes']
]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
mutually_exclusive=[
['token', 'auth_realm'],

View file

@ -311,8 +311,8 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
['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'},
)

View file

@ -155,8 +155,8 @@ def main():
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']]),
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'},
)

View file

@ -334,8 +334,8 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
['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'},
)

View file

@ -500,8 +500,8 @@ def main():
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']]),
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'},
)

View file

@ -705,8 +705,8 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['id', 'realm', 'enabled'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
['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'},
)

View file

@ -263,8 +263,8 @@ def main():
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']]),
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'},
)

View file

@ -104,8 +104,8 @@ def main():
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']]),
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'},
)

View file

@ -252,8 +252,8 @@ def main():
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']]),
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'},
)

View file

@ -266,8 +266,8 @@ def main():
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']]),
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'},
)

View file

@ -410,8 +410,8 @@ def main():
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']]),
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'},
)

View file

@ -841,8 +841,8 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
['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'},
)

View file

@ -242,9 +242,9 @@ def main():
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password'],
required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password', 'auth_client_id', 'auth_client_secret'],
['uid', 'target_username', 'service_account_user_client_id']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)

View file

@ -533,8 +533,8 @@ def main():
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']]),
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'},
)

View file

@ -3,6 +3,19 @@
# 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: Reset public login in master admin-cli (if potentially previous test failed)
community.general.keycloak_client:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
auth_client_id: "admin-cli"
auth_client_secret: "{{ client_secret }}"
client_id: "admin-cli"
secret: "{{ client_secret }}"
public_client: true
state: present
- name: Create realm
community.general.keycloak_realm:
auth_keycloak_url: "{{ url }}"
@ -201,6 +214,89 @@
debug:
var: result
- name: PREPARE - Temporarily disable public login in master admin-cli
community.general.keycloak_client:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
auth_client_id: "admin-cli"
auth_client_secret: "{{ client_secret }}"
client_id: "admin-cli"
secret: "{{ client_secret }}"
public_client: false
service_accounts_enabled: true
client_authenticator_type: "client-secret"
state: present
- name: PREPARE - Get admin role id
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
auth_client_id: "admin-cli"
auth_client_secret: "{{ client_secret }}"
name: "admin"
register: admin_role
- name: PREPARE - Assign admin role to admin-cli in master
community.general.keycloak_user_rolemapping:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
auth_client_id: "admin-cli"
auth_client_secret: "{{ client_secret }}"
realm: "master"
roles:
- name: "admin"
service_account_user_client_id: "admin-cli"
- name: Create new realm role with valid client_id and client_secret
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_client_id: "admin-cli"
auth_client_secret: "{{ client_secret }}"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Reset temporarily disabled public login in master admin-cli
community.general.keycloak_client:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
auth_client_id: "admin-cli"
auth_client_secret: "{{ client_secret }}"
client_id: "admin-cli"
secret: "{{ client_secret }}"
public_client: true
state: present
- name: Remove created realm role
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
name: "{{ role }}"
state: absent
register: result
- name: Debug
debug:
var: result
### Unhappy path tests
- name: Fail to create new realm role with invalid username/password
@ -215,7 +311,6 @@
state: present
register: result
failed_when: >
(result.exception is not defined) or
("HTTP Error 401: Unauthorized" not in result.msg)
- name: Fail to create new realm role with invalid auth token
@ -228,7 +323,6 @@
state: present
register: result
failed_when: >
(result.exception is not defined) or
("HTTP Error 401: Unauthorized" not in result.msg)
- name: Fail to create new realm role with invalid auth and refresh tokens, and invalid username/password
@ -245,5 +339,4 @@
state: present
register: result
failed_when: >
(result.exception is not defined) or
("HTTP Error 401: Unauthorized" not in result.msg)

View file

@ -9,6 +9,7 @@ admin_user: admin
admin_password: password
realm: myrealm
client_id: myclient
client_secret: myclientsecret
role: myrole
keycloak_role_name: test