Keycloak modules retry request on authentication error, support refresh token parameter (#9494)

* feat: begin refactor to support refresh token in keycloak modules

* chore: add start of tests for shared token usage

* feat: progress towards supporting refresh token; token introspection not yet working [8857]

* chore: reset to main branch previous state; a different approach is needed [8857]

* feat: add request methods to keycloak class, which will be expanded with retry logic [8857]

* feat: all requests to keycloak use request methods instead of open_url [8857]

* fix: data argument is optional in keycloak request methods [8857]

* feat: add integration test for keycloak module authentication methods [8857]

* chore: refactor get token logic to separate logic using username/pass credentials [8857]

* chore: refactor token request logic further to isolate request logic [8857]

* chore: fix minor lint issues [8857]

* test: add (currently failing) test for request with invalid auth token, valid refresh token [8857]

* chore: allow realm to be provided to role module with refresh_token, without username/pass [8857]

* feat: add retry logic to requests in keycloak module utils [8857]

* chore: rename keycloak module fail_open_url method to fail_request [8857]

* chore: update all keycloak modules to support refresh token param [8857]

* chore: add refresh_token param to keycloak doc_fragments [8857]

* chore: restore dependency between auth_realm and auth_username,auth_password params [8857]

* chore: rearrange module param checks to reduce future pr size [8857]

* chore: remove extra comma [8857]

* chore: update version added for refresh token param [8857]

* chore: add changelog fragment [8857]

* chore: re-add fail_open_url to keycloak module utils for backward compatability [8857]

* fix: do not make a new request to keycloak without reauth when refresh token not provided (#8857)

* fix: only make final auth attempt if username/pass provided, and return exception on failure (#8857)

* fix: make re-auth and retry code more consistent, ensure final exceptions are thrown (#8857)

* test: fix arguments for invalid token, valid refresh token test (#8857)

* feat: catch invalid refresh token errors during re-auth attempt (#8857)

Add test to verify this behaviour works.

* test: improve test coverage, including some unhappy path tests for authentication failures (#8857)

* chore: store auth errors from token request in backwards compatible way (#8857)

* fix: ensure method is still specified for all requests (#8857)

* chore: simplify token request logic (#8857)

* chore: rename functions to request tokens using refresh token or username/password (#8857)

To emphasize their difference from the `get_token` function,
which either gets the token from the module params
*or* makes a request for it.

* doc: add docstrings for new or significantly modified functions (#8857)

* test: repair unit test following change to exception message upon key error during auth request (#8857)
pull/9633/head
Mark Armstrong 2025-01-26 09:23:39 -05:00 committed by GitHub
parent fb4f7248c9
commit af0118278b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 938 additions and 661 deletions

View File

@ -0,0 +1,2 @@
major_changes:
- keycloak_* modules - ``refresh_token`` parameter added. When multiple authentication parameters are provided (``token``, ``refresh_token``, and ``auth_username``/``auth_password``), modules will now automatically retry requests upon authentication errors (401), using in order the token, refresh token, and username/password (https://github.com/ansible-collections/community.general/pull/9494).

View File

@ -57,6 +57,12 @@ options:
type: str
version_added: 3.0.0
refresh_token:
description:
- Authentication refresh token for Keycloak API.
type: str
version_added: 10.3.0
validate_certs:
description:
- Verify TLS certificates (do not disable this in production).

File diff suppressed because it is too large Load Diff

View File

@ -359,7 +359,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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', flow={})

View File

@ -238,7 +238,8 @@ def main():
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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={}))

View File

@ -154,7 +154,9 @@ def main():
supports_check_mode=True,
required_one_of=(
[['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={}))

View File

@ -140,7 +140,9 @@ def main():
supports_check_mode=True,
required_one_of=(
[['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', end_state={})

View File

@ -254,7 +254,9 @@ def main():
supports_check_mode=True,
required_one_of=(
[['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
# Convenience variables
state = module.params.get('state')

View File

@ -135,7 +135,9 @@ def main():
supports_check_mode=True,
required_one_of=(
[['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
# Convenience variables
name = module.params.get('name')

View File

@ -924,7 +924,9 @@ def main():
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']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -269,7 +269,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -355,7 +355,9 @@ def main():
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -149,11 +149,13 @@ def keycloak_clientscope_type_module():
['default_clientscopes', 'optional_clientscopes']
]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
mutually_exclusive=[
['token', 'auth_realm'],
['token', 'auth_username'],
['token', 'auth_password']
])
],
)
return module

View File

@ -297,7 +297,9 @@ def main():
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -156,7 +156,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={}))

View File

@ -335,7 +335,9 @@ def main():
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, group='')

View File

@ -498,7 +498,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -707,7 +707,9 @@ def main():
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']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -264,7 +264,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
# Initialize the result object. Only "changed" seems to have special
# meaning for Ansible.

View File

@ -105,7 +105,8 @@ def main():
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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg="", keys_metadata="")

View File

@ -253,7 +253,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -248,7 +248,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -408,7 +408,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -839,7 +839,9 @@ def main():
supports_check_mode=True,
required_one_of=([['id', 'name'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -244,7 +244,9 @@ def main():
supports_check_mode=True,
required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password'],
['uid', 'target_username', 'service_account_user_client_id']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})

View File

@ -534,7 +534,9 @@ 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_together=([['auth_realm', 'auth_username', 'auth_password']]),
required_by={'refresh_token': 'auth_realm'},
)
# Initialize the result object. Only "changed" seems to have special
# meaning for Ansible.

View File

@ -0,0 +1,26 @@
<!--
Copyright (c) Ansible Project
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
-->
# Running keycloak module authentication integration test
To run the Keycloak module authentication integration test, start a keycloak server using Docker or Podman:
```sh
podman|docker run -d --rm --name mykeycloak -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:latest start-dev --http-relative-path /auth
```
Source Ansible env-setup from ansible github repository.
Run the integration tests:
```sh
ansible-test integration keycloak_role --python 3.10 --allow-unsupported
```
To cleanup, run:
```sh
podman|docker stop mykeycloak
```

View File

@ -0,0 +1,5 @@
# Copyright (c) Ansible Project
# 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
unsupported

View File

@ -0,0 +1,249 @@
---
# Copyright (c) Ansible Project
# 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: Create realm
community.general.keycloak_realm:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
id: "{{ realm }}"
realm: "{{ realm }}"
state: present
- name: Create client
community.general.keycloak_client:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
client_id: "{{ client_id }}"
state: present
register: client
- name: Create new realm role with username/password authentication
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
- name: Debug
debug:
var: result
- 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
- name: Get Keycloak token
ansible.builtin.uri:
url: "{{ url }}/realms/{{ admin_realm }}/protocol/openid-connect/token"
method: POST
return_content: true
status_code: 200
body_format: form-urlencoded
body:
grant_type: "password"
client_id: "admin-cli"
username: "{{ admin_user }}"
password: "{{ admin_password }}"
register: token_response
- name: Extract tokens
ansible.builtin.set_fact:
access_token: "{{ token_response.json | json_query('access_token') }}"
refresh_token: "{{ token_response.json | json_query('refresh_token') }}"
- name: Create new realm role with provided token authentication
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
token: "{{ access_token }}"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
- name: Debug
debug:
var: result
- 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
- name: Create new realm role with invalid auth token and valid refresh token
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
token: "invalidtoken!!!"
refresh_token: "{{ refresh_token }}"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
- name: Debug
debug:
var: result
- 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
- name: Create new realm role with invalid auth token and valid username/password
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
token: "invalidtoken!!!"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
- name: Debug
debug:
var: result
- 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
- name: Create new realm role with invalid auth token, invalid refresh token, and valid username/password
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
token: "invalidtoken!!!"
refresh_token: "invalidrefreshtoken!!!"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
- name: Debug
debug:
var: result
- 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
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "invalid_password"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
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
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
token: "invalidtoken!!!"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
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
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "invalid_password"
token: "invalidtoken!!!"
refresh_token: "invalidtoken!!!"
realm: "{{ realm }}"
name: "{{ role }}"
description: "{{ keycloak_role_description }}"
state: present
register: result
failed_when: >
(result.exception is not defined) or
("HTTP Error 401: Unauthorized" not in result.msg)

View File

@ -0,0 +1,20 @@
---
# Copyright (c) Ansible Project
# 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
url: http://localhost:8080/auth
admin_realm: master
admin_user: admin
admin_password: password
realm: myrealm
client_id: myclient
role: myrole
keycloak_role_name: test
keycloak_role_description: test
keycloak_role_composite: false
keycloak_client_id: test-client
keycloak_client_name: test-client
keycloak_client_description: This is a client for testing purpose
role_state: present

View File

@ -160,6 +160,6 @@ def test_json_without_token_returned(mock_json_without_token_returned):
with pytest.raises(KeycloakError) as raised_error:
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'
'API did not include access_token field in response from '
'http://keycloak.url/auth/realms/master/protocol/openid-connect/token'
)