Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Module: Keycloak ClientSecret #3997

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/BOTMETA.yml
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,8 @@ files:
maintainers: elfelip Gaetan2907
$modules/identity/keycloak/keycloak_clientscope.py:
maintainers: Gaetan2907
$modules/identity/keycloak/keycloak_clientsecret.py:
maintainers: fynncfchen
$modules/identity/keycloak/keycloak_client_rolemapping.py:
maintainers: Gaetan2907
$modules/identity/keycloak/keycloak_group.py:
Expand Down
48 changes: 48 additions & 0 deletions plugins/module_utils/identity/keycloak/keycloak.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
URL_CLIENT_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available"
URL_CLIENT_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite"

URL_CLIENTSECRET = "{url}/admin/realms/{realm}/clients/{id}/client-secret"

URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows"
URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}"
URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy"
Expand Down Expand Up @@ -877,6 +879,52 @@ def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"):
self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s'
% (mapper_rep, realm, str(e)))

def create_clientsecret(self, id, realm="master"):
""" Generate a new client secret by id

:param id: id (not clientId) of client to be queried
:param realm: client from this realm
:return: dict of credential representation
"""
clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id)

try:
return json.loads(to_native(open_url(clientsecret_url, method='POST', headers=self.restheaders,
validate_certs=self.validate_certs).read()))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (and others) need to be adjusted similar to #4178.


except HTTPError as e:
if e.code == 404:
return None
else:
self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s'
% (id, realm, str(e)))
except Exception as e:
self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s'
% (id, realm, str(e)))

def get_clientsecret(self, id, realm="master"):
""" Obtain client secret by id

:param id: id (not clientId) of client to be queried
:param realm: client from this realm
:return: dict of credential representation
"""
clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id)

try:
return json.loads(to_native(open_url(clientsecret_url, method='GET', headers=self.restheaders,
validate_certs=self.validate_certs).read()))

except HTTPError as e:
if e.code == 404:
return None
else:
self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s'
% (id, realm, str(e)))
except Exception as e:
self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s'
% (id, realm, str(e)))

def get_groups(self, realm="master"):
""" Fetch the name and ID of all groups on the Keycloak server.

Expand Down
236 changes: 236 additions & 0 deletions plugins/modules/identity/keycloak/keycloak_clientsecret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = '''
---
module: keycloak_clientsecret

short_description: Administration of Keycloak client secret via Keycloak API

version_added: 4.4.0

description:
- This module allows you to get or generate new Keycloak client secret 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.

- The names of module options are snake_cased versions of the camelCase ones found in the
Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html).

- Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will
be returned that way by this module. You may pass single values for attributes when calling the module,
and this will be translated into a list suitable for the API.

- When generate a new client secret, where possible provide the client ID (not client_id) to the module.
This removes a lookup to the API to translate the client_id into the client ID.


options:
state:
description:
- State of the client secret.
- On C(present), get the current client secret.
- On C(absent), the new client secret will be generated.
default: 'present'
type: str
choices:
- present
- absent

realm:
type: str
description:
- They Keycloak realm under which this client resides.
default: 'master'

id:
description:
- The unique identifier for this client.
- This parameter is not required for getting or generating a client secret but
providing it will reduce the number of API calls required.
type: str
client_id:
description:
- The client_id of the client to lookup account client ID
aliases:
- clientId
type: str


extends_documentation_fragment:
- community.general.keycloak

author:
- Fynn Chen (@fynncfchen)
'''

EXAMPLES = '''
- name: Get a Keycloak client secret, authentication with credentials
community.general.keycloak_clientsecret:
id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
realm: MyCustomRealm
state: present
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
delegate_to: localhost

- name: Generate a new Keycloak client secret, authentication with token
community.general.keycloak_user:
id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
realm: MyCustomRealm
state: absent
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
token: TOKEN
delegate_to: localhost
'''

RETURN = '''
msg:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be Nice-to-Have(TM) consistency in the indentation on the YAML blocks. In some places it is 2 spaces, in others 4 spaces.

description: Message as to what action was taken.
returned: always
type: str

end_state:
description: Representation of the client credential after module execution (sample is truncated).
returned: on success
type: complex
contains:
type:
description: Credential type.
type: str
returned: always
sample: secret
value:
description: Secret of the client.
type: str
returned: always
sample: cUGnX1EIeTtPPAkcyGMv0ncyqDPu68P1

clientsecret:
description:
- Representation of the client credential after module execution.
- Deprecated return value, it will be removed in community.general 6.0.0. Please use the return value I(end_state) instead.
returned: always
type: complex
contains:
type:
description: Credential type.
type: str
returned: always
sample: secret
value:
description: Secret of the client.
type: str
returned: always
sample: cUGnX1EIeTtPPAkcyGMv0ncyqDPu68P1

'''

from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \
keycloak_argument_spec, get_token, KeycloakError
from ansible.module_utils.basic import AnsibleModule


def main():
"""
Module execution

:return:
"""
argument_spec = keycloak_argument_spec()

meta_args = dict(
state=dict(default='present', choices=['present', 'absent']),
realm=dict(default='master'),

id=dict(type='str'),
client_id=dict(type='str', aliases=['clientId']),
)

argument_spec.update(meta_args)

module = AnsibleModule(argument_spec=argument_spec,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the examples, it looks like one must pass (auth_realm, auth_username, auth_password) or token, but not both.

If that's indeed the case, there should be a mutually_exclusive clause here.

supports_check_mode=True,
required_one_of=([['id', 'client_id'],
['token', 'auth_realm', 'auth_username', 'auth_password']]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]))

result = dict(changed=False, msg='', diff={}, clientsecret='')

# Obtain access token, initialize API
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')
state = module.params.get('state')
id = module.params.get('id')
client_id = module.params.get('client_id')

# only lookup the client_id if id isn't provided.
# in the case that both are provided, prefer the ID, since it's one
# less lookup.
if id is None and client_id is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of the required_one_of spec in the Module, on line 165, this could be simplified to:

Suggested change
if id is None and client_id is not None:
if id is None:

client = kc.get_client_by_clientid(client_id, realm=realm)

if client is None:
raise Exception('Client does not exist {client_id}'.format(client_id=client_id))

id = client['id']

if state == 'present':
# Get secret
result['changed'] = False

if module.check_mode:
module.exit_json(**result)

# Create new secret
clientsecret = kc.get_clientsecret(id=id, realm=realm)

result['clientsecret'] = clientsecret
result['end_state'] = clientsecret
result['msg'] = 'Get client secret successful for ID {id}'.format(id=id)

module.exit_json(**result)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is redundant, as the exact same command will run after the if-else block in line 232

else:
if state == 'absent':
# Process a creation
result['changed'] = True

if module.check_mode:
module.exit_json(**result)

# Create new secret
clientsecret = kc.create_clientsecret(id=id, realm=realm)

result['end_state'] = clientsecret
result['msg'] = 'New client secret has been generated for ID {id}'.format(id=id)

module.exit_json(**result)

# Do nothing and exit
result['changed'] = False
result['end_state'] = {}
result['msg'] = 'State not specified; doing nothing.'
module.exit_json(**result)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is redundant, as the exact same command will run after the if-else block in line 232


module.exit_json(**result)


if __name__ == '__main__':
main()
1 change: 1 addition & 0 deletions plugins/modules/keycloak_clientsecret.py