diff --git a/.gitignore b/.gitignore index a9acd74..b7ce21c 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ main2.py s3air-authz-config.json .vscode _build + + +test.py \ No newline at end of file diff --git a/src/keycloak/asynchronous/__init__.py b/src/keycloak/asynchronous/__init__.py new file mode 100644 index 0000000..4e073ea --- /dev/null +++ b/src/keycloak/asynchronous/__init__.py @@ -0,0 +1,2 @@ +from .openid_connection import KeycloakOpenIDConnection +from .keycloak_admin import KeycloakAdmin \ No newline at end of file diff --git a/src/keycloak/asynchronous/connection.py b/src/keycloak/asynchronous/connection.py new file mode 100644 index 0000000..be81986 --- /dev/null +++ b/src/keycloak/asynchronous/connection.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Connection manager module.""" + +try: + from urllib.parse import urljoin +except ImportError: # pragma: no cover + from urlparse import urljoin + +import httpx +from requests.adapters import HTTPAdapter +import requests + +from .exceptions import KeycloakConnectionError + + +class ConnectionManager(object): + """Represents a simple server connection. + + :param base_url: The server URL. + :type base_url: str + :param headers: The header parameters of the requests to the server. + :type headers: dict + :param timeout: Timeout to use for requests to the server. + :type timeout: int + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param proxies: The proxies servers requests is sent by. + :type proxies: dict + """ + + def __init__(self, base_url, headers={}, timeout=60, verify=True, proxies=None): + """Init method. + + :param base_url: The server URL. + :type base_url: str + :param headers: The header parameters of the requests to the server. + :type headers: dict + :param timeout: Timeout to use for requests to the server. + :type timeout: int + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param proxies: The proxies servers requests is sent by. + :type proxies: dict + """ + print(base_url) + self.base_url = base_url + print(self.base_url) + self.headers = headers + self.timeout = timeout + self.verify = verify + self._s = httpx.AsyncClient(verify=verify, proxies=proxies) + self.sync_s = requests.Session() + self.sync_s.auth = lambda x: x # don't let requests add auth headers + + # retry once to reset connection with Keycloak after tomcat's ConnectionTimeout + # see https://github.com/marcospereirampj/python-keycloak/issues/36 + self._s.transport = httpx.AsyncHTTPTransport(retries=2) + for protocol in ("https://", "http://"): + adapter = HTTPAdapter(max_retries=1) + # adds POST to retry whitelist + allowed_methods = set(adapter.max_retries.allowed_methods) + allowed_methods.add("POST") + adapter.max_retries.allowed_methods = frozenset(allowed_methods) + + self.sync_s.mount(protocol, adapter) + + if proxies: + self.sync_s.proxies.update(proxies) + + async def aclose(self): + if hasattr(self, "_s"): + await self._s.aclose() + + def __del__(self): + """Del method.""" + return + if hasattr(self, "_s"): + self._s.close() + + @property + def base_url(self): + """Return base url in use for requests to the server. + + :returns: Base URL + :rtype: str + """ + return self._base_url + + @base_url.setter + def base_url(self, value): + self._base_url = value + + @property + def timeout(self): + """Return timeout in use for request to the server. + + :returns: Timeout + :rtype: int + """ + return self._timeout + + @timeout.setter + def timeout(self, value): + self._timeout = value + + @property + def verify(self): + """Return verify in use for request to the server. + + :returns: Verify indicator + :rtype: bool + """ + return self._verify + + @verify.setter + def verify(self, value): + self._verify = value + + @property + def headers(self): + """Return header request to the server. + + :returns: Request headers + :rtype: dict + """ + return self._headers + + @headers.setter + def headers(self, value): + self._headers = value + + def param_headers(self, key): + """Return a specific header parameter. + + :param key: Header parameters key. + :type key: str + :returns: If the header parameters exist, return its value. + :rtype: str + """ + return self.headers.get(key) + + def clean_headers(self): + """Clear header parameters.""" + self.headers = {} + + def exist_param_headers(self, key): + """Check if the parameter exists in the header. + + :param key: Header parameters key. + :type key: str + :returns: If the header parameters exist, return True. + :rtype: bool + """ + return self.param_headers(key) is not None + + def add_param_headers(self, key, value): + """Add a single parameter inside the header. + + :param key: Header parameters key. + :type key: str + :param value: Value to be added. + :type value: str + """ + self.headers[key] = value + + def del_param_headers(self, key): + """Remove a specific parameter. + + :param key: Key of the header parameters. + :type key: str + """ + self.headers.pop(key, None) + + def raw_get(self, path, **kwargs): + """Submit get request to the path. + + :param path: Path for request. + :type path: str + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + try: + return self._s.get( + urljoin(self.base_url, path), + params=kwargs, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) + + async def raw_post(self, path, data, **kwargs): + """Submit post request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + try: + return await self._s.post( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + ) + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) + + + def sync_raw_post(self, path, data, **kwargs): + """Submit post request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + try: + return self.sync_s.post( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) + + def raw_put(self, path, data, **kwargs): + """Submit put request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + try: + return self._s.put( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) + + def raw_delete(self, path, data=None, **kwargs): + """Submit delete request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | None + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + try: + return self._s.delete( + urljoin(self.base_url, path), + params=kwargs, + data=data or dict(), + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) diff --git a/src/keycloak/asynchronous/exceptions.py b/src/keycloak/asynchronous/exceptions.py new file mode 100644 index 0000000..a109e4a --- /dev/null +++ b/src/keycloak/asynchronous/exceptions.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak custom exceptions module.""" + +import requests + + +class KeycloakError(Exception): + """Base class for custom Keycloak errors. + + :param error_message: The error message + :type error_message: str + :param response_code: The response status code + :type response_code: int + """ + + def __init__(self, error_message="", response_code=None, response_body=None): + """Init method. + + :param error_message: The error message + :type error_message: str + :param response_code: The code of the response + :type response_code: int + :param response_body: Body of the response + :type response_body: bytes + """ + Exception.__init__(self, error_message) + + self.response_code = response_code + self.response_body = response_body + self.error_message = error_message + + def __str__(self): + """Str method. + + :returns: String representation of the object + :rtype: str + """ + if self.response_code is not None: + return "{0}: {1}".format(self.response_code, self.error_message) + else: + return "{0}".format(self.error_message) + + +class KeycloakAuthenticationError(KeycloakError): + """Keycloak authentication error exception.""" + + pass + + +class KeycloakConnectionError(KeycloakError): + """Keycloak connection error exception.""" + + pass + + +class KeycloakOperationError(KeycloakError): + """Keycloak operation error exception.""" + + pass + + +class KeycloakDeprecationError(KeycloakError): + """Keycloak deprecation error exception.""" + + pass + + +class KeycloakGetError(KeycloakOperationError): + """Keycloak request get error exception.""" + + pass + + +class KeycloakPostError(KeycloakOperationError): + """Keycloak request post error exception.""" + + pass + + +class KeycloakPutError(KeycloakOperationError): + """Keycloak request put error exception.""" + + pass + + +class KeycloakDeleteError(KeycloakOperationError): + """Keycloak request delete error exception.""" + + pass + + +class KeycloakSecretNotFound(KeycloakOperationError): + """Keycloak secret not found exception.""" + + pass + + +class KeycloakRPTNotFound(KeycloakOperationError): + """Keycloak RPT not found exception.""" + + pass + + +class KeycloakAuthorizationConfigError(KeycloakOperationError): + """Keycloak authorization config exception.""" + + pass + + +class KeycloakInvalidTokenError(KeycloakOperationError): + """Keycloak invalid token exception.""" + + pass + + +class KeycloakPermissionFormatError(KeycloakOperationError): + """Keycloak permission format exception.""" + + pass + + +class PermissionDefinitionError(Exception): + """Keycloak permission definition exception.""" + + pass + + +def raise_error_from_response(response, error, expected_codes=None, skip_exists=False): + """Raise an exception for the response. + + :param response: The response object + :type response: Response + :param error: Error object to raise + :type error: dict or Exception + :param expected_codes: Set of expected codes, which should not raise the exception + :type expected_codes: Sequence[int] + :param skip_exists: Indicates whether the response on already existing object should be ignored + :type skip_exists: bool + + :returns: Content of the response message + :type: bytes or dict + :raises KeycloakError: In case of unexpected status codes + """ # noqa: DAR401,DAR402 + if expected_codes is None: + expected_codes = [200, 201, 204] + + if hasattr(response, 'status_code'): + if response.status_code in expected_codes: + if response.status_code == requests.codes.no_content: + return {} + + try: + return response.json() + except ValueError: + return response.content + + if skip_exists and response.status_code == 409: + return {"msg": "Already exists"} + + try: + message = response.json()["message"] + except (KeyError, ValueError): + message = response.content + + if isinstance(error, dict): + error = error.get(response.status_code, KeycloakOperationError) + else: + if response.status_code == 401: + error = KeycloakAuthenticationError + + raise error( + error_message=message, response_code=response.status_code, response_body=response.content + ) diff --git a/src/keycloak/asynchronous/keycloak_admin.py b/src/keycloak/asynchronous/keycloak_admin.py new file mode 100644 index 0000000..6935e14 --- /dev/null +++ b/src/keycloak/asynchronous/keycloak_admin.py @@ -0,0 +1,4257 @@ +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# Unless otherwise stated in the comments, "id", in e.g. user_id, refers to the +# internal Keycloak server ID, usually a uuid string + +"""The keycloak admin module.""" + +import copy +import json +from builtins import isinstance +from typing import Optional +import asyncio + +from requests_toolbelt import MultipartEncoder + +from .. import urls_patterns +from .exceptions import ( + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, + raise_error_from_response, +) +from .openid_connection import KeycloakOpenIDConnection + + +class KeycloakAdmin: + """Keycloak Admin client. + + :param server_url: Keycloak server url + :type server_url: str + :param username: admin username + :type username: str + :param password: admin password + :type password: str + :param token: access and refresh tokens + :type token: dict + :param totp: Time based OTP + :type totp: str + :param realm_name: realm name + :type realm_name: str + :param client_id: client id + :type client_id: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param client_secret_key: client secret key + (optional, required only for access type confidential) + :type client_secret_key: str + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param user_realm_name: The realm name of the user, if different from realm_name + :type user_realm_name: str + :param timeout: connection timeout in seconds + :type timeout: int + :param connection: A KeycloakOpenIDConnection as an alternative to individual params. + :type connection: KeycloakOpenIDConnection + """ + + PAGE_SIZE = 100 + + def __init__( + self, + server_url=None, + username=None, + password=None, + token=None, + totp=None, + realm_name="master", + client_id="admin-cli", + verify=True, + client_secret_key=None, + custom_headers=None, + user_realm_name=None, + timeout=60, + connection: Optional[KeycloakOpenIDConnection] = None, + ): + """Init method. + + :param server_url: Keycloak server url + :type server_url: str + :param username: admin username + :type username: str + :param password: admin password + :type password: str + :param token: access and refresh tokens + :type token: dict + :param totp: Time based OTP + :type totp: str + :param realm_name: realm name + :type realm_name: str + :param client_id: client id + :type client_id: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param client_secret_key: client secret key + (optional, required only for access type confidential) + :type client_secret_key: str + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param user_realm_name: The realm name of the user, if different from realm_name + :type user_realm_name: str + :param timeout: connection timeout in seconds + :type timeout: int + :param connection: An OpenID Connection as an alternative to individual params. + :type connection: KeycloakOpenIDConnection + """ + self.connection = connection or KeycloakOpenIDConnection( + server_url=server_url, + username=username, + password=password, + token=token, + totp=totp, + realm_name=realm_name, + client_id=client_id, + verify=verify, + client_secret_key=client_secret_key, + user_realm_name=user_realm_name, + custom_headers=custom_headers, + timeout=timeout, + ) + self.event_loop = asyncio.new_event_loop() + + @property + def connection(self) -> KeycloakOpenIDConnection: + """Get connection. + + :returns: Connection manager + :rtype: KeycloakOpenIDConnection + """ + return self._connection + + @connection.setter + def connection(self, value: KeycloakOpenIDConnection) -> None: + self._connection = value + + def __fetch_all(self, url, query=None): + """Paginate over get requests. + + Wrapper function to paginate GET requests. + + :param url: The url on which the query is executed + :type url: str + :param query: Existing query parameters (optional) + :type query: dict + + :return: Combined results of paginated queries + :rtype: list + """ + results = [] + + # initialize query if it was called with None + if not query: + query = {} + page = 0 + query["max"] = self.PAGE_SIZE + + # fetch until we can + while True: + query["first"] = page * self.PAGE_SIZE + partial_results = raise_error_from_response( + self.connection.raw_get(url, **query), KeycloakGetError + ) + if not partial_results: + break + results.extend(partial_results) + if len(partial_results) < query["max"]: + break + page += 1 + return results + + def async_call(self, function_name, *args, **kwargs): + method = getattr(self, function_name, None) + if method: + self.event_loop.create_task(method(*args, **kwargs)) + + + def __fetch_paginated(self, url, query=None): + """Make a specific paginated request. + + :param url: The url on which the query is executed + :type url: str + :param query: Pagination settings + :type query: dict + :returns: Response + :rtype: dict + """ + query = query or {} + return raise_error_from_response(self.connection.raw_get(url, **query), KeycloakGetError) + + def get_current_realm(self) -> str: + """Return the currently configured realm. + + :returns: Currently configured realm name + :rtype: str + """ + return self.connection.realm_name + + def change_current_realm(self, realm_name: str) -> None: + """Change the current realm. + + :param realm_name: The name of the realm to be configured as current + :type realm_name: str + """ + self.connection.realm_name = realm_name + + def import_realm(self, payload): + """Import a new realm from a RealmRepresentation. + + Realm name must be unique. + + RealmRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param payload: RealmRepresentation + :type payload: dict + :return: RealmRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALMS, data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def partial_import_realm(self, realm_name, payload): + """Partial import realm configuration from PartialImportRepresentation. + + Realm partialImport is used for modifying configuration of existing realm. + + PartialImportRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_partialimportrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :param payload: PartialImportRepresentation + :type payload: dict + + :return: PartialImportResponse + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_PARTIAL_IMPORT.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[200]) + + def export_realm(self, export_clients=False, export_groups_and_role=False): + """Export the realm configurations in the json format. + + RealmRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_partialexport + + :param export_clients: Skip if not want to export realm clients + :type export_clients: bool + :param export_groups_and_role: Skip if not want to export realm groups and roles + :type export_groups_and_role: bool + + :return: realm configurations JSON + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "export-clients": export_clients, + "export-groups-and-roles": export_groups_and_role, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_EXPORT.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def get_realms(self): + """List all realms in Keycloak deployment. + + :return: realms list + :rtype: list + """ + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_REALMS) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_realm(self, realm_name): + """Get a specific realm. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :return: RealmRepresentation + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_REALM.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def create_realm(self, payload, skip_exists=False): + """Create a realm. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param payload: RealmRepresentation + :type payload: dict + :param skip_exists: Skip if Realm already exist. + :type skip_exists: bool + :return: Keycloak server response (RealmRepresentation) + :rtype: dict + """ + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALMS, data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def update_realm(self, realm_name, payload): + """Update a realm. + + This will only update top level attributes and will ignore any user, + role, or client information in the payload. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :param payload: RealmRepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_realm(self, realm_name): + """Delete a realm. + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_REALM.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_users(self, query=None): + """Get all users. + + Return a list of users, filtered according to query parameters + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param query: Query parameters (optional) + :type query: dict + :return: users list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_USERS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def create_idp(self, payload): + """Create an ID Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :param: payload: IdentityProviderRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_IDPS.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def update_idp(self, idp_alias, payload): + """Update an ID Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identity_providers_resource + + :param: idp_alias: alias for IdP to update + :type idp_alias: str + :param: payload: The IdentityProviderRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_IDP.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def add_mapper_to_idp(self, idp_alias, payload): + """Create an ID Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityprovidermapperrepresentation + + :param: idp_alias: alias for Idp to add mapper in + :type idp_alias: str + :param: payload: IdentityProviderMapperRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "idp-alias": idp_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def update_mapper_in_idp(self, idp_alias, mapper_id, payload): + """Update an IdP mapper. + + IdentityProviderMapperRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_update + + :param: idp_alias: alias for Idp to fetch mappers + :type idp_alias: str + :param: mapper_id: Mapper Id to update + :type mapper_id: str + :param: payload: IdentityProviderMapperRepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "idp-alias": idp_alias, + "mapper-id": mapper_id, + } + + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_IDP_MAPPER_UPDATE.format(**params_path), + data=json.dumps(payload), + ) + + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def get_idp_mappers(self, idp_alias): + """Get IDP mappers. + + Returns a list of ID Providers mappers + + IdentityProviderMapperRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getmappers + + :param: idp_alias: alias for Idp to fetch mappers + :type idp_alias: str + :return: array IdentityProviderMapperRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "idp-alias": idp_alias} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_idps(self): + """Get IDPs. + + Returns a list of ID Providers, + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :return: array IdentityProviderRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_IDPS.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_idp(self, idp_alias): + """Get IDP provider. + + Get the representation of a specific IDP Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :param: idp_alias: alias for IdP to get + :type idp_alias: str + :return: IdentityProviderRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_IDP.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_idp(self, idp_alias): + """Delete an ID Provider. + + :param: idp_alias: idp alias name + :type idp_alias: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_IDP.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def create_user(self, payload, exist_ok=False): + """Create a new user. + + Username must be unique + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param payload: UserRepresentation + :type payload: dict + :param exist_ok: If False, raise KeycloakGetError if username already exists. + Otherwise, return existing user ID. + :type exist_ok: bool + + :return: user_id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + + if exist_ok: + exists = self.get_user_id(username=payload["username"]) + + if exists is not None: + return str(exists) + + data_raw = await self.connection.raw_post( + urls_patterns.URL_ADMIN_USERS.format(**params_path), data=json.dumps(payload) + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + + def users_count(self, query=None): + """Count users. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_users_resource + + :param query: (dict) Query parameters for users count + :type query: dict + + :return: counter + :rtype: int + """ + query = query or dict() + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USERS_COUNT.format(**params_path), **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_user_id(self, username): + """Get internal keycloak user id from username. + + This is required for further actions against this user. + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param username: id in UserRepresentation + :type username: str + + :return: user_id + :rtype: str + """ + lower_user_name = username.lower() + users = self.get_users(query={"username": lower_user_name, "max": 1, "exact": True}) + return users[0]["id"] if len(users) == 1 else None + + def get_user(self, user_id): + """Get representation of the user. + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param user_id: User id + :type user_id: str + :return: UserRepresentation + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_USER.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_user_groups(self, user_id, query=None, brief_representation=True): + """Get user groups. + + Returns a list of groups of which the user is a member + + :param user_id: User id + :type user_id: str + :param query: Additional query options + :type query: dict + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: user groups list + :rtype: list + """ + query = query or {} + + params = {"briefRepresentation": brief_representation} + + query.update(params) + + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + + url = urls_patterns.URL_ADMIN_USER_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def update_user(self, user_id, payload): + """Update the user. + + :param user_id: User id + :type user_id: str + :param payload: UserRepresentation + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_USER.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def disable_user(self, user_id): + """Disable the user from the realm. Disabled users can not log in. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: bytes + """ + return self.update_user(user_id=user_id, payload={"enabled": False}) + + def enable_user(self, user_id): + """Enable the user from the realm. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: bytes + """ + return self.update_user(user_id=user_id, payload={"enabled": True}) + + def disable_all_users(self): + """Disable all existing users.""" + users = self.get_users() + for user in users: + user_id = user["id"] + self.disable_user(user_id=user_id) + + def enable_all_users(self): + """Disable all existing users.""" + users = self.get_users() + for user in users: + user_id = user["id"] + self.enable_user(user_id=user_id) + + def delete_user(self, user_id): + """Delete the user. + + :param user_id: User id + :type user_id: str + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_USER.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def set_user_password(self, user_id, password, temporary=True): + """Set up a password for the user. + + If temporary is True, the user will have to reset + the temporary password next time they log in. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_users_resource + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_credentialrepresentation + + :param user_id: User id + :type user_id: str + :param password: New password + :type password: str + :param temporary: True if password is temporary + :type temporary: bool + :returns: Response + :rtype: dict + """ + payload = {"type": "password", "temporary": temporary, "value": password} + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_RESET_PASSWORD.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def get_credentials(self, user_id): + """Get user credentials. + + Returns a list of credential belonging to the user. + + CredentialRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_credentialrepresentation + + :param: user_id: user id + :type user_id: str + :returns: Keycloak server response (CredentialRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_CREDENTIALS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_credential(self, user_id, credential_id): + """Delete credential of the user. + + CredentialRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_credentialrepresentation + + :param: user_id: user id + :type user_id: str + :param: credential_id: credential id + :type credential_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "credential_id": credential_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_CREDENTIAL.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def user_logout(self, user_id): + """Log out the user. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_logout + + :param user_id: User id + :type user_id: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_LOGOUT.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def user_consents(self, user_id): + """Get consents granted by the user. + + UserConsentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userconsentrepresentation + + :param user_id: User id + :type user_id: str + :returns: List of UserConsentRepresentations + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_CONSENTS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_user_social_logins(self, user_id): + """Get user social logins. + + Returns a list of federated identities/social logins of which the user has been associated + with + :param user_id: User id + :type user_id: str + :returns: Federated identities list + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITIES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def add_user_social_login(self, user_id, provider_id, provider_userid, provider_username): + """Add a federated identity / social login provider to the user. + + :param user_id: User id + :type user_id: str + :param provider_id: Social login provider id + :type provider_id: str + :param provider_userid: userid specified by the provider + :type provider_userid: str + :param provider_username: username specified by the provider + :type provider_username: str + :returns: Keycloak server response + :rtype: bytes + """ + payload = { + "identityProvider": provider_id, + "userId": provider_userid, + "userName": provider_username, + } + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "provider": provider_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201, 204]) + + def delete_user_social_login(self, user_id, provider_id): + """Delete a federated identity / social login provider from the user. + + :param user_id: User id + :type user_id: str + :param provider_id: Social login provider id + :type provider_id: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "provider": provider_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def send_update_account( + self, user_id, payload, client_id=None, lifespan=None, redirect_uri=None + ): + """Send an update account email to the user. + + An email contains a link the user can click to perform a set of required actions. + + :param user_id: User id + :type user_id: str + :param payload: A list of actions for the user to complete + :type payload: list + :param client_id: Client id (optional) + :type client_id: str + :param lifespan: Number of seconds after which the generated token expires (optional) + :type lifespan: int + :param redirect_uri: The redirect uri (optional) + :type redirect_uri: str + + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params_query = {"client_id": client_id, "lifespan": lifespan, "redirect_uri": redirect_uri} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), + data=json.dumps(payload), + **params_query, + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def send_verify_email(self, user_id, client_id=None, redirect_uri=None): + """Send a update account email to the user. + + An email contains a link the user can click to perform a set of required actions. + + :param user_id: User id + :type user_id: str + :param client_id: Client id (optional) + :type client_id: str + :param redirect_uri: Redirect uri (optional) + :type redirect_uri: str + + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params_query = {"client_id": client_id, "redirect_uri": redirect_uri} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), + data={}, + **params_query, + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def get_sessions(self, user_id): + """Get sessions associated with the user. + + UserSessionRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_usersessionrepresentation + + :param user_id: Id of user + :type user_id: str + :return: UserSessionRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GET_SESSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_server_info(self): + """Get themes, social providers, etc. on this server. + + ServerInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :return: ServerInfoRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_SERVER_INFO) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_groups(self, query=None, full_hierarchy=False): + """Get groups. + + Returns a list of groups belonging to the realm + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + Notice that when using full_hierarchy=True, the response will be a nested structure + containing all the children groups. If used with query parameters, the full_hierarchy + will be applied to the received groups only. + + :param query: Additional query options + :type query: dict + :param full_hierarchy: If True, return all of the nested children groups, otherwise only + the first level children are returned + :type full_hierarchy: bool + :return: array GroupRepresentation + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + groups = self.__fetch_paginated(url, query) + else: + groups = self.__fetch_all(url, query) + + # For version +23.0.0 + for group in groups: + if group.get("subGroupCount"): + group["subGroups"] = self.get_group_children( + group_id=group.get("id"), full_hierarchy=full_hierarchy + ) + + return groups + + def get_group(self, group_id, full_hierarchy=False): + """Get group by id. + + Returns full group details + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group_id: The group id + :type group_id: str + :param full_hierarchy: If True, return all of the nested children groups, otherwise only + the first level children are returned + :type full_hierarchy: bool + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + response = self.connection.raw_get(urls_patterns.URL_ADMIN_GROUP.format(**params_path)) + + if response.status_code >= 400: + return raise_error_from_response(response, KeycloakGetError) + + # For version +23.0.0 + group = response.json() + if group.get("subGroupCount"): + group["subGroups"] = self.get_group_children( + group.get("id"), full_hierarchy=full_hierarchy + ) + + return group + + def get_subgroups(self, group, path): + """Get subgroups. + + Utility function to iterate through nested group structures + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group: group (GroupRepresentation) + :type group: dict + :param path: group path (string) + :type path: str + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + for subgroup in group["subGroups"]: + if subgroup["path"] == path: + return subgroup + elif subgroup["subGroups"]: + for subgroup in group["subGroups"]: + result = self.get_subgroups(subgroup, path) + if result: + return result + # went through the tree without hits + return None + + def get_group_children(self, group_id, query=None, full_hierarchy=False): + """Get group children by parent id. + + Returns full group children details + + :param group_id: The parent group id + :type group_id: str + :param query: Additional query options + :type query: dict + :param full_hierarchy: If True, return all of the nested children groups + :type full_hierarchy: bool + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + :raises ValueError: If both query and full_hierarchy parameters are used + """ + query = query or {} + if query and full_hierarchy: + raise ValueError("Cannot use both query and full_hierarchy parameters") + + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + url = urls_patterns.URL_ADMIN_GROUP_CHILD.format(**params_path) + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + res = self.__fetch_all(url, query) + + if not full_hierarchy: + return res + + for group in res: + if group.get("subGroupCount"): + group["subGroups"] = self.get_group_children( + group_id=group.get("id"), full_hierarchy=full_hierarchy + ) + + return res + + def get_group_members(self, group_id, query=None): + """Get members by group id. + + Returns group members + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_userrepresentation + + :param group_id: The group id + :type group_id: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getmembers) + :type query: dict + :return: Keycloak server response (UserRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + url = urls_patterns.URL_ADMIN_GROUP_MEMBERS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def get_group_by_path(self, path): + """Get group id based on name or path. + + Returns full group details for a group defined by path + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param path: group path + :type path: str + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "path": path} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUP_BY_PATH.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_group(self, payload, parent=None, skip_exists=False): + """Create a group in the Realm. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param payload: GroupRepresentation + :type payload: dict + :param parent: parent group's id. Required to create a sub-group. + :type parent: str + :param skip_exists: If true then do not raise an error if it already exists + :type skip_exists: bool + + :return: Group id for newly created group or None for an existing group + :rtype: str + """ + if parent is None: + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUPS.format(**params_path), data=json.dumps(payload) + ) + else: + params_path = {"realm-name": self.connection.realm_name, "id": parent} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUP_CHILD.format(**params_path), data=json.dumps(payload) + ) + + raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + try: + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + except KeyError: + return + + def update_group(self, group_id, payload): + """Update group, ignores subgroups. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group_id: id of group + :type group_id: str + :param payload: GroupRepresentation with updated information. + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_GROUP.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def groups_count(self, query=None): + """Count groups. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_groups + + :param query: (dict) Query parameters for groups count + :type query: dict + + :return: Keycloak Server Response + :rtype: dict + """ + query = query or dict() + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_COUNT.format(**params_path), **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def group_set_permissions(self, group_id, enabled=True): + """Enable/Disable permissions for a group. + + Cannot delete group if disabled + + :param group_id: id of group + :type group_id: str + :param enabled: Enabled flag + :type enabled: bool + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), + data=json.dumps({"enabled": enabled}), + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def group_user_add(self, user_id, group_id): + """Add user to group (user_id and group_id). + + :param user_id: id of user + :type user_id: str + :param group_id: id of group to add to + :type group_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "group-id": group_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path), data=None + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def group_user_remove(self, user_id, group_id): + """Remove user from group (user_id and group_id). + + :param user_id: id of user + :type user_id: str + :param group_id: id of group to remove from + :type group_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "group-id": group_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def delete_group(self, group_id): + """Delete a group in the Realm. + + :param group_id: id of group to delete + :type group_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_GROUP.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_clients(self): + """Get clients. + + Returns a list of clients belonging to the realm + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :return: Keycloak server response (ClientRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_CLIENTS.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client(self, client_id): + """Get representation of the client. + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_CLIENT.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_id(self, client_id): + """Get internal keycloak client id from client-id. + + This is required for further actions against this client. + + :param client_id: clientId in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: client_id (uuid as string) + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name, "client-id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENTS_CLIENT_ID.format(**params_path) + ) + data_response = raise_error_from_response(data_raw, KeycloakGetError) + + for client in data_response: + if client_id == client.get("clientId"): + return client["id"] + + return None + + def get_client_authz_settings(self, client_id): + """Get authorization json from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SETTINGS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_client_authz_resource(self, client_id, payload, skip_exists=False): + """Create resources of client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type payload: dict + :param skip_exists: Skip the creation in case the resource exists + :type skip_exists: bool + + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def update_client_authz_resource(self, client_id, resource_id, payload): + """Update resource of client. + + Any parameter missing from the ResourceRepresentation in the payload WILL be set + to default by the Keycloak server. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_client_authz_resource(self, client_id: str, resource_id: str): + """Delete a client resource. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_client_authz_resources(self, client_id): + """Get resources from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (ResourceRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_authz_resource(self, client_id: str, resource_id: str): + """Get a client resource. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response (ResourceRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def create_client_authz_role_based_policy(self, client_id, payload, skip_exists=False): + """Create role-based policy of client. + + Payload example:: + + payload={ + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "Policy-1", + "roles": [ + { + "id": id + } + ] + } + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: No Document + :type payload: dict + :param skip_exists: Skip creation in case the object exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: bytes + + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def create_client_authz_policy(self, client_id, payload, skip_exists=False): + """Create an authz policy of client. + + Payload example:: + + payload={ + "name": "Policy-time-based", + "type": "time", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "hourEnd": "18", + "hour": "9" + } + } + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: No Document + :type payload: dict + :param skip_exists: Skip creation in case the object exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: bytes + + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def create_client_authz_resource_based_permission(self, client_id, payload, skip_exists=False): + """Create resource-based permission of client. + + Payload example:: + + payload={ + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "Permission-Name", + "resources": [ + resource_id + ], + "policies": [ + policy_id + ] + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type payload: dict + :param skip_exists: Skip creation in case the object already exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: bytes + + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def get_client_authz_scopes(self, client_id): + """Get scopes from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_client_authz_scopes(self, client_id, payload): + """Create scopes for client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :param payload: ScopeRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_ScopeRepresentation + :type payload: dict + :type client_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def get_client_authz_permissions(self, client_id): + """Get permissions from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_authz_policies(self, client_id): + """Get policies from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_client_authz_policy(self, client_id, policy_id): + """Delete a policy from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_client_authz_policy(self, client_id, policy_id): + """Get a policy from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_service_account_user(self, client_id): + """Get service account user from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: UserRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_default_client_scopes(self, client_id): + """Get all default client scopes from client. + + :param client_id: id of the client in which the new default client scope should be added + :type client_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def add_client_default_client_scope(self, client_id, client_scope_id, payload): + """Add a client scope to the default client scopes from client. + + Payload example:: + + payload={ + "realm":"testrealm", + "client":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "clientScopeId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + } + + :param client_id: id of the client in which the new default client scope should be added + :type client_id: str + :param client_scope_id: id of the new client scope that should be added + :type client_scope_id: str + :param payload: dictionary with realm, client and clientScopeId + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def delete_client_default_client_scope(self, client_id, client_scope_id): + """Delete a client scope from the default client scopes of the client. + + :param client_id: id of the client in which the default client scope should be deleted + :type client_id: str + :param client_scope_id: id of the client scope that should be deleted + :type client_scope_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def get_client_optional_client_scopes(self, client_id): + """Get all optional client scopes from client. + + :param client_id: id of the client in which the new optional client scope should be added + :type client_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def add_client_optional_client_scope(self, client_id, client_scope_id, payload): + """Add a client scope to the optional client scopes from client. + + Payload example:: + + payload={ + "realm":"testrealm", + "client":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "clientScopeId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + } + + :param client_id: id of the client in which the new optional client scope should be added + :type client_id: str + :param client_scope_id: id of the new client scope that should be added + :type client_scope_id: str + :param payload: dictionary with realm, client and clientScopeId + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def delete_client_optional_client_scope(self, client_id, client_scope_id): + """Delete a client scope from the optional client scopes of the client. + + :param client_id: id of the client in which the optional client scope should be deleted + :type client_id: str + :param client_scope_id: id of the client scope that should be deleted + :type client_scope_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def create_initial_access_token(self, count: int = 1, expiration: int = 1): + """Create an initial access token. + + :param count: Number of clients that can be registered + :type count: int + :param expiration: Days until expireation + :type expiration: int + :return: initial access token + :rtype: str + """ + payload = {"count": count, "expiration": expiration} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_INITIAL_ACCESS.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[200]) + + def create_client(self, payload, skip_exists=False): + """Create a client. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param skip_exists: If true then do not raise an error if client already exists + :type skip_exists: bool + :param payload: ClientRepresentation + :type payload: dict + :return: Client ID + :rtype: str + """ + if skip_exists: + client_id = self.get_client_id(client_id=payload["clientId"]) + + if client_id is not None: + return client_id + + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path), data=json.dumps(payload) + ) + raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + + def update_client(self, client_id, payload): + """Update a client. + + :param client_id: Client id + :type client_id: str + :param payload: ClientRepresentation + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_client(self, client_id): + """Get representation of the client. + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param client_id: keycloak client id (not oauth client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_CLIENT.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_client_installation_provider(self, client_id, provider_id): + """Get content for given installation provider. + + Related documentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource + + Possible provider_id list available in the ServerInfoRepresentation#clientInstallations + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :param client_id: Client id + :type client_id: str + :param provider_id: provider id to specify response format + :type provider_id: str + :returns: Installation providers + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "provider-id": provider_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_INSTALLATION_PROVIDER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def get_realm_roles(self, brief_representation=True, search_text=""): + """Get all roles for the realm or client. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :param search_text: optional search text to limit the returned result. + :type search_text: str + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + url = urls_patterns.URL_ADMIN_REALM_ROLES + params_path = {"realm-name": self.connection.realm_name} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES.format(**params_path), **params + ) + + # set the search_text path param, if it is a valid string + if search_text is not None and search_text.strip() != "": + params_path["search-text"] = search_text + url = urls_patterns.URL_ADMIN_REALM_ROLES_SEARCH + + data_raw = self.connection.raw_get(url.format(**params_path), **params) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_realm_role_groups(self, role_name, query=None, brief_representation=True): + """Get role groups of realm by role name. + + :param role_name: Name of the role. + :type role_name: str + :param query: Additional Query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_parameters_226) + :type query: dict + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak Server Response (GroupRepresentation) + :rtype: list + """ + query = query or {} + + params = {"briefRepresentation": brief_representation} + + query.update(params) + + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + + url = urls_patterns.URL_ADMIN_REALM_ROLES_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def get_realm_role_members(self, role_name, query=None): + """Get role members of realm by role name. + + :param role_name: Name of the role. + :type role_name: str + :param query: Additional Query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_roles_resource) + :type query: dict + :return: Keycloak Server Response (UserRepresentation) + :rtype: list + """ + query = query or dict() + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + return self.__fetch_all( + urls_patterns.URL_ADMIN_REALM_ROLES_MEMBERS.format(**params_path), query + ) + + def get_default_realm_role_id(self): + """Get the ID of the default realm role. + + :return: Realm role ID + :rtype: str + """ + all_realm_roles = self.get_realm_roles() + default_realm_roles = [ + realm_role + for realm_role in all_realm_roles + if realm_role["name"] == f"default-roles-{self.connection.realm_name}".lower() + ] + return default_realm_roles[0]["id"] + + def get_realm_default_roles(self): + """Get all the default realm roles. + + :return: Keycloak Server Response (UserRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": self.get_default_realm_role_id(), + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES_REALM.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def remove_realm_default_roles(self, payload): + """Remove a set of default realm roles. + + :param payload: List of RoleRepresentations + :type payload: list + :return: Keycloak Server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": self.get_default_realm_role_id(), + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def add_realm_default_roles(self, payload): + """Add a set of default realm roles. + + :param payload: List of RoleRepresentations + :type payload: list + :return: Keycloak Server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": self.get_default_realm_role_id(), + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def get_client_roles(self, client_id, brief_representation=True): + """Get all roles for the client. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_role(self, client_id, role_name): + """Get client role id by name. + + This is required for further actions with this role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :return: role_id + :rtype: str + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_role_id(self, client_id, role_name): + """Get client role id by name. + + This is required for further actions with this role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :return: role_id + :rtype: str + """ + role = self.get_client_role(client_id, role_name) + return role.get("id") + + def create_client_role(self, client_role_id, payload, skip_exists=False): + """Create a client role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param payload: RoleRepresentation + :type payload: dict + :param skip_exists: If true then do not raise an error if client role already exists + :type skip_exists: bool + :return: Client role name + :rtype: str + """ + if skip_exists: + try: + res = self.get_client_role(client_id=client_role_id, role_name=payload["name"]) + return res["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name, "id": client_role_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), data=json.dumps(payload) + ) + raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + + def add_composite_client_roles_to_role(self, client_role_id, role_name, roles): + """Add composite roles to client role. + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be updated + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def update_client_role(self, client_id, role_name, payload): + """Update a client role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :param payload: RoleRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_client_role(self, client_role_id, role_name): + """Delete a client role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: role's name (not id!) + :type role_name: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def assign_client_role(self, user_id, client_id, roles): + """Assign a client role to a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def get_client_role_members(self, client_id, role_name, **query): + """Get members by client role. + + :param client_id: The client id + :type client_id: str + :param role_name: the name of role to be queried. + :type role_name: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource) + :type query: dict + :return: Keycloak server response (UserRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + return self.__fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_MEMBERS.format(**params_path), query + ) + + def get_client_role_groups(self, client_id, role_name, **query): + """Get group members by client role. + + :param client_id: The client id + :type client_id: str + :param role_name: the name of role to be queried. + :type role_name: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource) + :type query: dict + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + return self.__fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_GROUPS.format(**params_path), query + ) + + def get_role_by_id(self, role_id): + """Get a specific role’s representation. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: id of role + :type role_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def update_role_by_id(self, role_id, payload): + """Update the role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param payload: RoleRepresentation + :type payload: dict + :param role_id: id of role + :type role_id: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_role_by_id(self, role_id): + """Delete a role by its id. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: id of role + :type role_id: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def create_realm_role(self, payload, skip_exists=False): + """Create a new role for the realm or client. + + :param payload: The role (use RoleRepresentation) + :type payload: dict + :param skip_exists: If true then do not raise an error if realm role already exists + :type skip_exists: bool + :return: Realm role name + :rtype: str + """ + if skip_exists: + try: + role = self.get_realm_role(role_name=payload["name"]) + return role["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_ROLES.format(**params_path), data=json.dumps(payload) + ) + raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + + def get_realm_role(self, role_name): + """Get realm role by role name. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_name: role's name, not id! + :type role_name: str + :return: role + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_realm_role_by_id(self, role_id: str): + """Get realm role by role id. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: role's id, not name! + :type role_id: str + :return: role + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def update_realm_role(self, role_name, payload): + """Update a role for the realm by name. + + :param role_name: The name of the role to be updated + :type role_name: str + :param payload: The role (use RoleRepresentation) + :type payload: dict + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_realm_role(self, role_name): + """Delete a role for the realm by name. + + :param role_name: The role name + :type role_name: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def add_composite_realm_roles_to_role(self, role_name, roles): + """Add composite roles to the role. + + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be updated + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def remove_composite_realm_roles_to_role(self, role_name, roles): + """Remove composite roles from the role. + + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be removed + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_composite_realm_roles_of_role(self, role_name): + """Get composite roles of the role. + + :param role_name: The name of the role + :type role_name: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def assign_realm_roles_to_client_scope(self, client_id, roles): + """Assign realm roles to a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def delete_realm_roles_of_client_scope(self, client_id, roles): + """Delete realm roles of a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_realm_roles_of_client_scope(self, client_id): + """Get all realm roles for a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def assign_client_roles_to_client_scope(self, client_id, client_roles_owner_id, roles): + """Assign client roles to a client's scope. + + :param client_id: id of client (not client-id) who is assigned the roles + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def delete_client_roles_of_client_scope(self, client_id, client_roles_owner_id, roles): + """Delete client roles of a client's scope. + + :param client_id: id of client (not client-id) who is assigned the roles + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_client_roles_of_client_scope(self, client_id, client_roles_owner_id): + """Get all client roles for a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def assign_realm_roles(self, user_id, roles): + """Assign realm roles to a user. + + :param user_id: id of user + :type user_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def delete_realm_roles_of_user(self, user_id, roles): + """Delete realm roles of a user. + + :param user_id: id of user + :type user_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_realm_roles_of_user(self, user_id): + """Get all realm roles for a user. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_available_realm_roles_of_user(self, user_id): + """Get all available (i.e. unassigned) realm roles for a user. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_AVAILABLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_composite_realm_roles_of_user(self, user_id, brief_representation=True): + """Get all composite (i.e. implicit) realm roles for a user. + + :param user_id: id of user + :type user_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_COMPOSITE.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def assign_group_realm_roles(self, group_id, roles): + """Assign realm roles to a group. + + :param group_id: id of group + :type group_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def delete_group_realm_roles(self, group_id, roles): + """Delete realm roles of a group. + + :param group_id: id of group + :type group_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_group_realm_roles(self, group_id, brief_representation=True): + """Get all realm roles for a group. + + :param group_id: id of the group + :type group_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def assign_group_client_roles(self, group_id, client_id, roles): + """Assign client roles to a group. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def get_group_client_roles(self, group_id, client_id): + """Get client roles of a group. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_group_client_roles(self, group_id, client_id, roles): + """Delete client roles of a group. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response (array RoleRepresentation) + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_all_roles_of_user(self, user_id): + """Get all level roles for a user. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_ALL_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_roles_of_user(self, user_id, client_id): + """Get all client roles for a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + return self._get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES, user_id, client_id + ) + + def get_available_client_roles_of_user(self, user_id, client_id): + """Get available client role-mappings for a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + return self._get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, user_id, client_id + ) + + def get_composite_client_roles_of_user(self, user_id, client_id, brief_representation=False): + """Get composite client role-mappings for a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params = {"briefRepresentation": brief_representation} + return self._get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, user_id, client_id, **params + ) + + def _get_client_roles_of_user( + self, client_level_role_mapping_url, user_id, client_id, **params + ): + """Get client roles of a single user helper. + + :param client_level_role_mapping_url: Url for the client role mapping + :type client_level_role_mapping_url: str + :param user_id: User id + :type user_id: str + :param client_id: Client id + :type client_id: str + :param params: Additional parameters + :type params: dict + :returns: Client roles of a user + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_get( + client_level_role_mapping_url.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_client_roles_of_user(self, user_id, client_id, roles): + """Delete client roles from a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client containing role (not client-id) + :type client_id: str + :param roles: roles list or role to delete (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_authentication_flows(self): + """Get authentication flows. + + Returns all flow details + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :return: Keycloak server response (AuthenticationFlowRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_FLOWS.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_authentication_flow_for_id(self, flow_id): + """Get one authentication flow by it's id. + + Returns all flow details + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param flow_id: the id of a flow NOT it's alias + :type flow_id: str + :return: Keycloak server response (AuthenticationFlowRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "flow-id": flow_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_FLOWS_ALIAS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_authentication_flow(self, payload, skip_exists=False): + """Create a new authentication flow. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :param skip_exists: Do not raise an error if authentication flow already exists + :type skip_exists: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def copy_authentication_flow(self, payload, flow_alias): + """Copy existing authentication flow under a new name. + + The new name is given as 'newName' attribute of the passed payload. + + :param payload: JSON containing 'newName' attribute + :type payload: dict + :param flow_alias: the flow alias + :type flow_alias: str + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_COPY.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def delete_authentication_flow(self, flow_id): + """Delete authentication flow. + + AuthenticationInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationinforepresentation + + :param flow_id: authentication flow id + :type flow_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": flow_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_FLOW.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_authentication_flow_executions(self, flow_alias): + """Get authentication flow executions. + + Returns all execution steps + + :param flow_alias: the flow alias + :type flow_alias: str + :return: Response(json) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def update_authentication_flow_executions(self, payload, flow_alias): + """Update an authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param payload: AuthenticationExecutionInfoRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[202, 204]) + + def get_authentication_flow_execution(self, execution_id): + """Get authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param execution_id: the execution ID + :type execution_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": execution_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_authentication_flow_execution(self, payload, flow_alias): + """Create an authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param payload: AuthenticationExecutionInfoRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def delete_authentication_flow_execution(self, execution_id): + """Delete authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param execution_id: keycloak client id (not oauth client-id) + :type execution_id: str + :return: Keycloak server response (json) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": execution_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def create_authentication_flow_subflow(self, payload, flow_alias, skip_exists=False): + """Create a new sub authentication flow for a given authentication flow. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :param skip_exists: Do not raise an error if authentication flow already exists + :type skip_exists: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS_FLOW.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + + def get_authenticator_providers(self): + """Get authenticator providers list. + + :return: Authenticator providers + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_PROVIDERS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_authenticator_provider_config_description(self, provider_id): + """Get authenticator's provider configuration description. + + AuthenticatorConfigInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfiginforepresentation + + :param provider_id: Provider Id + :type provider_id: str + :return: AuthenticatorConfigInfoRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "provider-id": provider_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG_DESCRIPTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_authenticator_config(self, config_id): + """Get authenticator configuration. + + Returns all configuration details. + + :param config_id: Authenticator config id + :type config_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def update_authenticator_config(self, payload, config_id): + """Update an authenticator configuration. + + AuthenticatorConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfigrepresentation + + :param payload: AuthenticatorConfigRepresentation + :type payload: dict + :param config_id: Authenticator config id + :type config_id: str + :return: Response(json) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_authenticator_config(self, config_id): + """Delete a authenticator configuration. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authentication_management_resource + + :param config_id: Authenticator config id + :type config_id: str + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def sync_users(self, storage_id, action): + """Trigger user sync from provider. + + :param storage_id: The id of the user storage provider + :type storage_id: str + :param action: Action can be "triggerFullSync" or "triggerChangedUsersSync" + :type action: str + :return: Keycloak server response + :rtype: bytes + """ + data = {"action": action} + params_query = {"action": action} + + params_path = {"realm-name": self.connection.realm_name, "id": storage_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_STORAGE.format(**params_path), + data=json.dumps(data), + **params_query, + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def get_client_scopes(self): + """Get client scopes. + + Get representation of the client scopes for the realm where we are connected to + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :return: Keycloak server response Array of (ClientScopeRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_scope(self, client_scope_id): + """Get client scope. + + Get representation of the client scopes for the realm where we are connected to + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :return: Keycloak server response (ClientScopeRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_scope_by_name(self, client_scope_name): + """Get client scope by name. + + Get representation of the client scope identified by the client scope name. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + :param client_scope_name: (str) Name of the client scope + :type client_scope_name: str + :returns: ClientScopeRepresentation or None + :rtype: dict + """ + client_scopes = self.get_client_scopes() + for client_scope in client_scopes: + if client_scope["name"] == client_scope_name: + return client_scope + + return None + + def create_client_scope(self, payload, skip_exists=False): + """Create a client scope. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :param payload: ClientScopeRepresentation + :type payload: dict + :param skip_exists: If true then do not raise an error if client scope already exists + :type skip_exists: bool + :return: Client scope id + :rtype: str + """ + if skip_exists: + exists = self.get_client_scope_by_name(client_scope_name=payload["name"]) + + if exists is not None: + return exists["id"] + + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path), data=json.dumps(payload) + ) + raise_error_from_response( + data_raw, KeycloakPostError, expected_codes=[201], skip_exists=skip_exists + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + + def update_client_scope(self, client_scope_id, payload): + """Update a client scope. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_client_scopes_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param payload: ClientScopeRepresentation + :type payload: dict + :return: Keycloak server response (ClientScopeRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_client_scope(self, client_scope_id): + """Delete existing client scope. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_client_scopes_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_mappers_from_client_scope(self, client_scope_id): + """Get a list of all mappers connected to the client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + :param client_scope_id: Client scope id + :type client_scope_id: str + :returns: Keycloak server response (ProtocolMapperRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def add_mapper_to_client_scope(self, client_scope_id, payload): + """Add a mapper to a client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_create_mapper + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), + data=json.dumps(payload), + ) + + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def delete_mapper_from_client_scope(self, client_scope_id, protocol_mapper_id): + """Delete a mapper from a client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_delete_mapper + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param protocol_mapper_id: Protocol mapper id + :type protocol_mapper_id: str + :return: Keycloak server Response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } + + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def update_mapper_in_client_scope(self, client_scope_id, protocol_mapper_id, payload): + """Update an existing protocol mapper in a client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param protocol_mapper_id: The id of the protocol mapper which exists in the client scope + and should to be updated + :type protocol_mapper_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } + + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def get_default_default_client_scopes(self): + """Get default default client scopes. + + Return list of default default client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_default_default_client_scope(self, scope_id): + """Delete default default client scope. + + :param scope_id: default default client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def add_default_default_client_scope(self, scope_id): + """Add default default client scope. + + :param scope_id: default default client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + payload = {"realm": self.connection.realm_name, "clientScopeId": scope_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def get_default_optional_client_scopes(self): + """Get default optional client scopes. + + Return list of default optional client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def delete_default_optional_client_scope(self, scope_id): + """Delete default optional client scope. + + :param scope_id: default optional client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def add_default_optional_client_scope(self, scope_id): + """Add default optional client scope. + + :param scope_id: default optional client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + payload = {"realm": self.connection.realm_name, "clientScopeId": scope_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def get_mappers_from_client(self, client_id): + """List of all client mappers. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocolmapperrepresentation + + :param client_id: Client id + :type client_id: str + :returns: KeycloakServerResponse (list of ProtocolMapperRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path) + ) + + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[200]) + + def add_mapper_to_client(self, client_id, payload): + """Add a mapper to a client. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_create_mapper + + :param client_id: The id of the client + :type client_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def update_client_mapper(self, client_id, mapper_id, payload): + """Update client mapper. + + :param client_id: The id of the client + :type client_id: str + :param mapper_id: The id of the mapper to be deleted + :type mapper_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "protocol-mapper-id": mapper_id, + } + + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), + data=json.dumps(payload), + ) + + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def remove_client_mapper(self, client_id, client_mapper_id): + """Remove a mapper from the client. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + + :param client_id: The id of the client + :type client_id: str + :param client_mapper_id: The id of the mapper to be deleted + :type client_mapper_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "protocol-mapper-id": client_mapper_id, + } + + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def generate_client_secrets(self, client_id): + """Generate a new secret for the client. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_regeneratesecret + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path), data=None + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def get_client_secrets(self, client_id): + """Get representation of the client secrets. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientsecret + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_components(self, query=None): + """Get components. + + Return a list of components, filtered according to query parameters + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param query: Query parameters (optional) + :type query: dict + :return: components list + :rtype: list + """ + query = query or dict() + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), data=None, **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_component(self, payload): + """Create a new component. + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param payload: ComponentRepresentation + :type payload: dict + :return: Component id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), data=json.dumps(payload) + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] # noqa: E203 + + def get_component(self, component_id): + """Get representation of the component. + + :param component_id: Component id + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param component_id: Id of the component + :type component_id: str + :return: ComponentRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_COMPONENT.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def update_component(self, component_id, payload): + """Update the component. + + :param component_id: Component id + :type component_id: str + :param payload: ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + :type payload: dict + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def delete_component(self, component_id): + """Delete the component. + + :param component_id: Component id + :type component_id: str + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_keys(self): + """Get keys. + + Return a list of keys, filtered according to query parameters + + KeysMetadataRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_key_resource + + :return: keys list + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_KEYS.format(**params_path), data=None + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_admin_events(self, query=None): + """Get Administrative events. + + Return a list of events, filtered according to query parameters + + AdminEvents Representation array + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getevents + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_get_adminrealmsrealmadmin_events + + :param query: Additional query parameters + :type query: dict + :return: events list + :rtype: list + """ + query = query or dict() + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ADMIN_EVENTS.format(**params_path), data=None, **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_events(self, query=None): + """Get events. + + Return a list of events, filtered according to query parameters + + EventRepresentation array + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_eventrepresentation + + :param query: Additional query parameters + :type query: dict + :return: events list + :rtype: list + """ + query = query or dict() + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_EVENTS.format(**params_path), data=None, **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def set_events(self, payload): + """Set realm events configuration. + + RealmEventsConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmeventsconfigrepresentation + + :param payload: Payload object for the events configuration + :type payload: dict + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_EVENTS_CONFIG.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def get_client_all_sessions(self, client_id): + """Get sessions associated with the client. + + UserSessionRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_usersessionrepresentation + + :param client_id: id of client + :type client_id: str + :return: UserSessionRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ALL_SESSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_sessions_stats(self): + """Get current session count for all clients with active sessions. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientsessionstats + + :return: Dict of clients and session count + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SESSION_STATS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_management_permissions(self, client_id): + """Get management permissions for a client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def update_client_management_permissions(self, payload, client_id): + """Update management permissions for a client. + + ManagementPermissionReference + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_managementpermissionreference + + Payload example:: + + payload={ + "enabled": true + } + + :param payload: ManagementPermissionReference + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[200]) + + def get_client_authz_policy_scopes(self, client_id, policy_id): + """Get scopes for a given policy. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: No Document + :type policy_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_authz_policy_resources(self, client_id, policy_id): + """Get resources for a given policy. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: No Document + :type policy_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_RESOURCES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_client_authz_scope_permission(self, client_id, scope_id): + """Get permissions for a given scope. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param scope_id: No Document + :type scope_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "scope-id": scope_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def create_client_authz_scope_permission(self, payload, client_id): + """Create permissions for a authz scope. + + Payload example:: + + payload={ + "name": "My Permission Name", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [some_scope_id], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_ADD_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def update_client_authz_scope_permission(self, payload, client_id, scope_id): + """Update permissions for a given scope. + + Payload example:: + + payload={ + "id": scope_id, + "name": "My Permission Name", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [some_scope_id], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param scope_id: No Document + :type scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "scope-id": scope_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[201]) + + def get_client_authz_client_policies(self, client_id): + """Get policies for a given client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def create_client_authz_client_policy(self, payload, client_id): + """Create a new policy for a given client. + + Payload example:: + + payload={ + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "My Policy", + "clients": [other_client_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def get_composite_client_roles_of_group(self, client_id, group_id, brief_representation=True): + """Get the composite client roles of the given group for the given client. + + :param client_id: id of the client. + :type client_id: str + :param group_id: id of the group. + :type group_id: str + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: the composite client roles of the group (list of RoleRepresentation). + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES_COMPOSITE.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_role_client_level_children(self, client_id, role_id): + """Get the child roles of which the given composite client role is composed of. + + :param client_id: id of the client. + :type client_id: str + :param role_id: id of the role. + :type role_id: str + :return: the child roles (list of RoleRepresentation). + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": role_id, + "client-id": client_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE_CHILDREN.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def upload_certificate(self, client_id, certcont): + """Upload a new certificate for the client. + + :param client_id: id of the client. + :type client_id: str + :param certcont: the content of the certificate. + :type certcont: str + :return: dictionary {"certificate": ""}, + where is the content of the uploaded certificate. + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "attr": "jwt.credential", + } + m = MultipartEncoder(fields={"keystoreFormat": "Certificate PEM", "file": certcont}) + new_headers = copy.deepcopy(self.connection.headers) + new_headers["Content-Type"] = m.content_type + self.connection.headers = new_headers + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_CERT_UPLOAD.format(**params_path), + data=m, + headers=new_headers, + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def get_required_action_by_alias(self, action_alias): + """Get a required action by its alias. + + :param action_alias: the alias of the required action. + :type action_alias: str + :return: the required action (RequiredActionProviderRepresentation). + :rtype: dict + """ + actions = self.get_required_actions() + for a in actions: + if a["alias"] == action_alias: + return a + return None + + def get_required_actions(self): + """Get the required actions for the realms. + + :return: the required actions (list of RequiredActionProviderRepresentation). + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def update_required_action(self, action_alias, payload): + """Update a required action. + + :param action_alias: the action alias. + :type action_alias: str + :param payload: the new required action (RequiredActionProviderRepresentation). + :type payload: dict + :return: empty dictionary. + :rtype: dict + """ + if not isinstance(payload, str): + payload = json.dumps(payload) + params_path = {"realm-name": self.connection.realm_name, "action-alias": action_alias} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS_ALIAS.format(**params_path), data=payload + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def get_bruteforce_detection_status(self, user_id): + """Get bruteforce detection status for user. + + :param user_id: User id + :type user_id: str + :return: Bruteforce status. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def clear_bruteforce_attempts_for_user(self, user_id): + """Clear bruteforce attempts for user. + + :param user_id: User id + :type user_id: str + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def clear_all_bruteforce_attempts(self): + """Clear bruteforce attempts for all users in realm. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def clear_keys_cache(self): + """Clear keys cache. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLEAR_KEYS_CACHE.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def clear_realm_cache(self): + """Clear realm cache. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLEAR_REALM_CACHE.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def clear_user_cache(self): + """Clear user cache. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLEAR_USER_CACHE.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) diff --git a/src/keycloak/asynchronous/keycloak_openid.py b/src/keycloak/asynchronous/keycloak_openid.py new file mode 100644 index 0000000..6fbf650 --- /dev/null +++ b/src/keycloak/asynchronous/keycloak_openid.py @@ -0,0 +1,786 @@ +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak OpenID module. + +The module contains mainly the implementation of KeycloakOpenID class, the main +class to handle authentication and token manipulation. +""" + +import json +from typing import Optional + +from jwcrypto import jwk, jwt + +from ..authorization import Authorization +from .connection import ConnectionManager +from .exceptions import ( + KeycloakAuthenticationError, + KeycloakAuthorizationConfigError, + KeycloakDeprecationError, + KeycloakGetError, + KeycloakInvalidTokenError, + KeycloakPostError, + KeycloakPutError, + KeycloakRPTNotFound, + raise_error_from_response, +) +from ..uma_permissions import AuthStatus, build_permission_param +from ..urls_patterns import ( + URL_AUTH, + URL_CERTS, + URL_CLIENT_REGISTRATION, + URL_CLIENT_UPDATE, + URL_DEVICE, + URL_ENTITLEMENT, + URL_INTROSPECT, + URL_LOGOUT, + URL_REALM, + URL_TOKEN, + URL_USERINFO, + URL_WELL_KNOWN, +) + + +class KeycloakOpenID: + """Keycloak OpenID client. + + :param server_url: Keycloak server url + :param client_id: client id + :param realm_name: realm name + :param client_secret_key: client secret key + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :param custom_headers: dict of custom header to pass to each HTML request + :param proxies: dict of proxies to sent the request by. + :param timeout: connection timeout in seconds + """ + + def __init__( + self, + server_url, + realm_name, + client_id, + client_secret_key=None, + verify=True, + custom_headers=None, + proxies=None, + timeout=60, + ): + """Init method. + + :param server_url: Keycloak server url + :type server_url: str + :param client_id: client id + :type client_id: str + :param realm_name: realm name + :type realm_name: str + :param client_secret_key: client secret key + :type client_secret_key: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param proxies: dict of proxies to sent the request by. + :type proxies: dict + :param timeout: connection timeout in seconds + :type timeout: int + """ + self.client_id = client_id + self.client_secret_key = client_secret_key + self.realm_name = realm_name + headers = custom_headers if custom_headers is not None else dict() + self.connection = ConnectionManager( + base_url=server_url, headers=headers, timeout=timeout, verify=verify, proxies=proxies + ) + + self.authorization = Authorization() + + @property + def client_id(self): + """Get client id. + + :returns: Client id + :rtype: str + """ + return self._client_id + + @client_id.setter + def client_id(self, value): + self._client_id = value + + @property + def client_secret_key(self): + """Get the client secret key. + + :returns: Client secret key + :rtype: str + """ + return self._client_secret_key + + @client_secret_key.setter + def client_secret_key(self, value): + self._client_secret_key = value + + @property + def realm_name(self): + """Get the realm name. + + :returns: Realm name + :rtype: str + """ + return self._realm_name + + @realm_name.setter + def realm_name(self, value): + self._realm_name = value + + @property + def connection(self): + """Get connection. + + :returns: Connection manager object + :rtype: ConnectionManager + """ + return self._connection + + @connection.setter + def connection(self, value): + self._connection = value + + @property + def authorization(self): + """Get authorization. + + :returns: The authorization manager + :rtype: Authorization + """ + return self._authorization + + @authorization.setter + def authorization(self, value): + self._authorization = value + + def _add_secret_key(self, payload): + """Add secret key if exists. + + :param payload: Payload + :type payload: dict + :returns: Payload with the secret key + :rtype: dict + """ + if self.client_secret_key: + payload.update({"client_secret": self.client_secret_key}) + + return payload + + def _build_name_role(self, role): + """Build name of a role. + + :param role: Role name + :type role: str + :returns: Role path + :rtype: str + """ + return self.client_id + "/" + role + + def _token_info(self, token, method_token_info, **kwargs): + """Getter for the token data. + + :param token: Token + :type token: str + :param method_token_info: Token info method to use + :type method_token_info: str + :param kwargs: Additional keyword arguments passed to the decode_token method + :type kwargs: dict + :returns: Token info + :rtype: dict + """ + if method_token_info == "introspect": + token_info = self.introspect(token) + else: + token_info = self.decode_token(token, **kwargs) + + return token_info + + def well_known(self): + """Get the well_known object. + + The most important endpoint to understand is the well-known configuration + endpoint. It lists endpoints and other configuration options relevant to + the OpenID Connect implementation in Keycloak. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def auth_url(self, redirect_uri, scope="email", state=""): + """Get authorization URL endpoint. + + :param redirect_uri: Redirect url to receive oauth code + :type redirect_uri: str + :param scope: Scope of authorization request, split with the blank space + :type scope: str + :param state: State will be returned to the redirect_uri + :type state: str + :returns: Authorization URL Full Build + :rtype: str + """ + params_path = { + "authorization-endpoint": self.well_known()["authorization_endpoint"], + "client-id": self.client_id, + "redirect-uri": redirect_uri, + "scope": scope, + "state": state, + } + return URL_AUTH.format(**params_path) + + def token( + self, + username="", + password="", + grant_type=["password"], + code="", + redirect_uri="", + totp=None, + scope="openid", + **extra + ): + """Retrieve user token. + + The token endpoint is used to obtain tokens. Tokens can either be obtained by + exchanging an authorization code or by supplying credentials directly depending on + what flow is used. The token endpoint is also used to obtain new access tokens + when they expire. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param username: Username + :type username: str + :param password: Password + :type password: str + :param grant_type: Grant type + :type grant_type: str + :param code: Code + :type code: str + :param redirect_uri: Redirect URI + :type redirect_uri: str + :param totp: Time-based one-time password + :type totp: int + :param scope: Scope, defaults to openid + :type scope: str + :param extra: Additional extra arguments + :type extra: dict + :returns: Keycloak token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "username": username, + "password": password, + "client_id": self.client_id, + "grant_type": grant_type, + "code": code, + "redirect_uri": redirect_uri, + "scope": scope, + } + if extra: + payload.update(extra) + + if totp: + payload["totp"] = totp + + payload = self._add_secret_key(payload) + data_raw = self.connection.sync_raw_post(URL_TOKEN.format(**params_path), data=payload) + print(data_raw) + return raise_error_from_response(data_raw, KeycloakPostError) + + def refresh_token(self, refresh_token, grant_type=["refresh_token"]): + """Refresh the user token. + + The token endpoint is used to obtain tokens. Tokens can either be obtained by + exchanging an authorization code or by supplying credentials directly depending on + what flow is used. The token endpoint is also used to obtain new access tokens + when they expire. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param refresh_token: Refresh token from Keycloak + :type refresh_token: str + :param grant_type: Grant type + :type grant_type: str + :returns: New token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "client_id": self.client_id, + "grant_type": grant_type, + "refresh_token": refresh_token, + } + payload = self._add_secret_key(payload) + data_raw = self.connection.sync_raw_post(URL_TOKEN.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError) + + def exchange_token( + self, + token: str, + audience: Optional[str] = None, + subject: Optional[str] = None, + subject_token_type: Optional[str] = None, + subject_issuer: Optional[str] = None, + requested_issuer: Optional[str] = None, + requested_token_type: str = "urn:ietf:params:oauth:token-type:refresh_token", + scope: str = "openid", + ) -> dict: + """Exchange user token. + + Use a token to obtain an entirely different token. See + https://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange + + :param token: Access token + :type token: str + :param audience: Audience + :type audience: str + :param subject: Subject + :type subject: str + :param subject_token_type: Token Type specification + :type subject_token_type: Optional[str] + :param subject_issuer: Issuer + :type subject_issuer: Optional[str] + :param requested_issuer: Issuer + :type requested_issuer: Optional[str] + :param requested_token_type: Token type specification + :type requested_token_type: str + :param scope: Scope, defaults to openid + :type scope: str + :returns: Exchanged token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "grant_type": ["urn:ietf:params:oauth:grant-type:token-exchange"], + "client_id": self.client_id, + "subject_token": token, + "subject_token_type": subject_token_type, + "subject_issuer": subject_issuer, + "requested_token_type": requested_token_type, + "audience": audience, + "requested_subject": subject, + "requested_issuer": requested_issuer, + "scope": scope, + } + payload = self._add_secret_key(payload) + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError) + + def userinfo(self, token): + """Get the user info object. + + The userinfo endpoint returns standard claims about the authenticated user, + and is protected by a bearer token. + + http://openid.net/specs/openid-connect-core-1_0.html#UserInfo + + :param token: Access token + :type token: str + :returns: Userinfo object + :rtype: dict + """ + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def logout(self, refresh_token): + """Log out the authenticated user. + + :param refresh_token: Refresh token from Keycloak + :type refresh_token: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "refresh_token": refresh_token} + payload = self._add_secret_key(payload) + data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def certs(self): + """Get certificates. + + The certificate endpoint returns the public keys enabled by the realm, encoded as a + JSON Web Key (JWK). Depending on the realm settings there can be one or more keys enabled + for verifying tokens. + + https://tools.ietf.org/html/rfc7517 + + :returns: Certificates + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + def public_key(self): + """Retrieve the public key. + + The public key is exposed by the realm page directly. + + :returns: The public key + :rtype: str + """ + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_REALM.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError)["public_key"] + + def entitlement(self, token, resource_server_id): + """Get entitlements from the token. + + Client applications can use a specific endpoint to obtain a special security token + called a requesting party token (RPT). This token consists of all the entitlements + (or permissions) for a user as a result of the evaluation of the permissions and + authorization policies associated with the resources being requested. With an RPT, + client applications can gain access to protected resources at the resource server. + + :param token: Access token + :type token: str + :param resource_server_id: Resource server ID + :type resource_server_id: str + :returns: Entitlements + :rtype: dict + """ + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} + data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) + + if data_raw.status_code == 404 or data_raw.status_code == 405: + return raise_error_from_response(data_raw, KeycloakDeprecationError) + + return raise_error_from_response(data_raw, KeycloakGetError) # pragma: no cover + + def introspect(self, token, rpt=None, token_type_hint=None): + """Introspect the user token. + + The introspection endpoint is used to retrieve the active state of a token. + It is can only be invoked by confidential clients. + + https://tools.ietf.org/html/rfc7662 + + :param token: Access token + :type token: str + :param rpt: Requesting party token + :type rpt: str + :param token_type_hint: Token type hint + :type token_type_hint: str + + :returns: Token info + :rtype: dict + :raises KeycloakRPTNotFound: In case of RPT not specified + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "token": token} + + if token_type_hint == "requesting_party_token": + if rpt: + payload.update({"token": rpt, "token_type_hint": token_type_hint}) + self.connection.add_param_headers("Authorization", "Bearer " + token) + else: + raise KeycloakRPTNotFound("Can't found RPT.") + + payload = self._add_secret_key(payload) + + data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError) + + def decode_token(self, token, validate: bool = True, **kwargs): + """Decode user token. + + A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data + structure that represents a cryptographic key. This specification + also defines a JWK Set JSON data structure that represents a set of + JWKs. Cryptographic algorithms and identifiers for use with this + specification are described in the separate JSON Web Algorithms (JWA) + specification and IANA registries established by that specification. + + https://tools.ietf.org/html/rfc7517 + + :param token: Keycloak token + :type token: str + :param validate: Determines whether the token should be validated with the public key. + Defaults to True. + :type validate: bool + :param kwargs: Additional keyword arguments for jwcrypto's JWT object + :type kwargs: dict + :returns: Decoded token + :rtype: dict + """ + if validate: + if "key" not in kwargs: + key = ( + "-----BEGIN PUBLIC KEY-----\n" + + self.public_key() + + "\n-----END PUBLIC KEY-----" + ) + key = jwk.JWK.from_pem(key.encode("utf-8")) + kwargs["key"] = key + + full_jwt = jwt.JWT(jwt=token, **kwargs) + return jwt.json_decode(full_jwt.claims) + else: + full_jwt = jwt.JWT(jwt=token, **kwargs) + full_jwt.token.objects["valid"] = True + return json.loads(full_jwt.token.payload.decode("utf-8")) + + def load_authorization_config(self, path): + """Load Keycloak settings (authorization). + + :param path: settings file (json) + :type path: str + """ + with open(path, "r") as fp: + authorization_json = json.load(fp) + + self.authorization.load_config(authorization_json) + + def get_policies(self, token, method_token_info="introspect", **kwargs): + """Get policies by user token. + + :param token: User token + :type token: str + :param method_token_info: Method for token info decoding + :type method_token_info: str + :param kwargs: Additional keyword arguments + :type kwargs: dict + :return: Policies + :rtype: dict + :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration + :raises KeycloakInvalidTokenError: In case of bad token + """ + if not self.authorization.policies: + raise KeycloakAuthorizationConfigError( + "Keycloak settings not found. Load Authorization Keycloak settings." + ) + + token_info = self._token_info(token, method_token_info, **kwargs) + + if method_token_info == "introspect" and not token_info["active"]: + raise KeycloakInvalidTokenError("Token expired or invalid.") + + user_resources = token_info["resource_access"].get(self.client_id) + + if not user_resources: + return None + + policies = [] + + for policy_name, policy in self.authorization.policies.items(): + for role in user_resources["roles"]: + if self._build_name_role(role) in policy.roles: + policies.append(policy) + + return list(set(policies)) + + def get_permissions(self, token, method_token_info="introspect", **kwargs): + """Get permission by user token. + + :param token: user token + :type token: str + :param method_token_info: Decode token method + :type method_token_info: str + :param kwargs: parameters for decode + :type kwargs: dict + :returns: permissions list + :rtype: list + :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration + :raises KeycloakInvalidTokenError: In case of bad token + """ + if not self.authorization.policies: + raise KeycloakAuthorizationConfigError( + "Keycloak settings not found. Load Authorization Keycloak settings." + ) + + token_info = self._token_info(token, method_token_info, **kwargs) + + if method_token_info == "introspect" and not token_info["active"]: + raise KeycloakInvalidTokenError("Token expired or invalid.") + + user_resources = token_info["resource_access"].get(self.client_id) + + if not user_resources: + return None + + permissions = [] + + for policy_name, policy in self.authorization.policies.items(): + for role in user_resources["roles"]: + if self._build_name_role(role) in policy.roles: + permissions += policy.permissions + + return list(set(permissions)) + + def uma_permissions(self, token, permissions=""): + """Get UMA permissions by user token with requested permissions. + + The token endpoint is used to retrieve UMA permissions from Keycloak. It can only be + invoked by confidential clients. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param token: user token + :type token: str + :param permissions: list of uma permissions list(resource:scope) requested by the user + :type permissions: str + :returns: Keycloak server response + :rtype: dict + """ + permission = build_permission_param(permissions) + + params_path = {"realm-name": self.realm_name} + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": permission, + "response_mode": "permissions", + "audience": self.client_id, + } + + self.connection.add_param_headers("Authorization", "Bearer " + token) + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError) + + def has_uma_access(self, token, permissions): + """Determine whether user has uma permissions with specified user token. + + :param token: user token + :type token: str + :param permissions: list of uma permissions (resource:scope) + :type permissions: str + :return: Authentication status + :rtype: AuthStatus + :raises KeycloakAuthenticationError: In case of failed authentication + :raises KeycloakPostError: In case of failed request to Keycloak + """ + needed = build_permission_param(permissions) + try: + granted = self.uma_permissions(token, permissions) + except (KeycloakPostError, KeycloakAuthenticationError) as e: + if e.response_code == 403: # pragma: no cover + return AuthStatus( + is_logged_in=True, is_authorized=False, missing_permissions=needed + ) + elif e.response_code == 401: + return AuthStatus( + is_logged_in=False, is_authorized=False, missing_permissions=needed + ) + raise + + for resource_struct in granted: + resource = resource_struct["rsname"] + scopes = resource_struct.get("scopes", None) + if not scopes: + needed.discard(resource) + continue + for scope in scopes: # pragma: no cover + needed.discard("{}#{}".format(resource, scope)) + + return AuthStatus( + is_logged_in=True, is_authorized=len(needed) == 0, missing_permissions=needed + ) + + def register_client(self, token: str, payload: dict): + """Create a client. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param token: Initial access token + :type token: str + :param payload: ClientRepresentation + :type payload: dict + :return: Client Representation + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + self.connection.add_param_headers("Authorization", "Bearer " + token) + self.connection.add_param_headers("Content-Type", "application/json") + data_raw = self.connection.raw_post( + URL_CLIENT_REGISTRATION.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def device(self): + """Get device authorization grant. + + The device endpoint is used to obtain a user code verification and user authentication. + The response contains a device_code, user_code, verification_uri, + verification_uri_complete, expires_in (lifetime in seconds for device_code + and user_code), and polling interval. + Users can either follow the verification_uri and enter the user_code or + follow the verification_uri_complete. + After authenticating with valid credentials, users can obtain tokens using the + "urn:ietf:params:oauth:grant-type:device_code" grant_type and the device_code. + + https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow + https://github.com/keycloak/keycloak-community/blob/main/design/oauth2-device-authorization-grant.md#how-to-try-it + + :returns: Device Authorization Response + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id} + + payload = self._add_secret_key(payload) + data_raw = self.connection.raw_post(URL_DEVICE.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError) + + def update_client(self, token: str, client_id: str, payload: dict): + """Update a client. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param token: registration access token + :type token: str + :param client_id: Keycloak client id + :type client_id: str + :param payload: ClientRepresentation + :type payload: dict + :return: Client Representation + :rtype: dict + """ + params_path = {"realm-name": self.realm_name, "client-id": client_id} + self.connection.add_param_headers("Authorization", "Bearer " + token) + self.connection.add_param_headers("Content-Type", "application/json") + + # Keycloak complains if the clientId is not set in the payload + if "clientId" not in payload: + payload["clientId"] = client_id + + data_raw = self.connection.raw_put( + URL_CLIENT_UPDATE.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError) diff --git a/src/keycloak/asynchronous/keycloak_uma.py b/src/keycloak/asynchronous/keycloak_uma.py new file mode 100644 index 0000000..2f3deb3 --- /dev/null +++ b/src/keycloak/asynchronous/keycloak_uma.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak UMA module. + +The module contains a UMA compatible client for keycloak: +https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-federated-authz-2.0.html +""" +import json +from typing import Iterable +from urllib.parse import quote_plus + +from .connection import ConnectionManager +from .exceptions import ( + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, + raise_error_from_response, +) +from .openid_connection import KeycloakOpenIDConnection +from .uma_permissions import UMAPermission +from .urls_patterns import URL_UMA_WELL_KNOWN + + +class KeycloakUMA: + """Keycloak UMA client. + + :param connection: OpenID connection manager + """ + + def __init__(self, connection: KeycloakOpenIDConnection): + """Init method. + + :param connection: OpenID connection manager + :type connection: KeycloakOpenIDConnection + """ + self.connection = connection + custom_headers = self.connection.custom_headers or {} + custom_headers.update({"Content-Type": "application/json"}) + self.connection.custom_headers = custom_headers + self._well_known = None + + def _fetch_well_known(self): + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + @staticmethod + def format_url(url, **kwargs): + """Substitute url path parameters. + + Given a parameterized url string, returns the string after url encoding and substituting + the given params. For example, + `format_url("https://myserver/{my_resource}/{id}", my_resource="hello world", id="myid")` + would produce `https://myserver/hello+world/myid`. + + :param url: url string to format + :type url: str + :param kwargs: dict containing kwargs to substitute + :type kwargs: dict + :return: formatted string + :rtype: str + """ + return url.format(**{k: quote_plus(v) for k, v in kwargs.items()}) + + @property + def uma_well_known(self): + """Get the well_known UMA2 config. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + # per instance cache + if not self._well_known: + self._well_known = self._fetch_well_known() + return self._well_known + + def resource_set_create(self, payload): + """Create a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#rfc.section.2.2.1 + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param payload: ResourceRepresentation + :type payload: dict + :return: ResourceRepresentation with the _id property assigned + :rtype: dict + """ + data_raw = self.connection.raw_post( + self.uma_well_known["resource_registration_endpoint"], data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + def resource_set_update(self, resource_id, payload): + """Update a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#update-resource-set + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param resource_id: id of the resource + :type resource_id: str + :param payload: ResourceRepresentation + :type payload: dict + :return: Response dict (empty) + :rtype: dict + """ + url = self.format_url( + self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id + ) + data_raw = self.connection.raw_put(url, data=json.dumps(payload)) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + def resource_set_read(self, resource_id): + """Read a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#read-resource-set + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param resource_id: id of the resource + :type resource_id: str + :return: ResourceRepresentation + :rtype: dict + """ + url = self.format_url( + self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id + ) + data_raw = self.connection.raw_get(url) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def resource_set_delete(self, resource_id): + """Delete a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#delete-resource-set + + :param resource_id: id of the resource + :type resource_id: str + :return: Response dict (empty) + :rtype: dict + """ + url = self.format_url( + self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id + ) + data_raw = self.connection.raw_delete(url) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def resource_set_list_ids( + self, + name: str = "", + exact_name: bool = False, + uri: str = "", + owner: str = "", + resource_type: str = "", + scope: str = "", + first: int = 0, + maximum: int = -1, + ): + """Query for list of resource set ids. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets + + :param name: query resource name + :type name: str + :param exact_name: query exact match for resource name + :type exact_name: bool + :param uri: query resource uri + :type uri: str + :param owner: query resource owner + :type owner: str + :param resource_type: query resource type + :type resource_type: str + :param scope: query resource scope + :type scope: str + :param first: index of first matching resource to return + :type first: int + :param maximum: maximum number of resources to return (-1 for all) + :type maximum: int + :return: List of ids + :rtype: List[str] + """ + query = dict() + if name: + query["name"] = name + if exact_name: + query["exactName"] = "true" + if uri: + query["uri"] = uri + if owner: + query["owner"] = owner + if resource_type: + query["type"] = resource_type + if scope: + query["scope"] = scope + if first > 0: + query["first"] = first + if maximum >= 0: + query["max"] = maximum + + data_raw = self.connection.raw_get( + self.uma_well_known["resource_registration_endpoint"], **query + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + def resource_set_list(self): + """List all resource sets. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :yields: Iterator over a list of ResourceRepresentations + :rtype: Iterator[dict] + """ + for resource_id in self.resource_set_list_ids(): + resource = self.resource_set_read(resource_id) + yield resource + + def permission_ticket_create(self, permissions: Iterable[UMAPermission]): + """Create a permission ticket. + + :param permissions: Iterable of uma permissions to validate the token against + :type permissions: Iterable[UMAPermission] + :returns: Keycloak decision + :rtype: boolean + :raises KeycloakPostError: In case permission resource not found + """ + resources = dict() + for permission in permissions: + resource_id = getattr(permission, "resource_id", None) + + if resource_id is None: + resource_ids = self.resource_set_list_ids( + exact_name=True, name=permission.resource, first=0, maximum=1 + ) + + if not resource_ids: + raise KeycloakPostError("Invalid resource specified") + + setattr(permission, "resource_id", resource_ids[0]) + + resources.setdefault(resource_id, set()) + if permission.scope: + resources[resource_id].add(permission.scope) + + payload = [ + {"resource_id": resource_id, "resource_scopes": list(scopes)} + for resource_id, scopes in resources.items() + ] + + data_raw = self.connection.raw_post( + self.uma_well_known["permission_endpoint"], data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def permissions_check(self, token, permissions: Iterable[UMAPermission]): + """Check UMA permissions by user token with requested permissions. + + The token endpoint is used to check UMA permissions from Keycloak. It can only be + invoked by confidential clients. + + https://www.keycloak.org/docs/latest/authorization_services/#_service_authorization_api + + :param token: user token + :type token: str + :param permissions: Iterable of uma permissions to validate the token against + :type permissions: Iterable[UMAPermission] + :returns: Keycloak decision + :rtype: boolean + """ + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": ",".join(str(permission) for permission in permissions), + "response_mode": "decision", + "audience": self.connection.client_id, + } + + # Everyone always has the null set of permissions + # However keycloak cannot evaluate the null set + if len(payload["permission"]) == 0: + return True + + connection = ConnectionManager(self.connection.base_url) + connection.add_param_headers("Authorization", "Bearer " + token) + connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = connection.raw_post(self.uma_well_known["token_endpoint"], data=payload) + try: + data = raise_error_from_response(data_raw, KeycloakPostError) + except KeycloakPostError: + return False + return data.get("result", False) + + def policy_resource_create(self, resource_id, payload): + """Create permission policy for resource. + + Supports name, description, scopes, roles, groups, clients + + https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource + + :param resource_id: _id of resource + :type resource_id: str + :param payload: permission configuration + :type payload: dict + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_post( + self.uma_well_known["policy_endpoint"] + f"/{resource_id}", data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + def policy_update(self, policy_id, payload): + """Update permission policy. + + https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + + :param policy_id: id of policy permission + :type policy_id: str + :param payload: policy permission configuration + :type payload: dict + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_put( + self.uma_well_known["policy_endpoint"] + f"/{policy_id}", data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + def policy_delete(self, policy_id): + """Delete permission policy. + + https://www.keycloak.org/docs/latest/authorization_services/#removing-a-permission + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + + :param policy_id: id of permission policy + :type policy_id: str + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_delete( + self.uma_well_known["policy_endpoint"] + f"/{policy_id}" + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + def policy_query( + self, + resource: str = "", + name: str = "", + scope: str = "", + first: int = 0, + maximum: int = -1, + ): + """Query permission policies. + + https://www.keycloak.org/docs/latest/authorization_services/#querying-permission + + :param resource: query resource id + :type resource: str + :param name: query resource name + :type name: str + :param scope: query resource scope + :type scope: str + :param first: index of first matching resource to return + :type first: int + :param maximum: maximum number of resources to return (-1 for all) + :type maximum: int + :return: List of ids + :return: List of ids + :rtype: List[str] + """ + query = dict() + if name: + query["name"] = name + if resource: + query["resource"] = resource + if scope: + query["scope"] = scope + if first > 0: + query["first"] = first + if maximum >= 0: + query["max"] = maximum + + data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query) + return raise_error_from_response(data_raw, KeycloakGetError) diff --git a/src/keycloak/asynchronous/openid_connection.py b/src/keycloak/asynchronous/openid_connection.py new file mode 100644 index 0000000..6913ed8 --- /dev/null +++ b/src/keycloak/asynchronous/openid_connection.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak OpenID Connection Manager module. + +The module contains mainly the implementation of KeycloakOpenIDConnection class. +This is an extension of the ConnectionManager class, and handles the automatic refresh +of openid tokens when required. +""" + +from datetime import datetime, timedelta + +from .connection import ConnectionManager +from keycloak import KeycloakPostError +from .keycloak_openid import KeycloakOpenID + + +class KeycloakOpenIDConnection(ConnectionManager): + """A class to help with OpenID connections which can auto refresh tokens. + + :param object: _description_ + :type object: _type_ + """ + + _server_url = None + _username = None + _password = None + _totp = None + _realm_name = None + _client_id = None + _verify = None + _client_secret_key = None + _connection = None + _custom_headers = None + _user_realm_name = None + _expires_at = None + _keycloak_openid = None + + def __init__( + self, + server_url, + username=None, + password=None, + token=None, + totp=None, + realm_name="master", + client_id="admin-cli", + verify=True, + client_secret_key=None, + custom_headers=None, + user_realm_name=None, + timeout=60, + ): + """Init method. + + :param server_url: Keycloak server url + :type server_url: str + :param username: admin username + :type username: str + :param password: admin password + :type password: str + :param token: access and refresh tokens + :type token: dict + :param totp: Time based OTP + :type totp: str + :param realm_name: realm name + :type realm_name: str + :param client_id: client id + :type client_id: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param client_secret_key: client secret key + (optional, required only for access type confidential) + :type client_secret_key: str + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param user_realm_name: The realm name of the user, if different from realm_name + :type user_realm_name: str + :param timeout: connection timeout in seconds + :type timeout: int + """ + # token is renewed when it hits 90% of its lifetime. This is to account for any possible + # clock skew. + self.token_lifetime_fraction = 0.9 + self.server_url = server_url + self.username = username + self.password = password + self.token = token + self.totp = totp + self.realm_name = realm_name + self.client_id = client_id + self.verify = verify + self.client_secret_key = client_secret_key + self.user_realm_name = user_realm_name + self.timeout = timeout + self.headers = {} + self.custom_headers = custom_headers + + super().__init__( + base_url=self.server_url, headers=self.headers, timeout=60, verify=self.verify + ) + if self.token is None: + self.get_token() + + if self.token is not None: + self.headers = { + **self.headers, + "Authorization": "Bearer " + self.token.get("access_token"), + "Content-Type": "application/json", + } + + + @property + def server_url(self): + """Get server url. + + :returns: Keycloak server url + :rtype: str + """ + return self.base_url + + @server_url.setter + def server_url(self, value): + self.base_url = value + + @property + def realm_name(self): + """Get realm name. + + :returns: Realm name + :rtype: str + """ + return self._realm_name + + @realm_name.setter + def realm_name(self, value): + self._realm_name = value + + @property + def client_id(self): + """Get client id. + + :returns: Client id + :rtype: str + """ + return self._client_id + + @client_id.setter + def client_id(self, value): + self._client_id = value + + @property + def client_secret_key(self): + """Get client secret key. + + :returns: Client secret key + :rtype: str + """ + return self._client_secret_key + + @client_secret_key.setter + def client_secret_key(self, value): + self._client_secret_key = value + + @property + def username(self): + """Get username. + + :returns: Admin username + :rtype: str + """ + return self._username + + @username.setter + def username(self, value): + self._username = value + + @property + def password(self): + """Get password. + + :returns: Admin password + :rtype: str + """ + return self._password + + @password.setter + def password(self, value): + self._password = value + + @property + def totp(self): + """Get totp. + + :returns: TOTP + :rtype: str + """ + return self._totp + + @totp.setter + def totp(self, value): + self._totp = value + + @property + def token(self): + """Get token. + + :returns: Access and refresh token + :rtype: dict + """ + return self._token + + @token.setter + def token(self, value): + self._token = value + self._expires_at = datetime.now() + timedelta( + seconds=int(self.token_lifetime_fraction * self.token["expires_in"] if value else 0) + ) + + @property + def expires_at(self): + """Get token expiry time. + + :returns: Datetime at which the current token will expire + :rtype: datetime + """ + return self._expires_at + + @property + def user_realm_name(self): + """Get user realm name. + + :returns: User realm name + :rtype: str + """ + return self._user_realm_name + + @user_realm_name.setter + def user_realm_name(self, value): + self._user_realm_name = value + + @property + def custom_headers(self): + """Get custom headers. + + :returns: Custom headers + :rtype: dict + """ + return self._custom_headers + + @custom_headers.setter + def custom_headers(self, value): + self._custom_headers = value + if self.custom_headers is not None: + # merge custom headers to main headers + self.headers.update(self.custom_headers) + + @property + def keycloak_openid(self) -> KeycloakOpenID: + """Get the KeycloakOpenID object. + + The KeycloakOpenID is used to refresh tokens + + :returns: KeycloakOpenID + :rtype: KeycloakOpenID + """ + if self._keycloak_openid is None: + if self.user_realm_name: + token_realm_name = self.user_realm_name + elif self.realm_name: + token_realm_name = self.realm_name + else: + token_realm_name = "master" + + self._keycloak_openid = KeycloakOpenID( + server_url=self.server_url, + client_id=self.client_id, + realm_name=token_realm_name, + verify=self.verify, + client_secret_key=self.client_secret_key, + timeout=self.timeout, + custom_headers=self.custom_headers, + ) + + return self._keycloak_openid + + def get_token(self): + """Get admin token. + + The admin token is then set in the `token` attribute. + """ + grant_type = [] + if self.username and self.password: + grant_type.append("password") + elif self.client_secret_key: + grant_type.append("client_credentials") + + if grant_type: + self.token = self.keycloak_openid.token( + self.username, self.password, grant_type=grant_type, totp=self.totp + ) + else: + self.token = {} + + def refresh_token(self): + """Refresh the token. + + :raises KeycloakPostError: In case the refresh token request failed. + """ + refresh_token = self.token.get("refresh_token", None) if self.token else None + if refresh_token is None: + self.get_token() + else: + try: + self.token = self.keycloak_openid.refresh_token(refresh_token) + except KeycloakPostError as e: + list_errors = [ + b"Refresh token expired", + b"Token is not active", + b"Session not active", + ] + if e.response_code == 400 and any(err in e.response_body for err in list_errors): + self.get_token() + else: + raise + + self.add_param_headers("Authorization", "Bearer " + self.token.get("access_token")) + + def _refresh_if_required(self): + if datetime.now() >= self.expires_at: + self.refresh_token() + + def raw_get(self, *args, **kwargs): + """Call connection.raw_get. + + If auto_refresh is set for *get* and *access_token* is expired, it will refresh the token + and try *get* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_get(*args, **kwargs) + return r + + def raw_post(self, *args, **kwargs): + """Call connection.raw_post. + + If auto_refresh is set for *post* and *access_token* is expired, it will refresh the token + and try *post* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_post(*args, **kwargs) + return r + + def raw_put(self, *args, **kwargs): + """Call connection.raw_put. + + If auto_refresh is set for *put* and *access_token* is expired, it will refresh the token + and try *put* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_put(*args, **kwargs) + return r + + def raw_delete(self, *args, **kwargs): + """Call connection.raw_delete. + + If auto_refresh is set for *delete* and *access_token* is expired, + it will refresh the token and try *delete* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_delete(*args, **kwargs) + return r diff --git a/src/keycloak/openid_connection.py b/src/keycloak/openid_connection.py index 583afcd..99b31af 100644 --- a/src/keycloak/openid_connection.py +++ b/src/keycloak/openid_connection.py @@ -117,6 +117,7 @@ class KeycloakOpenIDConnection(ConnectionManager): self.headers = {} self.custom_headers = custom_headers + if self.token is None: self.get_token()