Browse Source

Merge pull request #687 from marcospereirampj/fix/pyright

fix: strong and consistent typing across the library
pull/689/head v7.0.0
Richard Nemeth 1 week ago
committed by GitHub
parent
commit
15c0565760
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 270
      poetry.lock
  2. 3
      pyproject.toml
  3. 2
      src/keycloak/authorization/permission.py
  4. 7
      src/keycloak/authorization/policy.py
  5. 2
      src/keycloak/authorization/role.py
  6. 150
      src/keycloak/connection.py
  7. 16
      src/keycloak/exceptions.py
  8. 4848
      src/keycloak/keycloak_admin.py
  9. 423
      src/keycloak/keycloak_openid.py
  10. 318
      src/keycloak/keycloak_uma.py
  11. 98
      src/keycloak/openid_connection.py
  12. 27
      src/keycloak/uma_permissions.py
  13. 16
      test_keycloak_init.sh
  14. 7
      tests/conftest.py
  15. 311
      tests/test_keycloak_admin.py
  16. 84
      tests/test_keycloak_openid.py
  17. 16
      tests/test_keycloak_uma.py
  18. 4
      tests/test_pkce_flow.py
  19. 35
      tests/test_uma_permissions.py

270
poetry.lock

@ -110,27 +110,15 @@ typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""}
[[package]] [[package]]
name = "astroid" name = "astroid"
version = "4.0.2"
version = "4.0.3"
description = "An abstract syntax tree for Python with inference support." description = "An abstract syntax tree for Python with inference support."
optional = false optional = false
python-versions = ">=3.10.0" python-versions = ">=3.10.0"
groups = ["docs"] groups = ["docs"]
markers = "python_version >= \"3.12\"" markers = "python_version >= \"3.12\""
files = [ files = [
{file = "astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b"},
{file = "astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070"},
]
[[package]]
name = "async-property"
version = "0.2.2"
description = "Python decorator for async properties."
optional = false
python-versions = "*"
groups = ["main"]
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"},
{file = "astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14"},
{file = "astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3"},
] ]
[[package]] [[package]]
@ -215,14 +203,14 @@ files = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12"
version = "2026.1.4"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "dev", "docs"] groups = ["main", "dev", "docs"]
files = [ files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
{file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
] ]
[[package]] [[package]]
@ -522,6 +510,7 @@ description = "Python commitizen client tool"
optional = false optional = false
python-versions = "<4.0,>=3.9" python-versions = "<4.0,>=3.9"
groups = ["dev"] groups = ["dev"]
markers = "python_version < \"3.12\""
files = [ files = [
{file = "commitizen-4.10.1-py3-none-any.whl", hash = "sha256:ed4a377beed63aa4438f7ad5db791f66e117a5f597677a58b27a1c31e9f64fc4"}, {file = "commitizen-4.10.1-py3-none-any.whl", hash = "sha256:ed4a377beed63aa4438f7ad5db791f66e117a5f597677a58b27a1c31e9f64fc4"},
{file = "commitizen-4.10.1.tar.gz", hash = "sha256:14d12252970463db2fa7c7e7e4753321190a093e7d5c99efcd1a63be73e3c1f8"}, {file = "commitizen-4.10.1.tar.gz", hash = "sha256:14d12252970463db2fa7c7e7e4753321190a093e7d5c99efcd1a63be73e3c1f8"},
@ -543,6 +532,33 @@ termcolor = ">=1.1.0,<4.0.0"
tomlkit = ">=0.8.0,<1.0.0" tomlkit = ">=0.8.0,<1.0.0"
typing-extensions = {version = ">=4.0.1,<5.0.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1,<5.0.0", markers = "python_version < \"3.11\""}
[[package]]
name = "commitizen"
version = "4.11.0"
description = "Python commitizen client tool"
optional = false
python-versions = "<4.0,>=3.10"
groups = ["dev"]
markers = "python_version >= \"3.12\""
files = [
{file = "commitizen-4.11.0-py3-none-any.whl", hash = "sha256:a30fdf326bb0913b7b25f8b30530c899159b1757efa9969b0aa1808dad102c02"},
{file = "commitizen-4.11.0.tar.gz", hash = "sha256:d311297a0165ef9f30e0877e04608b786d5fd69760f32245fbf1c21e793e91df"},
]
[package.dependencies]
argcomplete = ">=1.12.1,<3.7"
charset-normalizer = ">=2.1.0,<4"
colorama = ">=0.4.1,<1.0"
decli = ">=0.6.0,<1.0"
deprecated = ">=1.2.13,<2"
jinja2 = ">=2.10.3"
packaging = ">=19"
prompt_toolkit = "!=3.0.52"
pyyaml = ">=3.08"
questionary = ">=2.0,<3.0"
termcolor = ">=1.1.0,<4.0.0"
tomlkit = ">=0.8.0,<1.0.0"
[[package]] [[package]]
name = "commonmark" name = "commonmark"
version = "0.9.1" version = "0.9.1"
@ -681,105 +697,105 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.0"
version = "7.13.1"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
markers = "python_version >= \"3.12\"" markers = "python_version >= \"3.12\""
files = [ files = [
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
{file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"},
{file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"},
{file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"},
{file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"},
{file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"},
{file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"},
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"},
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"},
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"},
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"},
{file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"},
{file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"},
{file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"},
{file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"},
{file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"},
{file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"},
{file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"},
{file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"},
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"},
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"},
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"},
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"},
{file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"},
{file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"},
{file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"},
{file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"},
{file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"},
{file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"},
{file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"},
{file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"},
{file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"},
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"},
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"},
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"},
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"},
{file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"},
{file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"},
{file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"},
{file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"},
{file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"},
{file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"},
{file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"},
{file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"},
{file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"},
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"},
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"},
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"},
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"},
{file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"},
{file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"},
{file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"},
{file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"},
{file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"},
{file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"},
{file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"},
{file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"},
{file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"},
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"},
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"},
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"},
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"},
{file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"},
{file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"},
{file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"},
{file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"},
{file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"},
{file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"},
{file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"},
{file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"},
{file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"},
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"},
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"},
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"},
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"},
{file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"},
{file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"},
{file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"},
{file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"},
{file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"},
{file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"},
{file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"},
{file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"},
{file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"},
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"},
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"},
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"},
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"},
{file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"},
{file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"},
{file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"},
{file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"},
{file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"},
] ]
[package.extras] [package.extras]
@ -1029,15 +1045,15 @@ files = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.20.1"
version = "3.20.2"
description = "A platform independent file lock." description = "A platform independent file lock."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
markers = "python_version >= \"3.12\"" markers = "python_version >= \"3.12\""
files = [ files = [
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
{file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"},
{file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"},
] ]
[[package]] [[package]]
@ -2574,15 +2590,15 @@ tests = ["pytest", "pytest-cov"]
[[package]] [[package]]
name = "termcolor" name = "termcolor"
version = "3.2.0"
version = "3.3.0"
description = "ANSI color formatting for output in terminal" description = "ANSI color formatting for output in terminal"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
markers = "python_version >= \"3.12\"" markers = "python_version >= \"3.12\""
files = [ files = [
{file = "termcolor-3.2.0-py3-none-any.whl", hash = "sha256:a10343879eba4da819353c55cb8049b0933890c2ebf9ad5d3ecd2bb32ea96ea6"},
{file = "termcolor-3.2.0.tar.gz", hash = "sha256:610e6456feec42c4bcd28934a8c87a06c3fa28b01561d46aa09a9881b8622c58"},
{file = "termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5"},
{file = "termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5"},
] ]
[package.extras] [package.extras]
@ -2681,27 +2697,27 @@ virtualenv = ">=20.31.2"
[[package]] [[package]]
name = "tox" name = "tox"
version = "4.32.0"
version = "4.33.0"
description = "tox is a generic virtualenv management and test command line tool" description = "tox is a generic virtualenv management and test command line tool"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
markers = "python_version >= \"3.12\"" markers = "python_version >= \"3.12\""
files = [ files = [
{file = "tox-4.32.0-py3-none-any.whl", hash = "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551"},
{file = "tox-4.32.0.tar.gz", hash = "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff"},
{file = "tox-4.33.0-py3-none-any.whl", hash = "sha256:8582ac5c3ca97095ce88ae6bcd310d22614350ea9751b0e4ad39acad7874e270"},
{file = "tox-4.33.0.tar.gz", hash = "sha256:a29244bce3f514f94043e173366aa191c8cf0106ec8ddd18ba53f985acd73cc4"},
] ]
[package.dependencies] [package.dependencies]
cachetools = ">=6.2"
cachetools = ">=6.2.4"
chardet = ">=5.2" chardet = ">=5.2"
colorama = ">=0.4.6" colorama = ">=0.4.6"
filelock = ">=3.20"
filelock = ">=3.20.2"
packaging = ">=25" packaging = ">=25"
platformdirs = ">=4.5"
platformdirs = ">=4.5.1"
pluggy = ">=1.6" pluggy = ">=1.6"
pyproject-api = ">=1.9.1"
virtualenv = ">=20.34"
pyproject-api = ">=1.10"
virtualenv = ">=20.35.4"
[[package]] [[package]]
name = "twine" name = "twine"
@ -2953,5 +2969,5 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.9,<4.0"
content-hash = "7845cd09ab893d3657e7d87bd9483e046b89f0653a5967ac41c2bb2382b3601b"
python-versions = ">=3.9,<=3.14"
content-hash = "ea21a4329e72bca27f2ac739764bae06f6503c6bae18b31fa2c9282e2f99deff"

3
pyproject.toml

@ -31,13 +31,12 @@ Documentation = "https://python-keycloak.readthedocs.io/en/latest/"
"Issue tracker" = "https://github.com/marcospereirampj/python-keycloak/issues" "Issue tracker" = "https://github.com/marcospereirampj/python-keycloak/issues"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.9,<4.0"
python = ">=3.9,<=3.14"
requests = ">=2.20.0" requests = ">=2.20.0"
requests-toolbelt = ">=0.6.0" requests-toolbelt = ">=0.6.0"
deprecation = ">=2.1.0" deprecation = ">=2.1.0"
jwcrypto = ">=1.5.4" jwcrypto = ">=1.5.4"
httpx = ">=0.23.2" httpx = ">=0.23.2"
async-property = ">=0.2.2"
aiofiles = ">=24.1.0" aiofiles = ">=24.1.0"
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]

2
src/keycloak/authorization/permission.py

@ -133,7 +133,7 @@ class Permission:
return self._logic return self._logic
@logic.setter @logic.setter
def logic(self, value: str) -> str:
def logic(self, value: str) -> None:
self._logic = value self._logic = value
@property @property

7
src/keycloak/authorization/policy.py

@ -24,6 +24,9 @@
from keycloak.exceptions import KeycloakAuthorizationConfigError from keycloak.exceptions import KeycloakAuthorizationConfigError
from .permission import Permission
from .role import Role
class Policy: class Policy:
""" """
@ -172,7 +175,7 @@ class Policy:
def permissions(self, value: list) -> None: def permissions(self, value: list) -> None:
self._permissions = value self._permissions = value
def add_role(self, role: dict) -> None:
def add_role(self, role: str | Role) -> None:
""" """
Add keycloak role in policy. Add keycloak role in policy.
@ -185,7 +188,7 @@ class Policy:
raise KeycloakAuthorizationConfigError(error_msg) raise KeycloakAuthorizationConfigError(error_msg)
self._roles.append(role) self._roles.append(role)
def add_permission(self, permission: dict) -> None:
def add_permission(self, permission: str | Permission) -> None:
""" """
Add keycloak permission in policy. Add keycloak permission in policy.

2
src/keycloak/authorization/role.py

@ -61,7 +61,7 @@ class Role:
""" """
return self.name return self.name
def __eq__(self, other: str | Role) -> bool:
def __eq__(self, other: object) -> bool:
""" """
Eq method. Eq method.

150
src/keycloak/connection.py

@ -27,13 +27,16 @@ from __future__ import annotations
try: try:
from urllib.parse import urljoin from urllib.parse import urljoin
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
from urlparse import urljoin
from urlparse import urljoin # pyright: ignore[reportMissingImports]
from typing import Any
import httpx import httpx
import requests import requests
from httpx import Response as AsyncResponse from httpx import Response as AsyncResponse
from requests import Response from requests import Response
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from requests_toolbelt import MultipartEncoder
from .exceptions import KeycloakConnectionError from .exceptions import KeycloakConnectionError
@ -67,8 +70,8 @@ class ConnectionManager:
self, self,
base_url: str, base_url: str,
headers: dict | None = None, headers: dict | None = None,
timeout: int = 60,
verify: bool = True,
timeout: int | None = 60,
verify: bool | str = True,
proxies: dict | None = None, proxies: dict | None = None,
cert: str | tuple | None = None, cert: str | tuple | None = None,
max_retries: int = 1, max_retries: int = 1,
@ -101,7 +104,9 @@ class ConnectionManager:
self.headers = headers self.headers = headers
self.timeout = timeout self.timeout = timeout
self.verify = verify self.verify = verify
self.proxies = proxies
self.cert = cert self.cert = cert
self.max_retries = max_retries
self.pool_maxsize = pool_maxsize self.pool_maxsize = pool_maxsize
self._s = requests.Session() self._s = requests.Session()
self._s.auth = lambda x: x # don't let requests add auth headers self._s.auth = lambda x: x # don't let requests add auth headers
@ -112,9 +117,13 @@ class ConnectionManager:
adapter_kwargs = {"max_retries": max_retries} adapter_kwargs = {"max_retries": max_retries}
if pool_maxsize is not None: if pool_maxsize is not None:
adapter_kwargs["pool_maxsize"] = pool_maxsize adapter_kwargs["pool_maxsize"] = pool_maxsize
adapter = HTTPAdapter(**adapter_kwargs)
adapter = HTTPAdapter(**adapter_kwargs) # pyright: ignore[reportArgumentType]
# adds POST to retry whitelist # adds POST to retry whitelist
allowed_methods = set(adapter.max_retries.allowed_methods)
allowed_methods = (
set(adapter.max_retries.allowed_methods)
if adapter.max_retries.allowed_methods
else set()
)
allowed_methods.add("POST") allowed_methods.add("POST")
adapter.max_retries.allowed_methods = frozenset(allowed_methods) adapter.max_retries.allowed_methods = frozenset(allowed_methods)
@ -132,8 +141,7 @@ class ConnectionManager:
max_keepalive_connections=20, max_keepalive_connections=20,
), ),
) )
self.async_s.auth = None # don't let requests add auth headers
self.async_s.transport = httpx.AsyncHTTPTransport(retries=1)
self.async_s.auth = None # pyright: ignore[reportAttributeAccessIssue]
async def aclose(self) -> None: async def aclose(self) -> None:
"""Close the async connection on delete.""" """Close the async connection on delete."""
@ -146,7 +154,7 @@ class ConnectionManager:
self._s.close() self._s.close()
@property @property
def base_url(self) -> str:
def base_url(self) -> str | None:
""" """
Return base url in use for requests to the server. Return base url in use for requests to the server.
@ -156,11 +164,11 @@ class ConnectionManager:
return self._base_url return self._base_url
@base_url.setter @base_url.setter
def base_url(self, value: str) -> None:
def base_url(self, value: str | None) -> None:
self._base_url = value self._base_url = value
@property @property
def timeout(self) -> int:
def timeout(self) -> int | None:
""" """
Return timeout in use for request to the server. Return timeout in use for request to the server.
@ -170,11 +178,11 @@ class ConnectionManager:
return self._timeout return self._timeout
@timeout.setter @timeout.setter
def timeout(self, value: int) -> None:
def timeout(self, value: int | None) -> None:
self._timeout = value self._timeout = value
@property @property
def verify(self) -> bool:
def verify(self) -> bool | str:
""" """
Return verify in use for request to the server. Return verify in use for request to the server.
@ -184,11 +192,25 @@ class ConnectionManager:
return self._verify return self._verify
@verify.setter @verify.setter
def verify(self, value: bool) -> None:
def verify(self, value: bool | str) -> None:
self._verify = value self._verify = value
@property @property
def cert(self) -> str | tuple:
def proxies(self) -> dict | None:
"""
Return proxies in use for request to the server.
:returns: Proxies
:rtype: dict | None
"""
return self._proxies
@proxies.setter
def proxies(self, value: dict | None) -> None:
self._proxies = value
@property
def cert(self) -> str | tuple | None:
""" """
Return client certificates in use for request to the server. Return client certificates in use for request to the server.
@ -198,9 +220,23 @@ class ConnectionManager:
return self._cert return self._cert
@cert.setter @cert.setter
def cert(self, value: str | tuple) -> None:
def cert(self, value: str | tuple | None) -> None:
self._cert = value self._cert = value
@property
def max_retries(self) -> int:
"""
Return maximum number of retries in use for requests to the server.
:returns: Maximum number of retries
:rtype: int
"""
return self._max_retries
@max_retries.setter
def max_retries(self, value: int) -> None:
self._max_retries = value
@property @property
def pool_maxsize(self) -> int | None: def pool_maxsize(self) -> int | None:
""" """
@ -216,7 +252,7 @@ class ConnectionManager:
self._pool_maxsize = value self._pool_maxsize = value
@property @property
def headers(self) -> dict:
def headers(self) -> dict | None:
""" """
Return header request to the server. Return header request to the server.
@ -226,7 +262,7 @@ class ConnectionManager:
return self._headers return self._headers
@headers.setter @headers.setter
def headers(self, value: dict) -> None:
def headers(self, value: dict | None) -> None:
self._headers = value or {} self._headers = value or {}
def param_headers(self, key: str) -> str | None: def param_headers(self, key: str) -> str | None:
@ -238,7 +274,7 @@ class ConnectionManager:
:returns: If the header parameters exist, return its value. :returns: If the header parameters exist, return its value.
:rtype: str :rtype: str
""" """
return self.headers.get(key)
return (self.headers or {}).get(key)
def clean_headers(self) -> None: def clean_headers(self) -> None:
"""Clear header parameters.""" """Clear header parameters."""
@ -264,6 +300,9 @@ class ConnectionManager:
:param value: Value to be added. :param value: Value to be added.
:type value: str :type value: str
""" """
if self.headers is None:
self.headers = {}
self.headers[key] = value self.headers[key] = value
def del_param_headers(self, key: str) -> None: def del_param_headers(self, key: str) -> None:
@ -273,9 +312,12 @@ class ConnectionManager:
:param key: Key of the header parameters. :param key: Key of the header parameters.
:type key: str :type key: str
""" """
if self.headers is None:
return
self.headers.pop(key, None) self.headers.pop(key, None)
def raw_get(self, path: str, **kwargs: dict) -> Response:
def raw_get(self, path: str, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Submit get request to the path. Submit get request to the path.
@ -287,6 +329,9 @@ class ConnectionManager:
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform GET call with base_url missing."
raise AttributeError(msg)
try: try:
return self._s.get( return self._s.get(
urljoin(self.base_url, path), urljoin(self.base_url, path),
@ -300,20 +345,23 @@ class ConnectionManager:
msg = "Can't connect to server" msg = "Can't connect to server"
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
def raw_post(self, path: str, data: dict, **kwargs: dict) -> Response:
def raw_post(self, path: str, data: dict | str | MultipartEncoder, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Submit post request to the path. Submit post request to the path.
:param path: Path for request. :param path: Path for request.
:type path: str :type path: str
:param data: Payload for request. :param data: Payload for request.
:type data: dict
:type data: dict | str | MultipartEncoder
:param kwargs: Additional arguments :param kwargs: Additional arguments
:type kwargs: dict :type kwargs: dict
:returns: Response the request. :returns: Response the request.
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform POST call with base_url missing."
raise AttributeError(msg)
try: try:
return self._s.post( return self._s.post(
urljoin(self.base_url, path), urljoin(self.base_url, path),
@ -328,20 +376,24 @@ class ConnectionManager:
msg = "Can't connect to server" msg = "Can't connect to server"
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
def raw_put(self, path: str, data: dict, **kwargs: dict) -> Response:
def raw_put(self, path: str, data: dict | str | MultipartEncoder, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Submit put request to the path. Submit put request to the path.
:param path: Path for request. :param path: Path for request.
:type path: str :type path: str
:param data: Payload for request. :param data: Payload for request.
:type data: dict
:type data: dict | str | MultipartEncoder
:param kwargs: Additional arguments :param kwargs: Additional arguments
:type kwargs: dict :type kwargs: dict
:returns: Response the request. :returns: Response the request.
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform PUT call with base_url missing."
raise AttributeError(msg)
try: try:
return self._s.put( return self._s.put(
urljoin(self.base_url, path), urljoin(self.base_url, path),
@ -356,7 +408,7 @@ class ConnectionManager:
msg = "Can't connect to server" msg = "Can't connect to server"
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
def raw_delete(self, path: str, data: dict | None = None, **kwargs: dict) -> Response:
def raw_delete(self, path: str, data: dict | None = None, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Submit delete request to the path. Submit delete request to the path.
@ -370,6 +422,10 @@ class ConnectionManager:
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform DELETE call with base_url missing."
raise AttributeError(msg)
try: try:
return self._s.delete( return self._s.delete(
urljoin(self.base_url, path), urljoin(self.base_url, path),
@ -384,7 +440,7 @@ class ConnectionManager:
msg = "Can't connect to server" msg = "Can't connect to server"
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
async def a_raw_get(self, path: str, **kwargs: dict) -> AsyncResponse:
async def a_raw_get(self, path: str, **kwargs: Any) -> AsyncResponse: # noqa: ANN401
""" """
Submit get request to the path. Submit get request to the path.
@ -396,6 +452,10 @@ class ConnectionManager:
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform GET call with base_url missing."
raise AttributeError(msg)
try: try:
return await self.async_s.get( return await self.async_s.get(
urljoin(self.base_url, path), urljoin(self.base_url, path),
@ -407,20 +467,29 @@ class ConnectionManager:
msg = "Can't connect to server" msg = "Can't connect to server"
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
async def a_raw_post(self, path: str, data: dict, **kwargs: dict) -> AsyncResponse:
async def a_raw_post(
self,
path: str,
data: dict | str | MultipartEncoder,
**kwargs: Any, # noqa: ANN401
) -> AsyncResponse:
""" """
Submit post request to the path. Submit post request to the path.
:param path: Path for request. :param path: Path for request.
:type path: str :type path: str
:param data: Payload for request. :param data: Payload for request.
:type data: dict
:type data: dict | str | MultipartEncoder
:param kwargs: Additional arguments :param kwargs: Additional arguments
:type kwargs: dict :type kwargs: dict
:returns: Response the request. :returns: Response the request.
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform POST call with base_url missing."
raise AttributeError(msg)
try: try:
return await self.async_s.request( return await self.async_s.request(
method="POST", method="POST",
@ -434,20 +503,29 @@ class ConnectionManager:
msg = "Can't connect to server" msg = "Can't connect to server"
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
async def a_raw_put(self, path: str, data: dict, **kwargs: dict) -> AsyncResponse:
async def a_raw_put(
self,
path: str,
data: dict | str | MultipartEncoder,
**kwargs: Any, # noqa: ANN401
) -> AsyncResponse:
""" """
Submit put request to the path. Submit put request to the path.
:param path: Path for request. :param path: Path for request.
:type path: str :type path: str
:param data: Payload for request. :param data: Payload for request.
:type data: dict
:type data: dict | str | MultipartEncoder
:param kwargs: Additional arguments :param kwargs: Additional arguments
:type kwargs: dict :type kwargs: dict
:returns: Response the request. :returns: Response the request.
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform PUT call with base_url missing."
raise AttributeError(msg)
try: try:
return await self.async_s.put( return await self.async_s.put(
urljoin(self.base_url, path), urljoin(self.base_url, path),
@ -464,7 +542,7 @@ class ConnectionManager:
self, self,
path: str, path: str,
data: dict | None = None, data: dict | None = None,
**kwargs: dict,
**kwargs: Any, # noqa: ANN401
) -> AsyncResponse: ) -> AsyncResponse:
""" """
Submit delete request to the path. Submit delete request to the path.
@ -479,6 +557,10 @@ class ConnectionManager:
:rtype: Response :rtype: Response
:raises KeycloakConnectionError: HttpError Can't connect to server. :raises KeycloakConnectionError: HttpError Can't connect to server.
""" """
if self.base_url is None:
msg = "Unable to perform DELETE call with base_url missing."
raise AttributeError(msg)
try: try:
return await self.async_s.request( return await self.async_s.request(
method="DELETE", method="DELETE",
@ -493,20 +575,24 @@ class ConnectionManager:
raise KeycloakConnectionError(msg) from e raise KeycloakConnectionError(msg) from e
@staticmethod @staticmethod
def _prepare_httpx_request_content(data: dict | str | None) -> dict:
def _prepare_httpx_request_content(data: dict | str | None | MultipartEncoder) -> dict:
""" """
Create the correct request content kwarg to `httpx.AsyncClient.request()`. Create the correct request content kwarg to `httpx.AsyncClient.request()`.
See https://www.python-httpx.org/compatibility/#request-content See https://www.python-httpx.org/compatibility/#request-content
:param data: the request content :param data: the request content
:type data: dict | str | None
:type data: dict | str | None | MultipartEncoder
:returns: A dict mapping the correct kwarg to the request content :returns: A dict mapping the correct kwarg to the request content
:rtype: dict :rtype: dict
""" """
if isinstance(data, MultipartEncoder):
return {"content": data.to_string()}
if isinstance(data, str): if isinstance(data, str):
# Note: this could also accept bytes, Iterable[bytes], or AsyncIterable[bytes] # Note: this could also accept bytes, Iterable[bytes], or AsyncIterable[bytes]
return {"content": data} return {"content": data}
return {"data": data} return {"data": data}
@staticmethod @staticmethod

16
src/keycloak/exceptions.py

@ -57,7 +57,7 @@ class KeycloakError(Exception):
def __init__( def __init__(
self, self,
error_message: str = "",
error_message: str | bytes = "",
response_code: int | None = None, response_code: int | None = None,
response_body: bytes | None = None, response_body: bytes | None = None,
) -> None: ) -> None:
@ -147,7 +147,15 @@ class PermissionDefinitionError(Exception):
def raise_error_from_response( def raise_error_from_response(
response: Response | AsyncResponse, response: Response | AsyncResponse,
error: dict | Exception,
error: type[
KeycloakGetError
| KeycloakPostError
| KeycloakDeprecationError
| KeycloakPutError
| KeycloakDeleteError
]
| dict
| Exception,
expected_codes: list[int] | None = None, expected_codes: list[int] | None = None,
skip_exists: bool = False, skip_exists: bool = False,
) -> bytes | dict | list: ) -> bytes | dict | list:
@ -190,9 +198,9 @@ def raise_error_from_response(
if isinstance(error, dict): if isinstance(error, dict):
error = error.get(response.status_code, KeycloakOperationError) error = error.get(response.status_code, KeycloakOperationError)
elif response.status_code == HTTP_UNAUTHORIZED: elif response.status_code == HTTP_UNAUTHORIZED:
error = KeycloakAuthenticationError
error = KeycloakAuthenticationError # pyright: ignore[reportAssignmentType]
raise error(
raise error( # pyright: ignore[reportCallIssue]
error_message=message, error_message=message,
response_code=response.status_code, response_code=response.status_code,
response_body=response.content, response_body=response.content,

4848
src/keycloak/keycloak_admin.py
File diff suppressed because it is too large
View File

423
src/keycloak/keycloak_openid.py

@ -31,6 +31,7 @@ from __future__ import annotations
import json import json
import pathlib import pathlib
from typing import Any
import aiofiles import aiofiles
from jwcrypto import jwk, jwt from jwcrypto import jwk, jwt
@ -100,7 +101,7 @@ class KeycloakOpenID:
verify: bool | str = True, verify: bool | str = True,
custom_headers: dict | None = None, custom_headers: dict | None = None,
proxies: dict | None = None, proxies: dict | None = None,
timeout: int = 60,
timeout: int | None = 60,
cert: str | tuple | None = None, cert: str | tuple | None = None,
max_retries: int = 1, max_retries: int = 1,
pool_maxsize: int | None = None, pool_maxsize: int | None = None,
@ -166,7 +167,7 @@ class KeycloakOpenID:
self._client_id = value self._client_id = value
@property @property
def client_secret_key(self) -> str:
def client_secret_key(self) -> str | None:
""" """
Get the client secret key. Get the client secret key.
@ -176,7 +177,7 @@ class KeycloakOpenID:
return self._client_secret_key return self._client_secret_key
@client_secret_key.setter @client_secret_key.setter
def client_secret_key(self, value: str) -> None:
def client_secret_key(self, value: str | None) -> None:
self._client_secret_key = value self._client_secret_key = value
@property @property
@ -246,7 +247,7 @@ class KeycloakOpenID:
""" """
return self.client_id + "/" + role return self.client_id + "/" + role
def _token_info(self, token: str, method_token_info: str, **kwargs: dict) -> dict:
def _token_info(self, token: str, method_token_info: str, **kwargs: Any) -> dict: # noqa: ANN401
""" """
Getter for the token data. Getter for the token data.
@ -279,7 +280,15 @@ class KeycloakOpenID:
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path)) data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
f"Unexpected response type on well_known. Expected 'dict', received '{type(res)}'"
f", value: {res}"
)
raise TypeError(msg)
return res
def auth_url( def auth_url(
self, self,
@ -325,15 +334,15 @@ class KeycloakOpenID:
def token( def token(
self, self,
username: str = "",
password: str = "",
username: str | None = "",
password: str | None = "",
grant_type: str = "password", grant_type: str = "password",
code: str = "", code: str = "",
redirect_uri: str = "", redirect_uri: str = "",
totp: int | None = None, totp: int | None = None,
scope: str = "openid", scope: str = "openid",
code_verifier: str | None = None, code_verifier: str | None = None,
**extra: dict,
**extra: Any, # noqa: ANN401
) -> dict: ) -> dict:
""" """
Retrieve user token. Retrieve user token.
@ -385,7 +394,7 @@ class KeycloakOpenID:
payload["totp"] = totp payload["totp"] = totp
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -393,7 +402,15 @@ class KeycloakOpenID:
if content_type if content_type
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
f"Unexpected response type from 'token'. Expected 'dict', received '{type(res)}'"
f", value {res}."
)
raise TypeError(msg)
return res
def refresh_token(self, refresh_token: str, grant_type: str = "refresh_token") -> dict: def refresh_token(self, refresh_token: str, grant_type: str = "refresh_token") -> dict:
""" """
@ -420,7 +437,7 @@ class KeycloakOpenID:
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -428,7 +445,16 @@ class KeycloakOpenID:
if content_type if content_type
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type from refresh_token. "
f"Expected 'dict', received '{type(res)}'"
f", value: {res}."
)
raise TypeError(msg)
return res
def exchange_token( def exchange_token(
self, self,
@ -480,7 +506,7 @@ class KeycloakOpenID:
"scope": scope, "scope": scope,
} }
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -488,7 +514,15 @@ class KeycloakOpenID:
if content_type if content_type
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type from exchange_token. Expected 'dict', received "
f"'{type(res)}', value '{res}'"
)
raise TypeError(msg)
return res
def userinfo(self, token: str) -> dict: def userinfo(self, token: str) -> dict:
""" """
@ -504,7 +538,7 @@ class KeycloakOpenID:
:returns: Userinfo object :returns: Userinfo object
:rtype: dict :rtype: dict
""" """
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path)) data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path))
@ -513,26 +547,42 @@ class KeycloakOpenID:
if orig_bearer is not None if orig_bearer is not None
else self.connection.del_param_headers("Authorization") else self.connection.del_param_headers("Authorization")
) )
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type from userinfo. Expected 'dict', "
f"received '{type(res)}', value: '{res}'."
)
raise TypeError(msg)
def logout(self, refresh_token: str) -> bytes:
return res
def logout(self, refresh_token: str) -> dict:
""" """
Log out the authenticated user. Log out the authenticated user.
:param refresh_token: Refresh token from Keycloak :param refresh_token: Refresh token from Keycloak
:type refresh_token: str :type refresh_token: str
:returns: Keycloak server response :returns: Keycloak server response
:rtype: bytes
:rtype: dict
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
payload = {"client_id": self.client_id, "refresh_token": refresh_token} payload = {"client_id": self.client_id, "refresh_token": refresh_token}
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), data=payload) data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), data=payload)
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakPostError, KeycloakPostError,
expected_codes=[HTTP_NO_CONTENT], expected_codes=[HTTP_NO_CONTENT],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type from logout. Expected 'dict', "
f"received '{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def certs(self) -> dict: def certs(self) -> dict:
""" """
@ -549,7 +599,15 @@ class KeycloakOpenID:
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) data_raw = self.connection.raw_get(URL_CERTS.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type from certs. Expected 'dict', "
f"received '{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def public_key(self) -> str: def public_key(self) -> str:
""" """
@ -562,7 +620,15 @@ class KeycloakOpenID:
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = self.connection.raw_get(URL_REALM.format(**params_path)) data_raw = self.connection.raw_get(URL_REALM.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)["public_key"]
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type from public_key. Expected 'dict', "
f"received '{type(res)}', value '{res}'"
)
raise TypeError(msg)
return res["public_key"]
def entitlement(self, token: str, resource_server_id: str) -> dict: def entitlement(self, token: str, resource_server_id: str) -> dict:
""" """
@ -581,7 +647,7 @@ class KeycloakOpenID:
:returns: Entitlements :returns: Entitlements
:rtype: dict :rtype: dict
""" """
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id}
data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path))
@ -592,9 +658,24 @@ class KeycloakOpenID:
) )
if data_raw.status_code in {HTTP_NOT_FOUND, HTTP_NOT_ALLOWED}: if data_raw.status_code in {HTTP_NOT_FOUND, HTTP_NOT_ALLOWED}:
return raise_error_from_response(data_raw, KeycloakDeprecationError)
res = raise_error_from_response(data_raw, KeycloakDeprecationError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', "
f"received '{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', "
f"received '{type(res)}', value '{res}'."
)
raise TypeError(msg)
return raise_error_from_response(data_raw, KeycloakGetError) # pragma: no cover
return res
def introspect( def introspect(
self, self,
@ -629,7 +710,7 @@ class KeycloakOpenID:
if token_type_hint == "requesting_party_token": # noqa: S105 if token_type_hint == "requesting_party_token": # noqa: S105
if rpt: if rpt:
payload.update({"token": rpt, "token_type_hint": token_type_hint}) payload.update({"token": rpt, "token_type_hint": token_type_hint})
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
bearer_changed = True bearer_changed = True
else: else:
@ -645,10 +726,19 @@ class KeycloakOpenID:
if orig_bearer is not None if orig_bearer is not None
else self.connection.del_param_headers("Authorization") else self.connection.del_param_headers("Authorization")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
@staticmethod @staticmethod
def _verify_token(token: str, key: jwk.JWK | jwk.JWKSet | None, **kwargs: dict) -> dict:
def _verify_token(token: str, key: jwk.JWK | jwk.JWKSet | None, **kwargs: Any) -> dict: # noqa: ANN401
""" """
Decode and optionally validate a token. Decode and optionally validate a token.
@ -669,12 +759,13 @@ class KeycloakOpenID:
full_jwt = jwt.JWT(jwt=token, **kwargs) full_jwt = jwt.JWT(jwt=token, **kwargs)
full_jwt.leeway = leeway full_jwt.leeway = leeway
full_jwt.validate(key) full_jwt.validate(key)
return jwt.json_decode(full_jwt.claims)
return jwt.json_decode(full_jwt.claims) # pyright: ignore[reportAttributeAccessIssue]
full_jwt = jwt.JWT(jwt=token, **kwargs) full_jwt = jwt.JWT(jwt=token, **kwargs)
full_jwt.token.objects["valid"] = True full_jwt.token.objects["valid"] = True
return json.loads(full_jwt.token.payload.decode("utf-8")) return json.loads(full_jwt.token.payload.decode("utf-8"))
def decode_token(self, token: str, validate: bool = True, **kwargs: dict) -> dict:
def decode_token(self, token: str, validate: bool = True, **kwargs: Any) -> dict: # noqa: ANN401
""" """
Decode user token. Decode user token.
@ -727,8 +818,8 @@ class KeycloakOpenID:
self, self,
token: str, token: str,
method_token_info: str = "introspect", # noqa: S107 method_token_info: str = "introspect", # noqa: S107
**kwargs: dict,
) -> list:
**kwargs: Any, # noqa: ANN401
) -> list | None:
""" """
Get policies by user token. Get policies by user token.
@ -771,8 +862,8 @@ class KeycloakOpenID:
self, self,
token: str, token: str,
method_token_info: str = "introspect", # noqa: S107 method_token_info: str = "introspect", # noqa: S107
**kwargs: dict,
) -> list:
**kwargs: Any, # noqa: ANN401
) -> list | None:
""" """
Get permission by user token. Get permission by user token.
@ -811,7 +902,7 @@ class KeycloakOpenID:
return list(set(permissions)) return list(set(permissions))
def uma_permissions(self, token: str, permissions: str = "", **extra_payload: dict) -> list:
def uma_permissions(self, token: str, permissions: str = "", **extra_payload: Any) -> list: # noqa: ANN401
""" """
Get UMA permissions by user token with requested permissions. Get UMA permissions by user token with requested permissions.
@ -840,9 +931,9 @@ class KeycloakOpenID:
**extra_payload, **extra_payload,
} }
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -855,9 +946,17 @@ class KeycloakOpenID:
if orig_bearer is not None if orig_bearer is not None
else self.connection.del_param_headers("Authorization") else self.connection.del_param_headers("Authorization")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, list):
msg = (
"Unexpected response type. Expected 'list', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
def has_uma_access(self, token: str, permissions: list) -> AuthStatus:
return res
def has_uma_access(self, token: str, permissions: str) -> AuthStatus:
""" """
Determine whether user has uma permissions with specified user token. Determine whether user has uma permissions with specified user token.
@ -918,9 +1017,9 @@ class KeycloakOpenID:
:rtype: dict :rtype: dict
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
orig_content_type = self.connection.headers.get("Content-Type")
orig_content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/json") self.connection.add_param_headers("Content-Type", "application/json")
data_raw = self.connection.raw_post( data_raw = self.connection.raw_post(
URL_CLIENT_REGISTRATION.format(**params_path), URL_CLIENT_REGISTRATION.format(**params_path),
@ -936,7 +1035,15 @@ class KeycloakOpenID:
if orig_content_type is not None if orig_content_type is not None
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def device(self, scope: str = "") -> dict: def device(self, scope: str = "") -> dict:
""" """
@ -964,9 +1071,17 @@ class KeycloakOpenID:
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
data_raw = self.connection.raw_post(URL_DEVICE.format(**params_path), data=payload) data_raw = self.connection.raw_post(URL_DEVICE.format(**params_path), data=payload)
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def update_client(self, token: str, client_id: str, payload: dict) -> bytes:
def update_client(self, token: str, client_id: str, payload: dict) -> dict:
""" """
Update a client. Update a client.
@ -983,9 +1098,9 @@ class KeycloakOpenID:
:rtype: bytes :rtype: bytes
""" """
params_path = {"realm-name": self.realm_name, "client-id": client_id} params_path = {"realm-name": self.realm_name, "client-id": client_id}
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
orig_content_type = self.connection.headers.get("Content-Type")
orig_content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/json") self.connection.add_param_headers("Content-Type", "application/json")
# Keycloak complains if the clientId is not set in the payload # Keycloak complains if the clientId is not set in the payload
@ -1006,9 +1121,17 @@ class KeycloakOpenID:
if orig_content_type is not None if orig_content_type is not None
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPutError)
res = raise_error_from_response(data_raw, KeycloakPutError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def _a_token_info(self, token: str, method_token_info: str, **kwargs: dict) -> dict:
async def _a_token_info(self, token: str, method_token_info: str, **kwargs: Any) -> dict: # noqa: ANN401
""" """
Asynchronous getter for the token data. Asynchronous getter for the token data.
@ -1041,7 +1164,15 @@ class KeycloakOpenID:
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = await self.connection.a_raw_get(URL_WELL_KNOWN.format(**params_path)) data_raw = await self.connection.a_raw_get(URL_WELL_KNOWN.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_auth_url( async def a_auth_url(
self, self,
@ -1087,15 +1218,15 @@ class KeycloakOpenID:
async def a_token( async def a_token(
self, self,
username: str = "",
password: str = "",
username: str | None = "",
password: str | None = "",
grant_type: str = "password", grant_type: str = "password",
code: str = "", code: str = "",
redirect_uri: str = "", redirect_uri: str = "",
totp: int | None = None, totp: int | None = None,
scope: str = "openid", scope: str = "openid",
code_verifier: str | None = None, code_verifier: str | None = None,
**extra: dict,
**extra: Any, # noqa: ANN401
) -> dict: ) -> dict:
""" """
Retrieve user token asynchronously. Retrieve user token asynchronously.
@ -1147,7 +1278,7 @@ class KeycloakOpenID:
payload["totp"] = totp payload["totp"] = totp
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -1155,7 +1286,15 @@ class KeycloakOpenID:
if content_type if content_type
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_refresh_token(self, refresh_token: str, grant_type: str = "refresh_token") -> dict: async def a_refresh_token(self, refresh_token: str, grant_type: str = "refresh_token") -> dict:
""" """
@ -1182,7 +1321,7 @@ class KeycloakOpenID:
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -1190,7 +1329,15 @@ class KeycloakOpenID:
if content_type if content_type
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_exchange_token( async def a_exchange_token(
self, self,
@ -1242,7 +1389,7 @@ class KeycloakOpenID:
"scope": scope, "scope": scope,
} }
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -1250,7 +1397,15 @@ class KeycloakOpenID:
if content_type if content_type
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_userinfo(self, token: str) -> dict: async def a_userinfo(self, token: str) -> dict:
""" """
@ -1266,7 +1421,7 @@ class KeycloakOpenID:
:returns: Userinfo object :returns: Userinfo object
:rtype: dict :rtype: dict
""" """
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = await self.connection.a_raw_get(URL_USERINFO.format(**params_path)) data_raw = await self.connection.a_raw_get(URL_USERINFO.format(**params_path))
@ -1275,26 +1430,42 @@ class KeycloakOpenID:
if orig_bearer is not None if orig_bearer is not None
else self.connection.del_param_headers("Authorization") else self.connection.del_param_headers("Authorization")
) )
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
async def a_logout(self, refresh_token: str) -> bytes:
return res
async def a_logout(self, refresh_token: str) -> dict:
""" """
Log out the authenticated user asynchronously. Log out the authenticated user asynchronously.
:param refresh_token: Refresh token from Keycloak :param refresh_token: Refresh token from Keycloak
:type refresh_token: str :type refresh_token: str
:returns: Keycloak server response :returns: Keycloak server response
:rtype: bytes
:rtype: dict
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
payload = {"client_id": self.client_id, "refresh_token": refresh_token} payload = {"client_id": self.client_id, "refresh_token": refresh_token}
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
data_raw = await self.connection.a_raw_post(URL_LOGOUT.format(**params_path), data=payload) data_raw = await self.connection.a_raw_post(URL_LOGOUT.format(**params_path), data=payload)
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakPostError, KeycloakPostError,
expected_codes=[HTTP_NO_CONTENT], expected_codes=[HTTP_NO_CONTENT],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_certs(self) -> dict: async def a_certs(self) -> dict:
""" """
@ -1311,7 +1482,15 @@ class KeycloakOpenID:
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = await self.connection.a_raw_get(URL_CERTS.format(**params_path)) data_raw = await self.connection.a_raw_get(URL_CERTS.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_public_key(self) -> str: async def a_public_key(self) -> str:
""" """
@ -1324,7 +1503,15 @@ class KeycloakOpenID:
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
data_raw = await self.connection.a_raw_get(URL_REALM.format(**params_path)) data_raw = await self.connection.a_raw_get(URL_REALM.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)["public_key"]
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res["public_key"]
async def a_entitlement(self, token: str, resource_server_id: str) -> dict: async def a_entitlement(self, token: str, resource_server_id: str) -> dict:
""" """
@ -1343,7 +1530,7 @@ class KeycloakOpenID:
:returns: Entitlements :returns: Entitlements
:rtype: dict :rtype: dict
""" """
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} 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)) data_raw = await self.connection.a_raw_get(URL_ENTITLEMENT.format(**params_path))
@ -1354,9 +1541,25 @@ class KeycloakOpenID:
) )
if data_raw.status_code in [HTTP_NOT_FOUND, HTTP_NOT_ALLOWED]: if data_raw.status_code in [HTTP_NOT_FOUND, HTTP_NOT_ALLOWED]:
return raise_error_from_response(data_raw, KeycloakDeprecationError)
res = raise_error_from_response(data_raw, KeycloakDeprecationError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
return raise_error_from_response(data_raw, KeycloakGetError) # pragma: no cover
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_introspect( async def a_introspect(
self, self,
@ -1391,7 +1594,7 @@ class KeycloakOpenID:
if token_type_hint == "requesting_party_token": # noqa: S105 if token_type_hint == "requesting_party_token": # noqa: S105
if rpt: if rpt:
payload.update({"token": rpt, "token_type_hint": token_type_hint}) payload.update({"token": rpt, "token_type_hint": token_type_hint})
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
bearer_changed = True bearer_changed = True
else: else:
@ -1410,9 +1613,17 @@ class KeycloakOpenID:
if orig_bearer is not None if orig_bearer is not None
else self.connection.del_param_headers("Authorization") else self.connection.del_param_headers("Authorization")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
async def a_decode_token(self, token: str, validate: bool = True, **kwargs: dict) -> dict:
return res
async def a_decode_token(self, token: str, validate: bool = True, **kwargs: Any) -> dict: # noqa: ANN401
""" """
Decode user token asynchronously. Decode user token asynchronously.
@ -1465,8 +1676,8 @@ class KeycloakOpenID:
self, self,
token: str, token: str,
method_token_info: str = "introspect", # noqa: S107 method_token_info: str = "introspect", # noqa: S107
**kwargs: dict,
) -> list:
**kwargs: Any, # noqa: ANN401
) -> list | None:
""" """
Get policies by user token asynchronously. Get policies by user token asynchronously.
@ -1477,7 +1688,7 @@ class KeycloakOpenID:
:param kwargs: Additional keyword arguments :param kwargs: Additional keyword arguments
:type kwargs: dict :type kwargs: dict
:return: Policies :return: Policies
:rtype: list
:rtype: list | None
:raises KeycloakAuthorizationConfigError: In case of bad authorization configuration :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration
:raises KeycloakInvalidTokenError: In case of bad token :raises KeycloakInvalidTokenError: In case of bad token
""" """
@ -1509,8 +1720,8 @@ class KeycloakOpenID:
self, self,
token: str, token: str,
method_token_info: str = "introspect", # noqa: S107 method_token_info: str = "introspect", # noqa: S107
**kwargs: dict,
) -> list:
**kwargs: Any, # noqa: ANN401
) -> list | None:
""" """
Get permission by user token asynchronously. Get permission by user token asynchronously.
@ -1521,7 +1732,7 @@ class KeycloakOpenID:
:param kwargs: parameters for decode :param kwargs: parameters for decode
:type kwargs: dict :type kwargs: dict
:returns: permissions list :returns: permissions list
:rtype: list
:rtype: list | None
:raises KeycloakAuthorizationConfigError: In case of bad authorization configuration :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration
:raises KeycloakInvalidTokenError: In case of bad token :raises KeycloakInvalidTokenError: In case of bad token
""" """
@ -1552,7 +1763,7 @@ class KeycloakOpenID:
self, self,
token: str, token: str,
permissions: str = "", permissions: str = "",
**extra_payload: dict,
**extra_payload: Any, # noqa: ANN401
) -> list: ) -> list:
""" """
Get UMA permissions by user token with requested permissions asynchronously. Get UMA permissions by user token with requested permissions asynchronously.
@ -1582,9 +1793,9 @@ class KeycloakOpenID:
**extra_payload, **extra_payload,
} }
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
content_type = self.connection.headers.get("Content-Type")
content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") 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) data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload)
( (
@ -1597,9 +1808,17 @@ class KeycloakOpenID:
if orig_bearer is not None if orig_bearer is not None
else self.connection.del_param_headers("Authorization") else self.connection.del_param_headers("Authorization")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, list):
msg = (
"Unexpected response type. Expected 'list', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
async def a_has_uma_access(self, token: str, permissions: list) -> AuthStatus:
return res
async def a_has_uma_access(self, token: str, permissions: str) -> AuthStatus:
""" """
Determine whether user has uma permissions with specified user token asynchronously. Determine whether user has uma permissions with specified user token asynchronously.
@ -1660,9 +1879,9 @@ class KeycloakOpenID:
:rtype: dict :rtype: dict
""" """
params_path = {"realm-name": self.realm_name} params_path = {"realm-name": self.realm_name}
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
orig_content_type = self.connection.headers.get("Content-Type")
orig_content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/json") self.connection.add_param_headers("Content-Type", "application/json")
data_raw = await self.connection.a_raw_post( data_raw = await self.connection.a_raw_post(
URL_CLIENT_REGISTRATION.format(**params_path), URL_CLIENT_REGISTRATION.format(**params_path),
@ -1678,7 +1897,15 @@ class KeycloakOpenID:
if orig_content_type is not None if orig_content_type is not None
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_device(self, scope: str = "") -> dict: async def a_device(self, scope: str = "") -> dict:
""" """
@ -1706,9 +1933,17 @@ class KeycloakOpenID:
payload = self._add_secret_key(payload) payload = self._add_secret_key(payload)
data_raw = await self.connection.a_raw_post(URL_DEVICE.format(**params_path), data=payload) data_raw = await self.connection.a_raw_post(URL_DEVICE.format(**params_path), data=payload)
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_update_client(self, token: str, client_id: str, payload: dict) -> bytes:
async def a_update_client(self, token: str, client_id: str, payload: dict) -> dict:
""" """
Update a client asynchronously. Update a client asynchronously.
@ -1722,12 +1957,12 @@ class KeycloakOpenID:
:param payload: ClientRepresentation :param payload: ClientRepresentation
:type payload: dict :type payload: dict
:return: Client Representation :return: Client Representation
:rtype: bytes
:rtype: dict
""" """
params_path = {"realm-name": self.realm_name, "client-id": client_id} params_path = {"realm-name": self.realm_name, "client-id": client_id}
orig_bearer = self.connection.headers.get("Authorization")
orig_bearer = (self.connection.headers or {}).get("Authorization")
self.connection.add_param_headers("Authorization", "Bearer " + token) self.connection.add_param_headers("Authorization", "Bearer " + token)
orig_content_type = self.connection.headers.get("Content-Type")
orig_content_type = (self.connection.headers or {}).get("Content-Type")
self.connection.add_param_headers("Content-Type", "application/json") self.connection.add_param_headers("Content-Type", "application/json")
# Keycloak complains if the clientId is not set in the payload # Keycloak complains if the clientId is not set in the payload
@ -1748,4 +1983,12 @@ class KeycloakOpenID:
if orig_content_type is not None if orig_content_type is not None
else self.connection.del_param_headers("Content-Type") else self.connection.del_param_headers("Content-Type")
) )
return raise_error_from_response(data_raw, KeycloakPutError)
res = raise_error_from_response(data_raw, KeycloakPutError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res

318
src/keycloak/keycloak_uma.py

@ -30,11 +30,9 @@ https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-federated-authz-2.0.html
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from urllib.parse import quote_plus from urllib.parse import quote_plus
from async_property import async_property
from .connection import ConnectionManager from .connection import ConnectionManager
from .exceptions import ( from .exceptions import (
HTTP_CREATED, HTTP_CREATED,
@ -49,7 +47,7 @@ from .exceptions import (
from .urls_patterns import URL_UMA_WELL_KNOWN from .urls_patterns import URL_UMA_WELL_KNOWN
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import AsyncGenerator, Generator, Iterable
from .openid_connection import KeycloakOpenIDConnection from .openid_connection import KeycloakOpenIDConnection
from .uma_permissions import UMAPermission from .uma_permissions import UMAPermission
@ -75,10 +73,18 @@ class KeycloakUMA:
def _fetch_well_known(self) -> dict: def _fetch_well_known(self) -> dict:
params_path = {"realm-name": self.connection.realm_name} params_path = {"realm-name": self.connection.realm_name}
data_raw = self.connection.raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) data_raw = self.connection.raw_get(URL_UMA_WELL_KNOWN.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
@staticmethod @staticmethod
def format_url(url: str, **kwargs: dict) -> str:
def format_url(url: str, **kwargs: Any) -> str: # noqa: ANN401
""" """
Substitute url path parameters. Substitute url path parameters.
@ -97,7 +103,7 @@ class KeycloakUMA:
return url.format(**{k: quote_plus(v) for k, v in kwargs.items()}) return url.format(**{k: quote_plus(v) for k, v in kwargs.items()})
@staticmethod @staticmethod
async def a_format_url(url: str, **kwargs: dict) -> str:
async def a_format_url(url: str, **kwargs: Any) -> str: # noqa: ANN401
""" """
Substitute url path parameters. Substitute url path parameters.
@ -129,7 +135,7 @@ class KeycloakUMA:
return self._well_known return self._well_known
@async_property
@property
async def a_uma_well_known(self) -> dict: async def a_uma_well_known(self) -> dict:
""" """
Get the well_known UMA2 config async. Get the well_known UMA2 config async.
@ -142,7 +148,7 @@ class KeycloakUMA:
return self._well_known return self._well_known
def resource_set_create(self, payload: dict) -> dict | bytes:
def resource_set_create(self, payload: dict) -> dict:
""" """
Create a resource set. Create a resource set.
@ -161,13 +167,21 @@ class KeycloakUMA:
self.uma_well_known["resource_registration_endpoint"], self.uma_well_known["resource_registration_endpoint"],
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakPostError, KeycloakPostError,
expected_codes=[HTTP_CREATED], expected_codes=[HTTP_CREATED],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
def resource_set_update(self, resource_id: str, payload: dict) -> bytes:
return res
def resource_set_update(self, resource_id: str, payload: dict) -> dict:
""" """
Update a resource set. Update a resource set.
@ -189,11 +203,19 @@ class KeycloakUMA:
id=resource_id, id=resource_id,
) )
data_raw = self.connection.raw_put(url, data=json.dumps(payload)) data_raw = self.connection.raw_put(url, data=json.dumps(payload))
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakPutError, KeycloakPutError,
expected_codes=[HTTP_NO_CONTENT], expected_codes=[HTTP_NO_CONTENT],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def resource_set_read(self, resource_id: str) -> dict: def resource_set_read(self, resource_id: str) -> dict:
""" """
@ -215,9 +237,17 @@ class KeycloakUMA:
id=resource_id, id=resource_id,
) )
data_raw = self.connection.raw_get(url) data_raw = self.connection.raw_get(url)
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
def resource_set_delete(self, resource_id: str) -> bytes:
return res
def resource_set_delete(self, resource_id: str) -> dict:
""" """
Delete a resource set. Delete a resource set.
@ -234,11 +264,19 @@ class KeycloakUMA:
id=resource_id, id=resource_id,
) )
data_raw = self.connection.raw_delete(url) data_raw = self.connection.raw_delete(url)
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakDeleteError, KeycloakDeleteError,
expected_codes=[HTTP_NO_CONTENT], expected_codes=[HTTP_NO_CONTENT],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def resource_set_list_ids( def resource_set_list_ids(
self, self,
@ -303,9 +341,17 @@ class KeycloakUMA:
self.uma_well_known["resource_registration_endpoint"], self.uma_well_known["resource_registration_endpoint"],
**query, **query,
) )
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
if not isinstance(res, list):
msg = (
"Unexpected response type. Expected 'list', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def resource_set_list(self) -> list:
def resource_set_list(self) -> Generator[dict, Any, Any]:
""" """
List all resource sets. List all resource sets.
@ -363,13 +409,21 @@ class KeycloakUMA:
self.uma_well_known["permission_endpoint"], self.uma_well_known["permission_endpoint"],
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def permissions_check( def permissions_check(
self, self,
token: str, token: str,
permissions: Iterable[UMAPermission], permissions: Iterable[UMAPermission],
**extra_payload: dict,
**extra_payload: Any, # noqa: ANN401
) -> bool: ) -> bool:
""" """
Check UMA permissions by user token with requested permissions. Check UMA permissions by user token with requested permissions.
@ -401,7 +455,21 @@ class KeycloakUMA:
if len(payload["permission"]) == 0: if len(payload["permission"]) == 0:
return True return True
connection = ConnectionManager(self.connection.base_url)
if self.connection.base_url is None:
msg = (
"Unable to perform permission check without base_url set on the connection object."
)
raise AttributeError(msg)
connection = ConnectionManager(
base_url=self.connection.base_url,
timeout=self.connection.timeout,
verify=self.connection.verify,
proxies=self.connection.proxies,
cert=self.connection.cert,
max_retries=self.connection.max_retries,
pool_maxsize=self.connection.pool_maxsize,
)
connection.add_param_headers("Authorization", "Bearer " + token) connection.add_param_headers("Authorization", "Bearer " + token)
connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded")
data_raw = connection.raw_post(self.uma_well_known["token_endpoint"], data=payload) data_raw = connection.raw_post(self.uma_well_known["token_endpoint"], data=payload)
@ -409,6 +477,14 @@ class KeycloakUMA:
data = raise_error_from_response(data_raw, KeycloakPostError) data = raise_error_from_response(data_raw, KeycloakPostError)
except KeycloakPostError: except KeycloakPostError:
return False return False
if not isinstance(data, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(data)}', value '{data}'."
)
raise TypeError(msg)
return data.get("result", False) return data.get("result", False)
def policy_resource_create(self, resource_id: str, payload: dict) -> dict: def policy_resource_create(self, resource_id: str, payload: dict) -> dict:
@ -430,9 +506,17 @@ class KeycloakUMA:
self.uma_well_known["policy_endpoint"] + f"/{resource_id}", self.uma_well_known["policy_endpoint"] + f"/{resource_id}",
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def policy_update(self, policy_id: str, payload: dict) -> dict:
def policy_update(self, policy_id: str, payload: dict) -> bytes:
""" """
Update permission policy. Update permission policy.
@ -444,13 +528,21 @@ class KeycloakUMA:
:param payload: policy permission configuration :param payload: policy permission configuration
:type payload: dict :type payload: dict
:return: PermissionRepresentation :return: PermissionRepresentation
:rtype: dict
:rtype: bytes
""" """
data_raw = self.connection.raw_put( data_raw = self.connection.raw_put(
self.uma_well_known["policy_endpoint"] + f"/{policy_id}", self.uma_well_known["policy_endpoint"] + f"/{policy_id}",
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(data_raw, KeycloakPutError)
res = raise_error_from_response(data_raw, KeycloakPutError)
if not isinstance(res, bytes):
msg = (
"Unexpected response type. Expected 'bytes', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def policy_delete(self, policy_id: str) -> dict: def policy_delete(self, policy_id: str) -> dict:
""" """
@ -467,7 +559,15 @@ class KeycloakUMA:
data_raw = self.connection.raw_delete( data_raw = self.connection.raw_delete(
self.uma_well_known["policy_endpoint"] + f"/{policy_id}", self.uma_well_known["policy_endpoint"] + f"/{policy_id}",
) )
return raise_error_from_response(data_raw, KeycloakDeleteError)
res = raise_error_from_response(data_raw, KeycloakDeleteError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
def policy_query( def policy_query(
self, self,
@ -509,7 +609,18 @@ class KeycloakUMA:
query["max"] = maximum query["max"] = maximum
data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query) data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query)
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if isinstance(res, dict) and res == {}:
return []
if not isinstance(res, list):
msg = (
"Unexpected response type. Expected 'list', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a__fetch_well_known(self) -> dict: async def a__fetch_well_known(self) -> dict:
""" """
@ -520,7 +631,15 @@ class KeycloakUMA:
""" """
params_path = {"realm-name": self.connection.realm_name} params_path = {"realm-name": self.connection.realm_name}
data_raw = await self.connection.a_raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) data_raw = await self.connection.a_raw_get(URL_UMA_WELL_KNOWN.format(**params_path))
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_resource_set_create(self, payload: dict) -> dict: async def a_resource_set_create(self, payload: dict) -> dict:
""" """
@ -541,13 +660,21 @@ class KeycloakUMA:
(await self.a_uma_well_known)["resource_registration_endpoint"], (await self.a_uma_well_known)["resource_registration_endpoint"],
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakPostError, KeycloakPostError,
expected_codes=[HTTP_CREATED], expected_codes=[HTTP_CREATED],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_resource_set_update(self, resource_id: str, payload: dict) -> bytes:
async def a_resource_set_update(self, resource_id: str, payload: dict) -> dict:
""" """
Update a resource set asynchronously. Update a resource set asynchronously.
@ -562,18 +689,26 @@ class KeycloakUMA:
:param payload: ResourceRepresentation :param payload: ResourceRepresentation
:type payload: dict :type payload: dict
:return: Response dict (empty) :return: Response dict (empty)
:rtype: bytes
:rtype: dict
""" """
url = self.format_url( url = self.format_url(
(await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}",
id=resource_id, id=resource_id,
) )
data_raw = await self.connection.a_raw_put(url, data=json.dumps(payload)) data_raw = await self.connection.a_raw_put(url, data=json.dumps(payload))
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakPutError, KeycloakPutError,
expected_codes=[HTTP_NO_CONTENT], expected_codes=[HTTP_NO_CONTENT],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_resource_set_read(self, resource_id: str) -> dict: async def a_resource_set_read(self, resource_id: str) -> dict:
""" """
@ -595,9 +730,17 @@ class KeycloakUMA:
id=resource_id, id=resource_id,
) )
data_raw = await self.connection.a_raw_get(url) data_raw = await self.connection.a_raw_get(url)
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
async def a_resource_set_delete(self, resource_id: str) -> bytes:
return res
async def a_resource_set_delete(self, resource_id: str) -> dict:
""" """
Delete a resource set asynchronously. Delete a resource set asynchronously.
@ -607,18 +750,26 @@ class KeycloakUMA:
:param resource_id: id of the resource :param resource_id: id of the resource
:type resource_id: str :type resource_id: str
:return: Response dict (empty) :return: Response dict (empty)
:rtype: bytes
:rtype: dict
""" """
url = self.format_url( url = self.format_url(
(await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}",
id=resource_id, id=resource_id,
) )
data_raw = await self.connection.a_raw_delete(url) data_raw = await self.connection.a_raw_delete(url)
return raise_error_from_response(
res = raise_error_from_response(
data_raw, data_raw,
KeycloakDeleteError, KeycloakDeleteError,
expected_codes=[HTTP_NO_CONTENT], expected_codes=[HTTP_NO_CONTENT],
) )
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_resource_set_list_ids( async def a_resource_set_list_ids(
self, self,
@ -683,9 +834,17 @@ class KeycloakUMA:
(await self.a_uma_well_known)["resource_registration_endpoint"], (await self.a_uma_well_known)["resource_registration_endpoint"],
**query, **query,
) )
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK])
if not isinstance(res, list):
msg = (
"Unexpected response type. Expected 'list', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_resource_set_list(self) -> list:
async def a_resource_set_list(self) -> AsyncGenerator[dict, Any]:
""" """
List all resource sets asynchronously. List all resource sets asynchronously.
@ -702,7 +861,7 @@ class KeycloakUMA:
resource = await self.a_resource_set_read(resource_id) resource = await self.a_resource_set_read(resource_id)
yield resource yield resource
async def a_permission_ticket_create(self, permissions: Iterable[UMAPermission]) -> bool:
async def a_permission_ticket_create(self, permissions: Iterable[UMAPermission]) -> dict:
""" """
Create a permission ticket asynchronously. Create a permission ticket asynchronously.
@ -743,13 +902,21 @@ class KeycloakUMA:
(await self.a_uma_well_known)["permission_endpoint"], (await self.a_uma_well_known)["permission_endpoint"],
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_permissions_check( async def a_permissions_check(
self, self,
token: str, token: str,
permissions: Iterable[UMAPermission], permissions: Iterable[UMAPermission],
**extra_payload: dict,
**extra_payload: Any, # noqa: ANN401
) -> bool: ) -> bool:
""" """
Check UMA permissions by user token with requested permissions asynchronously. Check UMA permissions by user token with requested permissions asynchronously.
@ -781,7 +948,21 @@ class KeycloakUMA:
if len(payload["permission"]) == 0: if len(payload["permission"]) == 0:
return True return True
connection = ConnectionManager(self.connection.base_url)
if self.connection.base_url is None:
msg = (
"Unable to perform permission check without base_url set on the connection object."
)
raise AttributeError(msg)
connection = ConnectionManager(
base_url=self.connection.base_url,
timeout=self.connection.timeout,
verify=self.connection.verify,
proxies=self.connection.proxies,
cert=self.connection.cert,
max_retries=self.connection.max_retries,
pool_maxsize=self.connection.pool_maxsize,
)
connection.add_param_headers("Authorization", "Bearer " + token) connection.add_param_headers("Authorization", "Bearer " + token)
connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded")
data_raw = await connection.a_raw_post( data_raw = await connection.a_raw_post(
@ -792,6 +973,14 @@ class KeycloakUMA:
data = raise_error_from_response(data_raw, KeycloakPostError) data = raise_error_from_response(data_raw, KeycloakPostError)
except KeycloakPostError: except KeycloakPostError:
return False return False
if not isinstance(data, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(data)}', value '{data}'."
)
raise TypeError(msg)
return data.get("result", False) return data.get("result", False)
async def a_policy_resource_create(self, resource_id: str, payload: dict) -> dict: async def a_policy_resource_create(self, resource_id: str, payload: dict) -> dict:
@ -813,9 +1002,17 @@ class KeycloakUMA:
(await self.a_uma_well_known)["policy_endpoint"] + f"/{resource_id}", (await self.a_uma_well_known)["policy_endpoint"] + f"/{resource_id}",
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(data_raw, KeycloakPostError)
res = raise_error_from_response(data_raw, KeycloakPostError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_policy_update(self, policy_id: str, payload: dict) -> dict:
async def a_policy_update(self, policy_id: str, payload: dict) -> bytes:
""" """
Update permission policy asynchronously. Update permission policy asynchronously.
@ -827,13 +1024,21 @@ class KeycloakUMA:
:param payload: policy permission configuration :param payload: policy permission configuration
:type payload: dict :type payload: dict
:return: PermissionRepresentation :return: PermissionRepresentation
:rtype: dict
:rtype: bytes
""" """
data_raw = await self.connection.a_raw_put( data_raw = await self.connection.a_raw_put(
(await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}", (await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}",
data=json.dumps(payload), data=json.dumps(payload),
) )
return raise_error_from_response(data_raw, KeycloakPutError)
res = raise_error_from_response(data_raw, KeycloakPutError)
if not isinstance(res, bytes):
msg = (
"Unexpected response type. Expected 'bytes', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_policy_delete(self, policy_id: str) -> dict: async def a_policy_delete(self, policy_id: str) -> dict:
""" """
@ -850,7 +1055,15 @@ class KeycloakUMA:
data_raw = await self.connection.a_raw_delete( data_raw = await self.connection.a_raw_delete(
(await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}", (await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}",
) )
return raise_error_from_response(data_raw, KeycloakDeleteError)
res = raise_error_from_response(data_raw, KeycloakDeleteError)
if not isinstance(res, dict):
msg = (
"Unexpected response type. Expected 'dict', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res
async def a_policy_query( async def a_policy_query(
self, self,
@ -895,4 +1108,15 @@ class KeycloakUMA:
(await self.a_uma_well_known)["policy_endpoint"], (await self.a_uma_well_known)["policy_endpoint"],
**query, **query,
) )
return raise_error_from_response(data_raw, KeycloakGetError)
res = raise_error_from_response(data_raw, KeycloakGetError)
if isinstance(res, dict) and res == {}:
return []
if not isinstance(res, list):
msg = (
"Unexpected response type. Expected 'list', received "
f"'{type(res)}', value '{res}'."
)
raise TypeError(msg)
return res

98
src/keycloak/openid_connection.py

@ -31,7 +31,7 @@ of openid tokens when required.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from httpx import Response as AsyncResponse from httpx import Response as AsyncResponse
@ -67,13 +67,13 @@ class KeycloakOpenIDConnection(ConnectionManager):
def __init__( def __init__(
self, self,
server_url: str,
server_url: str | None = None,
grant_type: str | None = None, grant_type: str | None = None,
username: str | None = None, username: str | None = None,
password: str | None = None, password: str | None = None,
token: str | None = None,
totp: str | None = None,
realm_name: str = "master",
token: dict | None = None,
totp: int | None = None,
realm_name: str | None = "master",
client_id: str = "admin-cli", client_id: str = "admin-cli",
verify: str | bool = True, verify: str | bool = True,
client_secret_key: str | None = None, client_secret_key: str | None = None,
@ -150,6 +150,10 @@ class KeycloakOpenIDConnection(ConnectionManager):
elif client_secret_key: elif client_secret_key:
self.grant_type = "client_credentials" self.grant_type = "client_credentials"
if self.server_url is None:
msg = "Unable to initialize KeycloakOpenIDConnection without server_url."
raise ValueError(msg)
super().__init__( super().__init__(
base_url=self.server_url, base_url=self.server_url,
headers=self.headers, headers=self.headers,
@ -161,7 +165,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
) )
@property @property
def server_url(self) -> str:
def server_url(self) -> str | None:
""" """
Get server url. Get server url.
@ -171,11 +175,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self.base_url return self.base_url
@server_url.setter @server_url.setter
def server_url(self, value: str) -> None:
def server_url(self, value: str | None) -> None:
self.base_url = value self.base_url = value
@property @property
def grant_type(self) -> str:
def grant_type(self) -> str | None:
""" """
Get grant type. Get grant type.
@ -185,11 +189,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._grant_type return self._grant_type
@grant_type.setter @grant_type.setter
def grant_type(self, value: str) -> None:
def grant_type(self, value: str | None) -> None:
self._grant_type = value self._grant_type = value
@property @property
def realm_name(self) -> str:
def realm_name(self) -> str | None:
""" """
Get realm name. Get realm name.
@ -199,11 +203,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._realm_name return self._realm_name
@realm_name.setter @realm_name.setter
def realm_name(self, value: str) -> None:
def realm_name(self, value: str | None) -> None:
self._realm_name = value self._realm_name = value
@property @property
def client_id(self) -> str:
def client_id(self) -> str | None:
""" """
Get client id. Get client id.
@ -213,11 +217,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._client_id return self._client_id
@client_id.setter @client_id.setter
def client_id(self, value: str) -> None:
def client_id(self, value: str | None) -> None:
self._client_id = value self._client_id = value
@property @property
def client_secret_key(self) -> str:
def client_secret_key(self) -> str | None:
""" """
Get client secret key. Get client secret key.
@ -227,11 +231,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._client_secret_key return self._client_secret_key
@client_secret_key.setter @client_secret_key.setter
def client_secret_key(self, value: str) -> None:
def client_secret_key(self, value: str | None) -> None:
self._client_secret_key = value self._client_secret_key = value
@property @property
def username(self) -> str:
def username(self) -> str | None:
""" """
Get username. Get username.
@ -241,11 +245,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._username return self._username
@username.setter @username.setter
def username(self, value: str) -> None:
def username(self, value: str | None) -> None:
self._username = value self._username = value
@property @property
def password(self) -> str:
def password(self) -> str | None:
""" """
Get password. Get password.
@ -255,11 +259,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._password return self._password
@password.setter @password.setter
def password(self, value: str) -> None:
def password(self, value: str | None) -> None:
self._password = value self._password = value
@property @property
def totp(self) -> str:
def totp(self) -> int | None:
""" """
Get totp. Get totp.
@ -269,11 +273,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._totp return self._totp
@totp.setter @totp.setter
def totp(self, value: str) -> None:
def totp(self, value: int | None) -> None:
self._totp = value self._totp = value
@property @property
def token(self) -> dict:
def token(self) -> dict | None:
""" """
Get token. Get token.
@ -283,16 +287,16 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._token return self._token
@token.setter @token.setter
def token(self, value: dict) -> None:
def token(self, value: dict | None) -> None:
self._token = value self._token = value
self._expires_at = datetime.now(tz=timezone.utc) + timedelta( self._expires_at = datetime.now(tz=timezone.utc) + timedelta(
seconds=int(self.token_lifetime_fraction * self.token["expires_in"] if value else 0),
seconds=int(self.token_lifetime_fraction * value["expires_in"] if value else 0),
) )
if value is not None: if value is not None:
self.add_param_headers("Authorization", "Bearer " + value.get("access_token"))
self.add_param_headers("Authorization", "Bearer " + value["access_token"])
@property @property
def expires_at(self) -> datetime:
def expires_at(self) -> datetime | None:
""" """
Get token expiry time. Get token expiry time.
@ -302,7 +306,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._expires_at return self._expires_at
@property @property
def user_realm_name(self) -> str:
def user_realm_name(self) -> str | None:
""" """
Get user realm name. Get user realm name.
@ -312,11 +316,11 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._user_realm_name return self._user_realm_name
@user_realm_name.setter @user_realm_name.setter
def user_realm_name(self, value: str) -> None:
def user_realm_name(self, value: str | None) -> None:
self._user_realm_name = value self._user_realm_name = value
@property @property
def custom_headers(self) -> dict:
def custom_headers(self) -> dict | None:
""" """
Get custom headers. Get custom headers.
@ -326,9 +330,9 @@ class KeycloakOpenIDConnection(ConnectionManager):
return self._custom_headers return self._custom_headers
@custom_headers.setter @custom_headers.setter
def custom_headers(self, value: dict) -> None:
def custom_headers(self, value: dict | None) -> None:
self._custom_headers = value self._custom_headers = value
if self.custom_headers is not None:
if self.custom_headers is not None and self.headers is not None:
# merge custom headers to main headers # merge custom headers to main headers
self.headers.update(self.custom_headers) self.headers.update(self.custom_headers)
@ -350,6 +354,14 @@ class KeycloakOpenIDConnection(ConnectionManager):
else: else:
token_realm_name = "master" # noqa: S105 token_realm_name = "master" # noqa: S105
if self.client_id is None:
msg = "Unable to get KeycloakOpenID client without client_id set."
raise AttributeError(msg)
if self.server_url is None:
msg = "Unable to get KeycloakOpenID without server_url set."
raise AttributeError(msg)
self._keycloak_openid = KeycloakOpenID( self._keycloak_openid = KeycloakOpenID(
server_url=self.server_url, server_url=self.server_url,
client_id=self.client_id, client_id=self.client_id,
@ -398,17 +410,17 @@ class KeycloakOpenIDConnection(ConnectionManager):
b"Session not active", b"Session not active",
] ]
if e.response_code == HTTP_BAD_REQUEST and any( if e.response_code == HTTP_BAD_REQUEST and any(
err in e.response_body for err in list_errors
err in (e.response_body or b"") for err in list_errors
): ):
self.get_token() self.get_token()
else: else:
raise raise
def _refresh_if_required(self) -> None: def _refresh_if_required(self) -> None:
if datetime.now(tz=timezone.utc) >= self.expires_at:
if self.expires_at is not None and datetime.now(tz=timezone.utc) >= self.expires_at:
self.refresh_token() self.refresh_token()
def raw_get(self, *args: list, **kwargs: dict) -> Response:
def raw_get(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Call connection.raw_get. Call connection.raw_get.
@ -430,7 +442,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return r return r
def raw_post(self, *args: list, **kwargs: dict) -> Response:
def raw_post(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Call connection.raw_post. Call connection.raw_post.
@ -452,7 +464,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return r return r
def raw_put(self, *args: list, **kwargs: dict) -> Response:
def raw_put(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Call connection.raw_put. Call connection.raw_put.
@ -474,7 +486,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return r return r
def raw_delete(self, *args: list, **kwargs: dict) -> Response:
def raw_delete(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401
""" """
Call connection.raw_delete. Call connection.raw_delete.
@ -531,7 +543,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
b"Session not active", b"Session not active",
] ]
if e.response_code == HTTP_BAD_REQUEST and any( if e.response_code == HTTP_BAD_REQUEST and any(
err in e.response_body for err in list_errors
err in (e.response_body or b"") for err in list_errors
): ):
await self.a_get_token() await self.a_get_token()
else: else:
@ -539,10 +551,10 @@ class KeycloakOpenIDConnection(ConnectionManager):
async def a__refresh_if_required(self) -> None: async def a__refresh_if_required(self) -> None:
"""Refresh the token if it is expired.""" """Refresh the token if it is expired."""
if datetime.now(tz=timezone.utc) >= self.expires_at:
if self.expires_at is not None and datetime.now(tz=timezone.utc) >= self.expires_at:
await self.a_refresh_token() await self.a_refresh_token()
async def a_raw_get(self, *args: list, **kwargs: dict) -> AsyncResponse:
async def a_raw_get(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401
""" """
Call connection.raw_get. Call connection.raw_get.
@ -564,7 +576,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return r return r
async def a_raw_post(self, *args: list, **kwargs: dict) -> AsyncResponse:
async def a_raw_post(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401
""" """
Call connection.raw_post. Call connection.raw_post.
@ -586,7 +598,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return r return r
async def a_raw_put(self, *args: list, **kwargs: dict) -> AsyncResponse:
async def a_raw_put(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401
""" """
Call connection.raw_put. Call connection.raw_put.
@ -608,7 +620,7 @@ class KeycloakOpenIDConnection(ConnectionManager):
return r return r
async def a_raw_delete(self, *args: list, **kwargs: dict) -> AsyncResponse:
async def a_raw_delete(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401
""" """
Call connection.raw_delete. Call connection.raw_delete.

27
src/keycloak/uma_permissions.py

@ -24,7 +24,7 @@
from __future__ import annotations from __future__ import annotations
from keycloak.exceptions import KeycloakPermissionFormatError, PermissionDefinitionError
from keycloak.exceptions import KeycloakPermissionFormatError
class UMAPermission: class UMAPermission:
@ -68,11 +68,9 @@ class UMAPermission:
""" """
self.resource = resource self.resource = resource
self.scope = scope self.scope = scope
self.resource_id = None
if permission:
if not isinstance(permission, UMAPermission):
msg = f"can't determine if '{permission}' is a resource or scope"
raise PermissionDefinitionError(msg)
if permission is not None:
if permission.resource: if permission.resource:
self.resource = str(permission.resource) self.resource = str(permission.resource)
if permission.scope: if permission.scope:
@ -146,10 +144,7 @@ class UMAPermission:
if scope: if scope:
result_scope = str(scope) result_scope = str(scope)
if permission:
if not isinstance(permission, UMAPermission):
msg = f"can't determine if '{permission}' is a resource or scope"
raise PermissionDefinitionError(msg)
if permission is not None:
if permission.resource: if permission.resource:
result_resource = str(permission.resource) result_resource = str(permission.resource)
if permission.scope: if permission.scope:
@ -168,7 +163,7 @@ class Resource(UMAPermission):
:type resource: str :type resource: str
""" """
def __init__(self, resource: Resource) -> None:
def __init__(self, resource: str) -> None:
""" """
Init method. Init method.
@ -188,7 +183,7 @@ class Scope(UMAPermission):
:type scope: str :type scope: str
""" """
def __init__(self, scope: Scope) -> None:
def __init__(self, scope: str) -> None:
""" """
Init method. Init method.
@ -213,7 +208,9 @@ class AuthStatus:
:type missing_permissions: set :type missing_permissions: set
""" """
def __init__(self, is_logged_in: bool, is_authorized: bool, missing_permissions: set) -> None:
def __init__(
self, is_logged_in: bool, is_authorized: bool, missing_permissions: set | str
) -> None:
""" """
Init method. Init method.
@ -252,7 +249,9 @@ class AuthStatus:
) )
def build_permission_param(permissions: str | list | dict) -> set:
def build_permission_param(
permissions: str | list | dict | UMAPermission | None | tuple | set,
) -> set:
""" """
Transform permissions to a set, so they are usable for requests. Transform permissions to a set, so they are usable for requests.
@ -268,6 +267,8 @@ def build_permission_param(permissions: str | list | dict) -> set:
return {permissions} return {permissions}
if isinstance(permissions, UMAPermission): if isinstance(permissions, UMAPermission):
return {str(permissions)} return {str(permissions)}
if isinstance(permissions, (list, tuple, set)):
return set(permissions)
try: # treat as dictionary of permissions try: # treat as dictionary of permissions
result = set() result = set()

16
test_keycloak_init.sh

@ -6,9 +6,9 @@ KEYCLOAK_DOCKER_IMAGE="quay.io/keycloak/keycloak:$KEYCLOAK_DOCKER_IMAGE_TAG"
function keycloak_stop() { function keycloak_stop() {
if [ "$(docker ps -q -f name=unittest_keycloak)" ]; then if [ "$(docker ps -q -f name=unittest_keycloak)" ]; then
docker logs unittest_keycloak > keycloak_test_logs.txt
docker stop unittest_keycloak &> /dev/null
docker rm unittest_keycloak &> /dev/null
docker logs unittest_keycloak >keycloak_test_logs.txt
docker stop unittest_keycloak &>/dev/null
docker rm unittest_keycloak &>/dev/null
fi fi
} }
@ -23,11 +23,11 @@ function keycloak_start() {
docker run --rm -d --name unittest_keycloak -e KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN}" -e KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD}" -p "${KEYCLOAK_PORT}:8080" -v $PWD/tests/providers:/opt/keycloak/providers "${KEYCLOAK_DOCKER_IMAGE}" start-dev --features="${KEYCLOAK_FEATURES}" docker run --rm -d --name unittest_keycloak -e KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN}" -e KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD}" -p "${KEYCLOAK_PORT}:8080" -v $PWD/tests/providers:/opt/keycloak/providers "${KEYCLOAK_DOCKER_IMAGE}" start-dev --features="${KEYCLOAK_FEATURES}"
SECONDS=0 SECONDS=0
until curl --silent --output /dev/null localhost:$KEYCLOAK_PORT; do until curl --silent --output /dev/null localhost:$KEYCLOAK_PORT; do
sleep 5;
if [ ${SECONDS} -gt 180 ]; then
echo "Timeout exceeded";
exit 1;
fi
sleep 5
if [ ${SECONDS} -gt 180 ]; then
echo "Timeout exceeded"
exit 1
fi
done done
} }

7
tests/conftest.py

@ -481,6 +481,7 @@ def group(admin: KeycloakAdmin, realm: str) -> Generator[str, None, None]:
admin.change_current_realm(realm) admin.change_current_realm(realm)
group_name = str(uuid.uuid4()) group_name = str(uuid.uuid4())
group_id = admin.create_group(payload={"name": group_name}) group_id = admin.create_group(payload={"name": group_name})
assert group_id is not None
yield group_id yield group_id
admin.delete_group(group_id=group_id) admin.delete_group(group_id=group_id)
@ -556,7 +557,7 @@ def composite_client_role(
@pytest.fixture @pytest.fixture
def selfsigned_cert() -> tuple[str, str]:
def selfsigned_cert() -> tuple[bytes, bytes]:
""" """
Generate self signed certificate for a hostname, and optional IP addresses. Generate self signed certificate for a hostname, and optional IP addresses.
@ -564,7 +565,7 @@ def selfsigned_cert() -> tuple[str, str]:
:rtype: Tuple[str, str] :rtype: Tuple[str, str]
""" """
hostname = "testcert" hostname = "testcert"
ip_addresses = None
ip_addresses: None | list = []
key = None key = None
# Generate our key # Generate our key
if key is None: if key is None:
@ -575,7 +576,7 @@ def selfsigned_cert() -> tuple[str, str]:
) )
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)])
alt_names = [x509.DNSName(hostname)]
alt_names: list = [x509.DNSName(hostname)]
# allow addressing by IP, for when you don't have real DNS (common in most testing scenarios # allow addressing by IP, for when you don't have real DNS (common in most testing scenarios
if ip_addresses: if ip_addresses:

311
tests/test_keycloak_admin.py
File diff suppressed because it is too large
View File

84
tests/test_keycloak_openid.py

@ -160,7 +160,7 @@ def test_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None:
} }
# Test with dummy totp # Test with dummy totp
token = oid.token(username=username, password=password, totp="123456")
token = oid.token(username=username, password=password, totp=123456)
assert token == { assert token == {
"access_token": mock.ANY, "access_token": mock.ANY,
"expires_in": mock.ANY, "expires_in": mock.ANY,
@ -205,15 +205,14 @@ def test_exchange_token(
# Allow impersonation # Allow impersonation
admin.change_current_realm(oid.realm_name) admin.change_current_realm(oid.realm_name)
user_id = admin.get_user_id(username=username)
assert user_id is not None
client_id = admin.get_client_id(client_id="realm-management")
assert client_id is not None
admin.assign_client_role( admin.assign_client_role(
user_id=admin.get_user_id(username=username),
client_id=admin.get_client_id(client_id="realm-management"),
roles=[
admin.get_client_role(
client_id=admin.get_client_id(client_id="realm-management"),
role_name="impersonation",
),
],
user_id=user_id,
client_id=client_id,
roles=[admin.get_client_role(client_id=client_id, role_name="impersonation")],
) )
token = oid.token(username=username, password=password) token = oid.token(username=username, password=password)
@ -297,9 +296,9 @@ def test_entitlement(
""" """
oid, username, password = oid_with_credentials_authz oid, username, password = oid_with_credentials_authz
token = oid.token(username=username, password=password) token = oid.token(username=username, password=password)
resource_server_id = admin.get_client_authz_resources(
client_id=admin.get_client_id(oid.client_id),
)[0]["_id"]
client_id = admin.get_client_id(oid.client_id)
assert client_id is not None
resource_server_id = admin.get_client_authz_resources(client_id=client_id)[0]["_id"]
with pytest.raises(KeycloakDeprecationError): with pytest.raises(KeycloakDeprecationError):
oid.entitlement(token=token["access_token"], resource_server_id=resource_server_id) oid.entitlement(token=token["access_token"], resource_server_id=resource_server_id)
@ -431,11 +430,11 @@ def test_get_policies(oid_with_credentials_authz: tuple[KeycloakOpenID, str, str
oid.authorization.policies["test"] = policy oid.authorization.policies["test"] = policy
assert [ assert [
str(x) str(x)
for x in oid.get_policies(token=token["access_token"], method_token_info="decode") # noqa: S106
for x in (oid.get_policies(token=token["access_token"], method_token_info="decode") or []) # noqa: S106
] == ["Policy: test (role)"] ] == ["Policy: test (role)"]
assert [ assert [
repr(x) repr(x)
for x in oid.get_policies(token=token["access_token"], method_token_info="decode") # noqa: S106
for x in (oid.get_policies(token=token["access_token"], method_token_info="decode") or []) # noqa: S106
] == ["<Policy: test (role)>"] ] == ["<Policy: test (role)>"]
oid.client_id = orig_client_id oid.client_id = orig_client_id
@ -477,11 +476,15 @@ def test_get_permissions(oid_with_credentials_authz: tuple[KeycloakOpenID, str,
oid.authorization.policies["test"] = policy oid.authorization.policies["test"] = policy
assert [ assert [
str(x) str(x)
for x in oid.get_permissions(token=token["access_token"], method_token_info="decode") # noqa: S106
for x in (
oid.get_permissions(token=token["access_token"], method_token_info="decode") or [] # noqa: S106
)
] == ["Permission: test-perm (resource)"] ] == ["Permission: test-perm (resource)"]
assert [ assert [
repr(x) repr(x)
for x in oid.get_permissions(token=token["access_token"], method_token_info="decode") # noqa: S106
for x in (
oid.get_permissions(token=token["access_token"], method_token_info="decode") or [] # noqa: S106
)
] == ["<Permission: test-perm (resource)>"] ] == ["<Permission: test-perm (resource)>"]
oid.client_id = orig_client_id oid.client_id = orig_client_id
@ -538,6 +541,7 @@ def test_has_uma_access(
str(oid.has_uma_access(token=token["access_token"], permissions="")) str(oid.has_uma_access(token=token["access_token"], permissions=""))
== "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())" == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())"
) )
assert admin.connection.token is not None
assert ( assert (
str( str(
oid.has_uma_access( oid.has_uma_access(
@ -684,7 +688,7 @@ async def test_a_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) ->
} }
# Test with dummy totp # Test with dummy totp
token = await oid.a_token(username=username, password=password, totp="123456")
token = await oid.a_token(username=username, password=password, totp=123456)
assert token == { assert token == {
"access_token": mock.ANY, "access_token": mock.ANY,
"expires_in": mock.ANY, "expires_in": mock.ANY,
@ -730,14 +734,15 @@ async def test_a_exchange_token(
# Allow impersonation # Allow impersonation
await admin.a_change_current_realm(oid.realm_name) await admin.a_change_current_realm(oid.realm_name)
user_id = await admin.a_get_user_id(username=username)
assert user_id is not None
client_id = await admin.a_get_client_id(client_id="realm-management")
assert client_id is not None
await admin.a_assign_client_role( 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"),
user_id=user_id,
client_id=client_id,
roles=[ roles=[
await admin.a_get_client_role(
client_id=admin.get_client_id(client_id="realm-management"),
role_name="impersonation",
),
await admin.a_get_client_role(client_id=client_id, role_name="impersonation"),
], ],
) )
@ -826,9 +831,9 @@ async def test_a_entitlement(
""" """
oid, username, password = oid_with_credentials_authz oid, username, password = oid_with_credentials_authz
token = await oid.a_token(username=username, password=password) 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"]
client_id = await admin.a_get_client_id(oid.client_id)
assert client_id is not None
resource_server_id = admin.get_client_authz_resources(client_id=client_id)[0]["_id"]
with pytest.raises(KeycloakDeprecationError): with pytest.raises(KeycloakDeprecationError):
await oid.a_entitlement(token=token["access_token"], resource_server_id=resource_server_id) await oid.a_entitlement(token=token["access_token"], resource_server_id=resource_server_id)
@ -989,6 +994,7 @@ async def test_a_has_uma_access(
str(await oid.a_has_uma_access(token=token["access_token"], permissions="")) str(await oid.a_has_uma_access(token=token["access_token"], permissions=""))
== "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())" == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())"
) )
assert admin.connection.token is not None
assert ( assert (
str( str(
await oid.a_has_uma_access( await oid.a_has_uma_access(
@ -1027,11 +1033,15 @@ async def test_a_get_policies(oid_with_credentials_authz: tuple[KeycloakOpenID,
oid.authorization.policies["test"] = policy oid.authorization.policies["test"] = policy
assert [ assert [
str(x) str(x)
for x in await oid.a_get_policies(token=token["access_token"], method_token_info="decode") # noqa: S106
for x in (
await oid.a_get_policies(token=token["access_token"], method_token_info="decode") or [] # noqa: S106
)
] == ["Policy: test (role)"] ] == ["Policy: test (role)"]
assert [ assert [
repr(x) repr(x)
for x in await oid.a_get_policies(token=token["access_token"], method_token_info="decode") # noqa: S106
for x in (
await oid.a_get_policies(token=token["access_token"], method_token_info="decode") or [] # noqa: S106
)
] == ["<Policy: test (role)>"] ] == ["<Policy: test (role)>"]
oid.client_id = orig_client_id oid.client_id = orig_client_id
@ -1078,16 +1088,22 @@ async def test_a_get_permissions(
oid.authorization.policies["test"] = policy oid.authorization.policies["test"] = policy
assert [ assert [
str(x) str(x)
for x in await oid.a_get_permissions(
token=token["access_token"],
method_token_info="decode", # noqa: S106
for x in (
await oid.a_get_permissions(
token=token["access_token"],
method_token_info="decode", # noqa: S106
)
or []
) )
] == ["Permission: test-perm (resource)"] ] == ["Permission: test-perm (resource)"]
assert [ assert [
repr(x) repr(x)
for x in await oid.a_get_permissions(
token=token["access_token"],
method_token_info="decode", # noqa: S106
for x in (
await oid.a_get_permissions(
token=token["access_token"],
method_token_info="decode", # noqa: S106
)
or []
) )
] == ["<Permission: test-perm (resource)>"] ] == ["<Permission: test-perm (resource)>"]
oid.client_id = orig_client_id oid.client_id = orig_client_id

16
tests/test_keycloak_uma.py

@ -216,7 +216,8 @@ def test_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None:
assert len(policies) == 1 assert len(policies) == 1
policy_id = policy["id"] policy_id = policy["id"]
uma.policy_delete(policy_id)
policy_delete_res = uma.policy_delete(policy_id)
assert policy_delete_res == {}
with pytest.raises(KeycloakDeleteError) as err: with pytest.raises(KeycloakDeleteError) as err:
uma.policy_delete(policy_id) uma.policy_delete(policy_id)
assert err.match( assert err.match(
@ -228,7 +229,8 @@ def test_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None:
assert len(policies) == 2 assert len(policies) == 2
policy = policies[0] policy = policies[0]
uma.policy_update(policy_id=policy["id"], payload=policy)
policy_update_res = uma.policy_update(policy_id=policy["id"], payload=policy)
assert policy_update_res == b""
policies = uma.policy_query() policies = uma.policy_query()
assert len(policies) == 2 assert len(policies) == 2
@ -254,6 +256,7 @@ def test_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None:
uma.resource_set_delete(resource_id) uma.resource_set_delete(resource_id)
admin.delete_client(other_client_id) admin.delete_client(other_client_id)
admin.delete_realm_role(role_id) admin.delete_realm_role(role_id)
assert group_id is not None
admin.delete_group(group_id) admin.delete_group(group_id)
@ -282,6 +285,7 @@ def test_uma_access(uma: KeycloakUMA) -> None:
token = uma.connection.token token = uma.connection.token
permissions = [] permissions = []
assert token is not None
assert uma.permissions_check(token["access_token"], permissions) assert uma.permissions_check(token["access_token"], permissions)
permissions.append(UMAPermission(resource=resource_to_create["name"])) permissions.append(UMAPermission(resource=resource_to_create["name"]))
@ -522,7 +526,8 @@ async def test_a_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None:
assert len(policies) == 1 assert len(policies) == 1
policy_id = policy["id"] policy_id = policy["id"]
await uma.a_policy_delete(policy_id)
policy_delete_res = await uma.a_policy_delete(policy_id)
assert policy_delete_res == {}
with pytest.raises(KeycloakDeleteError) as err: with pytest.raises(KeycloakDeleteError) as err:
await uma.a_policy_delete(policy_id) await uma.a_policy_delete(policy_id)
assert err.match( assert err.match(
@ -534,7 +539,8 @@ async def test_a_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None:
assert len(policies) == 2 assert len(policies) == 2
policy = policies[0] policy = policies[0]
await uma.a_policy_update(policy_id=policy["id"], payload=policy)
policy_update_res = await uma.a_policy_update(policy_id=policy["id"], payload=policy)
assert policy_update_res == b""
policies = await uma.a_policy_query() policies = await uma.a_policy_query()
assert len(policies) == 2 assert len(policies) == 2
@ -560,6 +566,7 @@ async def test_a_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None:
await uma.a_resource_set_delete(resource_id) await uma.a_resource_set_delete(resource_id)
await admin.a_delete_client(other_client_id) await admin.a_delete_client(other_client_id)
await admin.a_delete_realm_role(role_id) await admin.a_delete_realm_role(role_id)
assert group_id is not None
await admin.a_delete_group(group_id) await admin.a_delete_group(group_id)
@ -589,6 +596,7 @@ async def test_a_uma_access(uma: KeycloakUMA) -> None:
token = uma.connection.token token = uma.connection.token
permissions = [] permissions = []
assert token is not None
assert await uma.a_permissions_check(token["access_token"], permissions) assert await uma.a_permissions_check(token["access_token"], permissions)
permissions.append(UMAPermission(resource=resource_to_create["name"])) permissions.append(UMAPermission(resource=resource_to_create["name"]))

4
tests/test_pkce_flow.py

@ -9,9 +9,10 @@ from packaging.version import Version
from keycloak import KeycloakAdmin, KeycloakOpenID from keycloak import KeycloakAdmin, KeycloakOpenID
from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier
from tests.conftest import KeycloakTestEnv
def test_pkce_auth_url_and_token(env: object, admin: KeycloakAdmin) -> None:
def test_pkce_auth_url_and_token(env: KeycloakTestEnv, admin: KeycloakAdmin) -> None:
"""Test PKCE flow: auth_url includes code_challenge, token includes code_verifier.""" """Test PKCE flow: auth_url includes code_challenge, token includes code_verifier."""
if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] != "latest" and Version( if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] != "latest" and Version(
os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"], os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"],
@ -74,4 +75,5 @@ def test_pkce_auth_url_and_token(env: object, admin: KeycloakAdmin) -> None:
# Cleanup # Cleanup
client_id = admin.get_client_id("pkce-test") client_id = admin.get_client_id("pkce-test")
assert client_id is not None
admin.delete_client(client_id) admin.delete_client(client_id)

35
tests/test_uma_permissions.py

@ -20,7 +20,7 @@ import re
import pytest import pytest
from keycloak.exceptions import KeycloakPermissionFormatError, PermissionDefinitionError
from keycloak.exceptions import KeycloakPermissionFormatError
from keycloak.uma_permissions import ( from keycloak.uma_permissions import (
AuthStatus, AuthStatus,
Resource, Resource,
@ -32,9 +32,6 @@ from keycloak.uma_permissions import (
def test_uma_permission_obj() -> None: def test_uma_permission_obj() -> None:
"""Test generic UMA permission.""" """Test generic UMA permission."""
with pytest.raises(PermissionDefinitionError):
UMAPermission(permission="bad")
p1 = UMAPermission(permission=Resource("Resource")) p1 = UMAPermission(permission=Resource("Resource"))
assert p1.resource == "Resource" assert p1.resource == "Resource"
assert p1.scope == "" assert p1.scope == ""
@ -77,15 +74,6 @@ def test_scope_resource_str() -> None:
assert s(resource=r) == "Resource1#Scope1" assert s(resource=r) == "Resource1#Scope1"
def test_resource_scope_list() -> None:
"""Test resource scope as list."""
r = Resource("Resource1")
s = ["Scope1"]
with pytest.raises(PermissionDefinitionError) as err:
r(s)
assert err.match(re.escape("can't determine if '['Scope1']' is a resource or scope"))
def test_build_permission_none() -> None: def test_build_permission_none() -> None:
"""Test build permission param with None.""" """Test build permission param with None."""
assert build_permission_param(None) == set() assert build_permission_param(None) == set()
@ -172,27 +160,6 @@ def test_build_permission_misbuilt_dict_str_list_list_str() -> None:
assert err.match(re.escape("misbuilt permission {'res1': [['scope1', 'scope2']]}")) assert err.match(re.escape("misbuilt permission {'res1': [['scope1', 'scope2']]}"))
def test_build_permission_misbuilt_list_list_str() -> None:
"""Test bad build of permission param from list."""
with pytest.raises(KeycloakPermissionFormatError) as err:
build_permission_param([["scope1", "scope2"]])
assert err.match(re.escape("misbuilt permission [['scope1', 'scope2']]"))
def test_build_permission_misbuilt_list_set_str() -> None:
"""Test bad build of permission param from set."""
with pytest.raises(KeycloakPermissionFormatError) as err:
build_permission_param([{"scope1", "scope2"}])
assert err.match("misbuilt permission.*")
def test_build_permission_misbuilt_set_set_str() -> None:
"""Test bad build of permission param from list of set."""
with pytest.raises(KeycloakPermissionFormatError) as err:
build_permission_param([{"scope1"}])
assert err.match(re.escape("misbuilt permission [{'scope1'}]"))
def test_build_permission_misbuilt_dict_non_iterable() -> None: def test_build_permission_misbuilt_dict_non_iterable() -> None:
"""Test bad build of permission param from non-iterable.""" """Test bad build of permission param from non-iterable."""
with pytest.raises(KeycloakPermissionFormatError) as err: with pytest.raises(KeycloakPermissionFormatError) as err:

Loading…
Cancel
Save