diff --git a/changelogs/fragments/10527-keycloak-idp-well-known-url-support.yml b/changelogs/fragments/10527-keycloak-idp-well-known-url-support.yml new file mode 100644 index 0000000000..cc2ae7efa0 --- /dev/null +++ b/changelogs/fragments/10527-keycloak-idp-well-known-url-support.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_identity_provider – add support for ``fromUrl`` to automatically fetch OIDC endpoints from the well-known discovery URL, simplifying identity provider configuration (https://github.com/ansible-collections/community.general/pull/10527). \ No newline at end of file diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index e053eca305..70cf627e33 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -104,6 +104,7 @@ URL_IDENTITY_PROVIDERS = "{url}/admin/realms/{realm}/identity-provider/instances URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" URL_IDENTITY_PROVIDER_MAPPERS = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers" URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers/{id}" +URL_IDENTITY_PROVIDER_IMPORT = "{url}/admin/realms/{realm}/identity-provider/import-config" URL_COMPONENTS = "{url}/admin/realms/{realm}/components" URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}" @@ -2580,6 +2581,23 @@ class KeycloakAPI(object): self.fail_request(e, msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' % (alias, realm, str(e))) + def fetch_idp_endpoints_import_config_url(self, fromUrl, providerId='oidc', realm='master'): + """ Import an identity provider configuration through Keycloak server from a well-known URL. + :param fromUrl: URL to import the identity provider configuration from. + "param providerId: Provider ID of the identity provider to import, default 'oidc'. + :param realm: Realm + :return: IDP endpoins. + """ + try: + payload = { + "providerId": providerId, + "fromUrl": fromUrl + } + idps_url = URL_IDENTITY_PROVIDER_IMPORT.format(url=self.baseurl, realm=realm) + return self._request_and_deserialize(idps_url, method='POST', data=json.dumps(payload)) + except Exception as e: + self.fail_request(e, msg='Could not import the IdP config in realm %s: %s' % (realm, str(e))) + def get_identity_provider_mapper(self, mid, alias, realm='master'): """ Fetch identity provider representation from a realm using the idp's alias. If the identity provider does not exist, None is returned. diff --git a/plugins/modules/keycloak_identity_provider.py b/plugins/modules/keycloak_identity_provider.py index 40a06846d6..7d69611089 100644 --- a/plugins/modules/keycloak_identity_provider.py +++ b/plugins/modules/keycloak_identity_provider.py @@ -242,6 +242,15 @@ options: - Way to identify and track external users from the assertion. type: str + fromUrl: + description: + - IDP well-known OpenID Connect configuration URL. + - Support only O(provider_id=oidc). + - O(config.fromUrl) is mutually exclusive with O(config.userInfoUrl), O(config.authorizationUrl), + O(config.tokenUrl), O(config.logoutUrl), O(config.issuer) and O(config.jwksUrl). + type: str + version_added: '11.2.0' + mappers: description: - A list of dicts defining mappers associated with this Identity Provider. @@ -318,6 +327,24 @@ EXAMPLES = r""" user.attribute: last_name syncMode: INHERIT +- name: Create OIDC identity provider, with well-known configuration URL + community.general.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: oidc-idp + display_name: OpenID Connect IdP + enabled: true + provider_id: oidc + config: + fromUrl: https://the-idp.example.com/auth/realms/idprealm/.well-known/openid-configuration + clientAuthMethod: client_secret_post + clientId: my-client + clientSecret: secret + - name: Create SAML identity provider, authentication with credentials community.general.keycloak_identity_provider: state: present @@ -461,6 +488,29 @@ def get_identity_provider_with_mappers(kc, alias, realm): return idp +def fetch_identity_provider_wellknown_config(kc, config): + """ + Fetches OpenID Connect well-known configuration from a given URL and updates the config dict with discovered endpoints. + Support for oidc providers only. + :param kc: KeycloakAPI instance used to fetch endpoints and handle errors. + :param config: Dictionary containing identity provider configuration, must include 'fromUrl' key to trigger fetch. + :return: None. The config dict is updated in-place. + """ + if config and 'fromUrl' in config : + if 'providerId' in config and config['providerId'] != 'oidc': + kc.module.fail_json(msg="Only 'oidc' provider_id is supported when using 'fromUrl'.") + endpoints = ['userInfoUrl', 'authorizationUrl', 'tokenUrl', 'logoutUrl', 'issuer', 'jwksUrl'] + if any(k in config for k in endpoints): + kc.module.fail_json(msg="Cannot specify both 'fromUrl' and 'userInfoUrl', 'authorizationUrl', 'tokenUrl', 'logoutUrl', 'issuer' or 'jwksUrl'.") + openIdConfig = kc.fetch_idp_endpoints_import_config_url( + fromUrl=config['fromUrl'], + realm=kc.module.params.get('realm', 'master')) + for k in endpoints: + if k in openIdConfig: + config[k] = openIdConfig[k] + del config['fromUrl'] + + def main(): """ Module execution @@ -517,6 +567,9 @@ def main(): realm = module.params.get('realm') alias = module.params.get('alias') state = module.params.get('state') + config = module.params.get('config') + + fetch_identity_provider_wellknown_config(kc, config) # Filter and map the parameters names that apply to the identity provider. idp_params = [x for x in module.params diff --git a/tests/integration/targets/keycloak_identity_provider/README.md b/tests/integration/targets/keycloak_identity_provider/README.md new file mode 100644 index 0000000000..204ebbed66 --- /dev/null +++ b/tests/integration/targets/keycloak_identity_provider/README.md @@ -0,0 +1,20 @@ + +# Running keycloak_identity_provider module integration test + +To run Keycloak component info module's integration test, start a keycloak server using Docker: + + 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 + +Run integration tests: + + ansible-test integration -v keycloak_identity_provider --allow-unsupported --docker fedora35 --docker-network host + +Cleanup: + + docker stop mykeycloak + + diff --git a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml index fa118ed1d9..1cccd4219e 100644 --- a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml +++ b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml @@ -3,6 +3,15 @@ # 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: Delete realm if exists + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + state: absent + - name: Create realm community.general.keycloak_realm: auth_keycloak_url: "{{ url }}" @@ -62,7 +71,7 @@ - result.existing == {} - result.end_state.alias == "{{ idp }}" - result.end_state.mappers != [] - - result.end_state.config.client_secret = "**********" + - result.end_state.config.clientSecret == "**********" - name: Update existing identity provider (no change) community.general.keycloak_identity_provider: @@ -277,3 +286,79 @@ that: - result is not changed - result.end_state == {} + +- name: Create IDP realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ idp_realm }}" + realm: "{{ idp_realm }}" + state: present + +- name: Create new identity provider with fromUrl + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "{{ idp_fromurl }}" + display_name: OpenID Connect IdP from url + enabled: true + provider_id: oidc + config: + fromUrl: "{{ url }}/realms/{{ idp_realm }}/.well-known/openid-configuration" + clientAuthMethod: client_secret_post + clientId: clientid + clientSecret: clientsecret + syncMode: FORCE + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert identity provider created with IDP endpoints + assert: + that: + - result is changed + - result.end_state.config.authorizationUrl == "{{ url }}/realms/{{ idp_realm }}/protocol/openid-connect/auth" + - result.end_state.config.issuer == "{{ url }}/realms/{{ idp_realm }}" + - result.end_state.config.jwksUrl == "{{ url }}/realms/{{ idp_realm }}/protocol/openid-connect/certs" + - result.end_state.config.logoutUrl == "{{ url }}/realms/{{ idp_realm }}/protocol/openid-connect/logout" + - result.end_state.config.tokenUrl == "{{ url }}/realms/{{ idp_realm }}/protocol/openid-connect/token" + - result.end_state.config.userInfoUrl == "{{ url }}/realms/{{ idp_realm }}/protocol/openid-connect/userinfo" + +- name: Create new identity provider with fromUrl and exclusion should fail + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "mustfail" + display_name: Failed OpenID Connect IdP from url + enabled: true + provider_id: oidc + config: "{{ config | combine(endpoint) }}" + state: present + vars: + config: + fromUrl: "{{ url }}/realms/{{ idp_realm }}/.well-known/openid-configuration" + clientAuthMethod: client_secret_post + clientId: clientid + clientSecret: clientsecret + endpoint: "{{ '{\"' + item + '\": \"' + url + '/realms/' + idp_realm + '/protocol/openid-connect/' + item + '\"}' }}" + with_items: ['userInfoUrl', 'authorizationUrl', 'tokenUrl', 'logoutUrl', 'issuer', 'jwksUrl'] + register: result + ignore_errors: true + +- name: Check failure of identity provider creation with fromUrl and userInfoUrl + assert: + that: + - result is not changed + - result is failed + - result.results | selectattr('failed', 'equalto', false) | list | length == 0 diff --git a/tests/integration/targets/keycloak_identity_provider/vars/main.yml b/tests/integration/targets/keycloak_identity_provider/vars/main.yml index 6d2078ca0e..5ba2ca5872 100644 --- a/tests/integration/targets/keycloak_identity_provider/vars/main.yml +++ b/tests/integration/targets/keycloak_identity_provider/vars/main.yml @@ -9,3 +9,6 @@ admin_user: admin admin_password: password realm: myrealm idp: myidp + +idp_realm: myidprealm +idp_fromurl: myidpfromurl \ No newline at end of file