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/docs/source/getting_started.rst b/docs/source/getting_started.rst index 5285781..a549c47 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -13,3 +13,4 @@ For more details, see :ref:`api`. modules/openid_client modules/admin modules/uma + modules/async diff --git a/docs/source/modules/async.rst b/docs/source/modules/async.rst new file mode 100644 index 0000000..a287144 --- /dev/null +++ b/docs/source/modules/async.rst @@ -0,0 +1,408 @@ +.. admin: + +Use Python Keycloak Asynchronously +================================== + +Asynchronous admin client +------------------------- + +Configure admin client +------------------------ + +.. code-block:: python + + + admin = KeycloakAdmin( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master") + + +Configure admin client with connection +----------------------------------------- + +.. code-block:: python + + from keycloak import KeycloakAdmin + from keycloak import KeycloakOpenIDConnection + + keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master", + client_id="my_client", + client_secret_key="client-secret", + verify=True) + + keycloak_admin = KeycloakAdmin(connection=keycloak_connection) + + +Create user asynchronously +---------------------------- + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}) + + +Add user asynchronously and raise exception if username already exists +----------------------------------------------------------------------- + +The exist_ok currently defaults to True for backwards compatibility reasons. + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}, + exist_ok=False) + +Add user asynchronously and set password +---------------------------------------- + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "credentials": [{"value": "secret","type": "password",}]}) + + +Add user asynchronous and specify a locale +------------------------------------------- + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.fr", + "username": "example@example.fr", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "attributes": { + "locale": ["fr"] + }}) + +Asynchronous User counter +------------------------------ + +.. code-block:: python + + count_users = await keycloak_admin.a_users_count() + +Get users Returns a list of users asynchronously, filtered according to query parameters +----------------------------------------------------------------------------------------- + +.. code-block:: python + + users = await keycloak_admin.a_get_users({}) + +Get user ID asynchronously from username +----------------------------------------- + +.. code-block:: python + + user_id_keycloak = await keycloak_admin.a_get_user_id("username-keycloak") + + +Get user asynchronously +------------------------------ + +.. code-block:: python + + user = await keycloak_admin.a_get_user("user-id-keycloak") + +Update user asynchronously +------------------------------ + +.. code-block:: python + + response = await keycloak_admin.a_update_user(user_id="user-id-keycloak", + payload={'firstName': 'Example Update'}) + + +Update user password asynchronously +------------------------------------ + +.. code-block:: python + + response = await keycloak_admin.a_set_user_password(user_id="user-id-keycloak", password="secret", temporary=True) + + +Get user credentials asynchronously +------------------------------------ + +.. code-block:: python + + credentials = await keycloak_admin.a_get_credentials(user_id='user_id') + +Get user credential asynchronously by ID +----------------------------------------- + +.. code-block:: python + + credential = await keycloak_admin.a_get_credential(user_id='user_id', credential_id='credential_id') + +Delete user credential asynchronously +--------------------------------------- + +.. code-block:: python + + response = await keycloak_admin.a_delete_credential(user_id='user_id', credential_id='credential_id') + +Delete User asynchronously +------------------------------ + +.. code-block:: python + + response = await keycloak_admin.a_delete_user(user_id="user-id-keycloak") + +Get consents granted asynchronously by the user +------------------------------------------------ + +.. code-block:: python + + consents = await keycloak_admin.a_consents_user(user_id="user-id-keycloak") + +Send user action asynchronously +--------------------------------- + +.. code-block:: python + + response = await keycloak_admin.a_send_update_account(user_id="user-id-keycloak", + payload=['UPDATE_PASSWORD']) + +Send verify email asynchronously +---------------------------------- + +.. code-block:: python + + response = await keycloak_admin.a_send_verify_email(user_id="user-id-keycloak") + +Get sessions associated asynchronously with the user +----------------------------------------------------- + +.. code-block:: python + + sessions = await keycloak_admin.a_get_sessions(user_id="user-id-keycloak") + + + + +Asynchronous OpenID Client +=========================== + +Asynchronous Configure client OpenID +------------------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenID + + # Configure client + # For versions older than 18 /auth/ must be added at the end of the server_url. + keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/", + client_id="example_client", + realm_name="example_realm", + client_secret_key="secret") + + +Get .well_know asynchronously +------------------------------ + +.. code-block:: python + + config_well_known = await keycloak_openid.a_well_known() + + +Get code asynchronously with OAuth authorization request +--------------------------------------------------------- + +.. code-block:: python + + auth_url = await keycloak_openid.a_auth_url( + redirect_uri="your_call_back_url", + scope="email", + state="your_state_info") + + +Get access token asynchronously with code +---------------------------------------------- + +.. code-block:: python + + access_token = await keycloak_openid.a_token( + grant_type='authorization_code', + code='the_code_you_get_from_auth_url_callback', + redirect_uri="your_call_back_url") + + +Get access asynchronously token with user and password +------------------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + token = await keycloak_openid.a_token("user", "password", totp="012345") + + +Get token asynchronously using Token Exchange +---------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_exchange_token(token['access_token'], + "my_client", "other_client", "some_user") + + +Refresh token asynchronously +---------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_refresh_token(token['refresh_token']) + +Get UserInfo asynchronously +---------------------------------------------- + +.. code-block:: python + + userinfo = await keycloak_openid.a_userinfo(token['access_token']) + +Logout asynchronously +---------------------------------------------- + +.. code-block:: python + + await keycloak_openid.a_logout(token['refresh_token']) + +Get certs asynchronously +---------------------------------------------- + +.. code-block:: python + + certs = await keycloak_openid.a_certs() + +Introspect RPT asynchronously +---------------------------------------------- + +.. code-block:: python + + token_rpt_info = await keycloak_openid.a_introspect(await keycloak_openid.a_introspect(token['access_token'], + rpt=rpt['rpt'], + token_type_hint="requesting_party_token")) + +Introspect token asynchronously +---------------------------------------------- + +.. code-block:: python + + token_info = await keycloak_openid.a_introspect(token['access_token']) + + +Decode token asynchronously +---------------------------------------------- + +.. code-block:: python + + token_info = await keycloak_openid.a_decode_token(token['access_token']) + # Without validation + token_info = await keycloak_openid.a_decode_token(token['access_token'], validate=False) + + +Get UMA-permissions asynchronously by token +---------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + permissions = await keycloak_openid.a_uma_permissions(token['access_token']) + +Get UMA-permissions asynchronously by token with specific resource and scope requested +--------------------------------------------------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + permissions = await keycloak_openid.a_uma_permissions(token['access_token'], permissions="Resource#Scope") + +Get auth status asynchronously for a specific resource and scope by token +-------------------------------------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + auth_status = await keycloak_openid.a_has_uma_access(token['access_token'], "Resource#Scope") + + + + +Asynchronous UMA +======================== + + +Asynchronous Configure client UMA +---------------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenIDConnection + from keycloak import KeycloakUMA + + keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + realm_name="master", + client_id="my_client", + client_secret_key="client-secret") + + keycloak_uma = KeycloakUMA(connection=keycloak_connection) + + +Create a resource set asynchronously +--------------------------------------- + +.. code-block:: python + + resource_set = await keycloak_uma.a_resource_set_create({ + "name": "example_resource", + "scopes": ["example:read", "example:write"], + "type": "urn:example"}) + +List resource sets asynchronously +---------------------------------- + +.. code-block:: python + + resource_sets = await uma.a_resource_set_list() + +Get resource set asynchronously +-------------------------------- + +.. code-block:: python + + latest_resource = await uma.a_resource_set_read(resource_set["_id"]) + +Update resource set asynchronously +------------------------------------- + +.. code-block:: python + + latest_resource["name"] = "New Resource Name" + await uma.a_resource_set_update(resource_set["_id"], latest_resource) + +Delete resource set asynchronously +------------------------------------ +.. code-block:: python + + await uma.a_resource_set_delete(resource_id=resource_set["_id"]) diff --git a/poetry.lock b/poetry.lock index 5f6b1dc..9331ca5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,28 @@ files = [ {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, ] +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + [[package]] name = "argcomplete" version = "3.3.0" @@ -39,6 +61,17 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} +[[package]] +name = "async-property" +version = "0.2.2" +description = "Python decorator for async properties." +optional = false +python-versions = "*" +files = [ + {file = "async_property-0.2.2-py2.py3-none-any.whl", hash = "sha256:8924d792b5843994537f8ed411165700b27b2bd966cefc4daeefc1253442a9d7"}, + {file = "async_property-0.2.2.tar.gz", hash = "sha256:17d9bd6ca67e27915a75d92549df64b5c7174e9dc806b30a3934dc4ff0506380"}, +] + [[package]] name = "babel" version = "2.15.0" @@ -58,18 +91,18 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "backports-tarfile" -version = "1.1.1" +version = "1.2.0" description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" files = [ - {file = "backports.tarfile-1.1.1-py3-none-any.whl", hash = "sha256:73e0179647803d3726d82e76089d01d8549ceca9bace469953fcb4d97cf2d417"}, - {file = "backports_tarfile-1.1.1.tar.gz", hash = "sha256:9c2ef9696cb73374f7164e17fc761389393ca76777036f5aad42e8b93fcd8009"}, + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["jaraco.test", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] [[package]] name = "black" @@ -340,13 +373,13 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "codespell" -version = "2.2.6" +version = "2.3.0" description = "Codespell" optional = false python-versions = ">=3.8" files = [ - {file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"}, - {file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"}, + {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"}, + {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"}, ] [package.extras] @@ -406,63 +439,63 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] [[package]] name = "coverage" -version = "7.5.1" +version = "7.5.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0884920835a033b78d1c73b6d3bbcda8161a900f38a488829a83982925f6c2e"}, - {file = "coverage-7.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:39afcd3d4339329c5f58de48a52f6e4e50f6578dd6099961cf22228feb25f38f"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b0ceee8147444347da6a66be737c9d78f3353b0681715b668b72e79203e4a"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9ca3f2fae0088c3c71d743d85404cec8df9be818a005ea065495bedc33da35"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd215c0c7d7aab005221608a3c2b46f58c0285a819565887ee0b718c052aa4e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4bf0655ab60d754491004a5efd7f9cccefcc1081a74c9ef2da4735d6ee4a6223"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61c4bf1ba021817de12b813338c9be9f0ad5b1e781b9b340a6d29fc13e7c1b5e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db66fc317a046556a96b453a58eced5024af4582a8dbdc0c23ca4dbc0d5b3146"}, - {file = "coverage-7.5.1-cp310-cp310-win32.whl", hash = "sha256:b016ea6b959d3b9556cb401c55a37547135a587db0115635a443b2ce8f1c7228"}, - {file = "coverage-7.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:df4e745a81c110e7446b1cc8131bf986157770fa405fe90e15e850aaf7619bc8"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:796a79f63eca8814ca3317a1ea443645c9ff0d18b188de470ed7ccd45ae79428"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fc84a37bfd98db31beae3c2748811a3fa72bf2007ff7902f68746d9757f3746"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6175d1a0559986c6ee3f7fccfc4a90ecd12ba0a383dcc2da30c2b9918d67d8a3"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fc81d5878cd6274ce971e0a3a18a8803c3fe25457165314271cf78e3aae3aa2"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:556cf1a7cbc8028cb60e1ff0be806be2eded2daf8129b8811c63e2b9a6c43bca"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9981706d300c18d8b220995ad22627647be11a4276721c10911e0e9fa44c83e8"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7fed867ee50edf1a0b4a11e8e5d0895150e572af1cd6d315d557758bfa9c057"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef48e2707fb320c8f139424a596f5b69955a85b178f15af261bab871873bb987"}, - {file = "coverage-7.5.1-cp311-cp311-win32.whl", hash = "sha256:9314d5678dcc665330df5b69c1e726a0e49b27df0461c08ca12674bcc19ef136"}, - {file = "coverage-7.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fa567e99765fe98f4e7d7394ce623e794d7cabb170f2ca2ac5a4174437e90dd"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cf3764c030e5338e7f61f95bd21147963cf6aa16e09d2f74f1fa52013c1206"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ec92012fefebee89a6b9c79bc39051a6cb3891d562b9270ab10ecfdadbc0c34"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16db7f26000a07efcf6aea00316f6ac57e7d9a96501e990a36f40c965ec7a95d"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beccf7b8a10b09c4ae543582c1319c6df47d78fd732f854ac68d518ee1fb97fa"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8748731ad392d736cc9ccac03c9845b13bb07d020a33423fa5b3a36521ac6e4e"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7352b9161b33fd0b643ccd1f21f3a3908daaddf414f1c6cb9d3a2fd618bf2572"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7a588d39e0925f6a2bff87154752481273cdb1736270642aeb3635cb9b4cad07"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:68f962d9b72ce69ea8621f57551b2fa9c70509af757ee3b8105d4f51b92b41a7"}, - {file = "coverage-7.5.1-cp312-cp312-win32.whl", hash = "sha256:f152cbf5b88aaeb836127d920dd0f5e7edff5a66f10c079157306c4343d86c19"}, - {file = "coverage-7.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:5a5740d1fb60ddf268a3811bcd353de34eb56dc24e8f52a7f05ee513b2d4f596"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2213def81a50519d7cc56ed643c9e93e0247f5bbe0d1247d15fa520814a7cd7"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5037f8fcc2a95b1f0e80585bd9d1ec31068a9bcb157d9750a172836e98bc7a90"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3721c2c9e4c4953a41a26c14f4cef64330392a6d2d675c8b1db3b645e31f0e"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca498687ca46a62ae590253fba634a1fe9836bc56f626852fb2720f334c9e4e5"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cdcbc320b14c3e5877ee79e649677cb7d89ef588852e9583e6b24c2e5072661"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:57e0204b5b745594e5bc14b9b50006da722827f0b8c776949f1135677e88d0b8"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fe7502616b67b234482c3ce276ff26f39ffe88adca2acf0261df4b8454668b4"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9e78295f4144f9dacfed4f92935fbe1780021247c2fabf73a819b17f0ccfff8d"}, - {file = "coverage-7.5.1-cp38-cp38-win32.whl", hash = "sha256:1434e088b41594baa71188a17533083eabf5609e8e72f16ce8c186001e6b8c41"}, - {file = "coverage-7.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:0646599e9b139988b63704d704af8e8df7fa4cbc4a1f33df69d97f36cb0a38de"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4cc37def103a2725bc672f84bd939a6fe4522310503207aae4d56351644682f1"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0b4d8bfeabd25ea75e94632f5b6e047eef8adaed0c2161ada1e922e7f7cece"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d0a0f5e06881ecedfe6f3dd2f56dcb057b6dbeb3327fd32d4b12854df36bf26"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9735317685ba6ec7e3754798c8871c2f49aa5e687cc794a0b1d284b2389d1bd5"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d21918e9ef11edf36764b93101e2ae8cc82aa5efdc7c5a4e9c6c35a48496d601"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c3e757949f268364b96ca894b4c342b41dc6f8f8b66c37878aacef5930db61be"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79afb6197e2f7f60c4824dd4b2d4c2ec5801ceb6ba9ce5d2c3080e5660d51a4f"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d0d98d95dd18fe29dc66808e1accf59f037d5716f86a501fc0256455219668"}, - {file = "coverage-7.5.1-cp39-cp39-win32.whl", hash = "sha256:1cc0fe9b0b3a8364093c53b0b4c0c2dd4bb23acbec4c9240b5f284095ccf7981"}, - {file = "coverage-7.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:dde0070c40ea8bb3641e811c1cfbf18e265d024deff6de52c5950677a8fb1e0f"}, - {file = "coverage-7.5.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:6537e7c10cc47c595828b8a8be04c72144725c383c4702703ff4e42e44577312"}, - {file = "coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"}, + {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"}, + {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"}, + {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"}, + {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"}, + {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"}, + {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"}, + {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"}, + {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"}, + {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"}, + {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"}, + {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"}, + {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"}, ] [package.dependencies] @@ -577,8 +610,11 @@ name = "docutils" version = "0.20.1" description = "Docutils -- Python Documentation Utilities" optional = false -python-versions = "*" -files = [] +python-versions = ">=3.7" +files = [ + {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, + {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, +] [[package]] name = "exceptiongroup" @@ -655,6 +691,62 @@ files = [ [package.dependencies] python-dateutil = ">=2.7" +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "identify" version = "2.5.36" @@ -1070,18 +1162,15 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.0" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.0-py2.py3-none-any.whl", hash = "sha256:508ecec98f9f3330b636d4448c0f1a56fc68017c68f1e7857ebc52acf0eb879a"}, + {file = "nodeenv-1.9.0.tar.gz", hash = "sha256:07f144e90dae547bf0d4ee8da0ee42664a42a04e02ed68e06324348dafe4bdb1"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "packaging" version = "24.0" @@ -1106,13 +1195,13 @@ files = [ [[package]] name = "pkginfo" -version = "1.10.0" +version = "1.11.0" description = "Query metadata from sdists / bdists / installed packages." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pkginfo-1.10.0-py3-none-any.whl", hash = "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097"}, - {file = "pkginfo-1.10.0.tar.gz", hash = "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297"}, + {file = "pkginfo-1.11.0-py3-none-any.whl", hash = "sha256:6d4998d1cd42c297af72cc0eab5f5bab1d356fb8a55b828fa914173f8bc1ba05"}, + {file = "pkginfo-1.11.0.tar.gz", hash = "sha256:dba885aa82e31e80d615119874384923f4e011c2a39b0c4b7104359e36cb7087"}, ] [package.extras] @@ -1286,6 +1375,24 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -1467,13 +1574,13 @@ sphinx = ">=1.3.1" [[package]] name = "requests" -version = "2.32.2" +version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" files = [ - {file = "requests-2.32.2-py3-none-any.whl", hash = "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"}, - {file = "requests-2.32.2.tar.gz", hash = "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1574,6 +1681,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -1622,13 +1740,13 @@ test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] [[package]] name = "sphinx-autoapi" -version = "3.1.0" +version = "3.1.1" description = "Sphinx API documentation generator" optional = false python-versions = ">=3.8" files = [ - {file = "sphinx_autoapi-3.1.0-py2.py3-none-any.whl", hash = "sha256:b102ded12ff5397ff6f9536065644c0a01a203b1d53dac07419c267fd771367f"}, - {file = "sphinx_autoapi-3.1.0.tar.gz", hash = "sha256:c5455191c36af19e0de73dd52e15feb04a37ca4439fa5e8d77f1941768c15d32"}, + {file = "sphinx_autoapi-3.1.1-py2.py3-none-any.whl", hash = "sha256:ff202754c38e119fe60b9336fb3cdbd0b78f8623753fa9151aed42a7052506b3"}, + {file = "sphinx_autoapi-3.1.1.tar.gz", hash = "sha256:b5f6e3c61cd86c0cdb7ee77a9d580c0fd116726c5b29cdcb1f1d5f30a5bca1bd"}, ] [package.dependencies] @@ -1852,13 +1970,13 @@ urllib3 = ">=1.26.0" [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.0-py3-none-any.whl", hash = "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594"}, + {file = "typing_extensions-4.12.0.tar.gz", hash = "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8"}, ] [[package]] @@ -1925,20 +2043,20 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "zipp" -version = "3.18.2" +version = "3.19.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.2-py3-none-any.whl", hash = "sha256:dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e"}, - {file = "zipp-3.18.2.tar.gz", hash = "sha256:6278d9ddbcfb1f1089a88fde84481528b07b0e10474e09dcfe53dad4069fa059"}, + {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"}, + {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "ec75648f8f24c61cf48f630e25deedf5b2a94b0774b63c2ee1682d6208e1a538" +content-hash = "d595876f098ba3ac3601772c153cc547ec1f7029d52d1f87ebc77968349109ab" diff --git a/pyproject.toml b/pyproject.toml index 942418d..aa17e74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,9 @@ python = ">=3.8,<4.0" requests = ">=2.20.0" requests-toolbelt = ">=0.6.0" deprecation = ">=2.1.0" -jwcrypto = "^1.5.4" +jwcrypto = ">=1.5.4" +httpx = ">=0.23.2" +async-property = ">=0.2.2" [tool.poetry.group.docs.dependencies] alabaster = ">=0.7.0" @@ -44,11 +46,13 @@ sphinx-rtd-theme = ">=1.0.0" readthedocs-sphinx-ext = ">=2.1.9" m2r2 = ">=0.3.2" sphinx-autoapi = ">=3.0.0" +setuptools = ">=70.0.0" [tool.poetry.group.dev.dependencies] tox = ">=4.0.0" pytest = ">=7.1.2" pytest-cov = ">=3.0.0" +pytest-asyncio = ">=0.23.7" wheel = ">=0.38.4" pre-commit = ">=3.5.0" isort = ">=5.10.1" @@ -61,6 +65,11 @@ codespell = ">=2.1.0" darglint = ">=1.8.1" twine = ">=4.0.2" freezegun = ">=1.2.2" +docutils = "<0.21" + +[[tool.poetry.source]] +name = "PyPI" +priority = "primary" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/src/keycloak/connection.py b/src/keycloak/connection.py index 0dfeaff..fb22a71 100644 --- a/src/keycloak/connection.py +++ b/src/keycloak/connection.py @@ -28,6 +28,7 @@ try: except ImportError: # pragma: no cover from urlparse import urljoin +import httpx import requests from requests.adapters import HTTPAdapter @@ -86,6 +87,15 @@ class ConnectionManager(object): if proxies: self._s.proxies.update(proxies) + self.async_s = httpx.AsyncClient(verify=verify, proxies=proxies) + self.async_s.auth = None # don't let requests add auth headers + self.async_s.transport = httpx.AsyncHTTPTransport(retries=1) + + async def aclose(self): + """Close the async connection on delete.""" + if hasattr(self, "_s"): + await self.async_s.aclose() + def __del__(self): """Del method.""" if hasattr(self, "_s"): @@ -271,7 +281,7 @@ class ConnectionManager(object): :raises KeycloakConnectionError: HttpError Can't connect to server. """ try: - return self._s.delete( + r = self._s.delete( urljoin(self.base_url, path), params=kwargs, data=data or dict(), @@ -279,5 +289,101 @@ class ConnectionManager(object): timeout=self.timeout, verify=self.verify, ) + return r + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) + + async def a_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 await self.async_s.get( + urljoin(self.base_url, path), + params=kwargs, + headers=self.headers, + timeout=self.timeout, + ) + except Exception as e: + raise KeycloakConnectionError("Can't connect to server (%s)" % e) + + async def a_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.async_s.request( + method="POST", + url=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) + + async def a_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 await self.async_s.put( + 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) + + async def a_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 await self.async_s.request( + method="DELETE", + url=urljoin(self.base_url, path), + data=data or dict(), + params=kwargs, + headers=self.headers, + timeout=self.timeout, + ) except Exception as e: raise KeycloakConnectionError("Can't connect to server (%s)" % e) diff --git a/src/keycloak/keycloak_admin.py b/src/keycloak/keycloak_admin.py index 575ee6d..3e209a3 100644 --- a/src/keycloak/keycloak_admin.py +++ b/src/keycloak/keycloak_admin.py @@ -4247,3 +4247,4142 @@ class KeycloakAdmin: urls_patterns.URL_ADMIN_CLEAR_USER_CACHE.format(**params_path), data="" ) return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + # async functions start + async def a___fetch_all(self, url, query=None): + """Paginate asynchronously 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( + await self.connection.a_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 + + async def a___fetch_paginated(self, url, query=None): + """Make a specific paginated request asynchronously. + + :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( + await self.connection.a_raw_get(url, **query), KeycloakGetError + ) + + async def a_get_current_realm(self) -> str: + """Return the currently configured realm asynchronously. + + :returns: Currently configured realm name + :rtype: str + """ + return self.connection.realm_name + + async def a_change_current_realm(self, realm_name: str) -> None: + """Change the current realm asynchronously. + + :param realm_name: The name of the realm to be configured as current + :type realm_name: str + """ + self.connection.realm_name = realm_name + + async def a_import_realm(self, payload): + """Import a new realm asynchronously 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 = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALMS, data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + async def a_partial_import_realm(self, realm_name, payload): + """Partial import realm configuration asynchronously 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 = await self.connection.a_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]) + + async def a_export_realm(self, export_clients=False, export_groups_and_role=False): + """Export the realm configurations asynchronously 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 = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_EXPORT.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_get_realms(self): + """List all realms in asynchronouslyKeycloak deployment. + + :return: realms list + :rtype: list + """ + data_raw = await self.connection.a_raw_get(urls_patterns.URL_ADMIN_REALMS) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_realm(self, realm_name): + """Get a specific realm asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_create_realm(self, payload, skip_exists=False): + """Create a realm asynchronously. + + 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 = await self.connection.a_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 + ) + + async def a_update_realm(self, realm_name, payload): + """Update a realm asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_realm(self, realm_name): + """Delete a realm asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_users(self, query=None): + """Get all users asynchronously. + + 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 await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_create_idp(self, payload): + """Create an ID Provider asynchronously. + + 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 = await self.connection.a_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]) + + async def a_update_idp(self, idp_alias, payload): + """Update an ID Provider asynchronously. + + 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 = await self.connection.a_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]) + + async def a_add_mapper_to_idp(self, idp_alias, payload): + """Create an ID Provider asynchronously. + + 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 = await self.connection.a_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]) + + async def a_update_mapper_in_idp(self, idp_alias, mapper_id, payload): + """Update an IdP mapper asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_idp_mappers(self, idp_alias): + """Get IDP mappers asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_idps(self): + """Get IDPs asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_IDPS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_idp(self, idp_alias): + """Get IDP provider asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_IDP.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_idp(self, idp_alias): + """Delete an ID Provider asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_IDP.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_create_user(self, payload, exist_ok=False): + """Create a new user asynchronously. + + 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.a_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 + + async def a_users_count(self, query=None): + """Count users asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USERS_COUNT.format(**params_path), **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_user_id(self, username): + """Get internal keycloak user id from username asynchronously. + + 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 = await self.a_get_users( + query={"username": lower_user_name, "max": 1, "exact": True} + ) + return users[0]["id"] if len(users) == 1 else None + + async def a_get_user(self, user_id): + """Get representation of the user asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_user_groups(self, user_id, query=None, brief_representation=True): + """Get user groups asynchronously. + + 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 await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_update_user(self, user_id, payload): + """Update the user asynchronously. + + :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 = await self.connection.a_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]) + + async def a_disable_user(self, user_id): + """Disable the user asynchronously from the realm. Disabled users can not log in. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: bytes + """ + return await self.a_update_user(user_id=user_id, payload={"enabled": False}) + + async def a_enable_user(self, user_id): + """Enable the user from the realm asynchronously. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: bytes + """ + return await self.a_update_user(user_id=user_id, payload={"enabled": True}) + + async def a_disable_all_users(self): + """Disable all existing users asynchronously.""" + users = await self.a_get_users() + for user in users: + user_id = user["id"] + await self.a_disable_user(user_id=user_id) + + async def a_enable_all_users(self): + """Disable all existing users asynchronously.""" + users = await self.a_get_users() + for user in users: + user_id = user["id"] + await self.a_enable_user(user_id=user_id) + + async def a_delete_user(self, user_id): + """Delete the user asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_set_user_password(self, user_id, password, temporary=True): + """Set up a password for the user asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_credentials(self, user_id): + """Get user credentials asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_CREDENTIALS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_credential(self, user_id, credential_id): + """Delete credential of the user asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_CREDENTIAL.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_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 = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USER_LOGOUT.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + async def a_user_consents(self, user_id): + """Get consents granted asynchronously 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_CONSENTS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_user_social_logins(self, user_id): + """Get user social logins asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITIES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_add_user_social_login( + self, user_id, provider_id, provider_userid, provider_username + ): + """Add a federated identity / social login provider asynchronously 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 = await self.connection.a_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]) + + async def a_delete_user_social_login(self, user_id, provider_id): + """Delete a federated identity / social login provider asynchronously 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_send_update_account( + self, user_id, payload, client_id=None, lifespan=None, redirect_uri=None + ): + """Send an update account email to the user asynchronously. + + 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 = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), + data=json.dumps(payload), + kwargs=params_query, + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + async def a_send_verify_email(self, user_id, client_id=None, redirect_uri=None): + """Send a update account email to the user asynchronously. + + 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 = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), + data={}, + kwargs=params_query, + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + async def a_get_sessions(self, user_id): + """Get sessions associated with the user asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GET_SESSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_server_info(self): + """Get themes, social providers, etc. on this server asynchronously. + + ServerInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :return: ServerInfoRepresentation + :rtype: dict + """ + data_raw = await self.connection.a_raw_get(urls_patterns.URL_ADMIN_SERVER_INFO) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_groups(self, query=None, full_hierarchy=False): + """Get groups asynchronously. + + 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 = await self.a___fetch_paginated(url, query) + else: + groups = await self.a___fetch_all(url, query) + + # For version +23.0.0 + for group in groups: + if group.get("subGroupCount"): + group["subGroups"] = await self.a_get_group_children( + group_id=group.get("id"), full_hierarchy=full_hierarchy + ) + + return groups + + async def a_get_group(self, group_id, full_hierarchy=False): + """Get group by id asynchronously. + + 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 = await self.connection.a_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"] = await self.a_get_group_children( + group.get("id"), full_hierarchy=full_hierarchy + ) + + return group + + async def a_get_subgroups(self, group, path): + """Get subgroups asynchronously. + + 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 = await self.a_get_subgroups(subgroup, path) + if result: + return result + # went through the tree without hits + return None + + async def a_get_group_children(self, group_id, query=None, full_hierarchy=False): + """Get group children by parent id asynchronously. + + 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 await self.a___fetch_paginated(url, query) + res = await self.a___fetch_all(url, query) + + if not full_hierarchy: + return res + + for group in res: + if group.get("subGroupCount"): + group["subGroups"] = await self.a_get_group_children( + group_id=group.get("id"), full_hierarchy=full_hierarchy + ) + + return res + + async def a_get_group_members(self, group_id, query=None): + """Get members by group id asynchronously. + + 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 await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_get_group_by_path(self, path): + """Get group id based on name or path asynchronously . + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUP_BY_PATH.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_group(self, payload, parent=None, skip_exists=False): + """Create a group in the Realm asynchronously. + + 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 = await self.connection.a_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 = await self.connection.a_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 + + async def a_update_group(self, group_id, payload): + """Update group, ignores subgroups asynchronously. + + 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 = await self.connection.a_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]) + + async def a_groups_count(self, query=None): + """Count groups asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_COUNT.format(**params_path), **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_group_set_permissions(self, group_id, enabled=True): + """Enable/Disable permissions for a group asynchronously. + + 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 = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), + data=json.dumps({"enabled": enabled}), + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + async def a_group_user_add(self, user_id, group_id): + """Add user to group (user_id and group_id) asynchronously. + + :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 = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path), data=None + ) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + async def a_group_user_remove(self, user_id, group_id): + """Remove user from group (user_id and group_id) asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_delete_group(self, group_id): + """Delete a group in the Realm asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_GROUP.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_clients(self): + """Get clients asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client(self, client_id): + """Get representation of the client asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_id(self, client_id): + """Get internal keycloak client id from client-id asynchronously. + + 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 = await self.connection.a_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 + + async def a_get_client_authz_settings(self, client_id): + """Get authorization json from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SETTINGS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_client_authz_resource(self, client_id, payload, skip_exists=False): + """Create resources of client asynchronously. + + :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 = await self.connection.a_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 + ) + + async def a_update_client_authz_resource(self, client_id, resource_id, payload): + """Update resource of client asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_client_authz_resource(self, client_id: str, resource_id: str): + """Delete a client resource asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_client_authz_resources(self, client_id): + """Get resources from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_authz_resource(self, client_id: str, resource_id: str): + """Get a client resource asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_create_client_authz_role_based_policy(self, client_id, payload, skip_exists=False): + """Create role-based policy of client asynchronously. + + 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 = await self.connection.a_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 + ) + + async def a_create_client_authz_policy(self, client_id, payload, skip_exists=False): + """Create an authz policy of client asynchronously. + + 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 = await self.connection.a_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 + ) + + async def a_create_client_authz_resource_based_permission( + self, client_id, payload, skip_exists=False + ): + """Create resource-based permission of client asynchronously. + + 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 = await self.connection.a_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 + ) + + async def a_get_client_authz_scopes(self, client_id): + """Get scopes from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_client_authz_scopes(self, client_id, payload): + """Create scopes for client asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_client_authz_permissions(self, client_id): + """Get permissions from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_authz_policies(self, client_id): + """Get policies from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_client_authz_policy(self, client_id, policy_id): + """Delete a policy from client asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_client_authz_policy(self, client_id, policy_id): + """Get a policy from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_service_account_user(self, client_id): + """Get service account user from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_default_client_scopes(self, client_id): + """Get all default client scopes from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_add_client_default_client_scope(self, client_id, client_scope_id, payload): + """Add a client scope to the default client scopes from client asynchronously. + + 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 = await self.connection.a_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) + + async def a_delete_client_default_client_scope(self, client_id, client_scope_id): + """Delete a client scope from the default client scopes of the client asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_get_client_optional_client_scopes(self, client_id): + """Get all optional client scopes from client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_add_client_optional_client_scope(self, client_id, client_scope_id, payload): + """Add a client scope to the optional client scopes from client asynchronously. + + 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 = await self.connection.a_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) + + async def a_delete_client_optional_client_scope(self, client_id, client_scope_id): + """Delete a client scope from the optional client scopes of the client asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_create_initial_access_token(self, count: int = 1, expiration: int = 1): + """Create an initial access token asynchronously. + + :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 = await self.connection.a_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]) + + async def a_create_client(self, payload, skip_exists=False): + """Create a client asynchronously. + + 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 = await self.connection.a_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 + + async def a_update_client(self, client_id, payload): + """Update a client asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_client(self, client_id): + """Get representation of the client asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_client_installation_provider(self, client_id, provider_id): + """Get content for given installation provider asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_INSTALLATION_PROVIDER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_get_realm_roles(self, brief_representation=True, search_text=""): + """Get all roles for the realm or client asynchronously. + + 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 = await self.connection.a_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 = await self.connection.a_raw_get(url.format(**params_path), **params) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_realm_role_groups(self, role_name, query=None, brief_representation=True): + """Get role groups of realm by role name asynchronously. + + :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 await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_get_realm_role_members(self, role_name, query=None): + """Get role members of realm by role name asynchronously. + + :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 await self.a___fetch_all( + urls_patterns.URL_ADMIN_REALM_ROLES_MEMBERS.format(**params_path), query + ) + + async def a_get_default_realm_role_id(self): + """Get the ID of the default realm role asynchronously. + + :return: Realm role ID + :rtype: str + """ + all_realm_roles = await self.a_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"] + + async def a_get_realm_default_roles(self): + """Get all the default realm roles asyncho asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES_REALM.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_remove_realm_default_roles(self, payload): + """Remove a set of default realm roles asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_add_realm_default_roles(self, payload): + """Add a set of default realm roles asynchronously. + + :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 = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_get_client_roles(self, client_id, brief_representation=True): + """Get all roles for the client asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_role(self, client_id, role_name): + """Get client role id by name asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_role_id(self, client_id, role_name): + """Get client role id by name asynchronously. + + 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 = await self.a_get_client_role(client_id, role_name) + return role.get("id") + + async def a_create_client_role(self, client_role_id, payload, skip_exists=False): + """Create a client role asynchronously. + + 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 = await self.a_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 = await self.connection.a_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 + + async def a_add_composite_client_roles_to_role(self, client_role_id, role_name, roles): + """Add composite roles to client role asynchronously. + + :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 = await self.connection.a_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]) + + async def a_update_client_role(self, client_id, role_name, payload): + """Update a client role asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_client_role(self, client_role_id, role_name): + """Delete a client role asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_assign_client_role(self, user_id, client_id, roles): + """Assign a client role to a user asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_client_role_members(self, client_id, role_name, **query): + """Get members by client role asynchronously. + + :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 await self.a___fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_MEMBERS.format(**params_path), query + ) + + async def a_get_client_role_groups(self, client_id, role_name, **query): + """Get group members by client role asynchronously. + + :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 await self.a___fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_GROUPS.format(**params_path), query + ) + + async def a_get_role_by_id(self, role_id): + """Get a specific role’s representation asynchronously. + + 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 = await self.connection.a_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]) + + async def a_update_role_by_id(self, role_id, payload): + """Update the role asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_role_by_id(self, role_id): + """Delete a role by its id asynchronously. + + 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 = await self.connection.a_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]) + + async def a_create_realm_role(self, payload, skip_exists=False): + """Create a new role for the realm or client asynchronously. + + :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 = await self.a_get_realm_role(role_name=payload["name"]) + return role["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_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 + + async def a_get_realm_role(self, role_name): + """Get realm role by role name asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_update_realm_role(self, role_name, payload): + """Update a role for the realm by name asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_realm_role(self, role_name): + """Delete a role for the realm by name asynchronously. + + :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 = await self.connection.a_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]) + + async def a_add_composite_realm_roles_to_role(self, role_name, roles): + """Add composite roles to the role asynchronously. + + :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 = await self.connection.a_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]) + + async def a_remove_composite_realm_roles_to_role(self, role_name, roles): + """Remove composite roles from the role asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_composite_realm_roles_of_role(self, role_name): + """Get composite roles of the role asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_assign_realm_roles_to_client_scope(self, client_id, roles): + """Assign realm roles to a client's scope asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_realm_roles_of_client_scope(self, client_id, roles): + """Delete realm roles of a client's scope asynchronously. + + :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 = await self.connection.a_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]) + + async def a_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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_assign_client_roles_to_client_scope(self, client_id, client_roles_owner_id, roles): + """Assign client roles to a client's scope asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_client_roles_of_client_scope(self, client_id, client_roles_owner_id, roles): + """Delete client roles of a client's scope asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_client_roles_of_client_scope(self, client_id, client_roles_owner_id): + """Get all client roles for a client's scope asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_assign_realm_roles(self, user_id, roles): + """Assign realm roles to a user asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_realm_roles_of_user(self, user_id, roles): + """Delete realm roles of a user asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_realm_roles_of_user(self, user_id): + """Get all realm roles for a user asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_available_realm_roles_of_user(self, user_id): + """Get all available (i.e. unassigned) realm roles for a user asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_AVAILABLE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_composite_realm_roles_of_user(self, user_id, brief_representation=True): + """Get all composite (i.e. implicit) realm roles for a user asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_COMPOSITE.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_assign_group_realm_roles(self, group_id, roles): + """Assign realm roles to a group asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_group_realm_roles(self, group_id, roles): + """Delete realm roles of a group asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_group_realm_roles(self, group_id, brief_representation=True): + """Get all realm roles for a group asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_assign_group_client_roles(self, group_id, client_id, roles): + """Assign client roles to a group asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_group_client_roles(self, group_id, client_id): + """Get client roles of a group asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_group_client_roles(self, group_id, client_id, roles): + """Delete client roles of a group asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_all_roles_of_user(self, user_id): + """Get all level roles for a user asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_ALL_ROLES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_roles_of_user(self, user_id, client_id): + """Get all client roles for a user asynchronously. + + :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 await self.a__get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES, user_id, client_id + ) + + async def a_get_available_client_roles_of_user(self, user_id, client_id): + """Get available client role-mappings for a user asynchronously. + + :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 await self.a__get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, user_id, client_id + ) + + async def a_get_composite_client_roles_of_user( + self, user_id, client_id, brief_representation=False + ): + """Get composite client role-mappings for a user asynchronously. + + :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 await self.a__get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, user_id, client_id, **params + ) + + async def a__get_client_roles_of_user( + self, client_level_role_mapping_url, user_id, client_id, **params + ): + """Get client roles of a single user helper asynchronously. + + :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 = await self.connection.a_raw_get( + client_level_role_mapping_url.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_client_roles_of_user(self, user_id, client_id, roles): + """Delete client roles from a user asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_authentication_flows(self): + """Get authentication flows asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_authentication_flow_for_id(self, flow_id): + """Get one authentication flow by it's id asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS_ALIAS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_authentication_flow(self, payload, skip_exists=False): + """Create a new authentication flow asynchronously. + + 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 = await self.connection.a_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 + ) + + async def a_copy_authentication_flow(self, payload, flow_alias): + """Copy existing authentication flow under a new name asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_authentication_flow(self, flow_id): + """Delete authentication flow asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_FLOW.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_authentication_flow_executions(self, flow_alias): + """Get authentication flow executions asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_update_authentication_flow_executions(self, payload, flow_alias): + """Update an authentication flow execution asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_authentication_flow_execution(self, execution_id): + """Get authentication flow execution asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_authentication_flow_execution(self, payload, flow_alias): + """Create an authentication flow execution asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_authentication_flow_execution(self, execution_id): + """Delete authentication flow execution asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_create_authentication_flow_subflow(self, payload, flow_alias, skip_exists=False): + """Create a new sub authentication flow for a given authentication flow asynchronously. + + 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 = await self.connection.a_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 + ) + + async def a_get_authenticator_providers(self): + """Get authenticator providers list asynchronously. + + :return: Authenticator providers + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_PROVIDERS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_authenticator_provider_config_description(self, provider_id): + """Get authenticator's provider configuration description asynchronously. + + 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) + + async def a_get_authenticator_config(self, config_id): + """Get authenticator configuration asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_update_authenticator_config(self, payload, config_id): + """Update an authenticator configuration asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_authenticator_config(self, config_id): + """Delete a authenticator configuration asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_sync_users(self, storage_id, action): + """Trigger user sync from provider asynchronously. + + :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 = await self.connection.a_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) + + async def a_get_client_scopes(self): + """Get client scopes asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_scope(self, client_scope_id): + """Get client scope asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_scope_by_name(self, client_scope_name): + """Get client scope by name asynchronously. + + 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 = await self.a_get_client_scopes() + for client_scope in client_scopes: + if client_scope["name"] == client_scope_name: + return client_scope + + return None + + async def a_create_client_scope(self, payload, skip_exists=False): + """Create a client scope asynchronously. + + 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 = await self.connection.a_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 + + async def a_update_client_scope(self, client_scope_id, payload): + """Update a client scope asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_client_scope(self, client_scope_id): + """Delete existing client scope asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_mappers_from_client_scope(self, client_scope_id): + """Get a list of all mappers connected to the client scope asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_add_mapper_to_client_scope(self, client_scope_id, payload): + """Add a mapper to a client scope asynchronously. + + 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 = await self.connection.a_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]) + + async def a_delete_mapper_from_client_scope(self, client_scope_id, protocol_mapper_id): + """Delete a mapper from a client scope asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_update_mapper_in_client_scope(self, client_scope_id, protocol_mapper_id, payload): + """Update an existing protocol mapper in a client scope asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_default_default_client_scopes(self): + """Get default default client scopes asynchronously. + + Return list of default default client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_default_default_client_scope(self, scope_id): + """Delete default default client scope asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_add_default_default_client_scope(self, scope_id): + """Add default default client scope asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_default_optional_client_scopes(self): + """Get default optional client scopes asynchronously. + + Return list of default optional client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_delete_default_optional_client_scope(self, scope_id): + """Delete default optional client scope asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_add_default_optional_client_scope(self, scope_id): + """Add default optional client scope asynchronously. + + :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 = await self.connection.a_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]) + + async def a_get_mappers_from_client(self, client_id): + """List of all client mappers asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path) + ) + + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[200]) + + async def a_add_mapper_to_client(self, client_id, payload): + """Add a mapper to a client asynchronously. + + 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 = await self.connection.a_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]) + + async def a_update_client_mapper(self, client_id, mapper_id, payload): + """Update client mapper asynchronously. + + :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 = await self.connection.a_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]) + + async def a_remove_client_mapper(self, client_id, client_mapper_id): + """Remove a mapper from the client asynchronously. + + 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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_generate_client_secrets(self, client_id): + """Generate a new secret for the client asynchronously. + + 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 = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path), data=None + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_get_client_secrets(self, client_id): + """Get representation of the client secrets asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_components(self, query=None): + """Get components asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), data=None, **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_component(self, payload): + """Create a new component asynchronously. + + 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 = await self.connection.a_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 + + async def a_get_component(self, component_id): + """Get representation of the component asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_update_component(self, component_id, payload): + """Update the component asynchronously. + + :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 = await self.connection.a_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]) + + async def a_delete_component(self, component_id): + """Delete the component asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_keys(self): + """Get keys asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_KEYS.format(**params_path), data=None + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_admin_events(self, query=None): + """Get Administrative events asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ADMIN_EVENTS.format(**params_path), data=None, **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_events(self, query=None): + """Get events asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_EVENTS.format(**params_path), data=None, **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_set_events(self, payload): + """Set realm events configuration asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_client_all_sessions(self, client_id): + """Get sessions associated with the client asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ALL_SESSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_sessions_stats(self): + """Get current session count for all clients with active sessions asynchronously. + + 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SESSION_STATS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_management_permissions(self, client_id): + """Get management permissions for a client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_update_client_management_permissions(self, payload, client_id): + """Update management permissions for a client asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_client_authz_policy_scopes(self, client_id, policy_id): + """Get scopes for a given policy asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_SCOPES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_authz_policy_resources(self, client_id, policy_id): + """Get resources for a given policy asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_RESOURCES.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_client_authz_scope_permission(self, client_id, scope_id): + """Get permissions for a given scope asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_create_client_authz_scope_permission(self, payload, client_id): + """Create permissions for a authz scope asynchronously. + + 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 = await self.connection.a_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]) + + async def a_update_client_authz_scope_permission(self, payload, client_id, scope_id): + """Update permissions for a given scope asynchronously. + + 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 = await self.connection.a_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]) + + async def a_get_client_authz_client_policies(self, client_id): + """Get policies for a given client asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_create_client_authz_client_policy(self, payload, client_id): + """Create a new policy for a given client asynchronously. + + 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 = await self.connection.a_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]) + + async def a_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 asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES_COMPOSITE.format(**params_path), **params + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_role_client_level_children(self, client_id, role_id): + """Get the child roles async 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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE_CHILDREN.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_upload_certificate(self, client_id, certcont): + """Upload a new certificate for the client asynchronously. + + :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 = await self.connection.a_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) + + async def a_get_required_action_by_alias(self, action_alias): + """Get a required action by its alias asynchronously. + + :param action_alias: the alias of the required action. + :type action_alias: str + :return: the required action (RequiredActionProviderRepresentation). + :rtype: dict + """ + actions = await self.a_get_required_actions() + for a in actions: + if a["alias"] == action_alias: + return a + return None + + async def a_get_required_actions(self): + """Get the required actions for the realms asynchronously. + + :return: the required actions (list of RequiredActionProviderRepresentation). + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_update_required_action(self, action_alias, payload): + """Update a required action asynchronously. + + :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 = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS_ALIAS.format(**params_path), data=payload + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + async def a_get_bruteforce_detection_status(self, user_id): + """Get bruteforce detection status for user asynchronously. + + :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 = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_clear_bruteforce_attempts_for_user(self, user_id): + """Clear bruteforce attempts for user asynchronously. + + :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 = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_clear_all_bruteforce_attempts(self): + """Clear bruteforce attempts for all users in realm asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_clear_keys_cache(self): + """Clear keys cache asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLEAR_KEYS_CACHE.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + async def a_clear_realm_cache(self): + """Clear realm cache asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLEAR_REALM_CACHE.format(**params_path), data="" + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + async def a_clear_user_cache(self): + """Clear user cache asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_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/keycloak_openid.py b/src/keycloak/keycloak_openid.py index 0955e20..205e160 100644 --- a/src/keycloak/keycloak_openid.py +++ b/src/keycloak/keycloak_openid.py @@ -315,7 +315,14 @@ class KeycloakOpenID: payload["totp"] = totp payload = self._add_secret_key(payload) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) return raise_error_from_response(data_raw, KeycloakPostError) def refresh_token(self, refresh_token, grant_type=["refresh_token"]): @@ -342,7 +349,14 @@ class KeycloakOpenID: "refresh_token": refresh_token, } payload = self._add_secret_key(payload) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) return raise_error_from_response(data_raw, KeycloakPostError) def exchange_token( @@ -394,7 +408,14 @@ class KeycloakOpenID: "scope": scope, } payload = self._add_secret_key(payload) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) return raise_error_from_response(data_raw, KeycloakPostError) def userinfo(self, token): @@ -410,9 +431,15 @@ class KeycloakOpenID: :returns: Userinfo object :rtype: dict """ + orig_bearer = self.connection.headers.get("Authorization") 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)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) return raise_error_from_response(data_raw, KeycloakGetError) def logout(self, refresh_token): @@ -473,9 +500,15 @@ class KeycloakOpenID: :returns: Entitlements :rtype: dict """ + orig_bearer = self.connection.headers.get("Authorization") 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)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) if data_raw.status_code == 404 or data_raw.status_code == 405: return raise_error_from_response(data_raw, KeycloakDeprecationError) @@ -504,16 +537,26 @@ class KeycloakOpenID: params_path = {"realm-name": self.realm_name} payload = {"client_id": self.client_id, "token": token} + bearer_changed = False + orig_bearer = None if token_type_hint == "requesting_party_token": if rpt: payload.update({"token": rpt, "token_type_hint": token_type_hint}) + orig_bearer = self.connection.headers.get("Authorization") self.connection.add_param_headers("Authorization", "Bearer " + token) + bearer_changed = True 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) + if bearer_changed: + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) return raise_error_from_response(data_raw, KeycloakPostError) def decode_token(self, token, validate: bool = True, **kwargs): @@ -609,7 +652,7 @@ class KeycloakOpenID: return list(set(policies)) def get_permissions(self, token, method_token_info="introspect", **kwargs): - """Get permission by user token. + """Get permission by user token . :param token: user token :type token: str @@ -624,7 +667,7 @@ class KeycloakOpenID: """ if not self.authorization.policies: raise KeycloakAuthorizationConfigError( - "Keycloak settings not found. Load Authorization Keycloak settings." + "Keycloak settings not found. Load Authorization Keycloak settings ." ) token_info = self._token_info(token, method_token_info, **kwargs) @@ -671,8 +714,21 @@ class KeycloakOpenID: "audience": self.client_id, } + orig_bearer = self.connection.headers.get("Authorization") self.connection.add_param_headers("Authorization", "Bearer " + token) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) return raise_error_from_response(data_raw, KeycloakPostError) def has_uma_access(self, token, permissions): @@ -728,11 +784,23 @@ class KeycloakOpenID: :rtype: dict """ params_path = {"realm-name": self.realm_name} + orig_bearer = self.connection.headers.get("Authorization") self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = self.connection.headers.get("Content-Type") 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) ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) return raise_error_from_response(data_raw, KeycloakPostError) def device(self): @@ -776,7 +844,9 @@ class KeycloakOpenID: :rtype: dict """ params_path = {"realm-name": self.realm_name, "client-id": client_id} + orig_bearer = self.connection.headers.get("Authorization") self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = self.connection.headers.get("Content-Type") self.connection.add_param_headers("Content-Type", "application/json") # Keycloak complains if the clientId is not set in the payload @@ -786,4 +856,656 @@ class KeycloakOpenID: data_raw = self.connection.raw_put( URL_CLIENT_UPDATE.format(**params_path), data=json.dumps(payload) ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + async def a_well_known(self): + """Get the well_known object asynchronously. + + 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 = await self.connection.a_raw_get(URL_WELL_KNOWN.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_auth_url(self, redirect_uri, scope="email", state=""): + """Get authorization URL endpoint asynchronously. + + :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": (await self.a_well_known())["authorization_endpoint"], + "client-id": self.client_id, + "redirect-uri": redirect_uri, + "scope": scope, + "state": state, + } + return URL_AUTH.format(**params_path) + + async def a_token( + self, + username="", + password="", + grant_type=["password"], + code="", + redirect_uri="", + totp=None, + scope="openid", + **extra + ): + """Retrieve user token asynchronously. + + 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) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_refresh_token(self, refresh_token, grant_type=["refresh_token"]): + """Refresh the user token asynchronously. + + 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) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_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 asynchronously. + + 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) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_userinfo(self, token): + """Get the user info object asynchronously. + + 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 + """ + orig_bearer = self.connection.headers.get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name} + data_raw = await self.connection.a_raw_get(URL_USERINFO.format(**params_path)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_logout(self, refresh_token): + """Log out the authenticated user asynchronously. + + :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 = await self.connection.a_raw_post(URL_LOGOUT.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + async def a_certs(self): + """Get certificates asynchronously. + + 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 = await self.connection.a_raw_get(URL_CERTS.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_public_key(self): + """Retrieve the public key asynchronously. + + 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 = await self.connection.a_raw_get(URL_REALM.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError)["public_key"] + + async def a_entitlement(self, token, resource_server_id): + """Get entitlements from the token asynchronously. + + 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 + """ + orig_bearer = self.connection.headers.get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} + data_raw = await self.connection.a_raw_get(URL_ENTITLEMENT.format(**params_path)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + + 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 + + async def a_introspect(self, token, rpt=None, token_type_hint=None): + """Introspect the user token asynchronously. + + 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} + + orig_bearer = None + bearer_changed = False + if token_type_hint == "requesting_party_token": + if rpt: + payload.update({"token": rpt, "token_type_hint": token_type_hint}) + orig_bearer = self.connection.headers.get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + bearer_changed = True + else: + raise KeycloakRPTNotFound("Can't found RPT.") + + payload = self._add_secret_key(payload) + + data_raw = await self.connection.a_raw_post( + URL_INTROSPECT.format(**params_path), data=payload + ) + if bearer_changed: + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_decode_token(self, token, validate: bool = True, **kwargs): + """Decode user token asynchronously. + + 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")) + + async def a_load_authorization_config(self, path): + """Load Keycloak settings (authorization) asynchronously. + + :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) + + async def a_get_policies(self, token, method_token_info="introspect", **kwargs): + """Get policies by user token asynchronously. + + :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)) + + async def a_get_permissions(self, token, method_token_info="introspect", **kwargs): + """Get permission by user token asynchronously. + + :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)) + + async def a_uma_permissions(self, token, permissions=""): + """Get UMA permissions by user token with requested permissions asynchronously. + + 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, + } + + orig_bearer = self.connection.headers.get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_has_uma_access(self, token, permissions): + """Determine whether user has uma permissions with specified user token asynchronously. + + :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 = await self.a_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 + ) + + async def a_register_client(self, token: str, payload: dict): + """Create a client asynchronously. + + 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} + orig_bearer = self.connection.headers.get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = self.connection.headers.get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/json") + data_raw = await self.connection.a_raw_post( + URL_CLIENT_REGISTRATION.format(**params_path), data=json.dumps(payload) + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_device(self): + """Get device authorization grant asynchronously. + + 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 = await self.connection.a_raw_post(URL_DEVICE.format(**params_path), data=payload) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_update_client(self, token: str, client_id: str, payload: dict): + """Update a client asynchronously. + + 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} + orig_bearer = self.connection.headers.get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = self.connection.headers.get("Content-Type") + 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 = await self.connection.a_raw_put( + URL_CLIENT_UPDATE.format(**params_path), data=json.dumps(payload) + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) return raise_error_from_response(data_raw, KeycloakPutError) diff --git a/src/keycloak/keycloak_uma.py b/src/keycloak/keycloak_uma.py index 2f3deb3..d2d8bfa 100644 --- a/src/keycloak/keycloak_uma.py +++ b/src/keycloak/keycloak_uma.py @@ -30,6 +30,8 @@ import json from typing import Iterable from urllib.parse import quote_plus +from async_property import async_property + from .connection import ConnectionManager from .exceptions import ( KeycloakDeleteError, @@ -56,9 +58,6 @@ class KeycloakUMA: :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): @@ -84,6 +83,24 @@ class KeycloakUMA: """ return url.format(**{k: quote_plus(v) for k, v in kwargs.items()}) + @staticmethod + async def a_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. @@ -96,6 +113,17 @@ class KeycloakUMA: self._well_known = self._fetch_well_known() return self._well_known + @async_property + async def a_uma_well_known(self): + """Get the well_known UMA2 config async. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + if not self._well_known: + self._well_known = await self.a__fetch_well_known() + return self._well_known + def resource_set_create(self, payload): """Create a resource set. @@ -415,3 +443,343 @@ class KeycloakUMA: data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query) return raise_error_from_response(data_raw, KeycloakGetError) + + async def a__fetch_well_known(self): + """Get the well_known UMA2 config async. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_resource_set_create(self, payload): + """Create a resource set asynchronously. + + 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 = await self.connection.a_raw_post( + (await self.a_uma_well_known)["resource_registration_endpoint"], + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) + + async def a_resource_set_update(self, resource_id, payload): + """Update a resource set asynchronously. + + 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( + (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = await self.connection.a_raw_put(url, data=json.dumps(payload)) + return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + + async def a_resource_set_read(self, resource_id): + """Read a resource set asynchronously. + + 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( + (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = await self.connection.a_raw_get(url) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_resource_set_delete(self, resource_id): + """Delete a resource set asynchronously. + + 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( + (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = await self.connection.a_raw_delete(url) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_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 asynchronously. + + 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 = await self.connection.a_raw_get( + (await self.a_uma_well_known)["resource_registration_endpoint"], **query + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) + + async def a_resource_set_list(self): + """List all resource sets asynchronously. + + 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 await self.a_resource_set_list_ids(): + resource = await self.a_resource_set_read(resource_id) + yield resource + + async def a_permission_ticket_create(self, permissions: Iterable[UMAPermission]): + """Create a permission ticket asynchronously. + + :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 = await self.a_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 = await self.connection.a_raw_post( + (await self.a_uma_well_known)["permission_endpoint"], data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_permissions_check(self, token, permissions: Iterable[UMAPermission]): + """Check UMA permissions by user token with requested permissions asynchronously. + + 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 = await connection.a_raw_post( + (await self.a_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) + + async def a_policy_resource_create(self, resource_id, payload): + """Create permission policy for resource asynchronously. + + 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 = await self.connection.a_raw_post( + (await self.a_uma_well_known)["policy_endpoint"] + f"/{resource_id}", + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError) + + async def a_policy_update(self, policy_id, payload): + """Update permission policy asynchronously. + + 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 = await self.connection.a_raw_put( + (await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}", + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPutError) + + async def a_policy_delete(self, policy_id): + """Delete permission policy asynchronously. + + 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 = await self.connection.a_raw_delete( + (await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}" + ) + return raise_error_from_response(data_raw, KeycloakDeleteError) + + async def a_policy_query( + self, + resource: str = "", + name: str = "", + scope: str = "", + first: int = 0, + maximum: int = -1, + ): + """Query permission policies asynchronously. + + 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 = await self.connection.a_raw_get( + (await self.a_uma_well_known)["policy_endpoint"], **query + ) + return raise_error_from_response(data_raw, KeycloakGetError) diff --git a/src/keycloak/openid_connection.py b/src/keycloak/openid_connection.py index 583afcd..2200c42 100644 --- a/src/keycloak/openid_connection.py +++ b/src/keycloak/openid_connection.py @@ -103,6 +103,7 @@ class KeycloakOpenIDConnection(ConnectionManager): # 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.headers = {} self.server_url = server_url self.username = username self.password = password @@ -114,18 +115,8 @@ class KeycloakOpenIDConnection(ConnectionManager): self.client_secret_key = client_secret_key self.user_realm_name = user_realm_name self.timeout = timeout - self.headers = {} self.custom_headers = custom_headers - - 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", - } + self.headers = {**self.headers, "Content-Type": "application/json"} super().__init__( base_url=self.server_url, headers=self.headers, timeout=60, verify=self.verify @@ -237,6 +228,8 @@ class KeycloakOpenIDConnection(ConnectionManager): self._expires_at = datetime.now() + timedelta( seconds=int(self.token_lifetime_fraction * self.token["expires_in"] if value else 0) ) + if value is not None: + self.add_param_headers("Authorization", "Bearer " + value.get("access_token")) @property def expires_at(self): @@ -345,8 +338,6 @@ class KeycloakOpenIDConnection(ConnectionManager): 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() @@ -418,3 +409,116 @@ class KeycloakOpenIDConnection(ConnectionManager): self._refresh_if_required() r = super().raw_delete(*args, **kwargs) return r + + async def a_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 = await self.keycloak_openid.a_token( + self.username, self.password, grant_type=grant_type, totp=self.totp + ) + else: + self.token = None + + async def a_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: + await self.a_get_token() + else: + try: + self.token = await self.keycloak_openid.a_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): + await self.a_get_token() + else: + raise + + async def a__refresh_if_required(self): + """Refresh the token if it is expired.""" + if datetime.now() >= self.expires_at: + await self.a_refresh_token() + + async def a_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 + """ + await self.a__refresh_if_required() + r = await super().a_raw_get(*args, **kwargs) + return r + + async def a_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 + """ + await self.a__refresh_if_required() + r = await super().a_raw_post(*args, **kwargs) + return r + + async def a_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 + """ + await self.a__refresh_if_required() + r = await super().a_raw_put(*args, **kwargs) + return r + + async def a_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 + """ + await self.a__refresh_if_required() + r = await super().a_raw_delete(*args, **kwargs) + return r diff --git a/tests/test_connection.py b/tests/test_connection.py index 85730cd..448fcee 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,5 +1,7 @@ """Connection test module.""" +from inspect import iscoroutinefunction, signature + import pytest from keycloak.connection import ConnectionManager @@ -9,9 +11,9 @@ from keycloak.exceptions import KeycloakConnectionError def test_connection_proxy(): """Test proxies of connection manager.""" cm = ConnectionManager( - base_url="http://test.test", proxies={"http://test.test": "localhost:8080"} + base_url="http://test.test", proxies={"http://test.test": "http://localhost:8080"} ) - assert cm._s.proxies == {"http://test.test": "localhost:8080"} + assert cm._s.proxies == {"http://test.test": "http://localhost:8080"} def test_headers(): @@ -39,3 +41,56 @@ def test_bad_connection(): cm.raw_post(path="bad", data={}) with pytest.raises(KeycloakConnectionError): cm.raw_put(path="bad", data={}) + + +@pytest.mark.asyncio +async def a_test_bad_connection(): + """Test bad connection.""" + cm = ConnectionManager(base_url="http://not.real.domain") + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_get(path="bad") + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_delete(path="bad") + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_post(path="bad", data={}) + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_put(path="bad", data={}) + + +def test_counter_part(): + """Test that each function has its async counter part.""" + con_methods = [ + func for func in dir(ConnectionManager) if callable(getattr(ConnectionManager, func)) + ] + sync_methods = [ + method + for method in con_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in con_methods if iscoroutinefunction(getattr(ConnectionManager, method)) + ] + + for method in sync_methods: + if method in [ + "aclose", + "add_param_headers", + "del_param_headers", + "clean_headers", + "exist_param_headers", + "param_headers", + ]: + continue + async_method = f"a_{method}" + assert (async_method in con_methods) is True + sync_sign = signature(getattr(ConnectionManager, method)) + async_sign = signature(getattr(ConnectionManager, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method in ["aclose"]: + continue + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_keycloak_admin.py b/tests/test_keycloak_admin.py index cfd724e..600bab6 100644 --- a/tests/test_keycloak_admin.py +++ b/tests/test_keycloak_admin.py @@ -3,6 +3,7 @@ import copy import os import uuid +from inspect import iscoroutinefunction, signature from typing import Tuple import freezegun @@ -59,10 +60,9 @@ def test_keycloak_admin_init(env): assert admin.connection.username == env.KEYCLOAK_ADMIN, admin.connection.username assert admin.connection.password == env.KEYCLOAK_ADMIN_PASSWORD, admin.connection.password assert admin.connection.totp is None, admin.connection.totp - assert admin.connection.token is not None, admin.connection.token + assert admin.connection.token is None, admin.connection.token assert admin.connection.user_realm_name is None, admin.connection.user_realm_name assert admin.connection.custom_headers is None, admin.connection.custom_headers - assert admin.connection.token admin = KeycloakAdmin( server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", @@ -71,7 +71,7 @@ def test_keycloak_admin_init(env): realm_name=None, user_realm_name="master", ) - assert admin.connection.token + assert admin.connection.token is None admin = KeycloakAdmin( server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", username=env.KEYCLOAK_ADMIN, @@ -79,8 +79,9 @@ def test_keycloak_admin_init(env): realm_name=None, user_realm_name=None, ) - assert admin.connection.token + assert admin.connection.token is None + admin.get_realms() token = admin.connection.token admin = KeycloakAdmin( server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", @@ -106,12 +107,14 @@ def test_keycloak_admin_init(env): } ) secret = admin.generate_client_secrets(client_id=admin.get_client_id("authz-client")) - assert KeycloakAdmin( + adminAuth = KeycloakAdmin( server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", user_realm_name="authz", client_id="authz-client", client_secret_key=secret["value"], - ).connection.token + ) + adminAuth.connection.refresh_token() + assert adminAuth.connection.token is not None admin.delete_realm(realm_name="authz") assert ( @@ -134,6 +137,7 @@ def test_keycloak_admin_init(env): verify=True, ) keycloak_admin = KeycloakAdmin(connection=keycloak_connection) + keycloak_admin.connection.get_token() assert keycloak_admin.connection.token @@ -2609,6 +2613,7 @@ def test_auto_refresh(admin_frozen: KeycloakAdmin, realm: str): :type realm: str """ admin = admin_frozen + admin.get_realm(realm_name=realm) # Test get refresh admin.connection.custom_headers = { "Authorization": "Bearer bad", @@ -3059,6 +3064,3112 @@ def test_refresh_token(admin: KeycloakAdmin): :param admin: Keycloak admin :type admin: KeycloakAdmin """ + admin.get_realms() assert admin.connection.token is not None admin.user_logout(admin.get_user_id(admin.connection.username)) admin.connection.refresh_token() + + +# async function start + + +@pytest.mark.asyncio +async def test_a_realms(admin: KeycloakAdmin): + """Test realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Get realms + realms = await admin.a_get_realms() + assert len(realms) == 1, realms + assert "master" == realms[0]["realm"] + + # Create a test realm + res = await admin.a_create_realm(payload={"realm": "test"}) + assert res == b"", res + + # Create the same realm, should fail + with pytest.raises(KeycloakPostError) as err: + res = await admin.a_create_realm(payload={"realm": "test"}) + assert err.match('409: b\'{"errorMessage":"Conflict detected. See logs for details"}\'') + + # Create the same realm, skip_exists true + res = await admin.a_create_realm(payload={"realm": "test"}, skip_exists=True) + assert res == {"msg": "Already exists"}, res + + # Get a single realm + res = await admin.a_get_realm(realm_name="test") + assert res["realm"] == "test" + + # Get non-existing realm + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found.".*\'') + + # Update realm + res = await admin.a_update_realm(realm_name="test", payload={"accountTheme": "test"}) + assert res == dict(), res + + # Check that the update worked + res = await admin.a_get_realm(realm_name="test") + assert res["realm"] == "test" + assert res["accountTheme"] == "test" + + # Update wrong payload + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_realm(realm_name="test", payload={"wrong": "payload"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + # Check that get realms returns both realms + realms = await admin.a_get_realms() + realm_names = [x["realm"] for x in realms] + assert len(realms) == 2, realms + assert "master" in realm_names, realm_names + assert "test" in realm_names, realm_names + + # Delete the realm + res = await admin.a_delete_realm(realm_name="test") + assert res == dict(), res + + # Check that the realm does not exist anymore + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm(realm_name="test") + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + + # Delete non-existing realm + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + + +@pytest.mark.asyncio +async def test_a_changing_of_realms(admin: KeycloakAdmin, realm: str): + """Test changing of realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + assert await admin.a_get_current_realm() == "master" + await admin.a_change_current_realm(realm) + assert await admin.a_get_current_realm() == realm + + +@pytest.mark.asyncio +async def test_a_import_export_realms(admin: KeycloakAdmin, realm: str): + """Test import and export of realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + realm_export = await admin.a_export_realm(export_clients=True, export_groups_and_role=True) + assert realm_export != dict(), realm_export + + await admin.a_delete_realm(realm_name=realm) + admin.realm_name = "master" + res = await admin.a_import_realm(payload=realm_export) + assert res == b"", res + + # Test bad import + with pytest.raises(KeycloakPostError) as err: + await admin.a_import_realm(payload=dict()) + assert err.match( + '500: b\'{"error":"unknown_error"}\'|400: b\'{"errorMessage":"Realm name cannot be empty"}\'' # noqa: E501 + ) + + +@pytest.mark.asyncio +async def test_a_partial_import_realm(admin: KeycloakAdmin, realm: str): + """Test partial import of realm configuration. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + test_realm_role = str(uuid.uuid4()) + test_user = str(uuid.uuid4()) + test_client = str(uuid.uuid4()) + + await admin.a_change_current_realm(realm) + client_id = await admin.a_create_client(payload={"name": test_client, "clientId": test_client}) + + realm_export = await admin.a_export_realm(export_clients=True, export_groups_and_role=False) + + client_config = [ + client_entry for client_entry in realm_export["clients"] if client_entry["id"] == client_id + ][0] + + # delete before partial import + await admin.a_delete_client(client_id) + + payload = { + "ifResourceExists": "SKIP", + "id": realm_export["id"], + "realm": realm, + "clients": [client_config], + "roles": {"realm": [{"name": test_realm_role}]}, + "users": [{"username": test_user, "email": f"{test_user}@test.test"}], + } + + # check add + res = await admin.a_partial_import_realm(realm_name=realm, payload=payload) + assert res["added"] == 3 + + # check skip + res = await admin.a_partial_import_realm(realm_name=realm, payload=payload) + assert res["skipped"] == 3 + + # check overwrite + payload["ifResourceExists"] = "OVERWRITE" + res = await admin.a_partial_import_realm(realm_name=realm, payload=payload) + assert res["overwritten"] == 3 + + +@pytest.mark.asyncio +async def test_a_users(admin: KeycloakAdmin, realm: str): + """Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Check no users present + users = await admin.a_get_users() + assert users == list(), users + + # Test create user + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + assert user_id is not None, user_id + + # Test create the same user + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + assert err.match(".*User exists with same.*") + + # Test create the same user, exists_ok true + user_id_2 = await admin.a_create_user( + payload={"username": "test", "email": "test@test.test"}, exist_ok=True + ) + assert user_id == user_id_2 + + # Test get user + user = await admin.a_get_user(user_id=user_id) + assert user["username"] == "test", user["username"] + assert user["email"] == "test@test.test", user["email"] + + # Test update user + res = await admin.a_update_user(user_id=user_id, payload={"firstName": "Test"}) + assert res == dict(), res + user = await admin.a_get_user(user_id=user_id) + assert user["firstName"] == "Test" + + # Test update user fail + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_user(user_id=user_id, payload={"wrong": "payload"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + # Test disable user + res = await admin.a_disable_user(user_id=user_id) + assert res == {}, res + assert not (await admin.a_get_user(user_id=user_id))["enabled"] + + # Test enable user + res = await admin.a_enable_user(user_id=user_id) + assert res == {}, res + assert (await admin.a_get_user(user_id=user_id))["enabled"] + + # Test get users again + users = await admin.a_get_users() + usernames = [x["username"] for x in users] + assert "test" in usernames + + # Test users counts + count = await admin.a_users_count() + assert count == 1, count + + # Test users count with query + count = await admin.a_users_count(query={"username": "notpresent"}) + assert count == 0 + + # Test user groups + groups = await admin.a_get_user_groups(user_id=user["id"]) + assert len(groups) == 0 + + # Test user groups bad id + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_user_groups(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test logout + res = await admin.a_user_logout(user_id=user["id"]) + assert res == dict(), res + + # Test logout fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_user_logout(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test consents + res = await admin.a_user_consents(user_id=user["id"]) + assert len(res) == 0, res + + # Test consents fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_user_consents(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test delete user + res = await admin.a_delete_user(user_id=user_id) + assert res == dict(), res + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_user(user_id=user_id) + err.match(USER_NOT_FOUND_REGEX) + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_user(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + +@pytest.mark.asyncio +async def test_a_enable_disable_all_users(admin: KeycloakAdmin, realm: str): + """Test enable and disable all users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + user_id_1 = await admin.a_create_user( + payload={"username": "test", "email": "test@test.test", "enabled": True} + ) + user_id_2 = await admin.a_create_user( + payload={"username": "test2", "email": "test2@test.test", "enabled": True} + ) + user_id_3 = await admin.a_create_user( + payload={"username": "test3", "email": "test3@test.test", "enabled": True} + ) + + assert (await admin.a_get_user(user_id_1))["enabled"] + assert (await admin.a_get_user(user_id_2))["enabled"] + assert (await admin.a_get_user(user_id_3))["enabled"] + + await admin.a_disable_all_users() + + assert not (await admin.a_get_user(user_id_1))["enabled"] + assert not (await admin.a_get_user(user_id_2))["enabled"] + assert not (await admin.a_get_user(user_id_3))["enabled"] + + await admin.a_enable_all_users() + + assert (await admin.a_get_user(user_id_1))["enabled"] + assert (await admin.a_get_user(user_id_2))["enabled"] + assert (await admin.a_get_user(user_id_3))["enabled"] + + +@pytest.mark.asyncio +async def test_a_users_roles(admin: KeycloakAdmin, realm: str): + """Test users roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + + # Test all level user roles + client_id = await admin.a_create_client( + payload={"name": "test-client", "clientId": "test-client"} + ) + await admin.a_create_client_role(client_role_id=client_id, payload={"name": "test-role"}) + await admin.a_assign_client_role( + client_id=client_id, + user_id=user_id, + roles=[admin.get_client_role(client_id=client_id, role_name="test-role")], + ) + all_roles = await admin.a_get_all_roles_of_user(user_id=user_id) + realm_roles = all_roles["realmMappings"] + assert len(realm_roles) == 1, realm_roles + client_roles = all_roles["clientMappings"] + assert len(client_roles) == 1, client_roles + + # Test all level user roles fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_all_roles_of_user(user_id="non-existent-id") + err.match('404: b\'{"error":"User not found"') + + await admin.a_delete_user(user_id) + await admin.a_delete_client(client_id) + + +@pytest.mark.asyncio +async def test_a_users_pagination(admin: KeycloakAdmin, realm: str): + """Test user pagination. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + for ind in range(admin.PAGE_SIZE + 50): + username = f"user_{ind}" + admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) + + users = await admin.a_get_users() + assert len(users) == admin.PAGE_SIZE + 50, len(users) + + users = await admin.a_get_users(query={"first": 100}) + assert len(users) == 50, len(users) + + users = await admin.a_get_users(query={"max": 20}) + assert len(users) == 20, len(users) + + +@pytest.mark.asyncio +async def test_a_user_groups_pagination(admin: KeycloakAdmin, realm: str): + """Test user groups pagination. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + user_id = await admin.a_create_user( + payload={"username": "username_1", "email": "username_1@test.test"} + ) + + for ind in range(admin.PAGE_SIZE + 50): + group_name = f"group_{ind}" + group_id = await admin.a_create_group(payload={"name": group_name}) + await admin.a_group_user_add(user_id=user_id, group_id=group_id) + + groups = await admin.a_get_user_groups(user_id=user_id) + assert len(groups) == admin.PAGE_SIZE + 50, len(groups) + + groups = await admin.a_get_user_groups( + user_id=user_id, query={"first": 100, "max": -1, "search": ""} + ) + assert len(groups) == 50, len(groups) + + groups = await admin.a_get_user_groups( + user_id=user_id, query={"max": 20, "first": -1, "search": ""} + ) + assert len(groups) == 20, len(groups) + + +@pytest.mark.asyncio +async def test_a_idps(admin: KeycloakAdmin, realm: str): + """Test IDPs. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Create IDP + res = await admin.a_create_idp( + payload=dict( + providerId="github", alias="github", config=dict(clientId="test", clientSecret="test") + ) + ) + assert res == b"", res + + # Test create idp fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_idp(payload={"providerId": "does-not-exist", "alias": "something"}) + assert err.match("Invalid identity provider id"), err + + # Test listing + idps = await admin.a_get_idps() + assert len(idps) == 1 + assert "github" == idps[0]["alias"] + + # Test get idp + idp = await admin.a_get_idp("github") + assert "github" == idp["alias"] + assert idp.get("config") + assert "test" == idp["config"]["clientId"] + assert "**********" == idp["config"]["clientSecret"] + + # Test get idp fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_idp("does-not-exist") + assert err.match(HTTP_404_REGEX) + + # Test IdP update + res = await admin.a_update_idp(idp_alias="github", payload=idps[0]) + + assert res == {}, res + + # Test adding a mapper + res = await admin.a_add_mapper_to_idp( + idp_alias="github", + payload={ + "identityProviderAlias": "github", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + }, + ) + assert res == b"", res + + # Test mapper fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_mapper_to_idp(idp_alias="does-no-texist", payload=dict()) + assert err.match(HTTP_404_REGEX) + + # Test IdP mappers listing + idp_mappers = await admin.a_get_idp_mappers(idp_alias="github") + assert len(idp_mappers) == 1 + + # Test IdP mapper update + res = await admin.a_update_mapper_in_idp( + idp_alias="github", + mapper_id=idp_mappers[0]["id"], + # For an obscure reason, keycloak expect all fields + payload={ + "id": idp_mappers[0]["id"], + "identityProviderAlias": "github-alias", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + "config": idp_mappers[0]["config"], + }, + ) + assert res == dict(), res + + # Test delete + res = await admin.a_delete_idp(idp_alias="github") + assert res == dict(), res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_idp(idp_alias="does-not-exist") + assert err.match(HTTP_404_REGEX) + + +@pytest.mark.asyncio +async def test_a_user_credentials(admin: KeycloakAdmin, user: str): + """Test user credentials. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + res = await admin.a_set_user_password(user_id=user, password="booya", temporary=True) + assert res == dict(), res + + # Test user password set fail + with pytest.raises(KeycloakPutError) as err: + await admin.a_set_user_password(user_id="does-not-exist", password="") + assert err.match(USER_NOT_FOUND_REGEX) + + credentials = await admin.a_get_credentials(user_id=user) + assert len(credentials) == 1 + assert credentials[0]["type"] == "password", credentials + + # Test get credentials fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_credentials(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + res = await admin.a_delete_credential(user_id=user, credential_id=credentials[0]["id"]) + assert res == dict(), res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_credential(user_id=user, credential_id="does-not-exist") + assert err.match('404: b\'{"error":"Credential not found".*}\'') + + +@pytest.mark.asyncio +async def test_a_social_logins(admin: KeycloakAdmin, user: str): + """Test social logins. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + res = await admin.a_add_user_social_login( + user_id=user, provider_id="gitlab", provider_userid="test", provider_username="test" + ) + assert res == dict(), res + await admin.a_add_user_social_login( + user_id=user, provider_id="github", provider_userid="test", provider_username="test" + ) + assert res == dict(), res + + # Test add social login fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_user_social_login( + user_id="does-not-exist", + provider_id="does-not-exist", + provider_userid="test", + provider_username="test", + ) + assert err.match(USER_NOT_FOUND_REGEX) + + res = await admin.a_get_user_social_logins(user_id=user) + assert res == list(), res + + # Test get social logins fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_user_social_logins(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + res = await admin.a_delete_user_social_login(user_id=user, provider_id="gitlab") + assert res == {}, res + + res = await admin.a_delete_user_social_login(user_id=user, provider_id="github") + assert res == {}, res + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_user_social_login(user_id=user, provider_id="instagram") + assert err.match('404: b\'{"error":"Link not found".*}\''), err + + +@pytest.mark.asyncio +async def test_a_server_info(admin: KeycloakAdmin): + """Test server info. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + info = await admin.a_get_server_info() + assert set(info.keys()).issubset( + { + "systemInfo", + "memoryInfo", + "profileInfo", + "features", + "themes", + "socialProviders", + "identityProviders", + "providers", + "protocolMapperTypes", + "builtinProtocolMappers", + "clientInstallations", + "componentTypes", + "passwordPolicies", + "enums", + "cryptoInfo", + "features", + } + ), info.keys() + + +@pytest.mark.asyncio +async def test_a_groups(admin: KeycloakAdmin, user: str): + """Test groups. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + # Test get groups + groups = await admin.a_get_groups() + assert len(groups) == 0 + + # Test create group + group_id = await admin.a_create_group(payload={"name": "main-group"}) + assert group_id is not None, group_id + + # Test group count + count = await admin.a_groups_count() + assert count.get("count") == 1, count + + # Test group count with query + count = await admin.a_groups_count(query={"search": "notpresent"}) + assert count.get("count") == 0 + + # Test create subgroups + subgroup_id_1 = await admin.a_create_group(payload={"name": "subgroup-1"}, parent=group_id) + subgroup_id_2 = await admin.a_create_group(payload={"name": "subgroup-2"}, parent=group_id) + + # Test create group fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_group(payload={"name": "subgroup-1"}, parent=group_id) + assert err.match("409"), err + + # Test skip exists OK + subgroup_id_1_eq = await admin.a_create_group( + payload={"name": "subgroup-1"}, parent=group_id, skip_exists=True + ) + assert subgroup_id_1_eq is None + + # Test get groups again + groups = await admin.a_get_groups() + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups[0]["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get groups query + groups = await admin.a_get_groups(query={"max": 10}) + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups[0]["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get group + res = await admin.a_get_group(group_id=subgroup_id_1) + assert res["id"] == subgroup_id_1, res + assert res["name"] == "subgroup-1" + assert res["path"] == "/main-group/subgroup-1" + + # Test get group fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + # Create 1 more subgroup + subsubgroup_id_1 = await admin.a_create_group( + payload={"name": "subsubgroup-1"}, parent=subgroup_id_2 + ) + main_group = await admin.a_get_group(group_id=group_id) + + # Test nested searches + subgroup_2 = await admin.a_get_group(group_id=subgroup_id_2) + res = await admin.a_get_subgroups( + group=subgroup_2, path="/main-group/subgroup-2/subsubgroup-1" + ) + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + # Test nested search from main group + res = await admin.a_get_subgroups( + group=await admin.a_get_group(group_id=group_id, full_hierarchy=True), + path="/main-group/subgroup-2/subsubgroup-1", + ) + assert res["id"] == subsubgroup_id_1 + + # Test nested search from all groups + res = await admin.a_get_groups(full_hierarchy=True) + assert len(res) == 1 + assert len(res[0]["subGroups"]) == 2 + assert len([x for x in res[0]["subGroups"] if x["id"] == subgroup_id_1][0]["subGroups"]) == 0 + assert len([x for x in res[0]["subGroups"] if x["id"] == subgroup_id_2][0]["subGroups"]) == 1 + + # Test that query params are not allowed for full hierarchy + with pytest.raises(ValueError) as err: + await admin.a_get_group_children(group_id=group_id, full_hierarchy=True, query={"max": 10}) + + # Test that query params are passed + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] == "latest" or Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] + ) >= Version("23"): + res = await admin.a_get_group_children(group_id=group_id, query={"max": 1}) + assert len(res) == 1 + + assert err.match("Cannot use both query and full_hierarchy parameters") + + main_group_id_2 = await admin.a_create_group(payload={"name": "main-group-2"}) + assert len(await admin.a_get_groups(full_hierarchy=True)) == 2 + + # Test empty search + res = await admin.a_get_subgroups(group=main_group, path="/none") + assert res is None, res + + # Test get group by path + res = await admin.a_get_group_by_path(path="/main-group/subgroup-1") + assert res is not None, res + assert res["id"] == subgroup_id_1, res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group_by_path(path="/main-group/subgroup-2/subsubgroup-1/test") + assert err.match('404: b\'{"error":"Group path does not exist".*}\'') + + res = await admin.a_get_group_by_path(path="/main-group/subgroup-2/subsubgroup-1") + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + res = await admin.a_get_group_by_path(path="/main-group") + assert res is not None, res + assert res["id"] == group_id, res + + # Test group members + res = await admin.a_get_group_members(group_id=subgroup_id_2) + assert len(res) == 0, res + + # Test fail group members + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group_members(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\'') + + res = await admin.a_group_user_add(user_id=user, group_id=subgroup_id_2) + assert res == dict(), res + + res = await admin.a_get_group_members(group_id=subgroup_id_2) + assert len(res) == 1, res + assert res[0]["id"] == user + + # Test get group members query + res = await admin.a_get_group_members(group_id=subgroup_id_2, query={"max": 10}) + assert len(res) == 1, res + assert res[0]["id"] == user + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_group_user_remove(user_id="does-not-exist", group_id=subgroup_id_2) + assert err.match(USER_NOT_FOUND_REGEX), err + + res = await admin.a_group_user_remove(user_id=user, group_id=subgroup_id_2) + assert res == dict(), res + + # Test set permissions + res = await admin.a_group_set_permissions(group_id=subgroup_id_2, enabled=True) + assert res["enabled"], res + res = await admin.a_group_set_permissions(group_id=subgroup_id_2, enabled=False) + assert not res["enabled"], res + with pytest.raises(KeycloakPutError) as err: + await admin.a_group_set_permissions(group_id=subgroup_id_2, enabled="blah") + assert err.match(UNKOWN_ERROR_REGEX), err + + # Test update group + res = await admin.a_update_group(group_id=subgroup_id_2, payload={"name": "new-subgroup-2"}) + assert res == dict(), res + assert (await admin.a_get_group(group_id=subgroup_id_2))["name"] == "new-subgroup-2" + + # test update fail + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_group(group_id="does-not-exist", payload=dict()) + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + # Test delete + res = await admin.a_delete_group(group_id=group_id) + assert res == dict(), res + res = await admin.a_delete_group(group_id=main_group_id_2) + assert res == dict(), res + assert len(await admin.a_get_groups()) == 0 + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + +@pytest.mark.asyncio +async def test_a_clients(admin: KeycloakAdmin, realm: str): + """Test clients. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get clients + clients = await admin.a_get_clients() + assert len(clients) == 6, clients + assert {x["name"] for x in clients} == set( + [ + "${client_admin-cli}", + "${client_security-admin-console}", + "${client_account-console}", + "${client_broker}", + "${client_account}", + "${client_realm-management}", + ] + ), clients + + # Test create client + client_id = await admin.a_create_client( + payload={"name": "test-client", "clientId": "test-client"} + ) + assert client_id, client_id + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client(payload={"name": "test-client", "clientId": "test-client"}) + assert err.match('409: b\'{"errorMessage":"Client test-client already exists"}\''), err + + client_id_2 = await admin.a_create_client( + payload={"name": "test-client", "clientId": "test-client"}, skip_exists=True + ) + assert client_id == client_id_2, client_id_2 + + # Test get client + res = await admin.a_get_client(client_id=client_id) + assert res["clientId"] == "test-client", res + assert res["name"] == "test-client", res + assert res["id"] == client_id, res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + assert len(await admin.a_get_clients()) == 7 + + # Test get client id + assert await admin.a_get_client_id(client_id="test-client") == client_id + assert await admin.a_get_client_id(client_id="does-not-exist") is None + + # Test update client + res = await admin.a_update_client(client_id=client_id, payload={"name": "test-client-change"}) + assert res == dict(), res + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client( + client_id="does-not-exist", payload={"name": "test-client-change"} + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test client mappers + res = await admin.a_get_mappers_from_client(client_id=client_id) + assert len(res) == 0 + + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_mapper_to_client(client_id="does-not-exist", payload=dict()) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = await admin.a_add_mapper_to_client( + client_id=client_id, + payload={ + "name": "test-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + }, + ) + assert res == b"" + assert len(await admin.a_get_mappers_from_client(client_id=client_id)) == 1 + + mapper = (await admin.a_get_mappers_from_client(client_id=client_id))[0] + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client_mapper( + client_id=client_id, mapper_id="does-not-exist", payload=dict() + ) + assert err.match('404: b\'{"error":"Model not found".*}\'') + mapper["config"]["user.attribute"] = "test" + res = await admin.a_update_client_mapper( + client_id=client_id, mapper_id=mapper["id"], payload=mapper + ) + assert res == dict() + + res = await admin.a_remove_client_mapper(client_id=client_id, client_mapper_id=mapper["id"]) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_client_mapper(client_id=client_id, client_mapper_id=mapper["id"]) + assert err.match('404: b\'{"error":"Model not found".*}\'') + + # Test client sessions + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_all_sessions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + assert await admin.a_get_client_all_sessions(client_id=client_id) == list() + assert await admin.a_get_client_sessions_stats() == list() + + # Test authz + auth_client_id = await admin.a_create_client( + payload={ + "name": "authz-client", + "clientId": "authz-client", + "authorizationServicesEnabled": True, + "serviceAccountsEnabled": True, + } + ) + res = await admin.a_get_client_authz_settings(client_id=auth_client_id) + assert res["allowRemoteResourceManagement"] + assert res["decisionStrategy"] == "UNANIMOUS" + assert len(res["policies"]) >= 0 + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_settings(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + # Authz resources + res = await admin.a_get_client_authz_resources(client_id=auth_client_id) + assert len(res) == 1 + assert res[0]["name"] == "Default Resource" + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_resources(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + res = await admin.a_create_client_authz_resource( + client_id=auth_client_id, payload={"name": "test-resource"} + ) + assert res["name"] == "test-resource", res + test_resource_id = res["_id"] + + res = await admin.a_get_client_authz_resource( + client_id=auth_client_id, resource_id=test_resource_id + ) + assert res["_id"] == test_resource_id, res + assert res["name"] == "test-resource", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_resource( + client_id=auth_client_id, payload={"name": "test-resource"} + ) + assert err.match('409: b\'{"error":"invalid_request"') + assert await admin.a_create_client_authz_resource( + client_id=auth_client_id, payload={"name": "test-resource"}, skip_exists=True + ) == {"msg": "Already exists"} + + res = await admin.a_get_client_authz_resources(client_id=auth_client_id) + assert len(res) == 2 + assert {x["name"] for x in res} == {"Default Resource", "test-resource"} + + res = await admin.a_create_client_authz_resource( + client_id=auth_client_id, payload={"name": "temp-resource"} + ) + assert res["name"] == "temp-resource", res + temp_resource_id: str = res["_id"] + # Test update authz resources + await admin.a_update_client_authz_resource( + client_id=auth_client_id, + resource_id=temp_resource_id, + payload={"name": "temp-updated-resource"}, + ) + res = await admin.a_get_client_authz_resource( + client_id=auth_client_id, resource_id=temp_resource_id + ) + assert res["name"] == "temp-updated-resource", res + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client_authz_resource( + client_id=auth_client_id, + resource_id="invalid_resource_id", + payload={"name": "temp-updated-resource"}, + ) + assert err.match("404: b''"), err + await admin.a_delete_client_authz_resource( + client_id=auth_client_id, resource_id=temp_resource_id + ) + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_resource( + client_id=auth_client_id, resource_id=temp_resource_id + ) + assert err.match("404: b''") + + # Authz policies + res = await admin.a_get_client_authz_policies(client_id=auth_client_id) + assert len(res) == 1, res + assert res[0]["name"] == "Default Policy" + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_policies(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + role_id = (await admin.a_get_realm_role(role_name="offline_access"))["id"] + res = await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert res["name"] == "test-authz-rb-policy", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(await admin.a_get_client_authz_policies(client_id=auth_client_id)) == 2 + + res = await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy-delete", "roles": [{"id": role_id}]}, + ) + res2 = await admin.a_get_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert res["id"] == res2["id"] + await admin.a_delete_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert err.match("404: b''") + + res = await admin.a_create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + ) + assert res["name"] == "test-authz-policy", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert await admin.a_create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(await admin.a_get_client_authz_policies(client_id=auth_client_id)) == 3 + + # Test authz permissions + res = await admin.a_get_client_authz_permissions(client_id=auth_client_id) + assert len(res) == 1, res + assert res[0]["name"] == "Default Permission" + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_permissions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = await admin.a_create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert res, res + assert res["name"] == "test-permission-rb" + assert res["resources"] == [test_resource_id] + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert await admin.a_create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(await admin.a_get_client_authz_permissions(client_id=auth_client_id)) == 2 + + # Test authz scopes + res = await admin.a_get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 0, res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_scopes(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + res = await admin.a_create_client_authz_scopes( + client_id=auth_client_id, payload={"name": "test-authz-scope"} + ) + assert res["name"] == "test-authz-scope", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_scopes( + client_id="invalid_client_id", payload={"name": "test-authz-scope"} + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + assert await admin.a_create_client_authz_scopes( + client_id=auth_client_id, payload={"name": "test-authz-scope"} + ) + + res = await admin.a_get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 1 + assert {x["name"] for x in res} == {"test-authz-scope"} + + # Test service account user + res = await admin.a_get_client_service_account_user(client_id=auth_client_id) + assert res["username"] == "service-account-authz-client", res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_service_account_user(client_id=client_id) + assert err.match(UNKOWN_ERROR_REGEX) + + # Test delete client + res = await admin.a_delete_client(client_id=auth_client_id) + assert res == dict(), res + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client(client_id=auth_client_id) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test client credentials + await admin.a_create_client( + payload={ + "name": "test-confidential", + "enabled": True, + "protocol": "openid-connect", + "publicClient": False, + "redirectUris": ["http://localhost/*"], + "webOrigins": ["+"], + "clientId": "test-confidential", + "secret": "test-secret", + "clientAuthenticatorType": "client-secret", + } + ) + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_secrets(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + secrets = await admin.a_get_client_secrets( + client_id=await admin.a_get_client_id(client_id="test-confidential") + ) + assert secrets == {"type": "secret", "value": "test-secret"} + + with pytest.raises(KeycloakPostError) as err: + await admin.a_generate_client_secrets(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = await admin.a_generate_client_secrets( + client_id=await admin.a_get_client_id(client_id="test-confidential") + ) + assert res + assert ( + await admin.a_get_client_secrets( + client_id=await admin.a_get_client_id(client_id="test-confidential") + ) + == res + ) + + +@pytest.mark.asyncio +async def test_a_realm_roles(admin: KeycloakAdmin, realm: str): + """Test realm roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get realm roles + roles = await admin.a_get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # Test get realm roles with search text + searched_roles = await admin.a_get_realm_roles(search_text="uma_a") + searched_role_names = [x["name"] for x in searched_roles] + assert "uma_authorization" in searched_role_names, searched_role_names + assert "offline_access" not in searched_role_names, searched_role_names + + # Test empty members + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm_role_members(role_name="does-not-exist") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + members = await admin.a_get_realm_role_members(role_name="offline_access") + assert members == list(), members + + # Test create realm role + role_id = await admin.a_create_realm_role( + payload={"name": "test-realm-role"}, skip_exists=True + ) + assert role_id, role_id + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_realm_role(payload={"name": "test-realm-role"}) + assert err.match('409: b\'{"errorMessage":"Role with name test-realm-role already exists"}\'') + role_id_2 = await admin.a_create_realm_role( + payload={"name": "test-realm-role"}, skip_exists=True + ) + assert role_id == role_id_2 + + # Test get realm role by its id + role_id = (await admin.a_get_realm_role(role_name="test-realm-role"))["id"] + res = await admin.a_get_realm_role_by_id(role_id) + assert res["name"] == "test-realm-role" + + # Test update realm role + res = await admin.a_update_realm_role( + role_name="test-realm-role", payload={"name": "test-realm-role-update"} + ) + assert res == dict(), res + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_realm_role( + role_name="test-realm-role", payload={"name": "test-realm-role-update"} + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test realm role user assignment + user_id = await admin.a_create_user( + payload={"username": "role-testing", "email": "test@test.test"} + ) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_realm_roles(user_id=user_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_realm_roles( + user_id=user_id, + roles=[ + await admin.a_get_realm_role(role_name="offline_access"), + await admin.a_get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == dict(), res + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] for x in await admin.a_get_realm_role_members(role_name="offline_access") + ] + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] + for x in await admin.a_get_realm_role_members(role_name="test-realm-role-update") + ] + + roles = await admin.a_get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 3 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_roles_of_user(user_id=user_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_realm_roles_of_user( + user_id=user_id, roles=[await admin.a_get_realm_role(role_name="offline_access")] + ) + assert res == dict(), res + assert await admin.a_get_realm_role_members(role_name="offline_access") == list() + roles = await admin.a_get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" not in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + roles = await admin.a_get_available_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "uma_authorization" in [x["name"] for x in roles] + + # Test realm role group assignment + group_id = await admin.a_create_group(payload={"name": "test-group"}) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_group_realm_roles( + group_id=group_id, + roles=[ + await admin.a_get_realm_role(role_name="offline_access"), + await admin.a_get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == dict(), res + + roles = await admin.a_get_group_realm_roles(group_id=group_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX) + res = await admin.a_delete_group_realm_roles( + group_id=group_id, roles=[admin.get_realm_role(role_name="offline_access")] + ) + assert res == dict(), res + roles = await admin.a_get_group_realm_roles(group_id=group_id) + assert len(roles) == 1 + assert "test-realm-role-update" in [x["name"] for x in roles] + + # Test composite realm roles + composite_role = await admin.a_create_realm_role(payload={"name": "test-composite-role"}) + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_add_composite_realm_roles_to_role( + role_name=composite_role, roles=[admin.get_realm_role(role_name="test-realm-role-update")] + ) + assert res == dict(), res + + res = await admin.a_get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 1 + assert "test-realm-role-update" in res[0]["name"] + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_realm_roles_of_role(role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + res = await admin.a_get_composite_realm_roles_of_user(user_id=user_id) + assert len(res) == 4 + assert "offline_access" in {x["name"] for x in res} + assert "test-realm-role-update" in {x["name"] for x in res} + assert "uma_authorization" in {x["name"] for x in res} + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_realm_roles_of_user(user_id="bad") + assert err.match(USER_NOT_FOUND_REGEX), err + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_remove_composite_realm_roles_to_role( + role_name=composite_role, roles=[admin.get_realm_role(role_name="test-realm-role-update")] + ) + assert res == dict(), res + + res = await admin.a_get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 0 + + # Test realm role group list + res = await admin.a_get_realm_role_groups(role_name="test-realm-role-update") + assert len(res) == 1 + assert res[0]["id"] == group_id + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm_role_groups(role_name="non-existent-role") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test with query params + res = await admin.a_get_realm_role_groups(role_name="test-realm-role-update", query={"max": 1}) + assert len(res) == 1 + + # Test delete realm role + res = await admin.a_delete_realm_role(role_name=composite_role) + assert res == dict(), res + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_realm_role(role_name=composite_role) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "testcase, arg_brief_repr, includes_attributes", + [ + ("brief True", {"brief_representation": True}, False), + ("brief False", {"brief_representation": False}, True), + ("default", {}, False), + ], +) +async def test_a_role_attributes( + admin: KeycloakAdmin, + realm: str, + client: str, + arg_brief_repr: dict, + includes_attributes: bool, + testcase: str, +): + """Test getting role attributes for bulk calls. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param arg_brief_repr: Brief representation + :type arg_brief_repr: dict + :param includes_attributes: Indicator whether to include attributes + :type includes_attributes: bool + :param testcase: Test case + :type testcase: str + """ + # setup + attribute_role = "test-realm-role-w-attr" + test_attrs = {"attr1": ["val1"], "attr2": ["val2-1", "val2-2"]} + role_id = await admin.a_create_realm_role( + payload={"name": attribute_role, "attributes": test_attrs}, skip_exists=True + ) + assert role_id, role_id + + cli_role_id = await admin.a_create_client_role( + client, payload={"name": attribute_role, "attributes": test_attrs}, skip_exists=True + ) + assert cli_role_id, cli_role_id + + if not includes_attributes: + test_attrs = None + + # tests + roles = await admin.a_get_realm_roles(**arg_brief_repr) + roles_filtered = [role for role in roles if role["name"] == role_id] + assert roles_filtered, roles_filtered + role = roles_filtered[0] + assert role.get("attributes") == test_attrs, testcase + + roles = await admin.a_get_client_roles(client, **arg_brief_repr) + roles_filtered = [role for role in roles if role["name"] == cli_role_id] + assert roles_filtered, roles_filtered + role = roles_filtered[0] + assert role.get("attributes") == test_attrs, testcase + + # cleanup + res = await admin.a_delete_realm_role(role_name=attribute_role) + assert res == dict(), res + + res = await admin.a_delete_client_role(client, role_name=attribute_role) + assert res == dict(), res + + +@pytest.mark.asyncio +async def test_a_client_scope_realm_roles(admin: KeycloakAdmin, realm: str): + """Test client realm roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get realm roles + roles = await admin.a_get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # create realm role for test + role_id = await admin.a_create_realm_role( + payload={"name": "test-realm-role"}, skip_exists=True + ) + assert role_id, role_id + + # Test realm role client assignment + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"} + ) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_realm_roles_to_client_scope(client_id=client_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_realm_roles_to_client_scope( + client_id=client_id, + roles=[ + await admin.a_get_realm_role(role_name="offline_access"), + await admin.a_get_realm_role(role_name="test-realm-role"), + ], + ) + assert res == dict(), res + + roles = await admin.a_get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 2 + client_role_names = [x["name"] for x in roles] + assert "offline_access" in client_role_names, client_role_names + assert "test-realm-role" in client_role_names, client_role_names + assert "uma_authorization" not in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_realm_roles_of_client_scope(client_id=client_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_realm_roles_of_client_scope( + client_id=client_id, roles=[await admin.a_get_realm_role(role_name="offline_access")] + ) + assert res == dict(), res + roles = await admin.a_get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 1 + assert "test-realm-role" in [x["name"] for x in roles] + + res = await admin.a_delete_realm_roles_of_client_scope( + client_id=client_id, roles=[await admin.a_get_realm_role(role_name="test-realm-role")] + ) + assert res == dict(), res + roles = await admin.a_get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 0 + + +@pytest.mark.asyncio +async def test_a_client_scope_client_roles(admin: KeycloakAdmin, realm: str, client: str): + """Test client assignment of other client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + await admin.a_change_current_realm(realm) + + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"} + ) + + # Test get client roles + roles = await admin.a_get_client_roles_of_client_scope(client_id, client) + assert len(roles) == 0, roles + + # create client role for test + client_role_id = await admin.a_create_client_role( + client_role_id=client, payload={"name": "client-role-test"}, skip_exists=True + ) + assert client_role_id, client_role_id + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_client_roles_to_client_scope( + client_id=client_id, client_roles_owner_id=client, roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_client_roles_to_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name="client-role-test")], + ) + assert res == dict(), res + + roles = await admin.a_get_client_roles_of_client_scope( + client_id=client_id, client_roles_owner_id=client + ) + assert len(roles) == 1 + client_role_names = [x["name"] for x in roles] + assert "client-role-test" in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_roles_of_client_scope( + client_id=client_id, client_roles_owner_id=client, roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name="client-role-test")], + ) + assert res == dict(), res + roles = await admin.a_get_client_roles_of_client_scope( + client_id=client_id, client_roles_owner_id=client + ) + assert len(roles) == 0 + + +@pytest.mark.asyncio +async def test_a_client_default_client_scopes(admin: KeycloakAdmin, realm: str, client: str): + """Test client assignment of default client scopes. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + await admin.a_change_current_realm(realm) + + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"} + ) + # Test get client default scopes + # keycloak default roles: web-origins, acr, profile, roles, email + default_client_scopes = await admin.a_get_client_default_client_scopes(client_id) + assert len(default_client_scopes) == 5, default_client_scopes + + # Test add a client scope to client default scopes + default_client_scope = "test-client-default-scope" + new_client_scope = { + "name": default_client_scope, + "description": f"Test Client Scope: {default_client_scope}", + "protocol": "openid-connect", + "attributes": {}, + } + new_client_scope_id = await admin.a_create_client_scope(new_client_scope, skip_exists=False) + new_default_client_scope_data = { + "realm": realm, + "client": client_id, + "clientScopeId": new_client_scope_id, + } + await admin.a_add_client_default_client_scope( + client_id, new_client_scope_id, new_default_client_scope_data + ) + default_client_scopes = await admin.a_get_client_default_client_scopes(client_id) + assert len(default_client_scopes) == 6, default_client_scopes + + # Test remove a client default scope + await admin.a_delete_client_default_client_scope(client_id, new_client_scope_id) + default_client_scopes = await admin.a_get_client_default_client_scopes(client_id) + assert len(default_client_scopes) == 5, default_client_scopes + + +@pytest.mark.asyncio +async def test_a_client_optional_client_scopes(admin: KeycloakAdmin, realm: str, client: str): + """Test client assignment of optional client scopes. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + await admin.a_change_current_realm(realm) + + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"} + ) + # Test get client optional scopes + # keycloak optional roles: microprofile-jwt, offline_access, address, phone + optional_client_scopes = await admin.a_get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) == 4, optional_client_scopes + + # Test add a client scope to client optional scopes + optional_client_scope = "test-client-optional-scope" + new_client_scope = { + "name": optional_client_scope, + "description": f"Test Client Scope: {optional_client_scope}", + "protocol": "openid-connect", + "attributes": {}, + } + new_client_scope_id = await admin.a_create_client_scope(new_client_scope, skip_exists=False) + new_optional_client_scope_data = { + "realm": realm, + "client": client_id, + "clientScopeId": new_client_scope_id, + } + await admin.a_add_client_optional_client_scope( + client_id, new_client_scope_id, new_optional_client_scope_data + ) + optional_client_scopes = await admin.a_get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) == 5, optional_client_scopes + + # Test remove a client optional scope + await admin.a_delete_client_optional_client_scope(client_id, new_client_scope_id) + optional_client_scopes = await admin.a_get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) == 4, optional_client_scopes + + +@pytest.mark.asyncio +async def test_a_client_roles(admin: KeycloakAdmin, client: str): + """Test client roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param client: Keycloak client + :type client: str + """ + # Test get client roles + res = await admin.a_get_client_roles(client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_roles(client_id="bad") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test create client role + client_role_id = await admin.a_create_client_role( + client_role_id=client, payload={"name": "client-role-test"}, skip_exists=True + ) + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_role( + client_role_id=client, payload={"name": "client-role-test"} + ) + assert err.match('409: b\'{"errorMessage":"Role with name client-role-test already exists"}\'') + client_role_id_2 = await admin.a_create_client_role( + client_role_id=client, payload={"name": "client-role-test"}, skip_exists=True + ) + assert client_role_id == client_role_id_2 + + # Test get client role + res = await admin.a_get_client_role(client_id=client, role_name="client-role-test") + assert res["name"] == client_role_id + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + res_ = await admin.a_get_client_role_id(client_id=client, role_name="client-role-test") + assert res_ == res["id"] + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role_id(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + assert len(await admin.a_get_client_roles(client_id=client)) == 1 + + # Test update client role + res = await admin.a_update_client_role( + client_id=client, role_name="client-role-test", payload={"name": "client-role-test-update"} + ) + assert res == dict() + with pytest.raises(KeycloakPutError) as err: + res = await admin.a_update_client_role( + client_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test user with client role + res = await admin.a_get_client_role_members( + client_id=client, role_name="client-role-test-update" + ) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role_members(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_client_role(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_client_role( + user_id=user_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update") + ], + ) + assert res == dict() + assert ( + len( + await admin.a_get_client_role_members( + client_id=client, role_name="client-role-test-update" + ) + ) + == 1 + ) + + roles = await admin.a_get_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + roles = await admin.a_get_composite_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + roles = await admin.a_get_available_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 0, roles + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_roles_of_user(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + await admin.a_delete_client_roles_of_user( + user_id=user_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update") + ], + ) + assert len(await admin.a_get_client_roles_of_user(user_id=user_id, client_id=client)) == 0 + + # Test groups and client roles + res = await admin.a_get_client_role_groups( + client_id=client, role_name="client-role-test-update" + ) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role_groups(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + group_id = await admin.a_create_group(payload={"name": "test-group"}) + res = await admin.a_get_group_client_roles(group_id=group_id, client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group_client_roles(group_id=group_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_group_client_roles( + group_id=group_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update") + ], + ) + assert res == dict() + assert ( + len( + await admin.a_get_client_role_groups( + client_id=client, role_name="client-role-test-update" + ) + ) + == 1 + ) + assert len(await admin.a_get_group_client_roles(group_id=group_id, client_id=client)) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_group_client_roles( + group_id=group_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update") + ], + ) + assert res == dict() + + # Test composite client roles + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_composite_client_roles_to_role( + client_role_id=client, role_name="client-role-test-update", roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_add_composite_client_roles_to_role( + client_role_id=client, + role_name="client-role-test-update", + roles=[await admin.a_get_realm_role(role_name="offline_access")], + ) + assert res == dict() + assert (await admin.a_get_client_role(client_id=client, role_name="client-role-test-update"))[ + "composite" + ] + + # Test delete of client role + res = await admin.a_delete_client_role( + client_role_id=client, role_name="client-role-test-update" + ) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_role( + client_role_id=client, role_name="client-role-test-update" + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test of roles by id - Get role + await admin.a_create_client_role( + client_role_id=client, payload={"name": "client-role-by-id-test"}, skip_exists=True + ) + role = await admin.a_get_client_role(client_id=client, role_name="client-role-by-id-test") + res = await admin.a_get_role_by_id(role_id=role["id"]) + assert res["name"] == "client-role-by-id-test" + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_role_by_id(role_id="bad") + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + # Test of roles by id - Update role + res = await admin.a_update_role_by_id( + role_id=role["id"], payload={"name": "client-role-by-id-test-update"} + ) + assert res == dict() + with pytest.raises(KeycloakPutError) as err: + res = await admin.a_update_role_by_id( + role_id="bad", payload={"name": "client-role-by-id-test-update"} + ) + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + # Test of roles by id - Delete role + res = await admin.a_delete_role_by_id(role_id=role["id"]) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_role_by_id(role_id="bad") + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + +@pytest.mark.asyncio +async def test_a_enable_token_exchange(admin: KeycloakAdmin, realm: str): + """Test enable token exchange. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :raises AssertionError: In case of bad configuration + """ + # Test enabling token exchange between two confidential clients + await admin.a_change_current_realm(realm) + + # Create test clients + source_client_id = await admin.a_create_client( + payload={"name": "Source Client", "clientId": "source-client"} + ) + target_client_id = await admin.a_create_client( + payload={"name": "Target Client", "clientId": "target-client"} + ) + for c in await admin.a_get_clients(): + if c["clientId"] == "realm-management": + realm_management_id = c["id"] + break + else: + raise AssertionError("Missing realm management client") + + # Enable permissions on the Superset client + await admin.a_update_client_management_permissions( + payload={"enabled": True}, client_id=target_client_id + ) + + # Fetch various IDs and strings needed when creating the permission + token_exchange_permission_id = ( + await admin.a_get_client_management_permissions(client_id=target_client_id) + )["scopePermissions"]["token-exchange"] + scopes = await admin.a_get_client_authz_policy_scopes( + client_id=realm_management_id, policy_id=token_exchange_permission_id + ) + + for s in scopes: + if s["name"] == "token-exchange": + token_exchange_scope_id = s["id"] + break + else: + raise AssertionError("Missing token-exchange scope") + + resources = await admin.a_get_client_authz_policy_resources( + client_id=realm_management_id, policy_id=token_exchange_permission_id + ) + for r in resources: + if r["name"] == f"client.resource.{target_client_id}": + token_exchange_resource_id = r["_id"] + break + else: + raise AssertionError("Missing client resource") + + # Create a client policy for source client + policy_name = "Exchange source client token with target client token" + client_policy_id = ( + await admin.a_create_client_authz_client_policy( + payload={ + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": policy_name, + "clients": [source_client_id], + }, + client_id=realm_management_id, + ) + )["id"] + policies = await admin.a_get_client_authz_client_policies(client_id=realm_management_id) + for policy in policies: + if policy["name"] == policy_name: + assert policy["clients"] == [source_client_id] + break + else: + raise AssertionError("Missing client policy") + + # Update permissions on the target client to reference this policy + permission_name = ( + await admin.a_get_client_authz_scope_permission( + client_id=realm_management_id, scope_id=token_exchange_permission_id + ) + )["name"] + await admin.a_update_client_authz_scope_permission( + payload={ + "id": token_exchange_permission_id, + "name": permission_name, + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [token_exchange_resource_id], + "scopes": [token_exchange_scope_id], + "policies": [client_policy_id], + }, + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + ) + + # Create permissions on the target client to reference this policy + await admin.a_create_client_authz_scope_permission( + payload={ + "id": "some-id", + "name": "test-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [token_exchange_resource_id], + "scopes": [token_exchange_scope_id], + "policies": [client_policy_id], + }, + client_id=realm_management_id, + ) + permission_name = ( + await admin.a_get_client_authz_scope_permission( + client_id=realm_management_id, scope_id=token_exchange_permission_id + ) + )["name"] + assert permission_name.startswith("token-exchange.permission.client.") + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_scope_permission( + payload={"name": "test-permission", "scopes": [token_exchange_scope_id]}, + client_id="realm_management_id", + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + +@pytest.mark.asyncio +async def test_a_email(admin: KeycloakAdmin, user: str): + """Test email. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + # Emails will fail as we don't have SMTP test setup + with pytest.raises(KeycloakPutError) as err: + await admin.a_send_update_account(user_id=user, payload=dict()) + assert err.match(UNKOWN_ERROR_REGEX), err + + admin.update_user(user_id=user, payload={"enabled": True}) + with pytest.raises(KeycloakPutError) as err: + await admin.a_send_verify_email(user_id=user) + assert err.match('500: b\'{"errorMessage":"Failed to send .*"}\'') + + +@pytest.mark.asyncio +async def test_a_get_sessions(admin: KeycloakAdmin): + """Test get sessions. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + sessions = await admin.a_get_sessions( + user_id=admin.get_user_id(username=admin.connection.username) + ) + assert len(sessions) >= 1 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_sessions(user_id="bad") + assert err.match(USER_NOT_FOUND_REGEX) + + +@pytest.mark.asyncio +async def test_a_get_client_installation_provider(admin: KeycloakAdmin, client: str): + """Test get client installation provider. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param client: Keycloak client + :type client: str + """ + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_installation_provider(client_id=client, provider_id="bad") + assert err.match('404: b\'{"error":"Unknown Provider".*}\'') + + installation = await admin.a_get_client_installation_provider( + client_id=client, provider_id="keycloak-oidc-keycloak-json" + ) + assert set(installation.keys()) == { + "auth-server-url", + "confidential-port", + "credentials", + "realm", + "resource", + "ssl-required", + } + + +@pytest.mark.asyncio +async def test_a_auth_flows(admin: KeycloakAdmin, realm: str): + """Test auth flows. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + res = await admin.a_get_authentication_flows() + assert len(res) <= 8, res + default_flows = len(res) + assert {x["alias"] for x in res}.issubset( + { + "reset credentials", + "browser", + "registration", + "http challenge", + "docker auth", + "direct grant", + "first broker login", + "clients", + } + ) + assert set(res[0].keys()) == { + "alias", + "authenticationExecutions", + "builtIn", + "description", + "id", + "providerId", + "topLevel", + } + assert {x["alias"] for x in res}.issubset( + { + "reset credentials", + "browser", + "registration", + "docker auth", + "direct grant", + "first broker login", + "clients", + "http challenge", + } + ) + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authentication_flow_for_id(flow_id="bad") + assert err.match('404: b\'{"error":"Could not find flow with id".*}\'') + browser_flow_id = [x for x in res if x["alias"] == "browser"][0]["id"] + res = await admin.a_get_authentication_flow_for_id(flow_id=browser_flow_id) + assert res["alias"] == "browser" + + # Test copying + with pytest.raises(KeycloakPostError) as err: + await admin.a_copy_authentication_flow(payload=dict(), flow_alias="bad") + assert err.match("404: b''") + + res = await admin.a_copy_authentication_flow( + payload={"newName": "test-browser"}, flow_alias="browser" + ) + assert res == b"", res + assert len(await admin.a_get_authentication_flows()) == (default_flows + 1) + + # Test create + res = await admin.a_create_authentication_flow( + payload={"alias": "test-create", "providerId": "basic-flow"} + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_authentication_flow( + payload={"alias": "test-create", "builtIn": False} + ) + assert err.match('409: b\'{"errorMessage":"Flow test-create already exists"}\'') + assert await admin.a_create_authentication_flow( + payload={"alias": "test-create"}, skip_exists=True + ) == {"msg": "Already exists"} + + # Test flow executions + res = await admin.a_get_authentication_flow_executions(flow_alias="browser") + assert len(res) == 8, res + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authentication_flow_executions(flow_alias="bad") + assert err.match("404: b''") + exec_id = res[0]["id"] + + res = await admin.a_get_authentication_flow_execution(execution_id=exec_id) + assert set(res.keys()) == { + "alternative", + "authenticator", + "authenticatorFlow", + "conditional", + "disabled", + "enabled", + "id", + "parentFlow", + "priority", + "required", + "requirement", + }, res + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authentication_flow_execution(execution_id="bad") + assert err.match(ILLEGAL_EXECUTION_REGEX) + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_authentication_flow_execution(payload=dict(), flow_alias="browser") + assert err.match('400: b\'{"error":"It is illegal to add execution to a built in flow".*}\'') + + res = await admin.a_create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, flow_alias="test-create" + ) + assert res == b"" + assert len(await admin.a_get_authentication_flow_executions(flow_alias="test-create")) == 1 + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_authentication_flow_executions( + payload={"required": "yes"}, flow_alias="test-create" + ) + assert err.match('400: b\'{"error":"Unrecognized field') + payload = (await admin.a_get_authentication_flow_executions(flow_alias="test-create"))[0] + payload["displayName"] = "test" + res = await admin.a_update_authentication_flow_executions( + payload=payload, flow_alias="test-create" + ) + assert res + + exec_id = (await admin.a_get_authentication_flow_executions(flow_alias="test-create"))[0]["id"] + res = await admin.a_delete_authentication_flow_execution(execution_id=exec_id) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_authentication_flow_execution(execution_id=exec_id) + assert err.match(ILLEGAL_EXECUTION_REGEX) + + # Test subflows + res = await admin.a_create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-browser", + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_authentication_flow_subflow( + payload={"alias": "test-subflow", "providerId": "basic-flow"}, + flow_alias="test-browser", + ) + assert err.match('409: b\'{"errorMessage":"New flow alias name already exists"}\'') + res = await admin.a_create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-create", + skip_exists=True, + ) + assert res == {"msg": "Already exists"} + + # Test delete auth flow + flow_id = [ + x for x in await admin.a_get_authentication_flows() if x["alias"] == "test-browser" + ][0]["id"] + res = await admin.a_delete_authentication_flow(flow_id=flow_id) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_authentication_flow(flow_id=flow_id) + assert err.match('404: b\'{"error":"Could not find flow with id".*}\'') + + +@pytest.mark.asyncio +async def test_a_authentication_configs(admin: KeycloakAdmin, realm: str): + """Test authentication configs. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test list of auth providers + res = await admin.a_get_authenticator_providers() + assert len(res) <= 38 + + res = await admin.a_get_authenticator_provider_config_description(provider_id="auth-cookie") + assert res == { + "helpText": "Validates the SSO cookie set by the auth server.", + "name": "Cookie", + "properties": [], + "providerId": "auth-cookie", + } + + # Test authenticator config + # Currently unable to find a sustainable way to fetch the config id, + # therefore testing only failures + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authenticator_config(config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_authenticator_config(payload=dict(), config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_authenticator_config(config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + +@pytest.mark.asyncio +async def test_a_sync_users(admin: KeycloakAdmin, realm: str): + """Test sync users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Only testing the error message + with pytest.raises(KeycloakPostError) as err: + await admin.a_sync_users(storage_id="does-not-exist", action="triggerFullSync") + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + +@pytest.mark.asyncio +async def test_a_client_scopes(admin: KeycloakAdmin, realm: str): + """Test client scopes. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get client scopes + res = await admin.a_get_client_scopes() + scope_names = {x["name"] for x in res} + assert len(res) == 10 + assert "email" in scope_names + assert "profile" in scope_names + assert "offline_access" in scope_names + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_scope(client_scope_id="does-not-exist") + assert err.match(NO_CLIENT_SCOPE_REGEX) + + scope = await admin.a_get_client_scope(client_scope_id=res[0]["id"]) + assert res[0] == scope + + scope = await admin.a_get_client_scope_by_name(client_scope_name=res[0]["name"]) + assert res[0] == scope + + # Test create client scope + res = await admin.a_create_client_scope(payload={"name": "test-scope"}, skip_exists=True) + assert res + res2 = await admin.a_create_client_scope(payload={"name": "test-scope"}, skip_exists=True) + assert res == res2 + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_scope(payload={"name": "test-scope"}, skip_exists=False) + assert err.match('409: b\'{"errorMessage":"Client Scope test-scope already exists"}\'') + + # Test update client scope + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client_scope(client_scope_id="does-not-exist", payload=dict()) + assert err.match(NO_CLIENT_SCOPE_REGEX) + + res_update = await admin.a_update_client_scope( + client_scope_id=res, payload={"name": "test-scope-update"} + ) + assert res_update == dict() + assert (await admin.a_get_client_scope(client_scope_id=res))["name"] == "test-scope-update" + + # Test get mappers + mappers = await admin.a_get_mappers_from_client_scope(client_scope_id=res) + assert mappers == list() + + # Test add mapper + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_mapper_to_client_scope(client_scope_id=res, payload=dict()) + assert err.match('404: b\'{"error":"ProtocolMapper provider not found".*}\'') + + res_add = await admin.a_add_mapper_to_client_scope( + client_scope_id=res, + payload={ + "name": "test-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + }, + ) + assert res_add == b"" + assert len(await admin.a_get_mappers_from_client_scope(client_scope_id=res)) == 1 + + # Test update mapper + test_mapper = (await admin.a_get_mappers_from_client_scope(client_scope_id=res))[0] + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_mapper_in_client_scope( + client_scope_id="does-not-exist", protocol_mapper_id=test_mapper["id"], payload=dict() + ) + assert err.match(NO_CLIENT_SCOPE_REGEX) + test_mapper["config"]["user.attribute"] = "test" + res_update = await admin.a_update_mapper_in_client_scope( + client_scope_id=res, protocol_mapper_id=test_mapper["id"], payload=test_mapper + ) + assert res_update == dict() + assert (await admin.a_get_mappers_from_client_scope(client_scope_id=res))[0]["config"][ + "user.attribute" + ] == "test" + + # Test delete mapper + res_del = await admin.a_delete_mapper_from_client_scope( + client_scope_id=res, protocol_mapper_id=test_mapper["id"] + ) + assert res_del == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_mapper_from_client_scope( + client_scope_id=res, protocol_mapper_id=test_mapper["id"] + ) + assert err.match('404: b\'{"error":"Model not found".*}\'') + + # Test default default scopes + res_defaults = await admin.a_get_default_default_client_scopes() + assert len(res_defaults) == 6 + + with pytest.raises(KeycloakPutError) as err: + await admin.a_add_default_default_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_add = await admin.a_add_default_default_client_scope(scope_id=res) + assert res_add == dict() + assert len(await admin.a_get_default_default_client_scopes()) == 7 + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_default_default_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_del = await admin.a_delete_default_default_client_scope(scope_id=res) + assert res_del == dict() + assert len(await admin.a_get_default_default_client_scopes()) == 6 + + # Test default optional scopes + res_defaults = await admin.a_get_default_optional_client_scopes() + assert len(res_defaults) == 4 + + with pytest.raises(KeycloakPutError) as err: + await admin.a_add_default_optional_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_add = await admin.a_add_default_optional_client_scope(scope_id=res) + assert res_add == dict() + assert len(await admin.a_get_default_optional_client_scopes()) == 5 + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_default_optional_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_del = await admin.a_delete_default_optional_client_scope(scope_id=res) + assert res_del == dict() + assert len(await admin.a_get_default_optional_client_scopes()) == 4 + + # Test client scope delete + res_del = await admin.a_delete_client_scope(client_scope_id=res) + assert res_del == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_scope(client_scope_id=res) + assert err.match(NO_CLIENT_SCOPE_REGEX) + + +@pytest.mark.asyncio +async def test_a_components(admin: KeycloakAdmin, realm: str): + """Test components. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get components + res = await admin.a_get_components() + assert len(res) == 12 + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_component(component_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + res_get = await admin.a_get_component(component_id=res[0]["id"]) + assert res_get == res[0] + + # Test create component + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_component(payload={"bad": "dict"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + res = await admin.a_create_component( + payload={ + "name": "Test Component", + "providerId": "max-clients", + "providerType": "org.keycloak.services.clientregistration." + + "policy.ClientRegistrationPolicy", + "config": {"max-clients": ["1000"]}, + } + ) + assert res + assert (await admin.a_get_component(component_id=res))["name"] == "Test Component" + + # Test update component + component = await admin.a_get_component(component_id=res) + component["name"] = "Test Component Update" + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_component(component_id="does-not-exist", payload=dict()) + assert err.match('404: b\'{"error":"Could not find component".*}\'') + res_upd = await admin.a_update_component(component_id=res, payload=component) + assert res_upd == dict() + assert (await admin.a_get_component(component_id=res))["name"] == "Test Component Update" + + # Test delete component + res_del = await admin.a_delete_component(component_id=res) + assert res_del == dict() + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_component(component_id=res) + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + +@pytest.mark.asyncio +async def test_a_keys(admin: KeycloakAdmin, realm: str): + """Test keys. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + assert set((await admin.a_get_keys())["active"].keys()) == { + "AES", + "HS256", + "RS256", + "RSA-OAEP", + } or set((await admin.a_get_keys())["active"].keys()) == {"RSA-OAEP", "RS256", "HS512", "AES"} + assert {k["algorithm"] for k in (await admin.a_get_keys())["keys"]} == { + "HS256", + "RSA-OAEP", + "AES", + "RS256", + } or {k["algorithm"] for k in (await admin.a_get_keys())["keys"]} == { + "HS512", + "RSA-OAEP", + "AES", + "RS256", + } + + +@pytest.mark.asyncio +async def test_a_admin_events(admin: KeycloakAdmin, realm: str): + """Test events. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + await admin.a_create_client(payload={"name": "test", "clientId": "test"}) + + events = await admin.a_get_admin_events() + assert events == list() + + +@pytest.mark.asyncio +async def test_a_user_events(admin: KeycloakAdmin, realm: str): + """Test events. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + events = await admin.a_get_events() + assert events == list() + + with pytest.raises(KeycloakPutError) as err: + await admin.a_set_events(payload={"bad": "conf"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + res = await admin.a_set_events( + payload={"adminEventsDetailsEnabled": True, "adminEventsEnabled": True} + ) + assert res == dict() + + await admin.a_create_client(payload={"name": "test", "clientId": "test"}) + + events = await admin.a_get_events() + assert events == list() + + +@pytest.mark.asyncio +@freezegun.freeze_time("2023-02-25 10:00:00") +async def test_a_auto_refresh(admin_frozen: KeycloakAdmin, realm: str): + """Test auto refresh token. + + :param admin_frozen: Keycloak Admin client with time frozen in place + :type admin_frozen: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin = admin_frozen + admin.get_realm(realm) + # Test get refresh + admin.connection.custom_headers = { + "Authorization": "Bearer bad", + "Content-Type": "application/json", + } + + with pytest.raises(KeycloakAuthenticationError) as err: + await admin.a_get_realm(realm_name=realm) + assert err.match('401: b\'{"error":"HTTP 401 Unauthorized".*}\'') + + # Freeze time to simulate the access token expiring + with freezegun.freeze_time("2023-02-25 10:05:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25 10:05:00") + assert await admin.a_get_realm(realm_name=realm) + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25 10:05:00") + + # Test bad refresh token, but first make sure access token has expired again + with freezegun.freeze_time("2023-02-25 10:10:00"): + admin.connection.custom_headers = {"Content-Type": "application/json"} + admin.connection.token["refresh_token"] = "bad" + with pytest.raises(KeycloakPostError) as err: + await admin.a_get_realm(realm_name="test-refresh") + assert err.match( + '400: b\'{"error":"invalid_grant","error_description":"Invalid refresh token"}\'' + ) + admin.connection.get_token() + + # Test post refresh + with freezegun.freeze_time("2023-02-25 10:15:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25 10:15:00") + admin.connection.token = None + assert await admin.a_create_realm(payload={"realm": "test-refresh"}) == b"" + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25 10:15:00") + + # Test update refresh + with freezegun.freeze_time("2023-02-25 10:25:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25 10:25:00") + admin.connection.token = None + assert ( + await admin.a_update_realm(realm_name="test-refresh", payload={"accountTheme": "test"}) + == dict() + ) + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25 10:25:00") + + # Test delete refresh + with freezegun.freeze_time("2023-02-25 10:35:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25 10:35:00") + admin.connection.token = None + assert await admin.a_delete_realm(realm_name="test-refresh") == dict() + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25 10:35:00") + + +@pytest.mark.asyncio +async def test_a_get_required_actions(admin: KeycloakAdmin, realm: str): + """Test required actions. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + ractions = await admin.a_get_required_actions() + assert isinstance(ractions, list) + for ra in ractions: + for key in [ + "alias", + "name", + "providerId", + "enabled", + "defaultAction", + "priority", + "config", + ]: + assert key in ra + + +@pytest.mark.asyncio +async def test_a_get_required_action_by_alias(admin: KeycloakAdmin, realm: str): + """Test get required action by alias. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + ractions = await admin.a_get_required_actions() + ra = await admin.a_get_required_action_by_alias("UPDATE_PASSWORD") + assert ra in ractions + assert ra["alias"] == "UPDATE_PASSWORD" + assert await admin.a_get_required_action_by_alias("does-not-exist") is None + + +@pytest.mark.asyncio +async def test_a_update_required_action(admin: KeycloakAdmin, realm: str): + """Test update required action. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + ra = await admin.a_get_required_action_by_alias("UPDATE_PASSWORD") + old = copy.deepcopy(ra) + ra["enabled"] = False + admin.update_required_action("UPDATE_PASSWORD", ra) + newra = await admin.a_get_required_action_by_alias("UPDATE_PASSWORD") + assert old != newra + assert newra["enabled"] is False + + +@pytest.mark.asyncio +async def test_a_get_composite_client_roles_of_group( + admin: KeycloakAdmin, realm: str, client: str, group: str, composite_client_role: str +): + """Test get composite client roles of group. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param group: Keycloak group + :type group: str + :param composite_client_role: Composite client role + :type composite_client_role: str + """ + await admin.a_change_current_realm(realm) + role = await admin.a_get_client_role(client, composite_client_role) + await admin.a_assign_group_client_roles(group_id=group, client_id=client, roles=[role]) + result = await admin.a_get_composite_client_roles_of_group(client, group) + assert role["id"] in [x["id"] for x in result] + + +@pytest.mark.asyncio +async def test_a_get_role_client_level_children( + admin: KeycloakAdmin, realm: str, client: str, composite_client_role: str, client_role: str +): + """Test get children of composite client role. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param composite_client_role: Composite client role + :type composite_client_role: str + :param client_role: Client role + :type client_role: str + """ + await admin.a_change_current_realm(realm) + child = await admin.a_get_client_role(client, client_role) + parent = await admin.a_get_client_role(client, composite_client_role) + res = await admin.a_get_role_client_level_children(client, parent["id"]) + assert child["id"] in [x["id"] for x in res] + + +@pytest.mark.asyncio +async def test_a_upload_certificate( + admin: KeycloakAdmin, realm: str, client: str, selfsigned_cert: tuple +): + """Test upload certificate. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param selfsigned_cert: Selfsigned certificates + :type selfsigned_cert: tuple + """ + await admin.a_change_current_realm(realm) + cert, _ = selfsigned_cert + cert = cert.decode("utf-8").strip() + admin.upload_certificate(client, cert) + cl = await admin.a_get_client(client) + assert cl["attributes"]["jwt.credential.certificate"] == "".join(cert.splitlines()[1:-1]) + + +@pytest.mark.asyncio +async def test_a_get_bruteforce_status_for_user( + admin: KeycloakAdmin, oid_with_credentials: Tuple[KeycloakOpenID, str, str], realm: str +): + """Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, password = oid_with_credentials + await admin.a_change_current_realm(realm) + + # Turn on bruteforce protection + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + try: + oid.token(username=username, password="wrongpassword") + except KeycloakAuthenticationError: + pass + + user_id = await admin.a_get_user_id(username) + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + + assert bruteforce_status["numFailures"] == 1 + + # Cleanup + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +@pytest.mark.asyncio +async def test_a_clear_bruteforce_attempts_for_user( + admin: KeycloakAdmin, oid_with_credentials: Tuple[KeycloakOpenID, str, str], realm: str +): + """Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, password = oid_with_credentials + await admin.a_change_current_realm(realm) + + # Turn on bruteforce protection + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + try: + oid.token(username=username, password="wrongpassword") + except KeycloakAuthenticationError: + pass + + user_id = await admin.a_get_user_id(username) + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 1 + + res = await admin.a_clear_bruteforce_attempts_for_user(user_id) + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 0 + + # Cleanup + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +@pytest.mark.asyncio +async def test_a_clear_bruteforce_attempts_for_all_users( + admin: KeycloakAdmin, oid_with_credentials: Tuple[KeycloakOpenID, str, str], realm: str +): + """Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, password = oid_with_credentials + await admin.a_change_current_realm(realm) + + # Turn on bruteforce protection + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + try: + oid.token(username=username, password="wrongpassword") + except KeycloakAuthenticationError: + pass + + user_id = await admin.a_get_user_id(username) + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 1 + + res = await admin.a_clear_all_bruteforce_attempts() + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 0 + + # Cleanup + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +@pytest.mark.asyncio +async def test_a_default_realm_role_present(realm: str, admin: KeycloakAdmin) -> None: + """Test that the default realm role is present in a brand new realm. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + assert f"default-roles-{realm}" in [x["name"] for x in admin.get_realm_roles()] + assert ( + len( + [ + x["name"] + for x in await admin.a_get_realm_roles() + if x["name"] == f"default-roles-{realm}" + ] + ) + == 1 + ) + + +@pytest.mark.asyncio +async def test_a_get_default_realm_role_id(realm: str, admin: KeycloakAdmin) -> None: + """Test getter for the ID of the default realm role. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + assert ( + await admin.a_get_default_realm_role_id() + == [ + x["id"] + for x in await admin.a_get_realm_roles() + if x["name"] == f"default-roles-{realm}" + ][0] + ) + + +@pytest.mark.asyncio +async def test_a_realm_default_roles(admin: KeycloakAdmin, realm: str) -> None: + """Test getting, adding and deleting default realm roles. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + + # Test listing all default realm roles + roles = await admin.a_get_realm_default_roles() + assert len(roles) == 2 + assert {x["name"] for x in roles} == {"offline_access", "uma_authorization"} + + with pytest.raises(KeycloakGetError) as err: + await admin.a_change_current_realm("doesnotexist") + await admin.a_get_realm_default_roles() + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + await admin.a_change_current_realm(realm) + + # Test removing a default realm role + res = await admin.a_remove_realm_default_roles(payload=[roles[0]]) + assert res == {} + assert roles[0] not in await admin.a_get_realm_default_roles() + assert len(await admin.a_get_realm_default_roles()) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_realm_default_roles(payload=[{"id": "bad id"}]) + assert err.match('404: b\'{"error":"Could not find composite role".*}\'') + + # Test adding a default realm role + res = await admin.a_add_realm_default_roles(payload=[roles[0]]) + assert res == {} + assert roles[0] in await admin.a_get_realm_default_roles() + assert len(await admin.a_get_realm_default_roles()) == 2 + + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_realm_default_roles(payload=[{"id": "bad id"}]) + assert err.match('404: b\'{"error":"Could not find composite role".*}\'') + + +@pytest.mark.asyncio +async def test_a_clear_keys_cache(realm: str, admin: KeycloakAdmin) -> None: + """Test clearing the keys cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + res = await admin.a_clear_keys_cache() + assert res == {} + + +@pytest.mark.asyncio +async def test_a_clear_realm_cache(realm: str, admin: KeycloakAdmin) -> None: + """Test clearing the realm cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + res = await admin.a_clear_realm_cache() + assert res == {} + + +@pytest.mark.asyncio +async def test_a_clear_user_cache(realm: str, admin: KeycloakAdmin) -> None: + """Test clearing the user cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + res = await admin.a_clear_user_cache() + assert res == {} + + +@pytest.mark.asyncio +async def test_a_initial_access_token( + admin: KeycloakAdmin, oid_with_credentials: Tuple[KeycloakOpenID, str, str] +) -> None: + """Test initial access token and client creation. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + res = await admin.a_create_initial_access_token(2, 3) + assert "token" in res + assert res["count"] == 2 + assert res["expiration"] == 3 + + oid, username, password = oid_with_credentials + + client = str(uuid.uuid4()) + secret = str(uuid.uuid4()) + + res = await oid.a_register_client( + token=res["token"], + payload={ + "name": "DynamicRegisteredClient", + "clientId": client, + "enabled": True, + "publicClient": False, + "protocol": "openid-connect", + "secret": secret, + "clientAuthenticatorType": "client-secret", + }, + ) + assert res["clientId"] == client + + new_secret = str(uuid.uuid4()) + res = await oid.a_update_client( + res["registrationAccessToken"], client, payload={"secret": new_secret} + ) + assert res["secret"] == new_secret + + +@pytest.mark.asyncio +async def test_a_refresh_token(admin: KeycloakAdmin): + """Test refresh token on connection even if it is expired. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.get_realms() + assert admin.connection.token is not None + await admin.a_user_logout(await admin.a_get_user_id(admin.connection.username)) + admin.connection.refresh_token() + + +def test_counter_part(): + """Test that each function has its async counter part.""" + admin_methods = [func for func in dir(KeycloakAdmin) if callable(getattr(KeycloakAdmin, func))] + sync_methods = [ + method + for method in admin_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in admin_methods if iscoroutinefunction(getattr(KeycloakAdmin, method)) + ] + + for method in sync_methods: + async_method = f"a_{method}" + assert (async_method in admin_methods) is True + sync_sign = signature(getattr(KeycloakAdmin, method)) + async_sign = signature(getattr(KeycloakAdmin, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_keycloak_openid.py b/tests/test_keycloak_openid.py index dd3067a..20b1599 100644 --- a/tests/test_keycloak_openid.py +++ b/tests/test_keycloak_openid.py @@ -1,5 +1,6 @@ """Test module for KeycloakOpenID.""" +from inspect import iscoroutinefunction, signature from typing import Tuple from unittest import mock @@ -487,3 +488,515 @@ def test_device(oid_with_credentials_device: Tuple[KeycloakOpenID, str, str]): "expires_in": 600, "interval": 5, } + + +# async function start + + +@pytest.mark.asyncio +async def test_a_well_known(oid: KeycloakOpenID): + """Test the well_known method. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + res = await oid.a_well_known() + assert res is not None + assert res != dict() + for key in [ + "acr_values_supported", + "authorization_encryption_alg_values_supported", + "authorization_encryption_enc_values_supported", + "authorization_endpoint", + "authorization_signing_alg_values_supported", + "backchannel_authentication_endpoint", + "backchannel_authentication_request_signing_alg_values_supported", + "backchannel_logout_session_supported", + "backchannel_logout_supported", + "backchannel_token_delivery_modes_supported", + "check_session_iframe", + "claim_types_supported", + "claims_parameter_supported", + "claims_supported", + "code_challenge_methods_supported", + "device_authorization_endpoint", + "end_session_endpoint", + "frontchannel_logout_session_supported", + "frontchannel_logout_supported", + "grant_types_supported", + "id_token_encryption_alg_values_supported", + "id_token_encryption_enc_values_supported", + "id_token_signing_alg_values_supported", + "introspection_endpoint", + "introspection_endpoint_auth_methods_supported", + "introspection_endpoint_auth_signing_alg_values_supported", + "issuer", + "jwks_uri", + "mtls_endpoint_aliases", + "pushed_authorization_request_endpoint", + "registration_endpoint", + "request_object_encryption_alg_values_supported", + "request_object_encryption_enc_values_supported", + "request_object_signing_alg_values_supported", + "request_parameter_supported", + "request_uri_parameter_supported", + "require_pushed_authorization_requests", + "require_request_uri_registration", + "response_modes_supported", + "response_types_supported", + "revocation_endpoint", + "revocation_endpoint_auth_methods_supported", + "revocation_endpoint_auth_signing_alg_values_supported", + "scopes_supported", + "subject_types_supported", + "tls_client_certificate_bound_access_tokens", + "token_endpoint", + "token_endpoint_auth_methods_supported", + "token_endpoint_auth_signing_alg_values_supported", + "userinfo_encryption_alg_values_supported", + "userinfo_encryption_enc_values_supported", + "userinfo_endpoint", + "userinfo_signing_alg_values_supported", + ]: + assert key in res + + +@pytest.mark.asyncio +async def test_a_auth_url(env, oid: KeycloakOpenID): + """Test the auth_url method. + + :param env: Environment fixture + :type env: KeycloakTestEnv + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + res = await oid.a_auth_url(redirect_uri="http://test.test/*") + assert ( + res + == f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}/realms/{oid.realm_name}" + + f"/protocol/openid-connect/auth?client_id={oid.client_id}&response_type=code" + + "&redirect_uri=http://test.test/*&scope=email&state=" + ) + + +@pytest.mark.asyncio +async def test_a_token(oid_with_credentials: Tuple[KeycloakOpenID, str, str]): + """Test the token method. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + # Test with dummy totp + token = await oid.a_token(username=username, password=password, totp="123456") + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + # Test with extra param + token = await oid.a_token(username=username, password=password, extra_param="foo") + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + +@pytest.mark.asyncio +async def test_a_exchange_token( + oid_with_credentials: Tuple[KeycloakOpenID, str, str], admin: KeycloakAdmin +): + """Test the exchange token method. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Verify existing user + oid, username, password = oid_with_credentials + + # Allow impersonation + await admin.a_change_current_realm(oid.realm_name) + await admin.a_assign_client_role( + user_id=await admin.a_get_user_id(username=username), + client_id=await admin.a_get_client_id(client_id="realm-management"), + roles=[ + await admin.a_get_client_role( + client_id=admin.get_client_id(client_id="realm-management"), + role_name="impersonation", + ) + ], + ) + + token = await oid.a_token(username=username, password=password) + assert await oid.a_userinfo(token=token["access_token"]) == { + "email": f"{username}@test.test", + "email_verified": True, + "family_name": "last", + "given_name": "first", + "name": "first last", + "preferred_username": username, + "sub": mock.ANY, + } + + # Exchange token with the new user + new_token = oid.exchange_token( + token=token["access_token"], audience=oid.client_id, subject=username + ) + assert await oid.a_userinfo(token=new_token["access_token"]) == { + "email": f"{username}@test.test", + "email_verified": True, + "family_name": "last", + "given_name": "first", + "name": "first last", + "preferred_username": username, + "sub": mock.ANY, + } + assert token != new_token + + +@pytest.mark.asyncio +async def test_a_logout(oid_with_credentials): + """Test logout. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + + token = await oid.a_token(username=username, password=password) + assert await oid.a_userinfo(token=token["access_token"]) != dict() + assert await oid.a_logout(refresh_token=token["refresh_token"]) == dict() + + with pytest.raises(KeycloakAuthenticationError): + await oid.a_userinfo(token=token["access_token"]) + + +@pytest.mark.asyncio +async def test_a_certs(oid: KeycloakOpenID): + """Test certificates. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + assert len((await oid.a_certs())["keys"]) == 2 + + +@pytest.mark.asyncio +async def test_a_public_key(oid: KeycloakOpenID): + """Test public key. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + assert await oid.a_public_key() is not None + + +@pytest.mark.asyncio +async def test_a_entitlement( + oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str], admin: KeycloakAdmin +): + """Test entitlement. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + resource_server_id = admin.get_client_authz_resources( + client_id=admin.get_client_id(oid.client_id) + )[0]["_id"] + + with pytest.raises(KeycloakDeprecationError): + await oid.a_entitlement(token=token["access_token"], resource_server_id=resource_server_id) + + +@pytest.mark.asyncio +async def test_a_introspect(oid_with_credentials: Tuple[KeycloakOpenID, str, str]): + """Test introspect. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + + assert (await oid.a_introspect(token=token["access_token"]))["active"] + assert await oid.a_introspect( + token=token["access_token"], rpt="some", token_type_hint="requesting_party_token" + ) == {"active": False} + + with pytest.raises(KeycloakRPTNotFound): + await oid.a_introspect( + token=token["access_token"], token_type_hint="requesting_party_token" + ) + + +@pytest.mark.asyncio +async def test_a_decode_token(oid_with_credentials: Tuple[KeycloakOpenID, str, str]): + """Test decode token. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + decoded_access_token = await oid.a_decode_token(token=token["access_token"]) + decoded_access_token_2 = await oid.a_decode_token(token=token["access_token"], validate=False) + decoded_refresh_token = await oid.a_decode_token(token=token["refresh_token"], validate=False) + + assert decoded_access_token == decoded_access_token_2 + assert decoded_access_token["preferred_username"] == username, decoded_access_token + assert decoded_refresh_token["typ"] == "Refresh", decoded_refresh_token + + +@pytest.mark.asyncio +async def test_a_load_authorization_config( + oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] +): + """Test load authorization config. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + + await oid.a_load_authorization_config(path="tests/data/authz_settings.json") + assert "test-authz-rb-policy" in oid.authorization.policies + assert isinstance(oid.authorization.policies["test-authz-rb-policy"], Policy) + assert len(oid.authorization.policies["test-authz-rb-policy"].roles) == 1 + assert isinstance(oid.authorization.policies["test-authz-rb-policy"].roles[0], Role) + assert len(oid.authorization.policies["test-authz-rb-policy"].permissions) == 2 + assert isinstance( + oid.authorization.policies["test-authz-rb-policy"].permissions[0], Permission + ) + + +@pytest.mark.asyncio +async def test_a_has_uma_access( + oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str], admin: KeycloakAdmin +): + """Test has UMA access. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + assert ( + str(await oid.a_has_uma_access(token=token["access_token"], permissions="")) + == "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" + ) + assert ( + str( + await oid.a_has_uma_access(token=token["access_token"], permissions="Default Resource") + ) + == "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" + ) + + with pytest.raises(KeycloakPostError): + await oid.a_has_uma_access(token=token["access_token"], permissions="Does not exist") + + await oid.a_logout(refresh_token=token["refresh_token"]) + assert ( + str(await oid.a_has_uma_access(token=token["access_token"], permissions="")) + == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())" + ) + assert ( + str( + await oid.a_has_uma_access( + token=admin.connection.token["access_token"], permissions="Default Resource" + ) + ) + == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=" + + "{'Default Resource'})" + ) + + +@pytest.mark.asyncio +async def test_a_get_policies(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): + """Test get policies. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + with pytest.raises(KeycloakAuthorizationConfigError): + await oid.a_get_policies(token=token["access_token"]) + + await oid.a_load_authorization_config(path="tests/data/authz_settings.json") + assert await oid.a_get_policies(token=token["access_token"]) is None + + orig_client_id = oid.client_id + oid.client_id = "account" + assert await oid.a_get_policies(token=token["access_token"], method_token_info="decode") == [] + policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") + policy.add_role(role="account/view-profile") + oid.authorization.policies["test"] = policy + assert [ + str(x) + for x in await oid.a_get_policies(token=token["access_token"], method_token_info="decode") + ] == ["Policy: test (role)"] + assert [ + repr(x) + for x in await oid.a_get_policies(token=token["access_token"], method_token_info="decode") + ] == [""] + oid.client_id = orig_client_id + + await oid.a_logout(refresh_token=token["refresh_token"]) + with pytest.raises(KeycloakInvalidTokenError): + await oid.a_get_policies(token=token["access_token"]) + + +@pytest.mark.asyncio +async def test_a_get_permissions(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): + """Test get policies. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + with pytest.raises(KeycloakAuthorizationConfigError): + await oid.a_get_permissions(token=token["access_token"]) + + await oid.a_load_authorization_config(path="tests/data/authz_settings.json") + assert await oid.a_get_permissions(token=token["access_token"]) is None + + orig_client_id = oid.client_id + oid.client_id = "account" + assert ( + await oid.a_get_permissions(token=token["access_token"], method_token_info="decode") == [] + ) + policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") + policy.add_role(role="account/view-profile") + policy.add_permission( + permission=Permission( + name="test-perm", type="resource", logic="POSITIVE", decision_strategy="UNANIMOUS" + ) + ) + oid.authorization.policies["test"] = policy + assert [ + str(x) + for x in await oid.a_get_permissions( + token=token["access_token"], method_token_info="decode" + ) + ] == ["Permission: test-perm (resource)"] + assert [ + repr(x) + for x in await oid.a_get_permissions( + token=token["access_token"], method_token_info="decode" + ) + ] == [""] + oid.client_id = orig_client_id + + await oid.a_logout(refresh_token=token["refresh_token"]) + with pytest.raises(KeycloakInvalidTokenError): + await oid.a_get_permissions(token=token["access_token"]) + + +@pytest.mark.asyncio +async def test_a_uma_permissions(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): + """Test UMA permissions. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + assert len(await oid.a_uma_permissions(token=token["access_token"])) == 1 + assert (await oid.a_uma_permissions(token=token["access_token"]))[0][ + "rsname" + ] == "Default Resource" + + +@pytest.mark.asyncio +async def test_a_device(oid_with_credentials_device: Tuple[KeycloakOpenID, str, str]): + """Test device authorization flow. + + :param oid_with_credentials_device: Keycloak OpenID client with pre-configured user + credentials and device authorization flow enabled + :type oid_with_credentials_device: Tuple[KeycloakOpenID, str, str] + """ + oid, _, _ = oid_with_credentials_device + res = await oid.a_device() + assert res == { + "device_code": mock.ANY, + "user_code": mock.ANY, + "verification_uri": f"http://localhost:8081/realms/{oid.realm_name}/device", + "verification_uri_complete": f"http://localhost:8081/realms/{oid.realm_name}/" + + f"device?user_code={res['user_code']}", + "expires_in": 600, + "interval": 5, + } + + +def test_counter_part(): + """Test that each function has its async counter part.""" + openid_methods = [ + func for func in dir(KeycloakOpenID) if callable(getattr(KeycloakOpenID, func)) + ] + sync_methods = [ + method + for method in openid_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in openid_methods if iscoroutinefunction(getattr(KeycloakOpenID, method)) + ] + + for method in sync_methods: + async_method = f"a_{method}" + assert (async_method in openid_methods) is True + sync_sign = signature(getattr(KeycloakOpenID, method)) + async_sign = signature(getattr(KeycloakOpenID, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_keycloak_uma.py b/tests/test_keycloak_uma.py index 76682a5..aabc067 100644 --- a/tests/test_keycloak_uma.py +++ b/tests/test_keycloak_uma.py @@ -1,6 +1,7 @@ """Test module for KeycloakUMA.""" import re +from inspect import iscoroutinefunction, signature import pytest @@ -310,3 +311,318 @@ def test_uma_permission_ticket(uma: KeycloakUMA): uma.permission_ticket_create(permissions) uma.resource_set_delete(resource["_id"]) + + +# async function start + + +@pytest.mark.asyncio +async def test_a_uma_well_known(uma: KeycloakUMA): + """Test the well_known method. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + res = uma.uma_well_known + assert res is not None + assert res != dict() + for key in ["resource_registration_endpoint"]: + assert key in res + + +@pytest.mark.asyncio +async def test_a_uma_resource_sets(uma: KeycloakUMA): + """Test resource sets. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + # Check that only the default resource is present + resource_sets = uma.resource_set_list() + resource_set_list = list(resource_sets) + assert len(resource_set_list) == 1, resource_set_list + assert resource_set_list[0]["name"] == "Default Resource", resource_set_list[0]["name"] + + # Test query for resource sets + resource_set_list_ids = await uma.a_resource_set_list_ids() + assert len(resource_set_list_ids) == 1 + + resource_set_list_ids2 = await uma.a_resource_set_list_ids(name="Default") + assert resource_set_list_ids2 == resource_set_list_ids + + resource_set_list_ids2 = await uma.a_resource_set_list_ids(name="Default Resource") + assert resource_set_list_ids2 == resource_set_list_ids + + resource_set_list_ids = await uma.a_resource_set_list_ids(name="Default", exact_name=True) + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(first=1) + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(scope="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(owner="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(resource_type="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(name="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(uri="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(maximum=0) + assert len(resource_set_list_ids) == 0 + + # Test create resource set + resource_to_create = { + "name": "mytest", + "scopes": ["test:read", "test:write"], + "type": "urn:test", + } + created_resource = await uma.a_resource_set_create(resource_to_create) + assert created_resource + assert created_resource["_id"], created_resource + assert set(resource_to_create).issubset(set(created_resource)), created_resource + + # Test create the same resource set + with pytest.raises(KeycloakPostError) as err: + await uma.a_resource_set_create(resource_to_create) + assert err.match( + re.escape( + '409: b\'{"error":"invalid_request","error_description":' + '"Resource with name [mytest] already exists."}\'' + ) + ) + + # Test get resource set + latest_resource = await uma.a_resource_set_read(created_resource["_id"]) + assert latest_resource["name"] == created_resource["name"] + + # Test update resource set + latest_resource["name"] = "New Resource Name" + res = await uma.a_resource_set_update(created_resource["_id"], latest_resource) + assert res == dict(), res + updated_resource = await uma.a_resource_set_read(created_resource["_id"]) + assert updated_resource["name"] == "New Resource Name" + + # Test update resource set fail + with pytest.raises(KeycloakPutError) as err: + uma.resource_set_update(resource_id=created_resource["_id"], payload={"wrong": "payload"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + # Test delete resource set + res = await uma.a_resource_set_delete(resource_id=created_resource["_id"]) + assert res == dict(), res + with pytest.raises(KeycloakGetError) as err: + await uma.a_resource_set_read(created_resource["_id"]) + err.match("404: b''") + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await uma.a_resource_set_delete(resource_id=created_resource["_id"]) + assert err.match("404: b''") + + +@pytest.mark.asyncio +async def test_a_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin): + """Test policies. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Create some required test data + resource_to_create = { + "name": "mytest", + "scopes": ["test:read", "test:write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + created_resource = await uma.a_resource_set_create(resource_to_create) + group_id = admin.create_group({"name": "UMAPolicyGroup"}) + role_id = admin.create_realm_role(payload={"name": "roleUMAPolicy"}) + other_client_id = admin.create_client({"name": "UMAOtherClient"}) + client = admin.get_client(other_client_id) + + resource_id = created_resource["_id"] + + # Create a role policy + policy_to_create = { + "name": "TestPolicyRole", + "description": "Test resource policy description", + "scopes": ["test:read", "test:write"], + "roles": ["roleUMAPolicy"], + } + policy = await uma.a_policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + # Create a client policy + policy_to_create = { + "name": "TestPolicyClient", + "description": "Test resource policy description", + "scopes": ["test:read"], + "clients": [client["clientId"]], + } + policy = await uma.a_policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + policy_to_create = { + "name": "TestPolicyGroup", + "description": "Test resource policy description", + "scopes": ["test:read"], + "groups": ["/UMAPolicyGroup"], + } + policy = await uma.a_policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + policies = await uma.a_policy_query() + assert len(policies) == 3 + + policies = await uma.a_policy_query(name="TestPolicyGroup") + assert len(policies) == 1 + + policy_id = policy["id"] + await uma.a_policy_delete(policy_id) + with pytest.raises(KeycloakDeleteError) as err: + await uma.a_policy_delete(policy_id) + assert err.match( + '404: b\'{"error":"invalid_request","error_description":"Policy with .* does not exist"}\'' + ) + + policies = await uma.a_policy_query() + assert len(policies) == 2 + + policy = policies[0] + await uma.a_policy_update(policy_id=policy["id"], payload=policy) + + policies = await uma.a_policy_query() + assert len(policies) == 2 + + policies = await uma.a_policy_query(name="Invalid") + assert len(policies) == 0 + policies = await uma.a_policy_query(scope="Invalid") + assert len(policies) == 0 + policies = await uma.a_policy_query(resource="Invalid") + assert len(policies) == 0 + policies = await uma.a_policy_query(first=3) + assert len(policies) == 0 + policies = await uma.a_policy_query(maximum=0) + assert len(policies) == 0 + + policies = await uma.a_policy_query(name=policy["name"]) + assert len(policies) == 1 + policies = await uma.a_policy_query(scope=policy["scopes"][0]) + assert len(policies) == 2 + policies = await uma.a_policy_query(resource=resource_id) + assert len(policies) == 2 + + await uma.a_resource_set_delete(resource_id) + await admin.a_delete_client(other_client_id) + await admin.a_delete_realm_role(role_id) + await admin.a_delete_group(group_id) + + +@pytest.mark.asyncio +async def test_a_uma_access(uma: KeycloakUMA): + """Test permission access checks. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + resource_to_create = { + "name": "mytest", + "scopes": ["read", "write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + resource = await uma.a_resource_set_create(resource_to_create) + + policy_to_create = { + "name": "TestPolicy", + "description": "Test resource policy description", + "scopes": [resource_to_create["scopes"][0]], + "clients": [uma.connection.client_id], + } + await uma.a_policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) + + token = uma.connection.token + permissions = list() + assert await uma.a_permissions_check(token["access_token"], permissions) + + permissions.append(UMAPermission(resource=resource_to_create["name"])) + assert await uma.a_permissions_check(token["access_token"], permissions) + + permissions.append(UMAPermission(resource="not valid")) + assert not await uma.a_permissions_check(token["access_token"], permissions) + uma.resource_set_delete(resource["_id"]) + + +@pytest.mark.asyncio +async def test_a_uma_permission_ticket(uma: KeycloakUMA): + """Test permission ticket generation. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + resource_to_create = { + "name": "mytest", + "scopes": ["read", "write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + resource = await uma.a_resource_set_create(resource_to_create) + + policy_to_create = { + "name": "TestPolicy", + "description": "Test resource policy description", + "scopes": [resource_to_create["scopes"][0]], + "clients": [uma.connection.client_id], + } + await uma.a_policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) + permissions = ( + UMAPermission(resource=resource_to_create["name"], scope=resource_to_create["scopes"][0]), + ) + response = await uma.a_permission_ticket_create(permissions) + + rpt = await uma.connection.keycloak_openid.a_token( + grant_type="urn:ietf:params:oauth:grant-type:uma-ticket", ticket=response["ticket"] + ) + assert rpt + assert "access_token" in rpt + + permissions = (UMAPermission(resource="invalid"),) + with pytest.raises(KeycloakPostError): + uma.permission_ticket_create(permissions) + + await uma.a_resource_set_delete(resource["_id"]) + + +def test_counter_part(): + """Test that each function has its async counter part.""" + uma_methods = [func for func in dir(KeycloakUMA) if callable(getattr(KeycloakUMA, func))] + sync_methods = [ + method + for method in uma_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in uma_methods if iscoroutinefunction(getattr(KeycloakUMA, method)) + ] + + for method in sync_methods: + async_method = f"a_{method}" + assert (async_method in uma_methods) is True + sync_sign = signature(getattr(KeycloakUMA, method)) + async_sign = signature(getattr(KeycloakUMA, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tox.env b/tox.env index b19a1aa..5967d51 100644 --- a/tox.env +++ b/tox.env @@ -2,3 +2,4 @@ KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=admin KEYCLOAK_HOST={env:KEYCLOAK_HOST:localhost} KEYCLOAK_PORT=8081 +KEYCLOAK_DOCKER_IMAGE_TAG={env:KEYCLOAK_DOCKER_IMAGE_TAG:latest}