diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index e2ab2c35..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,37 +0,0 @@ -version: 2 -jobs: - build: - docker: - - image: circleci/python:3.6.1 - - working_directory: ~/repo - - steps: - - checkout - - restore_cache: - keys: - - v1-dependencies-{{ checksum "requirements.txt" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: - name: install dependencies - command: | - python3 -m venv venv - . venv/bin/activate - pip install -r requirements.txt - - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum "requirements.txt" }} - - - run: - name: run tests - command: | - . venv/bin/activate - python3 -m unittest discover - - - store_artifacts: - path: test-reports - destination: test-reports \ No newline at end of file diff --git a/.github/workflows/bump.yaml b/.github/workflows/bump.yaml new file mode 100644 index 00000000..9823c121 --- /dev/null +++ b/.github/workflows/bump.yaml @@ -0,0 +1,32 @@ +name: Bump version + +on: + workflow_run: + workflows: ["Lint"] + branches: [master] + types: + - completed + +jobs: + tag-version: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.PAT_TOKEN }} + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: determine-version + run: | + VERSION=$(npx semantic-release --branches master --dry-run | { grep -i 'the next release version is' || test $? = 1; } | sed -E 's/.* ([[:digit:].]+)$/\1/') + echo "VERSION=$VERSION" >> $GITHUB_ENV + id: version + - uses: rickstaa/action-create-tag@v1 + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + with: + tag: v${{ env.VERSION }} + message: "Releasing v${{ env.VERSION }}" + github_token: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/daily.yaml b/.github/workflows/daily.yaml new file mode 100644 index 00000000..2a2fb1c5 --- /dev/null +++ b/.github/workflows/daily.yaml @@ -0,0 +1,31 @@ +name: Daily check + +on: + schedule: + - cron: "0 4 * * *" + +jobs: + test: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + keycloak-version: ["22.0", "23.0", "24.0", "25.0", "26.0", "latest"] + env: + KEYCLOAK_DOCKER_IMAGE_TAG: ${{ matrix.keycloak-version }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: docker/setup-docker-action@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install + - name: Run tests + run: | + poetry run tox -e tests diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..36070b31 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,102 @@ +name: Lint + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + check-commits: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: webiny/action-conventional-commits@v1.3.0 + + check-linting: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install + - name: Check linting, formatting + run: | + poetry run tox -e check + + check-docs: + runs-on: ubuntu-22.04 + needs: + - check-commits + - check-linting + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install + - name: Check documentation build + run: | + poetry run tox -e docs + + test: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + keycloak-version: ["22.0", "23.0", "24.0", "25.0", "26.0", "latest"] + needs: + - check-commits + - check-linting + env: + KEYCLOAK_DOCKER_IMAGE_TAG: ${{ matrix.keycloak-version }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: docker/setup-docker-action@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install + - name: Run tests + run: | + poetry run tox -e tests + - name: Keycloak logs + run: | + cat keycloak_test_logs.txt + + build: + runs-on: ubuntu-22.04 + needs: + - test + - check-docs + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install + - name: Run build + run: | + poetry run tox -e build diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..835ef873 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,44 @@ +name: Publish + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: "0" + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install + - name: Apply the tag version + run: | + version=${{ github.ref_name }} + sed -Ei '/^version = /s|= "[0-9.]+"$|= "'${version:-1}'"|' pyproject.toml + - name: Run build + run: | + poetry run tox -e build + - name: Publish to PyPi + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + run: | + poetry run twine upload -u $TWINE_USERNAME -p $TWINE_PASSWORD dist/* + - name: Run changelog + run: | + poetry run tox -e changelog + - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "docs: changelog update" + branch: master + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore index 7ea99027..b43212e9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ nosetests.xml coverage.xml *.cover .hypothesis/ +keycloak_test_logs.txt # Translations *.mo @@ -81,6 +82,7 @@ celerybeat-schedule # dotenv .env +.envrc # virtualenv .venv @@ -103,4 +105,8 @@ ENV/ .idea/ main.py main2.py -s3air-authz-config.json \ No newline at end of file +s3air-authz-config.json +.vscode +_build +.ruff_cache +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..7e03ac0c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ["--maxkb=10000"] + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v1.2.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] # optional: list of Conventional Commits types to allow diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..937c9658 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,14 @@ +version: 2 + +sphinx: + configuration: docs/source/conf.py + +build: + os: "ubuntu-24.04" + tools: + python: "3.14" + jobs: + post_create_environment: + - python -m pip install poetry + post_install: + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 00000000..c89e61ef --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,8 @@ +{ + "plugins": ["@semantic-release/commit-analyzer"], + "verifyConditions": false, + "npmPublish": false, + "publish": false, + "fail": false, + "success": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c8891db5..da5b86c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,45 +1,894 @@ -Changelog -============ +## v7.0.2 (2026-01-08) -All notable changes to this project will be documented in this file. +### Fix -## [0.5.0] - 2017-08-21 +- Fix/python version and ci (#691) -* Basic functions for Keycloak API (well_know, token, userinfo, logout, certs, -entitlement, instropect) +## v7.0.1 (2026-01-05) -## [0.6.0] - 2017-08-23 +### Fix -* Added load authorization settings +- python version -## [0.7.0] - 2017-08-23 +## v7.0.0 (2026-01-04) -* Added polices +### BREAKING CHANGE -## [0.8.0] - 2017-08-23 +- this change introduces a very strong typing across the library. From now on, we demand that each method returns a single type and converts the results into the specified return type, otherwise it raises a TypeError exception. This should resolve any type checking linter errors within the library as well. -* Added permissions +### Fix -## [0.9.0] - 2017-09-05 +- updated lockfile +- pass along the default connection manager configuration into uma +- dont override transport for httpx +- pass query params in get groups +- removed async property as a dependency +- strong and consistent typing across the library -* Added functions for Admin Keycloak API +## v6.0.0 (2025-12-28) -## [0.10.0] - 2017-10-23 +### BREAKING CHANGE -* Updated libraries versions -* Updated Docs +- changes the behavior of get_group_by_path to raise an exception in case the path is not found, which is now the definitive new behavior -## [0.11.0] - 2017-12-12 +## v5.12.0 (2025-12-27) -* Changed Instropect RPT +### Feat -## [0.12.0] - 2018-01-25 +- Add PKCE support (RFC 7636) (#670) -* Add groups functions -* Add Admin Tasks for user and client role management -* Function to trigger user sync from provider +## v5.11.0 (2025-12-27) -## [0.12.1] - 2018-08-04 +### Feat -* Add get_idps -* Rework group functions +- implement authz import request (#685) + +## v5.10.0 (2025-12-27) + +### Feat + +- add get_role_composites_by_id method (#680) + +## v5.9.0 (2025-12-27) + +### Feat + +- add pool_maxsize parameter to connection managers (#651) + +## v5.8.1 (2025-08-19) + +### Fix + +- prevent all httpx deprecation warnings (#666) + +## v5.8.0 (2025-08-19) + +### Feat + +- implement endpoints returning the number of members in an organization (#665) + +## v5.7.0 (2025-07-17) + +### Feat + +- add `get_composite_client_roles_of_role` (#660) + +## v5.6.1 (2025-07-17) + +### Fix + +- fix tests (#661) + +## v5.6.0 (2025-06-22) + +### Feat + +- add pagination support to get realm roles (#659) + +## v5.5.1 (2025-05-25) + +### Fix + +- fix/latest version (#654) + +## v5.5.0 (2025-04-09) + +### Feat + +- implement client for revoking consents/offline access, with async and improved testing (#644) + +## v5.4.0 (2025-04-08) + +### Feat + +- adds support for keycloak organizations (#642) + +## v5.3.1 (2025-02-03) + +### Fix + +- add py.typed marker file (pep-561) (#637) + +## v5.3.0 (2025-02-02) + +### Feat + +- more authentication flows and executions methods + +## v5.2.0 (2025-01-30) + +### Feat + +- Add functions to get/update realm users profile (#634) + +## v5.1.2 (2025-01-26) + +### Fix + +- small bugs, use ruff as linter, added annotations + +## v5.1.1 (2024-12-15) + +### Fix + +- retry upon 401 + +## v5.1.0 (2024-12-14) + +### Feat + +- get_client_all_sessions now supports pagination +- uma extra payload +- user profile metadata parameter for get_user method +- uma extra payload + +### Fix + +- check uma permissions with resource ID as well +- get group by path should not raise on 404 + +## v5.0.0 (2024-12-10) + +## v4.7.3 (2024-11-29) + +### Fix + +- change to mounts (#622) + +## v4.7.2 (2024-11-17) + +### Fix + +- Feature parity for `a_decode_token` and `decode_token` (#616) + +## v4.7.1 (2024-11-13) + +### Fix + +- make sure to not call sync IO functions inside async functions (#615) + +## v4.7.0 (2024-11-03) + +### Feat + +- add client scope client-specific role mappings (#605) + +## v4.6.3 (2024-10-26) + +### Fix + +- Add optional Nonce parameter to the authorization URL requests (#606) + +## v4.6.2 (2024-10-05) + +### Fix + +- add scopes to device auth (#599) + +## v4.6.1 (2024-10-05) + +### Fix + +- changed sync get user id to async get user in create user async function (#600) + +## v4.6.0 (2024-10-04) + +### Feat + +- Add the max_retries parameter (#598) + +## v4.5.1 (2024-10-02) + +### Fix + +- Set client_credentials as grant_type also when x509 certificate is given (#597) + +## v4.5.0 (2024-09-28) + +### Feat + +- add ability to remove composite client roles (#596) + +## v4.4.0 (2024-09-14) + +### Feat + +- add matchingUri support for listing resources with wildcards (#592) + +## v4.3.0 (2024-08-01) + +### Feat + +- allow the use of client certificates in all requests (#584) + +## v4.2.3 (2024-07-24) + +### Fix + +- use a_public_key() in a_decode_token() instead of public_key() (#582) + +## v4.2.2 (2024-07-16) + +### Fix + +- correctly pass query params in a_send_update_account and a_send_verify_email (#581) + +## v4.2.1 (2024-07-11) + +### Fix + +- passing timeout values to ConnectionManager (#578) + +## v4.2.0 (2024-06-22) + +### Feat + +- functions for updating resource permissions and getting associated policies for a permission (#574) + +## v4.1.0 (2024-06-06) + +### Feat + +- Async feature (#566) + +## v4.0.1 (2024-06-04) + +### Fix + +- Leeway config (#568) + +## v4.0.0 (2024-04-27) + +### BREAKING CHANGE + +- changes signatures significantly +- Many attributes removed from the admin class + +### Feat + +- Merge pull request #556 from marcospereirampj/release/4.0.0 +- re-enable full group hierarchy fetching + +### Fix + +- removed dead code, stabilized tests +- removed deprecated functionality + +### Refactor + +- refactored decode_token + +## v3.12.0 (2024-04-10) + +### Feat + +- allows retrieval of realm and client level roles for a user (#512) + +## v3.11.1 (2024-04-08) + +### Fix + +- lowercase default role name (#547) + +## v3.11.0 (2024-04-08) + +### Feat + +- add admin group count (#540) + +## v3.10.2 (2024-04-08) + +### Fix + +- fix keycloak_admin.create_user documentation/ typehint (#545) + +## v3.10.1 (2024-04-07) + +### Fix + +- improve KeycloakAdmin.get_client_id() performances (#511) + +## v3.10.0 (2024-04-07) + +### Feat + +- Allow query parameters for group children (#534) + +## v3.9.3 (2024-04-07) + +### Fix + +- incorporate custom headers into default header setup (#533) + +## v3.9.2 (2024-04-07) + +### Fix + +- get_groups pagination call was not used #537 (#541) + +## v3.9.1 (2024-02-26) + +### Fix + +- use jwcrypto and remove python-jose +- replace python-jose with jwcrypto + +## v3.9.0 (2024-02-16) + +### Feat + +- new docs. +- new docs. +- new docs. +- new docs. +- new docs. +- new docs. +- new docs. + +### Fix + +- updated readme. + +## v3.8.4 (2024-02-15) + +### Fix + +- use grant type password with client secret + +## v3.8.3 (2024-02-14) + +### Fix + +- name of client_id parameter + +## v3.8.2 (2024-02-14) + +### Fix + +- update readme. + +## v3.8.1 (2024-02-13) + +### Fix + +- linter check +- updated dependencies + +## v3.8.0 (2024-02-13) + +### Feat + +- Adding additional methods to support roles-by-id api calls Most of the methods rely on the role name within python keycloak, which for the vast majority is fine, however there are some role names which cannot be used by the API endpoint as they contain characters that cannot be encoded properly. Therefore this change is to allow the use of the role's id to get, update and delete roles by their id instead.' + +### Fix + +- Removing the admin realm variable which I created and is no longer needed + +## v3.7.1 (2024-02-13) + +### Fix + +- action bump +- linter check. +- depracated endpoint and fix groups services. +- deprecate entitlement + +## v3.7.0 (2023-11-13) + +### Feat + +- realm changing helpers + +### Fix + +- no prints + +## v3.6.1 (2023-11-13) + +### Fix + +- Ci/fix tests (#506) + +## v3.6.0 (2023-11-13) + +### Feat + +- add KeycloakAdmin.get_idp() (#478) + +## v3.5.0 (2023-11-13) + +### Feat + +- Update dynamic client using registration access token (#491) + +## v3.4.0 (2023-11-13) + +### Feat + +- add an optional search criteria to the get_realm_roles function (#504) + +## v3.3.0 (2023-06-27) + +### Feat + +- added KeycloakAdmin.update_client_authz_resource() (#462) + +## v3.2.0 (2023-06-23) + +### Feat + +- Implement missing admin method create_client_authz_scope_based_permission() and create_client_authz_policy() (#460) + +## v3.1.1 (2023-06-23) + +### Fix + +- remove duplicate slash in URL_ADMIN_IDP (#459) + +## v3.1.0 (2023-06-23) + +### Feat + +- Add query to get users group method and permit pagination (#444) + +## v3.0.0 (2023-05-28) + +### BREAKING CHANGE + +- Changes the exchange token API + +### Refactor + +- Exchange token method + +## v2.16.6 (2023-05-28) + +### Fix + +- relax the version constraints + +## v2.16.5 (2023-05-28) + +### Fix + +- do not swap realm for user_realm when logging in with a client service account (#447) + +## v2.16.4 (2023-05-28) + +### Perf + +- improve performance of get_user_id (#449) + +## v2.16.3 (2023-05-15) + +### Fix + +- Fixes `Authorization.load_config` breaking if a scope based permission is linked with anything other than a role based policy. Fixes #445 (#446) + +## v2.16.2 (2023-05-09) + +### Fix + +- issue with app engine reported in #440 (#442) + +## v2.16.1 (2023-05-01) + +### Fix + +- Initializing KeycloakAdmin without server_url (#439) + +## v2.16.0 (2023-04-28) + +### Feat + +- Add get and delete methods for client authz resources (#435) + +## v2.15.4 (2023-04-28) + +### Fix + +- **pyproject.toml**: loose requests pgk and remove urllib3 as dependency (#434) + +## v2.15.3 (2023-04-06) + +### Fix + +- Check if _s exists in ConnectionManager before deleting it (#429) + +## v2.15.2 (2023-04-05) + +### Fix + +- deprecation warnings in keycloak_admin.py (#425) + +## v2.15.1 (2023-04-05) + +### Fix + +- improved type-hints (#427) + +## v2.15.0 (2023-04-05) + +### Feat + +- Add UMA policy management and permission tickets (#426) + +## v2.14.0 (2023-03-17) + +### Feat + +- add initial access token support and policy delete method + +## v2.13.2 (2023-03-06) + +### Fix + +- Refactor auto refresh (#415) + +## v2.13.1 (2023-03-05) + +### Fix + +- Check if applyPolicies exists in the config (#367) + +## v2.13.0 (2023-03-05) + +### Feat + +- implement cache clearing API (#414) + +## v2.12.2 (2023-03-05) + +### Fix + +- get_group_by_path uses Keycloak API to load (#417) + +## v2.12.1 (2023-03-05) + +### Fix + +- tests and upgraded deps (#419) + +## v2.12.0 (2023-02-10) + +### Feat + +- add Keycloak UMA client (#403) + +## v2.11.1 (2023-02-08) + +### Fix + +- do not include CODEOWNERS (#407) + +## v2.11.0 (2023-02-08) + +### Feat + +- Add Client Scopes of Client + +## v2.10.0 (2023-02-08) + +### Feat + +- update header if token is given +- init KeycloakAdmin with token + +## v2.9.0 (2023-01-11) + +### Feat + +- added default realm roles handlers + +## v2.8.0 (2022-12-29) + +### Feat + +- **api**: add tests for create_authz_scopes + +### Fix + +- fix testing create_client_authz_scopes parameters +- fix linting +- add testcase for invalid client id +- create authz clients test case +- create authz clients test case + +## v2.7.0 (2022-12-24) + +### Refactor + +- code formatting after tox checks +- remove print statements + +## v2.6.1 (2022-12-13) + +### Feat + +- option for enabling users +- helping functions for disabling users + +### Fix + +- use version from the package +- default scope to openid + +## v2.6.0 (2022-10-03) + +### Feat + +- attack detection API implementation + +## v2.5.0 (2022-08-19) + +### Feat + +- added missing functionality to include attributes when returning realm roles according to specifications + +## v2.4.0 (2022-08-19) + +### Feat + +- add client scope-mappings client roles operations + +## v2.3.0 (2022-08-13) + +### Feat + +- Add token_type/scope to token exchange api + +## v2.2.0 (2022-08-12) + +### Feat + +- add client scope-mappings realm roles operations + +## v2.1.1 (2022-07-19) + +### Fix + +- removed whitespace from urls + +### Refactor + +- applied linting + +## v2.1.0 (2022-07-18) + +### Feat + +- add unit tests +- add docstrings +- add functions covering some missing REST API calls + +### Fix + +- linting +- now get_required_action_by_alias now returns None if action does not exist +- moved imports at the top of the file +- remove duplicate function +- applied tox -e docs +- applied flake linting checks +- applied tox linting check + +## v2.0.0 (2022-07-17) + +### BREAKING CHANGE + +- Renamed parameter client_name to client_id in get_client_id method + +### Fix + +- check client existence based on clientId + +## v1.9.1 (2022-07-13) + +### Fix + +- turn get_name into a method, use setters in connection manager + +### Refactor + +- no need to try if the type check is performed + +## v1.9.0 (2022-07-13) + +### Refactor + +- merge master branch into local + +## v1.8.1 (2022-07-13) + +### Feat + +- added flake8-docstrings and upgraded dependencies + +### Fix + +- Support the auth_url method called with scope & state params now +- raise correct exceptions + +### Refactor + +- slight restructure of the base fixtures + +## v1.8.0 (2022-06-22) + +### Feat + +- Ability to set custom timeout for KCOpenId and KCAdmin + +## v1.7.0 (2022-06-16) + +### Feat + +- Allow fetching existing policies before calling create_client_authz_client_policy() + +## v1.6.0 (2022-06-13) + +### Feat + +- support token exchange config via admin API + +## v1.5.0 (2022-06-03) + +### Feat + +- Add update_idp + +## v1.4.0 (2022-06-02) + +### Feat + +- Add update_mapper_in_idp + +## v1.3.0 (2022-05-31) + +## v1.2.0 (2022-05-31) + +### Feat + +- Support Token Exchange. Fixes #305 +- Add get_idp_mappers, fix #329 + +## v1.1.1 (2022-05-27) + +### Fix + +- fixed bugs in events methods +- fixed components bugs +- use param for update client mapper + +## v1.1.0 (2022-05-26) + +### Feat + +- added new methods for client scopes + +## v1.0.1 (2022-05-25) + +### Fix + +- allow query parameters for users count + +## v1.0.0 (2022-05-25) + +### BREAKING CHANGE + +- Renames `KeycloakOpenID.well_know` to `KeycloakOpenID.well_known` + +### Fix + +- correct spelling of public API method + +## v0.29.1 (2022-05-24) + +### Fix + +- allow client_credentials token if username and password not specified + +## v0.29.0 (2022-05-23) + +### Feat + +- added UMA-permission request functionality + +### Fix + +- added fixes based on feedback + +## v0.28.3 (2022-05-23) + +### Fix + +- import classes in the base module + +## v0.28.2 (2022-05-19) + +### Fix + +- escape when get role fails + +## v0.28.1 (2022-05-19) + +### Fix + +- Add missing keycloak.authorization package + +## v0.28.0 (2022-05-19) + +### Feat + +- added authenticator providers getters +- fixed admin client to pass the tests +- initial setup of CICD and linting + +### Fix + +- full tox fix ready +- raise correct errors + +### Refactor + +- isort conf.py +- Merge branch 'master' into feature/cicd + +## v0.27.1 (2022-05-18) + +### Fix + +- **release**: version bumps for hotfix release + +## v0.27.0 (2022-02-16) + +### Fix + +- handle refresh_token error "Session not active" + +## v0.26.1 (2021-08-30) + +### Feat + +- add KeycloakAdmin.set_events + +## v0.25.0 (2021-05-05) + +## v0.24.0 (2020-12-18) + +## 0.23.0 (2020-11-19) + +## v0.22.0 (2020-08-16) + +## v0.21.0 (2020-06-30) + +### Feat + +- add components + +## v0.20.0 (2020-04-11) + +## v0.19.0 (2020-02-18) + +## v0.18.0 (2019-12-10) + +## v0.17.6 (2019-10-10) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..853ebe53 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @ryshoooo @marcospereirampj diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..12a948ba --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing + +Welcome to the Python Keycloak contributing guidelines. We are all more than happy to receive +any contributions to the repository and want to thank you in advance for your contributions! +This document outlines the process and the guidelines on how contributions work for this repository. + +## Setting up the dev environment + +The development environment is mainly up to the developer. Our recommendations are to create a python +virtual environment and install the necessary requirements. Example + +```sh +# Install and upgrade pip & poetry +python -m pip install --upgrade pip poetry + +# Create virtualenv +python -m poetry env use + +# install package dependencies including dev dependencies +python -m poetry install + +# Activate virtualenv +python -m poetry shell +``` + +## Running checks and tests + +We're utilizing `tox` for most of the testing workflows. However we also have an external dependency on `docker`. +We're using docker to spin up a local keycloak instance which we run our test cases against. This is to avoid +a lot of unnecessary mocking and yet have immediate feedback from the actual Keycloak instance. All of the setup +is done for you with the tox environments, all you need is to have both tox and docker installed +(`tox` is included in the `dev-requirements.txt`). + +To run the unit tests, simply run + +```sh +tox -e tests +``` + +The project is also adhering to strict linting (flake8) and formatting (black + isort). You can always check that +your code changes adhere to the format by running + +```sh +tox -e check +``` + +If the check fails, you'll see an error message specifying what went wrong. To simplify things, you can also run + +```sh +tox -e apply-check +``` + +which will apply isort and black formatting for you in the repository. The flake8 problems however need to be resolved +manually by the developer. + +Additionally we require that the documentation pages are built without warnings. This check is also run via tox, using +the command + +```sh +tox -e docs +``` + +The check is also run in the CICD pipelines. We require that the documentation pages built from the code docstrings +do not create visually "bad" pages. + +## Conventional commits + +Commits to this project must adhere to the [Conventional Commits +specification](https://www.conventionalcommits.org/en/v1.0.0/) that will allow +us to automate version bumps and changelog entry creation. + +After cloning this repository, you must install the pre-commit hook for +conventional commits (this is included in the `dev-requirements.txt`) + +```sh +# Create virtualenv +python -m poetry env use + +# Activate virtualenv +python -m poetry shell + +pre-commit install --install-hooks -t pre-commit -t pre-push -t commit-msg +``` + +## How to contribute + +1. Fork this repository, develop and test your changes +2. Make sure that your changes do not decrease the test coverage +3. Make sure your commits follow the conventional commits +4. Submit a pull request + +## How to release + +The CICD pipelines are set up for the repository. When a PR is merged, a new version of the library +will be automatically deployed to the PyPi server, meaning you'll be able to see your changes immediately. diff --git a/LICENSE b/LICENSE index f193f7d2..781617cd 100644 --- a/LICENSE +++ b/LICENSE @@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Pipfile b/Pipfile deleted file mode 100644 index a85e8e54..00000000 --- a/Pipfile +++ /dev/null @@ -1,14 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -requests = ">=2.20.0" -httmock = ">=1.2.5" -python-jose = ">=1.4.0" - -[dev-packages] - -[requires] -python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 172a600d..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,107 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "8c12705e89c665da92fc69ef0d312a9ca313703c839c15d18fcc833dcb87d7f7" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", - "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" - ], - "version": "==2019.9.11" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "ecdsa": { - "hashes": [ - "sha256:163c80b064a763ea733870feb96f9dd9b92216cfcacd374837af18e4e8ec3d4d", - "sha256:9814e700890991abeceeb2242586024d4758c8fc18445b194a49bd62d85861db" - ], - "index": "pypi", - "version": "==0.13.3" - }, - "future": { - "hashes": [ - "sha256:6142ef79e2416e432931d527452a1cab3aa4a754a0a53d25b2589f79e1106f34" - ], - "version": "==0.18.0" - }, - "httmock": { - "hashes": [ - "sha256:4696306d1ff835c3ca865fdef2684d7e130b4120cc00126f862ba4797b1602ac" - ], - "index": "pypi", - "version": "==1.2.6" - }, - "idna": { - "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" - ], - "version": "==2.7" - }, - "pyasn1": { - "hashes": [ - "sha256:62cdade8b5530f0b185e09855dd422bc05c0bbff6b72ff61381c09dac7befd8c", - "sha256:a9495356ca1d66ed197a0f72b41eb1823cf7ea8b5bd07191673e8147aecf8604" - ], - "version": "==0.4.7" - }, - "python-jose": { - "hashes": [ - "sha256:29701d998fe560e52f17246c3213a882a4a39da7e42c7015bcc1f7823ceaff1c", - "sha256:ed7387f0f9af2ea0ddc441d83a6eb47a5909bd0c8a72ac3250e75afec2cc1371" - ], - "index": "pypi", - "version": "==3.0.1" - }, - "requests": { - "hashes": [ - "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", - "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" - ], - "index": "pypi", - "version": "==2.19.1" - }, - "rsa": { - "hashes": [ - "sha256:14ba45700ff1ec9eeb206a2ce76b32814958a98e372006c8fb76ba820211be66", - "sha256:1a836406405730121ae9823e19c6e806c62bbad73f890574fff50efa4122c487" - ], - "version": "==4.0" - }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "urllib3": { - "hashes": [ - "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", - "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" - ], - "version": "==1.23" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index 5f39f86a..973ce32f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,15 @@ -[![CircleCI](https://circleci.com/gh/marcospereirampj/python-keycloak/tree/master.svg?style=svg)](https://circleci.com/gh/marcospereirampj/python-keycloak/tree/master) +[![CircleCI](https://github.com/marcospereirampj/python-keycloak/actions/workflows/daily.yaml/badge.svg)](https://github.com/marcospereirampj/python-keycloak/) [![Documentation Status](https://readthedocs.org/projects/python-keycloak/badge/?version=latest)](http://python-keycloak.readthedocs.io/en/latest/?badge=latest) - -Python Keycloak -==================== - -For review- see https://github.com/marcospereirampj/python-keycloak +# Python Keycloak **python-keycloak** is a Python package providing access to the Keycloak API. ## Installation -### Via Pypi Package: - -``` $ pip install python-keycloak ``` - -### Manually - -``` $ python setup.py install ``` - -## Dependencies - -python-keycloak depends on: - -* Python 3 -* [requests](http://docs.python-requests.org/en/master/) -* [python-jose](http://python-jose.readthedocs.io/en/latest/) +Install via PyPI: -### Tests Dependencies - -* unittest -* [httmock](https://github.com/patrys/httmock) +`$ pip install python-keycloak` ## Bug reports @@ -41,38 +20,57 @@ https://github.com/marcospereirampj/python-keycloak/issues The documentation for python-keycloak is available on [readthedocs](http://python-keycloak.readthedocs.io). -## Contributors +## Keycloak version support + +The library strives to always support Keycloak's latest version. Additionally to that, we also support 5 latest major versions of Keycloak, +in order to give our user base more time for smoother upgrades. + +Current list of supported Keycloak versions: + +- 26.X +- 25.X +- 24.X +- 23.X +- 22.X -* [Agriness Team](http://www.agriness.com/pt/) -* [Marcos Pereira](marcospereira.mpj@gmail.com) -* [Martin Devlin](https://bitbucket.org/devlinmpearson/) -* [Shon T. Urbas](https://bitbucket.org/surbas/) -* [Markus Spanier](https://bitbucket.org/spanierm/) -* [Remco Kranenburg](https://bitbucket.org/Remco47/) -* [Armin](https://bitbucket.org/arminfelder/) -* [njordr](https://bitbucket.org/njordr/) -* [Josha Inglis](https://bitbucket.org/joshainglis/) -* [Alex](https://bitbucket.org/alex_zel/) -* [Ewan Jone](https://bitbucket.org/kisamoto/) +## Python version support -## Usage +We only support Python versions that have active or security support by the Python Software Foundation. You can find the list of active Python versions [here](https://endoflife.date/python). + +## Example of Using Keycloak OpenID ```python from keycloak import KeycloakOpenID # Configure client keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/", - client_id="example_client", - realm_name="example_realm", - client_secret_key="secret") + client_id="example_client", + realm_name="example_realm", + client_secret_key="secret") + +# Get WellKnown +config_well_known = keycloak_openid.well_known() + +# Get Code With Oauth Authorization Request +auth_url = keycloak_openid.auth_url( + redirect_uri="your_call_back_url", + scope="email", + state="your_state_info") + +# Get Access Token With Code +access_token = keycloak_openid.token( + grant_type='authorization_code', + code='the_code_you_get_from_auth_url_callback', + redirect_uri="your_call_back_url") -# Get WellKnow -config_well_know = keycloak_openid.well_know() # Get Token token = keycloak_openid.token("user", "password") token = keycloak_openid.token("user", "password", totp="012345") +# Get token using Token Exchange +token = keycloak_openid.exchange_token(token['access_token'], "my_client", "other_client", "some_user") + # Get Userinfo userinfo = keycloak_openid.userinfo(token['access_token']) @@ -81,164 +79,49 @@ token = keycloak_openid.refresh_token(token['refresh_token']) # Logout keycloak_openid.logout(token['refresh_token']) +``` -# Get Certs -certs = keycloak_openid.certs() - -# Get RPT (Entitlement) -token = keycloak_openid.token("user", "password") -rpt = keycloak_openid.entitlement(token['access_token'], "resource_id") - -# Instropect RPT -token_rpt_info = keycloak_openid.introspect(keycloak_openid.introspect(token['access_token'], rpt=rpt['rpt'], - token_type_hint="requesting_party_token")) - -# Introspect Token -token_info = keycloak_openid.introspect(token['access_token'])) - -# Decode Token -KEYCLOAK_PUBLIC_KEY = "secret" -options = {"verify_signature": True, "verify_aud": True, "exp": True} -token_info = keycloak_openid.decode_token(token['access_token'], key=KEYCLOAK_PUBLIC_KEY, options=options) - -# Get permissions by token -token = keycloak_openid.token("user", "password") -keycloak_openid.load_authorization_config("example-authz-config.json") -policies = keycloak_openid.get_policies(token['access_token'], method_token_info='decode', key=KEYCLOAK_PUBLIC_KEY) -permissions = keycloak_openid.get_permissions(token['access_token'], method_token_info='introspect') - -# KEYCLOAK ADMIN +## Example of Using Keycloak Admin API +```python from keycloak import KeycloakAdmin +from keycloak import KeycloakOpenIDConnection -keycloak_admin = KeycloakAdmin(server_url="http://localhost:8080/auth/", - username='example-admin', - password='secret', - realm_name="example_realm", - verify=True) - -# Add user -new_user = keycloak_admin.create_user({"email": "example@example.com", - "username": "example@example.com", - "enabled": True, - "firstName": "Example", - "lastName": "Example"}) - -# Add user and set password -new_user = keycloak_admin.create_user({"email": "example@example.com", - "username": "example@example.com", - "enabled": True, - "firstName": "Example", - "lastName": "Example", - "credentials": [{"value": "secret","type": "password",}]}) - -# User counter -count_users = keycloak_admin.users_count() - -# Get users Returns a list of users, filtered according to query parameters -users = keycloak_admin.get_users({}) - -# Get user ID from name -user-id-keycloak = keycloak_admin.get_user_id("example@example.com") +keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master", + client_id="my_client", + client_secret_key="client-secret", + verify=True) -# Get User -user = keycloak_admin.get_user("user-id-keycloak") +keycloak_admin = KeycloakAdmin(connection=keycloak_connection) -# Update User -response = keycloak_admin.update_user(user_id="user-id-keycloak", - payload={'firstName': 'Example Update'}) - -# Update User Password -response = set_user_password(user_id="user-id-keycloak", password="secret", temporary=True) - -# Delete User -response = keycloak_admin.delete_user(user_id="user-id-keycloak") - -# Get consents granted by the user -consents = keycloak_admin.consents_user(user_id="user-id-keycloak") - -# Send User Action -response = keycloak_admin.send_update_account(user_id="user-id-keycloak", - payload=json.dumps(['UPDATE_PASSWORD'])) - -# Send Verify Email -response = keycloak_admin.send_verify_email(user_id="user-id-keycloak") - -# Get sessions associated with the user -sessions = keycloak_admin.get_sessions(user_id="user-id-keycloak") - -# Get themes, social providers, auth providers, and event listeners available on this server -server_info = keycloak_admin.get_server_info() - -# Get clients belonging to the realm Returns a list of clients belonging to the realm -clients = keycloak_admin.get_clients() - -# Get client - id (not client-id) from client by name -client_id=keycloak_admin.get_client_id("my-client") - -# Get representation of the client - id of client (not client-id) -client = keycloak_admin.get_client(client_id="client_id") - -# Get all roles for the realm or client -realm_roles = keycloak_admin.get_realm_roles() - -# Get all roles for the client -client_roles = keycloak_admin.get_client_roles(client_id="client_id") - -# Get client role -role = keycloak_admin.get_client_role(client_id="client_id", role_name="role_name") - -# Warning: Deprecated -# Get client role id from name -role_id = keycloak_admin.get_client_role_id(client_id="client_id", role_name="test") - -# Create client role -keycloak_admin.create_client_role(client_id='client_id', {'name': 'roleName', 'clientRole': True}) - -# Assign client role to user. Note that BOTH role_name and role_id appear to be required. -keycloak_admin.assign_client_role(client_id="client_id", user_id="user_id", role_id="role_id", role_name="test") - -# Retrieve client roles of a user. -keycloak_admin.get_client_roles_of_user(user_id="user_id", client_id="client_id") - -# Retrieve available client roles of a user. -keycloak_admin.get_available_client_roles_of_user(user_id="user_id", client_id="client_id") - -# Retrieve composite client roles of a user. -keycloak_admin.get_composite_client_roles_of_user(user_id="user_id", client_id="client_id") - -# Delete client roles of a user. -keycloak_admin.delete_client_roles_of_user(client_id="client_id", user_id="user_id", roles={"id": "role-id"}) -keycloak_admin.delete_client_roles_of_user(client_id="client_id", user_id="user_id", roles=[{"id": "role-id_1"}, {"id": "role-id_2"}]) - -# Create new group -group = keycloak_admin.create_group(name="Example Group") - -# Get all groups -groups = keycloak_admin.get_groups() - -# Get group -group = keycloak_admin.get_group(group_id='group_id') - -# Get group by name -group = keycloak_admin.get_group_by_path(path='/group/subgroup', search_in_subgroups=True) - -# Function to trigger user sync from provider -sync_users(storage_id="storage_di", action="action") - -# Get client role id from name -role_id = keycloak_admin.get_client_role_id(client_id=client_id, role_name="test") - -# Get all roles for the realm or client -realm_roles = keycloak_admin.get_roles() - -# Assign client role to user. Note that BOTH role_name and role_id appear to be required. -keycloak_admin.assign_client_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test") - -# Get all ID Providers -idps = keycloak_admin.get_idps() +# Add user +new_user = keycloak_admin.create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}) -# Create a new Realm -keycloak_admin.create_realm(payload={"realm": "demo"}, skip_exists=False) +# Add user and raise exception if username already exists +# exist_ok currently defaults to True for backwards compatibility reasons +new_user = keycloak_admin.create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}, + exist_ok=False) +# Add user and set password +new_user = keycloak_admin.create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "credentials": [{"value": "secret","type": "password",}]}) ``` + +For more details, see the documentation available on [readthedocs](http://python-keycloak.readthedocs.io). diff --git a/bin/deploy.sh b/bin/deploy.sh old mode 100644 new mode 100755 index e4b4d021..9086dec9 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -9,5 +9,5 @@ username=${PYPI_USERNAME} password=${PYPI_PASSWORD} EOF -python setup.py sdist +python setup.py sdist bdist_wheel twine upload dist/* diff --git a/docs/Makefile b/docs/Makefile index 28027de6..c86fc187 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,4 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 00000000..991cd045 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,9 @@ +.. _api: + +The API Documentation +======================== + +.. toctree:: + :maxdepth: 2 + + reference/keycloak/index diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 00000000..dedf70ba --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,6 @@ +.. _changelog: + +Changelog +======================== + +.. mdinclude:: ../../CHANGELOG.md diff --git a/docs/source/conf.py b/docs/source/conf.py index ced16471..caaec76f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- # # python-keycloak documentation build configuration file, created by # sphinx-quickstart on Tue Aug 15 11:02:59 2017. @@ -20,7 +19,10 @@ # import os # import sys # sys.path.insert(0, os.path.abspath('.')) -import sphinx_rtd_theme + +"""Sphinx documentation configuration.""" + +import os # -- General configuration ------------------------------------------------ @@ -32,55 +34,64 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "m2r2", + "autoapi.extension", ] +autoapi_type = "python" +autoapi_dirs = ["../../src/keycloak"] +autoapi_root = "reference" +autoapi_keep_files = False +autoapi_add_toctree_entry = False + # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'python-keycloak' -copyright = '2017, Marcos Pereira' -author = 'Marcos Pereira' +project = "python-keycloak" +copyright = "2017, Marcos Pereira" +author = "Marcos Pereira" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.17.6' +version = os.getenv("READTHEDOCS_VERSION", "latest") # The full version, including alpha/beta/rc tags. -release = '0.17.6' +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +suppress_warnings = ["ref.python"] add_function_parentheses = False add_module_names = True # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True @@ -91,19 +102,18 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "sphinx_book_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -# + # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ["_static"] html_use_smartypants = False @@ -116,7 +126,7 @@ # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -#html_sidebars = { +# html_sidebars = { # '**': [ # 'about.html', # 'navigation.html', @@ -124,13 +134,13 @@ # 'searchbox.html', # 'donate.html', # ] -#} +# } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = 'python-keycloakdoc' +htmlhelp_basename = "python-keycloakdoc" # -- Options for LaTeX output --------------------------------------------- @@ -139,15 +149,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -157,8 +164,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'python-keycloak.tex', 'python-keycloak Documentation', - 'Marcos Pereira', 'manual'), + ( + master_doc, + "python-keycloak.tex", + "python-keycloak Documentation", + "Marcos Pereira", + "manual", + ), ] @@ -166,10 +178,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'python-keycloak', 'python-keycloak Documentation', - [author], 1) -] +man_pages = [(master_doc, "python-keycloak", "python-keycloak Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -178,10 +187,13 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'python-keycloak', 'python-keycloak Documentation', - author, 'python-keycloak', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "python-keycloak", + "python-keycloak Documentation", + author, + "python-keycloak", + "One line description of project.", + "Miscellaneous", + ), ] - - - diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst new file mode 100644 index 00000000..801de921 --- /dev/null +++ b/docs/source/contributing.rst @@ -0,0 +1,104 @@ +.. _contributing: + +The Contributor Guide +======================== + +Welcome to the Python Keycloak contributing guidelines. We are all more than happy to receive +any contributions to the repository and want to thank you in advance for your contributions! +This document outlines the process and the guidelines on how contributions work for this repository. + +Setting up the dev environment +------------------------------- + +The development environment is mainly up to the developer. Our recommendations are to create a python +virtual environment and install the necessary requirements. Example + +.. code-block:: console + + # Install and upgrade pip & poetry + python -m pip install --upgrade pip poetry + + # Create virtualenv + python -m poetry env use + + # install package dependencies including dev dependencies + python -m poetry install + + # Activate virtualenv + python -m poetry shell + +Running checks and tests +--------------------------- + +We're utilizing `tox` for most of the testing workflows. However we also have an external dependency on `docker`. +We're using docker to spin up a local keycloak instance which we run our test cases against. This is to avoid +a lot of unnecessary mocking and yet have immediate feedback from the actual Keycloak instance. All of the setup +is done for you with the tox environments, all you need is to have both tox and docker installed +(`tox` is included in the `dev-requirements.txt`). + +To run the unit tests, simply run + +.. code-block:: console + + tox -e tests + +The project is also adhering to strict linting (flake8) and formatting (black + isort). You can always check that +your code changes adhere to the format by running + +.. code-block:: console + + tox -e check + +If the check fails, you'll see an error message specifying what went wrong. To simplify things, you can also run + +.. code-block:: console + + tox -e apply-check + +which will apply isort and black formatting for you in the repository. The flake8 problems however need to be resolved +manually by the developer. + +Additionally we require that the documentation pages are built without warnings. This check is also run via tox, using +the command + +.. code-block:: console + + tox -e docs + + +The check is also run in the CICD pipelines. We require that the documentation pages built from the code docstrings +do not create visually "bad" pages. + +Conventional commits +---------------------- + +Commits to this project must adhere to the [Conventional Commits +specification](https://www.conventionalcommits.org/en/v1.0.0/) that will allow +us to automate version bumps and changelog entry creation. + +After cloning this repository, you must install the pre-commit hook for +conventional commits (this is included in the `dev-requirements.txt`) + +.. code-block:: console + + # Create virtualenv + python -m poetry env use + + # Activate virtualenv + python -m poetry shell + + pre-commit install --install-hooks -t pre-commit -t pre-push -t commit-msg + +How to contribute +------------------- + +1. Fork this repository, develop and test your changes +2. Make sure that your changes do not decrease the test coverage +3. Make sure you're commits follow the conventional commits +4. Submit a pull request + +How to release +------------------- + +The CICD pipelines are set up for the repository. When a PR is merged, a new version of the library +will be automatically deployed to the PyPi server, meaning you'll be able to see your changes immediately. diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst new file mode 100644 index 00000000..a549c473 --- /dev/null +++ b/docs/source/getting_started.rst @@ -0,0 +1,16 @@ +.. _getting_started: + +Quickstart +======================== + +Some examples of using OpenID, Admin and UMA integration. + +For more details, see :ref:`api`. + +.. toctree:: + :maxdepth: 2 + + modules/openid_client + modules/admin + modules/uma + modules/async diff --git a/docs/source/index.rst b/docs/source/index.rst index f5249201..230c0584 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,252 +3,27 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +Python-Keycloak +======================== -.. image:: https://readthedocs.org/projects/python-keycloak/badge/?version=latest - :target: http://python-keycloak.readthedocs.io/en/latest/?badge=latest - - -Welcome to python-keycloak's documentation! -=========================================== +.. image:: https://github.com/marcospereirampj/python-keycloak/actions/workflows/daily.yaml/badge.svg + :target: https://github.com/marcospereirampj/python-keycloak/actions/workflows/daily.yaml/badge.svg +.. image:: https://readthedocs.org/projects/adamatics-keycloak/badge/?version=latest + :target: https://adamatics-keycloak.readthedocs.io/en/latest/?badge=latest **python-keycloak** is a Python package providing access to the Keycloak API. -Installation -================== - -Via Pypi Package:: - - $ pip install python-keycloak - -Manually:: - - $ python setup.py install - -Dependencies -================== - -python-keycloak depends on: - -* Python 3 -* `requests `_ -* `python-jose `_ - -Tests Dependencies ------------------- - -* unittest -* `httmock `_ - -Bug reports -================== - -Please report bugs and feature requests at -`https://github.com/marcospereirampj/python-keycloak/issues `_ - -Documentation -================== - -The documentation for python-keycloak is available on `readthedocs `_. - -Contributors -================== - -* `Agriness Team `_ -* `Marcos Pereira `_ -* `Martin Devlin `_ -* `Shon T. Urbas `_ -* `Markus Spanier `_ -* `Remco Kranenburg `_ -* `Armin `_ -* `Njordr `_ -* `Josha Inglis `_ -* `Alex `_ -* `Ewan Jone `_ - -Usage -===== - -Main methods:: - - # KEYCLOAK OPENID - - from keycloak import KeycloakOpenID - - # Configure client - keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/", - client_id="example_client", - realm_name="example_realm", - client_secret_key="secret", - verify=True) - - # Get WellKnow - config_well_know = keycloak_openid.well_know() - - # Get Token - token = keycloak_openid.token("user", "password") - token = keycloak_openid.token("user", "password", totp="012345") - - # Get Userinfo - userinfo = keycloak_openid.userinfo(token['access_token']) - - # Refresh token - token = keycloak_openid.refresh_token(token['refresh_token']) - - # Logout - keycloak_openid.logout(token['refresh_token']) - - # Get Certs - certs = keycloak_openid.certs() - - # Get RPT (Entitlement) - token = keycloak_openid.token("user", "password") - rpt = keycloak_openid.entitlement(token['access_token'], "resource_id") - - # Instropect RPT - token_rpt_info = keycloak_openid.introspect(keycloak_openid.introspect(token['access_token'], rpt=rpt['rpt'], - token_type_hint="requesting_party_token")) - - # Introspect Token - token_info = keycloak_openid.introspect(token['access_token'])) +------------------- - # Decode Token - KEYCLOAK_PUBLIC_KEY = "secret" - options = {"verify_signature": True, "verify_aud": True, "exp": True} - token_info = keycloak_openid.decode_token(token['access_token'], key=KEYCLOAK_PUBLIC_KEY, options=options) +Useful Links +---------------- - # Get permissions by token - token = keycloak_openid.token("user", "password") - keycloak_openid.load_authorization_config("example-authz-config.json") - policies = keycloak_openid.get_policies(token['access_token'], method_token_info='decode', key=KEYCLOAK_PUBLIC_KEY) - permissions = keycloak_openid.get_permissions(token['access_token'], method_token_info='introspect') - - # KEYCLOAK ADMIN - - from keycloak import KeycloakAdmin - - keycloak_admin = KeycloakAdmin(server_url="http://localhost:8080/auth/", - username='example-admin', - password='secret', - realm_name="example_realm", - verify=True) - - # Add user - new_user = keycloak_admin.create_user({"email": "example@example.com", - "username": "example@example.com", - "enabled": True, - "firstName": "Example", - "lastName": "Example", - "realmRoles": ["user_default", ], - "attributes": {"example": "1,2,3,3,"}}) - - - # Add user and set password - new_user = keycloak_admin.create_user({"email": "example@example.com", - "username": "example@example.com", - "enabled": True, - "firstName": "Example", - "lastName": "Example", - "credentials": [{"value": "secret","type": "password",}], - "realmRoles": ["user_default", ], - "attributes": {"example": "1,2,3,3,"}}) - - # User counter - count_users = keycloak_admin.users_count() - - # Get users Returns a list of users, filtered according to query parameters - users = keycloak_admin.get_users({}) - - # Get user ID from name - user-id-keycloak = keycloak_admin.get_user_id("example@example.com") - - # Get User - user = keycloak_admin.get_user("user-id-keycloak") - - # Update User - response = keycloak_admin.update_user(user_id="user-id-keycloak", - payload={'firstName': 'Example Update'}) - - # Update User Password - response = set_user_password(user_id="user-id-keycloak", password="secret", temporary=True) - - # Delete User - response = keycloak_admin.delete_user(user_id="user-id-keycloak") - - # Get consents granted by the user - consents = keycloak_admin.consents_user(user_id="user-id-keycloak") - - # Send User Action - response = keycloak_admin.send_update_account(user_id="user-id-keycloak", - payload=json.dumps(['UPDATE_PASSWORD'])) - - # Send Verify Email - response = keycloak_admin.send_verify_email(user_id="user-id-keycloak") - - # Get sessions associated with the user - sessions = keycloak_admin.get_sessions(user_id="user-id-keycloak") - - # Get themes, social providers, auth providers, and event listeners available on this server - server_info = keycloak_admin.get_server_info() - - # Get clients belonging to the realm Returns a list of clients belonging to the realm - clients = keycloak_admin.get_clients() - - # Get client - id (not client-id) from client by name - client_id=keycloak_admin.get_client_id("my-client") - - # Get representation of the client - id of client (not client-id) - client = keycloak_admin.get_client(client_id="client_id") - - # Get all roles for the realm or client - realm_roles = keycloak_admin.get_realm_roles() - - # Get all roles for the client - client_roles = keycloak_admin.get_client_roles(client_id="client_id") - - # Get client role - role = keycloak_admin.get_client_role(client_id="client_id", role_name="role_name") - - # Warning: Deprecated - # Get client role id from name - role_id = keycloak_admin.get_client_role_id(client_id="client_id", role_name="test") - - # Create client role - keycloak_admin.create_client_role(client_id="client_id", {'name': 'roleName', 'clientRole': True}) - - # Get client role id from name - role_id = keycloak_admin.get_client_role_id(client_id=client_id, role_name="test") - - # Get all roles for the realm or client - realm_roles = keycloak_admin.get_roles() - - # Assign client role to user. Note that BOTH role_name and role_id appear to be required. - keycloak_admin.assign_client_role(client_id="client_id", user_id="user_id", role_id="role_id", role_name="test") - - # Assign realm roles to user. Note that BOTH role_name and role_id appear to be required. - keycloak_admin.assign_realm_roles(client_id="client_id", user_id="user_id", roles=[{"roles_representation"}]) - - # Create new group - group = keycloak_admin.create_group(name="Example Group") - - # Get all groups - groups = keycloak_admin.get_groups() - - # Get group - group = keycloak_admin.get_group(group_id='group_id') - - # Get group by path - group = keycloak_admin.get_group_by_path(path='/group/subgroup', search_in_subgroups=True) +.. toctree:: + :maxdepth: 2 + :caption: Contents - # Function to trigger user sync from provider - sync_users(storage_id="storage_di", action="action") + getting_started + install + api + contributing + changelog diff --git a/docs/source/install.rst b/docs/source/install.rst new file mode 100644 index 00000000..9bd4ebc9 --- /dev/null +++ b/docs/source/install.rst @@ -0,0 +1,28 @@ +.. _install: + +Installation +======================== + +This part of the documentation covers the installation of Python-Keycloak. This is the first step to using the software library. + +Via Pypi Package +----------------- + +To install python-keycloak, run this command in your terminal of choice:: + + pip install python-keycloak + +Manually +----------------- + +The python-keycloak code is `available `_. on Github. + +You can either clone the public repository:: + + git clone https://github.com/marcospereirampj/python-keycloak.git + +Or, download the source code. + +Once you have a copy of the source, you can embed it in your own Python package, or install it into your site-packages easily:: + + python -m pip install . diff --git a/docs/source/modules/admin.rst b/docs/source/modules/admin.rst new file mode 100644 index 00000000..7f205226 --- /dev/null +++ b/docs/source/modules/admin.rst @@ -0,0 +1,198 @@ +.. admin: + +Admin Client +======================== + + +Configure admin client +------------------------- + +.. code-block:: python + + + admin = KeycloakAdmin( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master", + pool_maxsize=20) + + +Configure admin client with connection +-------------------------------------------------- + +.. code-block:: python + + from keycloak import KeycloakAdmin + from keycloak import KeycloakOpenIDConnection + + keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master", + client_id="my_client", + client_secret_key="client-secret", + pool_maxsize=25, + verify=True) + + keycloak_admin = KeycloakAdmin(connection=keycloak_connection) + + +Create user +------------------------- + +.. code-block:: python + + new_user = keycloak_admin.create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}) + + +Add user and raise exception if username already exists +----------------------------------------------------------- + +The exist_ok currently defaults to True for backwards compatibility reasons. + +.. code-block:: python + + new_user = keycloak_admin.create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}, + exist_ok=False) + +Add user and set password +--------------------------- + +.. code-block:: python + + new_user = keycloak_admin.create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "credentials": [{"value": "secret","type": "password",}]}) + + +Add user and specify a locale +------------------------------ + +.. code-block:: python + + new_user = keycloak_admin.create_user({"email": "example@example.fr", + "username": "example@example.fr", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "attributes": { + "locale": ["fr"] + }}) + +User counter +------------------------------ + +.. code-block:: python + + count_users = keycloak_admin.users_count() + +Get users Returns a list of users, filtered according to query parameters +---------------------------------------------------------------------------- + +.. code-block:: python + + users = keycloak_admin.get_users({}) + +Get user ID from username +------------------------------ + +.. code-block:: python + + user_id_keycloak = keycloak_admin.get_user_id("username-keycloak") + + +Get user +------------------------------ + +.. code-block:: python + + user = keycloak_admin.get_user("user-id-keycloak") + +Update user +------------------------------ + +.. code-block:: python + + response = keycloak_admin.update_user(user_id="user-id-keycloak", + payload={'firstName': 'Example Update'}) + + +Update user password +------------------------------ + +.. code-block:: python + + response = keycloak_admin.set_user_password(user_id="user-id-keycloak", password="secret", temporary=True) + + +Get user credentials +------------------------------ + +.. code-block:: python + + credentials = keycloak_admin.get_credentials(user_id='user_id') + +Get user credential by ID +------------------------------ + +.. code-block:: python + + credential = keycloak_admin.get_credential(user_id='user_id', credential_id='credential_id') + +Delete user credential +------------------------------ + +.. code-block:: python + + response = keycloak_admin.delete_credential(user_id='user_id', credential_id='credential_id') + +Delete User +------------------------------ + +.. code-block:: python + + response = keycloak_admin.delete_user(user_id="user-id-keycloak") + +Get consents granted by the user +-------------------------------- + +.. code-block:: python + + consents = keycloak_admin.consents_user(user_id="user-id-keycloak") + +Send user action +------------------------------ + +.. code-block:: python + + response = keycloak_admin.send_update_account(user_id="user-id-keycloak", + payload=['UPDATE_PASSWORD']) + +Send verify email +------------------------------ + +.. code-block:: python + + response = keycloak_admin.send_verify_email(user_id="user-id-keycloak") + +Get sessions associated with the user +-------------------------------------- + +.. code-block:: python + + sessions = keycloak_admin.get_sessions(user_id="user-id-keycloak") diff --git a/docs/source/modules/async.rst b/docs/source/modules/async.rst new file mode 100644 index 00000000..a2871442 --- /dev/null +++ b/docs/source/modules/async.rst @@ -0,0 +1,408 @@ +.. admin: + +Use Python Keycloak Asynchronously +================================== + +Asynchronous admin client +------------------------- + +Configure admin client +------------------------ + +.. code-block:: python + + + admin = KeycloakAdmin( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master") + + +Configure admin client with connection +----------------------------------------- + +.. code-block:: python + + from keycloak import KeycloakAdmin + from keycloak import KeycloakOpenIDConnection + + keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + username='example-admin', + password='secret', + realm_name="master", + user_realm_name="only_if_other_realm_than_master", + client_id="my_client", + client_secret_key="client-secret", + verify=True) + + keycloak_admin = KeycloakAdmin(connection=keycloak_connection) + + +Create user asynchronously +---------------------------- + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}) + + +Add user asynchronously and raise exception if username already exists +----------------------------------------------------------------------- + +The exist_ok currently defaults to True for backwards compatibility reasons. + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example"}, + exist_ok=False) + +Add user asynchronously and set password +---------------------------------------- + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.com", + "username": "example@example.com", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "credentials": [{"value": "secret","type": "password",}]}) + + +Add user asynchronous and specify a locale +------------------------------------------- + +.. code-block:: python + + new_user = await keycloak_admin.a_create_user({"email": "example@example.fr", + "username": "example@example.fr", + "enabled": True, + "firstName": "Example", + "lastName": "Example", + "attributes": { + "locale": ["fr"] + }}) + +Asynchronous User counter +------------------------------ + +.. code-block:: python + + count_users = await keycloak_admin.a_users_count() + +Get users Returns a list of users asynchronously, filtered according to query parameters +----------------------------------------------------------------------------------------- + +.. code-block:: python + + users = await keycloak_admin.a_get_users({}) + +Get user ID asynchronously from username +----------------------------------------- + +.. code-block:: python + + user_id_keycloak = await keycloak_admin.a_get_user_id("username-keycloak") + + +Get user asynchronously +------------------------------ + +.. code-block:: python + + user = await keycloak_admin.a_get_user("user-id-keycloak") + +Update user asynchronously +------------------------------ + +.. code-block:: python + + response = await keycloak_admin.a_update_user(user_id="user-id-keycloak", + payload={'firstName': 'Example Update'}) + + +Update user password asynchronously +------------------------------------ + +.. code-block:: python + + response = await keycloak_admin.a_set_user_password(user_id="user-id-keycloak", password="secret", temporary=True) + + +Get user credentials asynchronously +------------------------------------ + +.. code-block:: python + + credentials = await keycloak_admin.a_get_credentials(user_id='user_id') + +Get user credential asynchronously by ID +----------------------------------------- + +.. code-block:: python + + credential = await keycloak_admin.a_get_credential(user_id='user_id', credential_id='credential_id') + +Delete user credential asynchronously +--------------------------------------- + +.. code-block:: python + + response = await keycloak_admin.a_delete_credential(user_id='user_id', credential_id='credential_id') + +Delete User asynchronously +------------------------------ + +.. code-block:: python + + response = await keycloak_admin.a_delete_user(user_id="user-id-keycloak") + +Get consents granted asynchronously by the user +------------------------------------------------ + +.. code-block:: python + + consents = await keycloak_admin.a_consents_user(user_id="user-id-keycloak") + +Send user action asynchronously +--------------------------------- + +.. code-block:: python + + response = await keycloak_admin.a_send_update_account(user_id="user-id-keycloak", + payload=['UPDATE_PASSWORD']) + +Send verify email asynchronously +---------------------------------- + +.. code-block:: python + + response = await keycloak_admin.a_send_verify_email(user_id="user-id-keycloak") + +Get sessions associated asynchronously with the user +----------------------------------------------------- + +.. code-block:: python + + sessions = await keycloak_admin.a_get_sessions(user_id="user-id-keycloak") + + + + +Asynchronous OpenID Client +=========================== + +Asynchronous Configure client OpenID +------------------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenID + + # Configure client + # For versions older than 18 /auth/ must be added at the end of the server_url. + keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/", + client_id="example_client", + realm_name="example_realm", + client_secret_key="secret") + + +Get .well_know asynchronously +------------------------------ + +.. code-block:: python + + config_well_known = await keycloak_openid.a_well_known() + + +Get code asynchronously with OAuth authorization request +--------------------------------------------------------- + +.. code-block:: python + + auth_url = await keycloak_openid.a_auth_url( + redirect_uri="your_call_back_url", + scope="email", + state="your_state_info") + + +Get access token asynchronously with code +---------------------------------------------- + +.. code-block:: python + + access_token = await keycloak_openid.a_token( + grant_type='authorization_code', + code='the_code_you_get_from_auth_url_callback', + redirect_uri="your_call_back_url") + + +Get access asynchronously token with user and password +------------------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + token = await keycloak_openid.a_token("user", "password", totp="012345") + + +Get token asynchronously using Token Exchange +---------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_exchange_token(token['access_token'], + "my_client", "other_client", "some_user") + + +Refresh token asynchronously +---------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_refresh_token(token['refresh_token']) + +Get UserInfo asynchronously +---------------------------------------------- + +.. code-block:: python + + userinfo = await keycloak_openid.a_userinfo(token['access_token']) + +Logout asynchronously +---------------------------------------------- + +.. code-block:: python + + await keycloak_openid.a_logout(token['refresh_token']) + +Get certs asynchronously +---------------------------------------------- + +.. code-block:: python + + certs = await keycloak_openid.a_certs() + +Introspect RPT asynchronously +---------------------------------------------- + +.. code-block:: python + + token_rpt_info = await keycloak_openid.a_introspect(await keycloak_openid.a_introspect(token['access_token'], + rpt=rpt['rpt'], + token_type_hint="requesting_party_token")) + +Introspect token asynchronously +---------------------------------------------- + +.. code-block:: python + + token_info = await keycloak_openid.a_introspect(token['access_token']) + + +Decode token asynchronously +---------------------------------------------- + +.. code-block:: python + + token_info = await keycloak_openid.a_decode_token(token['access_token']) + # Without validation + token_info = await keycloak_openid.a_decode_token(token['access_token'], validate=False) + + +Get UMA-permissions asynchronously by token +---------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + permissions = await keycloak_openid.a_uma_permissions(token['access_token']) + +Get UMA-permissions asynchronously by token with specific resource and scope requested +--------------------------------------------------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + permissions = await keycloak_openid.a_uma_permissions(token['access_token'], permissions="Resource#Scope") + +Get auth status asynchronously for a specific resource and scope by token +-------------------------------------------------------------------------- + +.. code-block:: python + + token = await keycloak_openid.a_token("user", "password") + auth_status = await keycloak_openid.a_has_uma_access(token['access_token'], "Resource#Scope") + + + + +Asynchronous UMA +======================== + + +Asynchronous Configure client UMA +---------------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenIDConnection + from keycloak import KeycloakUMA + + keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + realm_name="master", + client_id="my_client", + client_secret_key="client-secret") + + keycloak_uma = KeycloakUMA(connection=keycloak_connection) + + +Create a resource set asynchronously +--------------------------------------- + +.. code-block:: python + + resource_set = await keycloak_uma.a_resource_set_create({ + "name": "example_resource", + "scopes": ["example:read", "example:write"], + "type": "urn:example"}) + +List resource sets asynchronously +---------------------------------- + +.. code-block:: python + + resource_sets = await uma.a_resource_set_list() + +Get resource set asynchronously +-------------------------------- + +.. code-block:: python + + latest_resource = await uma.a_resource_set_read(resource_set["_id"]) + +Update resource set asynchronously +------------------------------------- + +.. code-block:: python + + latest_resource["name"] = "New Resource Name" + await uma.a_resource_set_update(resource_set["_id"], latest_resource) + +Delete resource set asynchronously +------------------------------------ +.. code-block:: python + + await uma.a_resource_set_delete(resource_id=resource_set["_id"]) diff --git a/docs/source/modules/openid_client.rst b/docs/source/modules/openid_client.rst new file mode 100644 index 00000000..e184033e --- /dev/null +++ b/docs/source/modules/openid_client.rst @@ -0,0 +1,180 @@ +.. _openid_client: + + +OpenID Client +======================== + +Configure client OpenID +------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenID + + # Configure client + # For versions older than 18 /auth/ must be added at the end of the server_url. + keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/", + client_id="example_client", + realm_name="example_realm", + client_secret_key="secret", + pool_maxsize=15) # Example: Set connection pool size + + +Get .well_know +----------------------- + +.. code-block:: python + + config_well_known = keycloak_openid.well_known() + + +Get code with OAuth authorization request +---------------------------------------------- + +.. code-block:: python + + auth_url = keycloak_openid.auth_url( + redirect_uri="your_call_back_url", + scope="email", + state="your_state_info") + + +Get access token with code +---------------------------------------------- + +.. code-block:: python + + access_token = keycloak_openid.token( + grant_type='authorization_code', + code='the_code_you_get_from_auth_url_callback', + redirect_uri="your_call_back_url") + + +Get access token with user and password +---------------------------------------------- + +.. code-block:: python + + token = keycloak_openid.token("user", "password") + token = keycloak_openid.token("user", "password", totp="012345") + + +Get token using Token Exchange +---------------------------------------------- + +.. code-block:: python + + token = keycloak_openid.exchange_token(token['access_token'], + "my_client", "other_client", "some_user") + + +Refresh token +---------------------------------------------- + +.. code-block:: python + + token = keycloak_openid.refresh_token(token['refresh_token']) + +Get UserInfo +---------------------------------------------- + +.. code-block:: python + + userinfo = keycloak_openid.userinfo(token['access_token']) + +Logout +---------------------------------------------- + +.. code-block:: python + + keycloak_openid.logout(token['refresh_token']) + +Get certs +---------------------------------------------- + +.. code-block:: python + + certs = keycloak_openid.certs() + +Introspect RPT +---------------------------------------------- + +.. code-block:: python + + token_rpt_info = keycloak_openid.introspect(keycloak_openid.introspect(token['access_token'], + rpt=rpt['rpt'], + token_type_hint="requesting_party_token")) + +Introspect token +---------------------------------------------- + +.. code-block:: python + + token_info = keycloak_openid.introspect(token['access_token']) + + +Decode token +---------------------------------------------- + +.. code-block:: python + + token_info = keycloak_openid.decode_token(token['access_token']) + # Without validation + token_info = keycloak_openid.decode_token(token['access_token'], validate=False) + + +Get UMA-permissions by token +---------------------------------------------- + +.. code-block:: python + + token = keycloak_openid.token("user", "password") + permissions = keycloak_openid.uma_permissions(token['access_token']) + +Get UMA-permissions by token with specific resource and scope requested +-------------------------------------------------------------------------- + +.. code-block:: python + + token = keycloak_openid.token("user", "password") + permissions = keycloak_openid.uma_permissions(token['access_token'], permissions="Resource#Scope") + +Get auth status for a specific resource and scope by token +-------------------------------------------------------------------------- + +.. code-block:: python + + token = keycloak_openid.token("user", "password") + auth_status = keycloak_openid.has_uma_access(token['access_token'], "Resource#Scope") + +PKCE Authorization Flow Example +---------------------------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenID + from keycloak.pkce_utils import generate_code_verifier, generate_code_challenge + + # Configure client + keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/", + client_id="example_client", + realm_name="example_realm") + + # Generate PKCE values + code_verifier = generate_code_verifier() + code_challenge, code_challenge_method = generate_code_challenge(code_verifier) + + # Get Code With Oauth Authorization Request (PKCE) + auth_url = keycloak_openid.auth_url( + redirect_uri="your_call_back_url", + scope="email", + state="your_state_info", + code_challenge=code_challenge, + code_challenge_method=code_challenge_method) + + # Get Access Token With Code (PKCE) + access_token = keycloak_openid.token( + grant_type='authorization_code', + code='the_code_you_get_from_auth_url_callback', + redirect_uri="your_call_back_url", + code_verifier=code_verifier) diff --git a/docs/source/modules/uma.rst b/docs/source/modules/uma.rst new file mode 100644 index 00000000..c0867a0b --- /dev/null +++ b/docs/source/modules/uma.rst @@ -0,0 +1,60 @@ +.. _uma: + +UMA +======================== + + +Configure client UMA +------------------------- + +.. code-block:: python + + from keycloak import KeycloakOpenIDConnection + from keycloak import KeycloakUMA + + keycloak_connection = KeycloakOpenIDConnection( + server_url="http://localhost:8080/", + realm_name="master", + client_id="my_client", + client_secret_key="client-secret") + + keycloak_uma = KeycloakUMA(connection=keycloak_connection) + + +Create a resource set +------------------------- + +.. code-block:: python + + resource_set = keycloak_uma.resource_set_create({ + "name": "example_resource", + "scopes": ["example:read", "example:write"], + "type": "urn:example"}) + +List resource sets +------------------------- + +.. code-block:: python + + resource_sets = uma.resource_set_list() + +Get resource set +------------------------- + +.. code-block:: python + + latest_resource = uma.resource_set_read(resource_set["_id"]) + +Update resource set +------------------------- + +.. code-block:: python + + latest_resource["name"] = "New Resource Name" + uma.resource_set_update(resource_set["_id"], latest_resource) + +Delete resource set +------------------------ +.. code-block:: python + + uma.resource_set_delete(resource_id=resource_set["_id"]) diff --git a/keycloak/authorization/permission.py b/keycloak/authorization/permission.py deleted file mode 100644 index 9988730c..00000000 --- a/keycloak/authorization/permission.py +++ /dev/null @@ -1,103 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -class Permission: - """ - Consider this simple and very common permission: - - A permission associates the object being protected with the policies that must be evaluated to determine whether access is granted. - - X CAN DO Y ON RESOURCE Z - - where … - X represents one or more users, roles, or groups, or a combination of them. You can - also use claims and context here. - Y represents an action to be performed, for example, write, view, and so on. - Z represents a protected resource, for example, "/accounts". - - https://keycloak.gitbooks.io/documentation/authorization_services/topics/permission/overview.html - - """ - - def __init__(self, name, type, logic, decision_strategy): - self._name = name - self._type = type - self._logic = logic - self._decision_strategy = decision_strategy - self._resources = [] - self._scopes = [] - - def __repr__(self): - return "" % (self.name, self.type) - - def __str__(self): - return "Permission: %s (%s)" % (self.name, self.type) - - @property - def name(self): - return self._name - - @name.setter - def name(self, value): - self._name = value - - @property - def type(self): - return self._type - - @type.setter - def type(self, value): - self._type = value - - @property - def logic(self): - return self._logic - - @logic.setter - def logic(self, value): - self._logic = value - - @property - def decision_strategy(self): - return self._decision_strategy - - @decision_strategy.setter - def decision_strategy(self, value): - self._decision_strategy = value - - @property - def resources(self): - return self._resources - - @resources.setter - def resources(self, value): - self._resources = value - - @property - def scopes(self): - return self._scopes - - @scopes.setter - def scopes(self, value): - self._scopes = value diff --git a/keycloak/authorization/policy.py b/keycloak/authorization/policy.py deleted file mode 100644 index 9f688f76..00000000 --- a/keycloak/authorization/policy.py +++ /dev/null @@ -1,113 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from ..exceptions import KeycloakAuthorizationConfigError - - -class Policy: - """ - A policy defines the conditions that must be satisfied to grant access to an object. - Unlike permissions, you do not specify the object being protected but rather the conditions - that must be satisfied for access to a given object (for example, resource, scope, or both). - Policies are strongly related to the different access control mechanisms (ACMs) that you can use to - protect your resources. With policies, you can implement strategies for attribute-based access control - (ABAC), role-based access control (RBAC), context-based access control, or any combination of these. - - https://keycloak.gitbooks.io/documentation/authorization_services/topics/policy/overview.html - - """ - - def __init__(self, name, type, logic, decision_strategy): - self._name = name - self._type = type - self._logic = logic - self._decision_strategy = decision_strategy - self._roles = [] - self._permissions = [] - - def __repr__(self): - return "" % (self.name, self.type) - - def __str__(self): - return "Policy: %s (%s)" % (self.name, self.type) - - @property - def name(self): - return self._name - - @name.setter - def name(self, value): - self._name = value - - @property - def type(self): - return self._type - - @type.setter - def type(self, value): - self._type = value - - @property - def logic(self): - return self._logic - - @logic.setter - def logic(self, value): - self._logic = value - - @property - def decision_strategy(self): - return self._decision_strategy - - @decision_strategy.setter - def decision_strategy(self, value): - self._decision_strategy = value - - @property - def roles(self): - return self._roles - - @property - def permissions(self): - return self._permissions - - def add_role(self, role): - """ - Add keycloak role in policy. - - :param role: keycloak role. - :return: - """ - if self.type != 'role': - raise KeycloakAuthorizationConfigError( - "Can't add role. Policy type is different of role") - self._roles.append(role) - - def add_permission(self, permission): - """ - Add keycloak permission in policy. - - :param permission: keycloak permission. - :return: - """ - self._permissions.append(permission) diff --git a/keycloak/connection.py b/keycloak/connection.py deleted file mode 100644 index 3826936e..00000000 --- a/keycloak/connection.py +++ /dev/null @@ -1,207 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -try: - from urllib.parse import urljoin -except ImportError: - from urlparse import urljoin - -import requests - -from .exceptions import (KeycloakConnectionError) - - -class ConnectionManager(object): - """ Represents a simple server connection. - Args: - base_url (str): The server URL. - headers (dict): The header parameters of the requests to the server. - timeout (int): Timeout to use for requests to the server. - verify (bool): Verify server SSL. - """ - - def __init__(self, base_url, headers={}, timeout=60, verify=True): - self._base_url = base_url - self._headers = headers - self._timeout = timeout - self._verify = verify - self._s = requests.Session() - - @property - def base_url(self): - """ Return base url in use for requests to the server. """ - return self._base_url - - @base_url.setter - def base_url(self, value): - """ """ - self._base_url = value - - @property - def timeout(self): - """ Return timeout in use for request to the server. """ - return self._timeout - - @timeout.setter - def timeout(self, value): - """ """ - self._timeout = value - - @property - def verify(self): - """ Return verify in use for request to the server. """ - return self._verify - - @verify.setter - def verify(self, value): - """ """ - self._verify = value - - @property - def headers(self): - """ Return header request to the server. """ - return self._headers - - @headers.setter - def headers(self, value): - """ """ - self._headers = value - - def param_headers(self, key): - """ Return a specific header parameter. - :arg - key (str): Header parameters key. - :return: - If the header parameters exist, return its value. - """ - return self.headers.get(key) - - def clean_headers(self): - """ Clear header parameters. """ - self.headers = {} - - def exist_param_headers(self, key): - """ Check if the parameter exists in the header. - :arg - key (str): Header parameters key. - :return: - If the header parameters exist, return True. - """ - return self.param_headers(key) is not None - - def add_param_headers(self, key, value): - """ Add a single parameter inside the header. - :arg - key (str): Header parameters key. - value (str): Value to be added. - """ - self.headers[key] = value - - def del_param_headers(self, key): - """ Remove a specific parameter. - :arg - key (str): Key of the header parameters. - """ - self.headers.pop(key, None) - - def raw_get(self, path, **kwargs): - """ Submit get request to the path. - :arg - path (str): Path for request. - :return - Response the request. - :exception - HttpError: Can't connect to server. - """ - - try: - return self._s.get(urljoin(self.base_url, path), - params=kwargs, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) - except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) - - def raw_post(self, path, data, **kwargs): - """ Submit post request to the path. - :arg - path (str): Path for request. - data (dict): Payload for request. - :return - Response the request. - :exception - HttpError: Can't connect to server. - """ - try: - return self._s.post(urljoin(self.base_url, path), - params=kwargs, - data=data, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) - except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) - - def raw_put(self, path, data, **kwargs): - """ Submit put request to the path. - :arg - path (str): Path for request. - data (dict): Payload for request. - :return - Response the request. - :exception - HttpError: Can't connect to server. - """ - try: - return self._s.put(urljoin(self.base_url, path), - params=kwargs, - data=data, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) - except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) - - def raw_delete(self, path, **kwargs): - """ Submit delete request to the path. - - :arg - path (str): Path for request. - :return - Response the request. - :exception - HttpError: Can't connect to server. - """ - try: - return self._s.delete(urljoin(self.base_url, path), - params=kwargs, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) - except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) diff --git a/keycloak/exceptions.py b/keycloak/exceptions.py deleted file mode 100644 index a3894e7e..00000000 --- a/keycloak/exceptions.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import requests - - -class KeycloakError(Exception): - def __init__(self, error_message="", response_code=None, - response_body=None): - - Exception.__init__(self, error_message) - - self.response_code = response_code - self.response_body = response_body - self.error_message = error_message - - def __str__(self): - if self.response_code is not None: - return "{0}: {1}".format(self.response_code, self.error_message) - else: - return "{0}".format(self.error_message) - - -class KeycloakAuthenticationError(KeycloakError): - pass - - -class KeycloakConnectionError(KeycloakError): - pass - - -class KeycloakOperationError(KeycloakError): - pass - - -class KeycloakGetError(KeycloakOperationError): - pass - - -class KeycloakSecretNotFound(KeycloakOperationError): - pass - - -class KeycloakRPTNotFound(KeycloakOperationError): - pass - - -class KeycloakAuthorizationConfigError(KeycloakOperationError): - pass - - -class KeycloakInvalidTokenError(KeycloakOperationError): - pass - - -def raise_error_from_response(response, error, expected_code=200, skip_exists=False): - if expected_code == response.status_code: - if expected_code == requests.codes.no_content: - return {} - - try: - return response.json() - except ValueError: - return response.content - - if skip_exists and response.status_code == 409: - return {"Already exists"} - - try: - message = response.json()['message'] - except (KeyError, ValueError): - message = response.content - - if isinstance(error, dict): - error = error.get(response.status_code, KeycloakOperationError) - else: - if response.status_code == 401: - error = KeycloakAuthenticationError - - raise error(error_message=message, - response_code=response.status_code, - response_body=response.content) diff --git a/keycloak/keycloak_admin.py b/keycloak/keycloak_admin.py deleted file mode 100644 index 5d57661a..00000000 --- a/keycloak/keycloak_admin.py +++ /dev/null @@ -1,1021 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -# Unless otherwise stated in the comments, "id", in e.g. user_id, refers to the -# internal Keycloak server ID, usually a uuid string - -import json - -from .connection import ConnectionManager -from .exceptions import raise_error_from_response, KeycloakGetError -from .keycloak_openid import KeycloakOpenID -from .urls_patterns import URL_ADMIN_SERVER_INFO, URL_ADMIN_CLIENT_AUTHZ_RESOURCES, URL_ADMIN_CLIENT_ROLES, \ - URL_ADMIN_GET_SESSIONS, URL_ADMIN_RESET_PASSWORD, URL_ADMIN_SEND_UPDATE_ACCOUNT, \ - URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, URL_ADMIN_USER_GROUP, URL_ADMIN_REALM_ROLES, URL_ADMIN_GROUP_CHILD, \ - URL_ADMIN_USER_CONSENTS, URL_ADMIN_SEND_VERIFY_EMAIL, URL_ADMIN_CLIENT, URL_ADMIN_USER, URL_ADMIN_CLIENT_ROLE, \ - URL_ADMIN_USER_GROUPS, URL_ADMIN_CLIENTS, URL_ADMIN_FLOWS_EXECUTIONS, URL_ADMIN_GROUPS, URL_ADMIN_USER_CLIENT_ROLES, \ - URL_ADMIN_REALMS, URL_ADMIN_USERS_COUNT, URL_ADMIN_FLOWS, URL_ADMIN_GROUP, URL_ADMIN_CLIENT_AUTHZ_SETTINGS, \ - URL_ADMIN_GROUP_MEMBERS, URL_ADMIN_USER_STORAGE, URL_ADMIN_GROUP_PERMISSIONS, URL_ADMIN_IDPS, \ - URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, URL_ADMIN_USERS, URL_ADMIN_CLIENT_SCOPES, \ - URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER, URL_ADMIN_CLIENT_SCOPE, URL_ADMIN_CLIENT_SECRETS, \ - URL_ADMIN_USER_REALM_ROLES - - -class KeycloakAdmin: - - PAGE_SIZE = 100 - - def __init__(self, server_url, username, password, realm_name='master', client_id='admin-cli', verify=True, client_secret_key=None): - """ - - :param server_url: Keycloak server url - :param username: admin username - :param password: admin password - :param realm_name: realm name - :param client_id: client id - :param verify: True if want check connection SSL - :param client_secret_key: client secret key - """ - self._username = username - self._password = password - self._client_id = client_id - self._realm_name = realm_name - - # Get token Admin - keycloak_openid = KeycloakOpenID(server_url=server_url, client_id=client_id, realm_name=realm_name, - verify=verify, client_secret_key=client_secret_key) - - grant_type = ["password"] - if client_secret_key: - grant_type = ["client_credentials"] - self._token = keycloak_openid.token(username, password, grant_type=grant_type) - self._connection = ConnectionManager(base_url=server_url, - headers={'Authorization': 'Bearer ' + self.token.get('access_token'), - 'Content-Type': 'application/json'}, - timeout=60, - verify=verify) - - @property - def realm_name(self): - return self._realm_name - - @realm_name.setter - def realm_name(self, value): - self._realm_name = value - - @property - def connection(self): - return self._connection - - @connection.setter - def connection(self, value): - self._connection = value - - @property - def client_id(self): - return self._client_id - - @client_id.setter - def client_id(self, value): - self._client_id = value - - @property - def username(self): - return self._username - - @username.setter - def username(self, value): - self._username = value - - @property - def password(self): - return self._password - - @password.setter - def password(self, value): - self._password = value - - @property - def token(self): - return self._token - - @token.setter - def token(self, value): - self._token = value - - - def __fetch_all(self, url, query=None): - '''Wrapper function to paginate GET requests - - :param url: The url on which the query is executed - :param query: Existing query parameters (optional) - - :return: Combined results of paginated queries - ''' - results = [] - - # initalize query if it was called with None - if not query: - query = {} - page = 0 - query['max'] = self.PAGE_SIZE - - # fetch until we can - while True: - query['first'] = page*self.PAGE_SIZE - partial_results = raise_error_from_response( - self.connection.raw_get(url, **query), - KeycloakGetError) - if not partial_results: - break - results.extend(partial_results) - page += 1 - return results - - def import_realm(self, payload): - """ - Import a new realm from a RealmRepresentation. Realm name must be unique. - - RealmRepresentation - https://www.keycloak.org/docs-api/4.4/rest-api/index.html#_realmrepresentation - - :param payload: RealmRepresentation - - :return: RealmRepresentation - """ - - data_raw = self.connection.raw_post(URL_ADMIN_REALMS, - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201) - - def get_realms(self): - """ - Lists all realms in Keycloak deployment - - :return: realms list - """ - data_raw = self.connection.raw_get(URL_ADMIN_REALMS) - return raise_error_from_response(data_raw, KeycloakGetError) - - def create_realm(self, payload, skip_exists=False): - """ - Create a realm - - ClientRepresentation: http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_realmrepresentation - - :param skip_exists: Skip if Realm already exist. - :param payload: RealmRepresentation - :return: Keycloak server response (RealmRepresentation) - """ - - data_raw = self.connection.raw_post(URL_ADMIN_REALMS, - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201, skip_exists=skip_exists) - - - def get_users(self, query=None): - """ - Get users Returns a list of users, filtered according to query parameters - - :return: users list - """ - params_path = {"realm-name": self.realm_name} - return self.__fetch_all(URL_ADMIN_USERS.format(**params_path), query) - - def get_idps(self): - """ - Returns a list of ID Providers, - - IdentityProviderRepresentation - https://www.keycloak.org/docs-api/3.3/rest-api/index.html#_identityproviderrepresentation - - :return: array IdentityProviderRepresentation - """ - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_ADMIN_IDPS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def create_user(self, payload): - """ - Create a new user Username must be unique - - UserRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_userrepresentation - - :param payload: UserRepresentation - - :return: UserRepresentation - """ - params_path = {"realm-name": self.realm_name} - - exists = self.get_user_id(username=payload['username']) - - if exists is not None: - return str(exists) - - data_raw = self.connection.raw_post(URL_ADMIN_USERS.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201) - - def users_count(self): - """ - User counter - - :return: counter - """ - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_ADMIN_USERS_COUNT.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_user_id(self, username): - """ - Get internal keycloak user id from username - This is required for further actions against this user. - - UserRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_userrepresentation - - :param username: id in UserRepresentation - - :return: user_id - """ - - users = self.get_users(query={"search": username}) - return next((user["id"] for user in users if user["username"] == username), None) - - def get_user(self, user_id): - """ - Get representation of the user - - :param user_id: User id - - UserRepresentation: http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_userrepresentation - - :return: UserRepresentation - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_get(URL_ADMIN_USER.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_user_groups(self, user_id): - """ - Get user groups Returns a list of groups of which the user is a member - - :param user_id: User id - - :return: user groups list - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_get(URL_ADMIN_USER_GROUPS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def update_user(self, user_id, payload): - """ - Update the user - - :param user_id: User id - :param payload: UserRepresentation - - :return: Http response - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_put(URL_ADMIN_USER.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def delete_user(self, user_id): - """ - Delete the user - - :param user_id: User id - - :return: Http response - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_delete(URL_ADMIN_USER.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def set_user_password(self, user_id, password, temporary=True): - """ - Set up a password for the user. If temporary is True, the user will have to reset - the temporary password next time they log in. - - http://www.keycloak.org/docs-api/3.2/rest-api/#_users_resource - http://www.keycloak.org/docs-api/3.2/rest-api/#_credentialrepresentation - - :param user_id: User id - :param password: New password - :param temporary: True if password is temporary - - :return: - """ - payload = {"type": "password", "temporary": temporary, "value": password} - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_put(URL_ADMIN_RESET_PASSWORD.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def consents_user(self, user_id): - """ - Get consents granted by the user - - :param user_id: User id - - :return: consents - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_get(URL_ADMIN_USER_CONSENTS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def send_update_account(self, user_id, payload, client_id=None, lifespan=None, redirect_uri=None): - """ - Send a update account email to the user An email contains a - link the user can click to perform a set of required actions. - - :param user_id: - :param payload: - :param client_id: - :param lifespan: - :param redirect_uri: - - :return: - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - params_query = {"client_id": client_id, "lifespan": lifespan, "redirect_uri": redirect_uri} - data_raw = self.connection.raw_put(URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), - data=payload, **params_query) - return raise_error_from_response(data_raw, KeycloakGetError) - - def send_verify_email(self, user_id, client_id=None, redirect_uri=None): - """ - Send a update account email to the user An email contains a - link the user can click to perform a set of required actions. - - :param user_id: User id - :param client_id: Client id - :param redirect_uri: Redirect uri - - :return: - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - params_query = {"client_id": client_id, "redirect_uri": redirect_uri} - data_raw = self.connection.raw_put(URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), - data={}, **params_query) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_sessions(self, user_id): - """ - Get sessions associated with the user - - :param user_id: id of user - - UserSessionRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_usersessionrepresentation - - :return: UserSessionRepresentation - """ - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_get(URL_ADMIN_GET_SESSIONS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_server_info(self): - """ - Get themes, social providers, auth providers, and event listeners available on this server - - ServerInfoRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_serverinforepresentation - - :return: ServerInfoRepresentation - """ - data_raw = self.connection.raw_get(URL_ADMIN_SERVER_INFO) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_groups(self): - """ - Get groups belonging to the realm. Returns a list of groups belonging to the realm - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_grouprepresentation - - :return: array GroupRepresentation - """ - params_path = {"realm-name": self.realm_name} - return self.__fetch_all(URL_ADMIN_GROUPS.format(**params_path)) - - def get_group(self, group_id): - """ - Get group by id. Returns full group details - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_grouprepresentation - - :return: Keycloak server response (GroupRepresentation) - """ - params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.connection.raw_get(URL_ADMIN_GROUP.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_subgroups(self, group, path): - """ - Utility function to iterate through nested group structures - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_grouprepresentation - - :param name: group (GroupRepresentation) - :param path: group path (string) - - :return: Keycloak server response (GroupRepresentation) - """ - - for subgroup in group["subGroups"]: - if subgroup['path'] == path: - return subgroup - elif subgroup["subGroups"]: - for subgroup in group["subGroups"]: - result = self.get_subgroups(subgroup, path) - if result: - return result - # went through the tree without hits - return None - - def get_group_members(self, group_id, **query): - """ - Get members by group id. Returns group members - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_userrepresentation - - :return: Keycloak server response (UserRepresentation) - """ - params_path = {"realm-name": self.realm_name, "id": group_id} - return self.__fetch_all(URL_ADMIN_GROUP_MEMBERS.format(**params_path), query) - - def get_group_by_path(self, path, search_in_subgroups=False): - """ - Get group id based on name or path. - A straight name or path match with a top-level group will return first. - Subgroups are traversed, the first to match path (or name with path) is returned. - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_grouprepresentation - - :param path: group path - :param search_in_subgroups: True if want search in the subgroups - :return: Keycloak server response (GroupRepresentation) - """ - - groups = self.get_groups() - - # TODO: Review this code is necessary - for group in groups: - if group['path'] == path: - return group - elif search_in_subgroups and group["subGroups"]: - for group in group["subGroups"]: - if group['path'] == path: - return group - res = self.get_subgroups(group, path) - if res != None: - return res - return None - - def create_group(self, payload, parent=None, skip_exists=False): - """ - Creates a group in the Realm - - :param payload: GroupRepresentation - :param parent: parent group's id. Required to create a sub-group. - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_grouprepresentation - - :return: Http response - """ - - if parent is None: - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_post(URL_ADMIN_GROUPS.format(**params_path), - data=json.dumps(payload)) - else: - params_path = {"realm-name": self.realm_name, "id": parent, } - data_raw = self.connection.raw_post(URL_ADMIN_GROUP_CHILD.format(**params_path), - data=json.dumps(payload)) - - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201, skip_exists=skip_exists) - - def update_group(self, group_id, payload): - """ - Update group, ignores subgroups. - - :param group_id: id of group - :param payload: GroupRepresentation with updated information. - - GroupRepresentation - http://www.keycloak.org/docs-api/3.2/rest-api/#_grouprepresentation - - :return: Http response - """ - - params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.connection.raw_put(URL_ADMIN_GROUP.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def group_set_permissions(self, group_id, enabled=True): - """ - Enable/Disable permissions for a group. Cannot delete group if disabled - - :param group_id: id of group - :param enabled: boolean - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.connection.raw_put(URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), - data=json.dumps({"enabled": enabled})) - return raise_error_from_response(data_raw, KeycloakGetError) - - def group_user_add(self, user_id, group_id): - """ - Add user to group (user_id and group_id) - - :param group_id: id of group - :param user_id: id of user - :param group_id: id of group to add to - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "id": user_id, "group-id": group_id} - data_raw = self.connection.raw_put(URL_ADMIN_USER_GROUP.format(**params_path), data=None) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def group_user_remove(self, user_id, group_id): - """ - Remove user from group (user_id and group_id) - - :param group_id: id of group - :param user_id: id of user - :param group_id: id of group to add to - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "id": user_id, "group-id": group_id} - data_raw = self.connection.raw_delete(URL_ADMIN_USER_GROUP.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def delete_group(self, group_id): - """ - Deletes a group in the Realm - - :param group_id: id of group to delete - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.connection.raw_delete(URL_ADMIN_GROUP.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def get_clients(self): - """ - Get clients belonging to the realm Returns a list of clients belonging to the realm - - ClientRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - - :return: Keycloak server response (ClientRepresentation) - """ - - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENTS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client(self, client_id): - """ - Get representation of the client - - ClientRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - - :param client_id: id of client (not client-id) - :return: Keycloak server response (ClientRepresentation) - """ - - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client_id(self, client_name): - """ - Get internal keycloak client id from client-id. - This is required for further actions against this client. - - :param client_name: name in ClientRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - :return: client_id (uuid as string) - """ - - clients = self.get_clients() - - for client in clients: - if client_name == client.get('name') or client_name == client.get('clientId'): - return client["id"] - - return None - - def get_client_authz_settings(self, client_id): - """ - Get authorization json from client. - - :param client_id: id in ClientRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_AUTHZ_SETTINGS.format(**params_path)) - return data_raw - - def get_client_authz_resources(self, client_id): - """ - Get resources from client. - - :param client_id: id in ClientRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path)) - return data_raw - - def create_client(self, payload, skip_exists=False): - """ - Create a client - - ClientRepresentation: http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - - :param skip_exists: Skip if client already exist. - :param payload: ClientRepresentation - :return: Keycloak server response (UserRepresentation) - """ - - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_post(URL_ADMIN_CLIENTS.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201, skip_exists=skip_exists) - - def update_client(self, client_id, payload): - """ - Update a client - - :param client_id: Client id - :param payload: ClientRepresentation - - :return: Http response - """ - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_put(URL_ADMIN_CLIENT.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def delete_client(self, client_id): - """ - Get representation of the client - - ClientRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_clientrepresentation - - :param client_id: keycloak client id (not oauth client-id) - :return: Keycloak server response (ClientRepresentation) - """ - - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_delete(URL_ADMIN_CLIENT.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def get_realm_roles(self): - """ - Get all roles for the realm or client - - RoleRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation - - :return: Keycloak server response (RoleRepresentation) - """ - - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_ADMIN_REALM_ROLES.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client_roles(self, client_id): - """ - Get all roles for the client - - :param client_id: id of client (not client-id) - - RoleRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation - - :return: Keycloak server response (RoleRepresentation) - """ - - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_ROLES.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client_role(self, client_id, role_name): - """ - Get client role id by name - This is required for further actions with this role. - - :param client_id: id of client (not client-id) - :param role_name: role’s name (not id!) - - RoleRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation - - :return: role_id - """ - params_path = {"realm-name": self.realm_name, "id": client_id, "role-name": role_name} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_ROLE.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client_role_id(self, client_id, role_name): - """ - Warning: Deprecated - - Get client role id by name - This is required for further actions with this role. - - :param client_id: id of client (not client-id) - :param role_name: role’s name (not id!) - - RoleRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation - - :return: role_id - """ - role = self.get_client_role(client_id, role_name) - return role.get("id") - - def create_client_role(self, client_role_id, payload, skip_exists=False): - """ - Create a client role - - RoleRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation - - :param client_role_id: id of client (not client-id) - :param payload: RoleRepresentation - :return: Keycloak server response (RoleRepresentation) - """ - - params_path = {"realm-name": self.realm_name, "id": client_role_id} - data_raw = self.connection.raw_post(URL_ADMIN_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201, skip_exists=skip_exists) - - def delete_client_role(self, client_role_id, role_name): - """ - Create a client role - - RoleRepresentation - http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation - - :param client_role_id: id of client (not client-id) - :param role_name: role’s name (not id!) - """ - params_path = {"realm-name": self.realm_name, "id": client_role_id, "role-name": role_name} - data_raw = self.connection.raw_delete(URL_ADMIN_CLIENT_ROLE.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def assign_client_role(self, user_id, client_id, roles): - """ - Assign a client role to a user - - :param client_id: id of client (not client-id) - :param user_id: id of user - :param client_id: id of client containing role, - :param roles: roles list or role (use RoleRepresentation) - :return Keycloak server response - """ - - payload = roles if isinstance(roles, list) else [roles] - params_path = {"realm-name": self.realm_name, "id": user_id, "client-id": client_id} - data_raw = self.connection.raw_post(URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def assign_realm_roles(self, user_id, client_id, roles): - """ - Assign realm roles to a user - - :param client_id: id of client (not client-id) - :param user_id: id of user - :param client_id: id of client containing role, - :param roles: roles list or role (use RoleRepresentation) - :return Keycloak server response - """ - - payload = roles if isinstance(roles, list) else [roles] - params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.connection.raw_post(URL_ADMIN_USER_REALM_ROLES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def get_client_roles_of_user(self, user_id, client_id): - """ - Get all client roles for a user. - - :param client_id: id of client (not client-id) - :param user_id: id of user - :return: Keycloak server response (array RoleRepresentation) - """ - return self._get_client_roles_of_user(URL_ADMIN_USER_CLIENT_ROLES, user_id, client_id) - - def get_available_client_roles_of_user(self, user_id, client_id): - """ - Get available client role-mappings for a user. - - :param client_id: id of client (not client-id) - :param user_id: id of user - :return: Keycloak server response (array RoleRepresentation) - """ - return self._get_client_roles_of_user(URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, user_id, client_id) - - def get_composite_client_roles_of_user(self, user_id, client_id): - """ - Get composite client role-mappings for a user. - - :param client_id: id of client (not client-id) - :param user_id: id of user - :return: Keycloak server response (array RoleRepresentation) - """ - return self._get_client_roles_of_user(URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, user_id, client_id) - - def _get_client_roles_of_user(self, client_level_role_mapping_url, user_id, client_id): - params_path = {"realm-name": self.realm_name, "id": user_id, "client-id": client_id} - data_raw = self.connection.raw_get(client_level_role_mapping_url.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def delete_client_roles_of_user(self, user_id, client_id, roles): - """ - Delete client roles from a user. - - :param client_id: id of client (not client-id) - :param user_id: id of user - :param client_id: id of client containing role, - :param roles: roles list or role to delete (use RoleRepresentation) - :return: Keycloak server response - """ - payload = roles if isinstance(roles, list) else [roles] - params_path = {"realm-name": self.realm_name, "id": user_id, "client-id": client_id} - data_raw = self.connection.raw_delete(URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def get_authentication_flows(self): - """ - Get authentication flows. Returns all flow details - - AuthenticationFlowRepresentation - https://www.keycloak.org/docs-api/4.1/rest-api/index.html#_authenticationflowrepresentation - - :return: Keycloak server response (AuthenticationFlowRepresentation) - """ - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_ADMIN_FLOWS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def create_authentication_flow(self, payload, skip_exists=False): - """ - Create a new authentication flow - - AuthenticationFlowRepresentation - https://www.keycloak.org/docs-api/4.1/rest-api/index.html#_authenticationflowrepresentation - - :param payload: AuthenticationFlowRepresentation - :return: Keycloak server response (RoleRepresentation) - """ - - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_post(URL_ADMIN_FLOWS.format(**params_path), - data=payload) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201, skip_exists=skip_exists) - - def get_authentication_flow_executions(self, flow_alias): - """ - Get authentication flow executions. Returns all execution steps - - :return: Response(json) - """ - params_path = {"realm-name": self.realm_name, "flow-alias": flow_alias} - data_raw = self.connection.raw_get(URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def update_authentication_flow_executions(self, payload, flow_alias): - """ - Update an authentication flow execution - - AuthenticationExecutionInfoRepresentation - https://www.keycloak.org/docs-api/4.1/rest-api/index.html#_authenticationexecutioninforepresentation - - :param payload: AuthenticationExecutionInfoRepresentation - :return: Keycloak server response - """ - - params_path = {"realm-name": self.realm_name, "flow-alias": flow_alias} - data_raw = self.connection.raw_put(URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), - data=payload) - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def sync_users(self, storage_id, action): - """ - Function to trigger user sync from provider - - :param storage_id: - :param action: - :return: - """ - data = {'action': action} - params_query = {"action": action} - - params_path = {"realm-name": self.realm_name, "id": storage_id} - data_raw = self.connection.raw_post(URL_ADMIN_USER_STORAGE.format(**params_path), - data=json.dumps(data), **params_query) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client_scopes(self): - """ - Get representation of the client scopes for the realm where we are connected to - https://www.keycloak.org/docs-api/4.5/rest-api/index.html#_getclientscopes - - :return: Keycloak server response Array of (ClientScopeRepresentation) - """ - - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_SCOPES.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def get_client_scope(self, client_scope_id): - """ - Get representation of the client scopes for the realm where we are connected to - https://www.keycloak.org/docs-api/4.5/rest-api/index.html#_getclientscopes - - :return: Keycloak server response (ClientScopeRepresentation) - """ - - params_path = {"realm-name": self.realm_name, "scope-id": client_scope_id} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_SCOPE.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - - def add_mapper_to_client_scope(self, client_scope_id, payload): - """ - Add a mapper to a client scope - https://www.keycloak.org/docs-api/4.5/rest-api/index.html#_create_mapper - - :param payload: ProtocolMapperRepresentation - :return: Keycloak server Response - """ - - params_path = {"realm-name": self.realm_name, "scope-id": client_scope_id} - - data_raw = self.connection.raw_post(URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), data=json.dumps(payload)) - - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=201) - - def get_client_secrets(self, client_id): - """ - - Get representation of the client secrets - https://www.keycloak.org/docs-api/4.5/rest-api/index.html#_getclientsecret - - :param client_id: id of client (not client-id) - :return: Keycloak server response (ClientRepresentation) - """ - - params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.connection.raw_get(URL_ADMIN_CLIENT_SECRETS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) diff --git a/keycloak/keycloak_openid.py b/keycloak/keycloak_openid.py deleted file mode 100644 index 61703e7d..00000000 --- a/keycloak/keycloak_openid.py +++ /dev/null @@ -1,411 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import json - -from jose import jwt - -from .authorization import Authorization -from .connection import ConnectionManager -from .exceptions import raise_error_from_response, KeycloakGetError, \ - KeycloakRPTNotFound, KeycloakAuthorizationConfigError, KeycloakInvalidTokenError -from .urls_patterns import ( - URL_AUTH, - URL_TOKEN, - URL_USERINFO, - URL_WELL_KNOWN, - URL_LOGOUT, - URL_CERTS, - URL_ENTITLEMENT, - URL_INTROSPECT -) - - -class KeycloakOpenID: - - def __init__(self, server_url, realm_name, client_id, client_secret_key=None, verify=True): - """ - - :param server_url: Keycloak server url - :param client_id: client id - :param realm_name: realm name - :param client_secret_key: client secret key - :param verify: True if want check connection SSL - """ - self._client_id = client_id - self._client_secret_key = client_secret_key - self._realm_name = realm_name - self._connection = ConnectionManager(base_url=server_url, - headers={}, - timeout=60, - verify=verify) - - self._authorization = Authorization() - - @property - def client_id(self): - return self._client_id - - @client_id.setter - def client_id(self, value): - self._client_id = value - - @property - def client_secret_key(self): - return self._client_secret_key - - @client_secret_key.setter - def client_secret_key(self, value): - self._client_secret_key = value - - @property - def realm_name(self): - return self._realm_name - - @realm_name.setter - def realm_name(self, value): - self._realm_name = value - - @property - def connection(self): - return self._connection - - @connection.setter - def connection(self, value): - self._connection = value - - @property - def authorization(self): - return self._authorization - - @authorization.setter - def authorization(self, value): - self._authorization = value - - def _add_secret_key(self, payload): - """ - Add secret key if exist. - - :param payload: - :return: - """ - if self.client_secret_key: - payload.update({"client_secret": self.client_secret_key}) - - return payload - - def _build_name_role(self, role): - """ - - :param role: - :return: - """ - return self.client_id + "/" + role - - def _token_info(self, token, method_token_info, **kwargs): - """ - - :param token: - :param method_token_info: - :param kwargs: - :return: - """ - if method_token_info == 'introspect': - token_info = self.introspect(token) - else: - token_info = self.decode_token(token, **kwargs) - - return token_info - - def well_know(self): - """ The most important endpoint to understand is the well-known configuration - endpoint. It lists endpoints and other configuration options relevant to - the OpenID Connect implementation in Keycloak. - - :return It lists endpoints and other configuration options relevant. - """ - - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path)) - - return raise_error_from_response(data_raw, KeycloakGetError) - - def auth_url(self, redirect_uri): - """ - - http://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint - - :return: - """ - params_path = {"authorization-endpoint": self.well_know()['authorization_endpoint'], - "client-id": self.client_id, - "redirect-uri": redirect_uri} - return URL_AUTH.format(**params_path) - - def token(self, username="", password="", grant_type=["password"], code="", redirect_uri="", totp=None, **extra): - """ - The token endpoint is used to obtain tokens. Tokens can either be obtained by - exchanging an authorization code or by supplying credentials directly depending on - what flow is used. The token endpoint is also used to obtain new access tokens - when they expire. - - http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint - - :param username: - :param password: - :param grant_type: - :param code: - :param redirect_uri - :param totp - :return: - """ - params_path = {"realm-name": self.realm_name} - payload = {"username": username, "password": password, - "client_id": self.client_id, "grant_type": grant_type, - "code": code, "redirect_uri": redirect_uri} - if payload: - payload.update(extra) - - if totp: - payload["totp"] = totp - - payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), - data=payload) - return raise_error_from_response(data_raw, KeycloakGetError) - - def refresh_token(self, refresh_token, grant_type=["refresh_token"]): - """ - The token endpoint is used to obtain tokens. Tokens can either be obtained by - exchanging an authorization code or by supplying credentials directly depending on - what flow is used. The token endpoint is also used to obtain new access tokens - when they expire. - - http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint - - :param refresh_token: - :param grant_type: - :return: - """ - params_path = {"realm-name": self.realm_name} - payload = {"client_id": self.client_id, "grant_type": grant_type, "refresh_token": refresh_token} - payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), - data=payload) - return raise_error_from_response(data_raw, KeycloakGetError) - - def userinfo(self, token): - """ - The userinfo endpoint returns standard claims about the authenticated user, - and is protected by a bearer token. - - http://openid.net/specs/openid-connect-core-1_0.html#UserInfo - - :param token: - :return: - """ - - self.connection.add_param_headers("Authorization", "Bearer " + token) - params_path = {"realm-name": self.realm_name} - - data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path)) - - return raise_error_from_response(data_raw, KeycloakGetError) - - def logout(self, refresh_token): - """ - The logout endpoint logs out the authenticated user. - :param refresh_token: - :return: - """ - params_path = {"realm-name": self.realm_name} - payload = {"client_id": self.client_id, "refresh_token": refresh_token} - - payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), - data=payload) - - return raise_error_from_response(data_raw, KeycloakGetError, expected_code=204) - - def certs(self): - """ - The certificate endpoint returns the public keys enabled by the realm, encoded as a - JSON Web Key (JWK). Depending on the realm settings there can be one or more keys enabled - for verifying tokens. - - https://tools.ietf.org/html/rfc7517 - - :return: - """ - params_path = {"realm-name": self.realm_name} - data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError) - - def entitlement(self, token, resource_server_id): - """ - Client applications can use a specific endpoint to obtain a special security token - called a requesting party token (RPT). This token consists of all the entitlements - (or permissions) for a user as a result of the evaluation of the permissions and authorization - policies associated with the resources being requested. With an RPT, client applications can - gain access to protected resources at the resource server. - - :return: - """ - self.connection.add_param_headers("Authorization", "Bearer " + token) - params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} - data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) - - return raise_error_from_response(data_raw, KeycloakGetError) - - def introspect(self, token, rpt=None, token_type_hint=None): - """ - The introspection endpoint is used to retrieve the active state of a token. It is can only be - invoked by confidential clients. - - https://tools.ietf.org/html/rfc7662 - - :param token: - :param rpt: - :param token_type_hint: - - :return: - """ - params_path = {"realm-name": self.realm_name} - - payload = {"client_id": self.client_id, "token": token} - - if token_type_hint == 'requesting_party_token': - if rpt: - payload.update({"token": rpt, "token_type_hint": token_type_hint}) - self.connection.add_param_headers("Authorization", "Bearer " + token) - else: - raise KeycloakRPTNotFound("Can't found RPT.") - - payload = self._add_secret_key(payload) - - data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), - data=payload) - - return raise_error_from_response(data_raw, KeycloakGetError) - - def decode_token(self, token, key, algorithms=['RS256'], **kwargs): - """ - A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data - structure that represents a cryptographic key. This specification - also defines a JWK Set JSON data structure that represents a set of - JWKs. Cryptographic algorithms and identifiers for use with this - specification are described in the separate JSON Web Algorithms (JWA) - specification and IANA registries established by that specification. - - https://tools.ietf.org/html/rfc7517 - - :param token: - :param key: - :param algorithms: - :return: - """ - - return jwt.decode(token, key, algorithms=algorithms, - audience=self.client_id, **kwargs) - - def load_authorization_config(self, path): - """ - Load Keycloak settings (authorization) - - :param path: settings file (json) - :return: - """ - authorization_file = open(path, 'r') - authorization_json = json.loads(authorization_file.read()) - self.authorization.load_config(authorization_json) - authorization_file.close() - - def get_policies(self, token, method_token_info='introspect', **kwargs): - """ - Get policies by user token - - :param token: user token - :return: policies list - """ - - if not self.authorization.policies: - raise KeycloakAuthorizationConfigError( - "Keycloak settings not found. Load Authorization Keycloak settings." - ) - - token_info = self._token_info(token, method_token_info, **kwargs) - - if method_token_info == 'introspect' and not token_info['active']: - raise KeycloakInvalidTokenError( - "Token expired or invalid." - ) - - user_resources = token_info['resource_access'].get(self.client_id) - - if not user_resources: - return None - - policies = [] - - for policy_name, policy in self.authorization.policies.items(): - for role in user_resources['roles']: - if self._build_name_role(role) in policy.roles: - policies.append(policy) - - return list(set(policies)) - - def get_permissions(self, token, method_token_info='introspect', **kwargs): - """ - Get permission by user token - - :param token: user token - :param method_token_info: Decode token method - :param kwargs: parameters for decode - :return: permissions list - """ - - if not self.authorization.policies: - raise KeycloakAuthorizationConfigError( - "Keycloak settings not found. Load Authorization Keycloak settings." - ) - - token_info = self._token_info(token, method_token_info, **kwargs) - - if method_token_info == 'introspect' and not token_info['active']: - raise KeycloakInvalidTokenError( - "Token expired or invalid." - ) - - user_resources = token_info['resource_access'].get(self.client_id) - - if not user_resources: - return None - - permissions = [] - - for policy_name, policy in self.authorization.policies.items(): - for role in user_resources['roles']: - if self._build_name_role(role) in policy.roles: - permissions += policy.permissions - - return list(set(permissions)) diff --git a/keycloak/tests/test_connection.py b/keycloak/tests/test_connection.py deleted file mode 100644 index 69496f16..00000000 --- a/keycloak/tests/test_connection.py +++ /dev/null @@ -1,143 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2017 Marcos Pereira -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program. If not, see . - -from httmock import urlmatch, response, HTTMock, all_requests - -from ..connection import ConnectionManager - -try: - import unittest -except ImportError: - import unittest2 as unittest - - -class TestConnection(unittest.TestCase): - - def setUp(self): - self._conn = ConnectionManager( - base_url="http://localhost/", - headers={}, - timeout=60) - - @all_requests - def response_content_success(self, url, request): - headers = {'content-type': 'application/json'} - content = b'response_ok' - return response(200, content, headers, None, 5, request) - - def test_raw_get(self): - with HTTMock(self.response_content_success): - resp = self._conn.raw_get("/known_path") - self.assertEqual(resp.content, b'response_ok') - self.assertEqual(resp.status_code, 200) - - def test_raw_post(self): - @urlmatch(path="/known_path", method="post") - def response_post_success(url, request): - headers = {'content-type': 'application/json'} - content = 'response'.encode("utf-8") - return response(201, content, headers, None, 5, request) - - with HTTMock(response_post_success): - resp = self._conn.raw_post("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, b'response') - self.assertEqual(resp.status_code, 201) - - def test_raw_put(self): - @urlmatch(netloc="localhost", path="/known_path", method="put") - def response_put_success(url, request): - headers = {'content-type': 'application/json'} - content = 'response'.encode("utf-8") - return response(200, content, headers, None, 5, request) - - with HTTMock(response_put_success): - resp = self._conn.raw_put("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, b'response') - self.assertEqual(resp.status_code, 200) - - def test_raw_get_fail(self): - @urlmatch(netloc="localhost", path="/known_path", method="get") - def response_get_fail(url, request): - headers = {'content-type': 'application/json'} - content = "404 page not found".encode("utf-8") - return response(404, content, headers, None, 5, request) - - with HTTMock(response_get_fail): - resp = self._conn.raw_get("/known_path") - - self.assertEqual(resp.content, b"404 page not found") - self.assertEqual(resp.status_code, 404) - - def test_raw_post_fail(self): - @urlmatch(netloc="localhost", path="/known_path", method="post") - def response_post_fail(url, request): - headers = {'content-type': 'application/json'} - content = str(["Start can't be blank"]).encode("utf-8") - return response(404, content, headers, None, 5, request) - - with HTTMock(response_post_fail): - resp = self._conn.raw_post("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, str(["Start can't be blank"]).encode("utf-8")) - self.assertEqual(resp.status_code, 404) - - def test_raw_put_fail(self): - @urlmatch(netloc="localhost", path="/known_path", method="put") - def response_put_fail(url, request): - headers = {'content-type': 'application/json'} - content = str(["Start can't be blank"]).encode("utf-8") - return response(404, content, headers, None, 5, request) - - with HTTMock(response_put_fail): - resp = self._conn.raw_put("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, str(["Start can't be blank"]).encode("utf-8")) - self.assertEqual(resp.status_code, 404) - - def test_add_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertEqual(self._conn.headers, - {"test": "value"}) - - def test_del_param_headers(self): - self._conn.add_param_headers("test", "value") - self._conn.del_param_headers("test") - self.assertEqual(self._conn.headers, {}) - - def test_clean_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertEqual(self._conn.headers, - {"test": "value"}) - self._conn.clean_headers() - self.assertEqual(self._conn.headers, {}) - - def test_exist_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertTrue(self._conn.exist_param_headers("test")) - self.assertFalse(self._conn.exist_param_headers("test_no")) - - def test_get_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertTrue(self._conn.exist_param_headers("test")) - self.assertFalse(self._conn.exist_param_headers("test_no")) - - def test_get_headers(self): - self._conn.add_param_headers("test", "value") - self.assertEqual(self._conn.headers, - {"test": "value"}) diff --git a/keycloak/urls_patterns.py b/keycloak/urls_patterns.py deleted file mode 100644 index fad3455d..00000000 --- a/keycloak/urls_patterns.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -# -# The MIT License (MIT) -# -# Copyright (C) 2017 Marcos Pereira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -# OPENID URLS -URL_WELL_KNOWN = "realms/{realm-name}/.well-known/openid-configuration" -URL_TOKEN = "realms/{realm-name}/protocol/openid-connect/token" -URL_USERINFO = "realms/{realm-name}/protocol/openid-connect/userinfo" -URL_LOGOUT = "realms/{realm-name}/protocol/openid-connect/logout" -URL_CERTS = "realms/{realm-name}/protocol/openid-connect/certs" -URL_INTROSPECT = "realms/{realm-name}/protocol/openid-connect/token/introspect" -URL_ENTITLEMENT = "realms/{realm-name}/authz/entitlement/{resource-server-id}" -URL_AUTH = "{authorization-endpoint}?client_id={client-id}&response_type=code&redirect_uri={redirect-uri}" - -# ADMIN URLS -URL_ADMIN_USERS = "admin/realms/{realm-name}/users" -URL_ADMIN_USERS_COUNT = "admin/realms/{realm-name}/users/count" -URL_ADMIN_USER = "admin/realms/{realm-name}/users/{id}" -URL_ADMIN_USER_CONSENTS = "admin/realms/{realm-name}/users/{id}/consents" -URL_ADMIN_SEND_UPDATE_ACCOUNT = "admin/realms/{realm-name}/users/{id}/execute-actions-email" -URL_ADMIN_SEND_VERIFY_EMAIL = "admin/realms/{realm-name}/users/{id}/send-verify-email" -URL_ADMIN_RESET_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password" -URL_ADMIN_GET_SESSIONS = "admin/realms/{realm-name}/users/{id}/sessions" -URL_ADMIN_USER_CLIENT_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}" -URL_ADMIN_USER_REALM_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/realm" -URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/available" -URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/composite" -URL_ADMIN_USER_GROUP = "admin/realms/{realm-name}/users/{id}/groups/{group-id}" -URL_ADMIN_USER_GROUPS = "admin/realms/{realm-name}/users/{id}/groups" -URL_ADMIN_USER_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password" -URL_ADMIN_USER_STORAGE = "admin/realms/{realm-name}/user-storage/{id}/sync" - -URL_ADMIN_SERVER_INFO = "admin/serverinfo" - -URL_ADMIN_GROUPS = "admin/realms/{realm-name}/groups" -URL_ADMIN_GROUP = "admin/realms/{realm-name}/groups/{id}" -URL_ADMIN_GROUP_CHILD = "admin/realms/{realm-name}/groups/{id}/children" -URL_ADMIN_GROUP_PERMISSIONS = "admin/realms/{realm-name}/groups/{id}/management/permissions" -URL_ADMIN_GROUP_MEMBERS = "admin/realms/{realm-name}/groups/{id}/members" - -URL_ADMIN_CLIENTS = "admin/realms/{realm-name}/clients" -URL_ADMIN_CLIENT = URL_ADMIN_CLIENTS + "/{id}" -URL_ADMIN_CLIENT_SECRETS= URL_ADMIN_CLIENT + "/client-secret" -URL_ADMIN_CLIENT_ROLES = URL_ADMIN_CLIENT + "/roles" -URL_ADMIN_CLIENT_ROLE = URL_ADMIN_CLIENT + "/roles/{role-name}" -URL_ADMIN_CLIENT_AUTHZ_SETTINGS = URL_ADMIN_CLIENT + "/authz/resource-server/settings" -URL_ADMIN_CLIENT_AUTHZ_RESOURCES = URL_ADMIN_CLIENT + "/authz/resource-server/resource" -URL_ADMIN_CLIENT_CERTS = URL_ADMIN_CLIENT + "/certificates/{attr}" - -URL_ADMIN_CLIENT_SCOPES = "admin/realms/{realm-name}/client-scopes" -URL_ADMIN_CLIENT_SCOPE = URL_ADMIN_CLIENT_SCOPES + "/{scope-id}" -URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER = URL_ADMIN_CLIENT_SCOPE + "/protocol-mappers/models" - -URL_ADMIN_REALM_ROLES = "admin/realms/{realm-name}/roles" -URL_ADMIN_REALMS = "admin/realms" -URL_ADMIN_IDPS = "admin/realms/{realm-name}/identity-provider/instances" - -URL_ADMIN_FLOWS = "admin/realms/{realm-name}/authentication/flows" -URL_ADMIN_FLOWS_EXECUTIONS = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions" diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..07a75bcb --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2510 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +description = "A collection of accessible pygments styles" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"}, + {file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"}, +] + +[package.dependencies] +pygments = ">=1.5" + +[package.extras] +dev = ["pillow", "pkginfo (>=1.10)", "playwright", "pre-commit", "setuptools", "twine (>=5.0)"] +tests = ["hypothesis", "pytest"] + +[[package]] +name = "aiofiles" +version = "25.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.10" +groups = ["docs"] +files = [ + {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, + {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, +] + +[[package]] +name = "anyio" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "argcomplete" +version = "3.6.3" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"}, + {file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "astroid" +version = "3.3.11" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.9.0" +groups = ["docs"] +markers = "python_version < \"3.12\"" +files = [ + {file = "astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec"}, + {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[[package]] +name = "astroid" +version = "4.0.3" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.10.0" +groups = ["docs"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14"}, + {file = "astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3"}, +] + +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["docs"] +files = [ + {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, + {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, +] + +[package.dependencies] +soupsieve = ">=1.6.1" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "cachetools" +version = "6.2.4" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51"}, + {file = "cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607"}, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "codespell" +version = "2.4.1" +description = "Fix common misspellings in text files" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425"}, + {file = "codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli ; python_version < \"3.11\""] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev", "docs"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {docs = "sys_platform == \"win32\""} + +[[package]] +name = "commitizen" +version = "4.12.1" +description = "Python commitizen client tool" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["dev"] +files = [ + {file = "commitizen-4.12.1-py3-none-any.whl", hash = "sha256:779438b4881803433342b32aab55485ece9c1f05be60add6399570811b03f9f0"}, + {file = "commitizen-4.12.1.tar.gz", hash = "sha256:3bf952793cf19466116e23802df56ca019c5d34aaaa4785bba718b556b3732c1"}, +] + +[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.8" +questionary = ">=2.0,<3.0" +termcolor = ">=1.1.0,<4.0.0" +tomlkit = ">=0.8.0,<1.0.0" +typing-extensions = {version = ">=4.0.1,<5.0.0", markers = "python_full_version < \"3.11.0\""} + +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "coverage" +version = "7.13.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {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.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cryptography" +version = "46.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main", "dev"] +files = [ + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "darglint" +version = "1.8.1" +description = "A utility for ensuring Google-style docstrings stay up to date with the source code." +optional = false +python-versions = ">=3.6,<4.0" +groups = ["dev"] +files = [ + {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"}, + {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"}, +] + +[[package]] +name = "decli" +version = "0.6.3" +description = "Minimal, easy-to-use, declarative cli tool" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3"}, + {file = "decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656"}, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["dev"] +files = [ + {file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"}, + {file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"}, +] + +[package.dependencies] +wrapt = ">=1.10,<3" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] + +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] + +[package.dependencies] +packaging = "*" + +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + +[[package]] +name = "docutils" +version = "0.20.1" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.7" +groups = ["dev", "docs"] +files = [ + {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, + {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.20.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "id" +version = "1.5.0" +description = "A tool for generating OIDC identities" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658"}, + {file = "id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d"}, +] + +[package.dependencies] +requests = "*" + +[package.extras] +dev = ["build", "bump (>=1.3.2)", "id[lint,test]"] +lint = ["bandit", "interrogate", "mypy", "ruff (<0.8.2)", "types-requests"] +test = ["coverage[toml]", "pretend", "pytest", "pytest-cov"] + +[[package]] +name = "identify" +version = "2.6.16" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0"}, + {file = "identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["docs"] +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda"}, + {file = "jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176"}, + {file = "jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["dev", "docs"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jwcrypto" +version = "1.5.6" +description = "Implementation of JOSE Web standards" +optional = false +python-versions = ">= 3.8" +groups = ["main"] +files = [ + {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, + {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, +] + +[package.dependencies] +cryptography = ">=3.4" +typing-extensions = ">=4.5.0" + +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + +[[package]] +name = "m2r2" +version = "0.3.4" +description = "Markdown and reStructuredText in a single file." +optional = false +python-versions = ">=3.7" +groups = ["docs"] +files = [ + {file = "m2r2-0.3.4-py3-none-any.whl", hash = "sha256:1a445514af8a229496bfb1380c52da8dd38313e48600359ee92b2c9d2e4df34a"}, + {file = "m2r2-0.3.4.tar.gz", hash = "sha256:e278f5f337e9aa7b2080fcc3e94b051bda9615b02e36c6fb3f23ff019872f043"}, +] + +[package.dependencies] +docutils = ">=0.19" +mistune = "0.8.4" + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["dev", "docs"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mistune" +version = "0.8.4" +description = "The fastest markdown parser in pure Python" +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, + {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + +[[package]] +name = "nh3" +version = "0.3.2" +description = "Python binding to Ammonia HTML sanitizer Rust crate" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d"}, + {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130"}, + {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99"}, + {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868"}, + {file = "nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93"}, + {file = "nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13"}, + {file = "nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80"}, + {file = "nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7"}, + {file = "nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0"}, + {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6"}, + {file = "nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b"}, + {file = "nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe"}, + {file = "nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104"}, + {file = "nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376"}, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, + {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "4.5.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, + {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07"}, + {file = "prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pydata-sphinx-theme" +version = "0.15.4" +description = "Bootstrap-based Sphinx theme from the PyData community" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "pydata_sphinx_theme-0.15.4-py3-none-any.whl", hash = "sha256:2136ad0e9500d0949f96167e63f3e298620040aea8f9c74621959eda5d4cf8e6"}, + {file = "pydata_sphinx_theme-0.15.4.tar.gz", hash = "sha256:7762ec0ac59df3acecf49fd2f889e1b4565dbce8b88b2e29ee06fdd90645a06d"}, +] + +[package.dependencies] +accessible-pygments = "*" +Babel = "*" +beautifulsoup4 = "*" +docutils = "!=0.17.0" +packaging = "*" +pygments = ">=2.7" +sphinx = ">=5" +typing-extensions = "*" + +[package.extras] +a11y = ["pytest-playwright"] +dev = ["pandoc", "pre-commit", "pydata-sphinx-theme[doc,test]", "pyyaml", "sphinx-theme-builder[cli]", "tox"] +doc = ["ablog (>=0.11.8)", "colorama", "graphviz", "ipykernel", "ipyleaflet", "ipywidgets", "jupyter_sphinx", "jupyterlite-sphinx", "linkify-it-py", "matplotlib", "myst-parser", "nbsphinx", "numpy", "numpydoc", "pandas", "plotly", "rich", "sphinx-autoapi (>=3.0.0)", "sphinx-copybutton", "sphinx-design", "sphinx-favicon (>=1.0.1)", "sphinx-sitemap", "sphinx-togglebutton", "sphinxcontrib-youtube (>=1.4.1)", "sphinxext-rediraffe", "xarray"] +i18n = ["Babel", "jinja2"] +test = ["pytest", "pytest-cov", "pytest-regressions", "sphinx[test]"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev", "docs"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyproject-api" +version = "1.10.0" +description = "API to interact with the python pyproject.toml based projects" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09"}, + {file = "pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330"}, +] + +[package.dependencies] +packaging = ">=25" +tomli = {version = ">=2.3", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2025.9.25)", "sphinx-autodoc-typehints (>=3.5.1)"] +testing = ["covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)", "setuptools (>=80.9)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev", "docs"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "questionary" +version = "2.1.1" +description = "Python library to build pretty command line user prompts ⭐️" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59"}, + {file = "questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d"}, +] + +[package.dependencies] +prompt_toolkit = ">=2.0,<4.0" + +[[package]] +name = "readme-renderer" +version = "43.0" +description = "readme_renderer is a library for rendering readme descriptions for Warehouse" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "readme_renderer-43.0-py3-none-any.whl", hash = "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9"}, + {file = "readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311"}, +] + +[package.dependencies] +docutils = ">=0.13.1" +nh3 = ">=0.2.14" +Pygments = ">=2.5.1" + +[package.extras] +md = ["cmarkgfm (>=0.8.0)"] + +[[package]] +name = "recommonmark" +version = "0.7.1" +description = "A docutils-compatibility bridge to CommonMark, enabling you to write CommonMark inside of Docutils & Sphinx projects." +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "recommonmark-0.7.1-py2.py3-none-any.whl", hash = "sha256:1b1db69af0231efce3fa21b94ff627ea33dee7079a01dd0a7f8482c3da148b3f"}, + {file = "recommonmark-0.7.1.tar.gz", hash = "sha256:bdb4db649f2222dcd8d2d844f0006b958d627f732415d399791ee436a3686d67"}, +] + +[package.dependencies] +commonmark = ">=0.8.1" +docutils = ">=0.11" +sphinx = ">=1.3.1" + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev", "docs"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main", "dev"] +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rfc3986" +version = "2.0.0" +description = "Validating URI References per RFC 3986" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, + {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, +] + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["dev"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +description = "Manipulate well-formed Roman numerals" +optional = false +python-versions = ">=3.10" +groups = ["docs"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7"}, + {file = "roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2"}, +] + +[[package]] +name = "ruff" +version = "0.14.13" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b"}, + {file = "ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed"}, + {file = "ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c"}, + {file = "ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680"}, + {file = "ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef"}, + {file = "ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247"}, + {file = "ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47"}, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" +files = [ + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "setuptools" +version = "80.10.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e"}, + {file = "setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyobjc (<12) ; sys_platform == \"darwin\" and python_version <= \"3.9\"", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" +groups = ["docs"] +files = [ + {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, + {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, + {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +description = "Python documentation generator" +optional = false +python-versions = ">=3.10" +groups = ["docs"] +markers = "python_version < \"3.12\"" +files = [ + {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, + {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, +] + +[package.dependencies] +alabaster = ">=0.7.14" +babel = ">=2.13" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +docutils = ">=0.20,<0.22" +imagesize = ">=1.3" +Jinja2 = ">=3.1" +packaging = ">=23.0" +Pygments = ">=2.17" +requests = ">=2.30.0" +snowballstemmer = ">=2.2" +sphinxcontrib-applehelp = ">=1.0.7" +sphinxcontrib-devhelp = ">=1.0.6" +sphinxcontrib-htmlhelp = ">=2.0.6" +sphinxcontrib-jsmath = ">=1.0.1" +sphinxcontrib-qthelp = ">=1.0.6" +sphinxcontrib-serializinghtml = ">=1.1.9" +tomli = {version = ">=2", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] +test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] + +[[package]] +name = "sphinx" +version = "9.0.4" +description = "Python documentation generator" +optional = false +python-versions = ">=3.11" +groups = ["docs"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb"}, + {file = "sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3"}, +] + +[package.dependencies] +alabaster = ">=0.7.14" +babel = ">=2.13" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +docutils = ">=0.20,<0.23" +imagesize = ">=1.3" +Jinja2 = ">=3.1" +packaging = ">=23.0" +Pygments = ">=2.17" +requests = ">=2.30.0" +roman-numerals = ">=1.0.0" +snowballstemmer = ">=2.2" +sphinxcontrib-applehelp = ">=1.0.7" +sphinxcontrib-devhelp = ">=1.0.6" +sphinxcontrib-htmlhelp = ">=2.0.6" +sphinxcontrib-jsmath = ">=1.0.1" +sphinxcontrib-qthelp = ">=1.0.6" +sphinxcontrib-serializinghtml = ">=1.1.9" + +[[package]] +name = "sphinx-autoapi" +version = "3.6.1" +description = "Sphinx API documentation generator" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinx_autoapi-3.6.1-py3-none-any.whl", hash = "sha256:6b7af0d5650f6eac1f4b85c1eb9f9a4911160ec7138bdc4451c77a5e94d5832c"}, + {file = "sphinx_autoapi-3.6.1.tar.gz", hash = "sha256:1ff2992b7d5e39ccf92413098a376e0f91e7b4ca532c4f3e71298dbc8a4a9900"}, +] + +[package.dependencies] +astroid = [ + {version = ">=3.0,<4.0", markers = "python_version < \"3.12\""}, + {version = ">=4.0,<5.0", markers = "python_version >= \"3.12\""}, +] +Jinja2 = "*" +PyYAML = "*" +sphinx = ">=7.4.0" + +[[package]] +name = "sphinx-book-theme" +version = "1.1.4" +description = "A clean book theme for scientific explanations and documentation with Sphinx" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinx_book_theme-1.1.4-py3-none-any.whl", hash = "sha256:843b3f5c8684640f4a2d01abd298beb66452d1b2394cd9ef5be5ebd5640ea0e1"}, + {file = "sphinx_book_theme-1.1.4.tar.gz", hash = "sha256:73efe28af871d0a89bd05856d300e61edce0d5b2fbb7984e84454be0fedfe9ed"}, +] + +[package.dependencies] +pydata-sphinx-theme = "0.15.4" +sphinx = ">=6.1" + +[package.extras] +code-style = ["pre-commit"] +doc = ["ablog", "folium", "ipywidgets", "matplotlib", "myst-nb", "nbclient", "numpy", "numpydoc", "pandas", "plotly", "sphinx-copybutton", "sphinx-design", "sphinx-examples", "sphinx-tabs", "sphinx-thebe", "sphinx-togglebutton", "sphinxcontrib-bibtex", "sphinxcontrib-youtube", "sphinxext-opengraph"] +test = ["beautifulsoup4", "coverage", "defusedxml", "myst-nb", "pytest", "pytest-cov", "pytest-regressions", "sphinx_thebe"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, + {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, + {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, + {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +groups = ["docs"] +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, + {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["defusedxml (>=0.7.1)", "pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, + {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "termcolor" +version = "3.3.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5"}, + {file = "termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "tomli" +version = "2.4.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev", "docs"] +files = [ + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, +] +markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version == \"3.10\""} + +[[package]] +name = "tomlkit" +version = "0.14.0" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, + {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, +] + +[[package]] +name = "tox" +version = "4.34.1" +description = "tox is a generic virtualenv management and test command line tool" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "tox-4.34.1-py3-none-any.whl", hash = "sha256:5610d69708bab578d618959b023f8d7d5d3386ed14a2392aeebf9c583615af60"}, + {file = "tox-4.34.1.tar.gz", hash = "sha256:ef1e82974c2f5ea02954d590ee0b967fad500c3879b264ea19efb9a554f3cc60"}, +] + +[package.dependencies] +cachetools = ">=6.2.4" +chardet = ">=5.2" +colorama = ">=0.4.6" +filelock = ">=3.20.2" +packaging = ">=25" +platformdirs = ">=4.5.1" +pluggy = ">=1.6" +pyproject-api = ">=1.10" +tomli = {version = ">=2.3", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.15", markers = "python_version < \"3.11\""} +virtualenv = ">=20.35.4" + +[[package]] +name = "twine" +version = "6.2.0" +description = "Collection of utilities for publishing packages on PyPI" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8"}, + {file = "twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf"}, +] + +[package.dependencies] +id = "*" +keyring = {version = ">=21.2.0", markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""} +packaging = ">=24.0" +readme-renderer = ">=35.0" +requests = ">=2.20" +requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" +rfc3986 = ">=1.4.0" +rich = ">=12.0.0" +urllib3 = ">=1.26.0" + +[package.extras] +keyring = ["keyring (>=21.2.0)"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev", "docs"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.13\""} + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev", "docs"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "virtualenv" +version = "20.36.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, + {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "wcwidth" +version = "0.3.0" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "wcwidth-0.3.0-py3-none-any.whl", hash = "sha256:073a1acb250e4add96cfd5ef84e0036605cd6e0d0782c8c15c80e42202348458"}, + {file = "wcwidth-0.3.0.tar.gz", hash = "sha256:af1a2fb0b83ef4a7fc0682a4c95ca2576e14d0280bca2a9e67b7dc9f2733e123"}, +] + +[[package]] +name = "wheel" +version = "0.46.3" +description = "Command line tool for manipulating wheel files" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"}, + {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"}, +] + +[package.dependencies] +packaging = ">=24.0" + +[package.extras] +test = ["pytest (>=6.0.0)", "setuptools (>=77)"] + +[[package]] +name = "wrapt" +version = "2.0.1" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd"}, + {file = "wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374"}, + {file = "wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489"}, + {file = "wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31"}, + {file = "wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef"}, + {file = "wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013"}, + {file = "wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38"}, + {file = "wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1"}, + {file = "wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25"}, + {file = "wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4"}, + {file = "wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45"}, + {file = "wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7"}, + {file = "wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590"}, + {file = "wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6"}, + {file = "wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7"}, + {file = "wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28"}, + {file = "wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb"}, + {file = "wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c"}, + {file = "wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16"}, + {file = "wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2"}, + {file = "wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd"}, + {file = "wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be"}, + {file = "wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b"}, + {file = "wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf"}, + {file = "wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c"}, + {file = "wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841"}, + {file = "wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62"}, + {file = "wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf"}, + {file = "wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9"}, + {file = "wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b"}, + {file = "wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba"}, + {file = "wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684"}, + {file = "wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb"}, + {file = "wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9"}, + {file = "wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75"}, + {file = "wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b"}, + {file = "wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9"}, + {file = "wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f"}, + {file = "wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218"}, + {file = "wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9"}, + {file = "wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c"}, + {file = "wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db"}, + {file = "wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233"}, + {file = "wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2"}, + {file = "wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b"}, + {file = "wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7"}, + {file = "wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3"}, + {file = "wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8"}, + {file = "wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3"}, + {file = "wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1"}, + {file = "wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d"}, + {file = "wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7"}, + {file = "wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3"}, + {file = "wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b"}, + {file = "wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10"}, + {file = "wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf"}, + {file = "wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e"}, + {file = "wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c"}, + {file = "wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92"}, + {file = "wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f"}, + {file = "wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1"}, + {file = "wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55"}, + {file = "wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0"}, + {file = "wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509"}, + {file = "wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1"}, + {file = "wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970"}, + {file = "wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c"}, + {file = "wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41"}, + {file = "wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed"}, + {file = "wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0"}, + {file = "wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c"}, + {file = "wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e"}, + {file = "wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b"}, + {file = "wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec"}, + {file = "wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa"}, + {file = "wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815"}, + {file = "wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa"}, + {file = "wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef"}, + {file = "wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747"}, + {file = "wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f"}, + {file = "wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349"}, + {file = "wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c"}, + {file = "wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395"}, + {file = "wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad"}, + {file = "wrapt-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:90897ea1cf0679763b62e79657958cd54eae5659f6360fc7d2ccc6f906342183"}, + {file = "wrapt-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:50844efc8cdf63b2d90cd3d62d4947a28311e6266ce5235a219d21b195b4ec2c"}, + {file = "wrapt-2.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49989061a9977a8cbd6d20f2efa813f24bf657c6990a42967019ce779a878dbf"}, + {file = "wrapt-2.0.1-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:09c7476ab884b74dce081ad9bfd07fe5822d8600abade571cb1f66d5fc915af6"}, + {file = "wrapt-2.0.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1a8a09a004ef100e614beec82862d11fc17d601092c3599afd22b1f36e4137e"}, + {file = "wrapt-2.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:89a82053b193837bf93c0f8a57ded6e4b6d88033a499dadff5067e912c2a41e9"}, + {file = "wrapt-2.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f26f8e2ca19564e2e1fdbb6a0e47f36e0efbab1acc31e15471fad88f828c75f6"}, + {file = "wrapt-2.0.1-cp38-cp38-win32.whl", hash = "sha256:115cae4beed3542e37866469a8a1f2b9ec549b4463572b000611e9946b86e6f6"}, + {file = "wrapt-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:c4012a2bd37059d04f8209916aa771dfb564cccb86079072bdcd48a308b6a5c5"}, + {file = "wrapt-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:68424221a2dc00d634b54f92441914929c5ffb1c30b3b837343978343a3512a3"}, + {file = "wrapt-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd1a18f5a797fe740cb3d7a0e853a8ce6461cc62023b630caec80171a6b8097"}, + {file = "wrapt-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb3a86e703868561c5cad155a15c36c716e1ab513b7065bd2ac8ed353c503333"}, + {file = "wrapt-2.0.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dc1b852337c6792aa111ca8becff5bacf576bf4a0255b0f05eb749da6a1643e"}, + {file = "wrapt-2.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c046781d422f0830de6329fa4b16796096f28a92c8aef3850674442cdcb87b7f"}, + {file = "wrapt-2.0.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f73f9f7a0ebd0db139253d27e5fc8d2866ceaeef19c30ab5d69dcbe35e1a6981"}, + {file = "wrapt-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b667189cf8efe008f55bbda321890bef628a67ab4147ebf90d182f2dadc78790"}, + {file = "wrapt-2.0.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a9a83618c4f0757557c077ef71d708ddd9847ed66b7cc63416632af70d3e2308"}, + {file = "wrapt-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e9b121e9aeb15df416c2c960b8255a49d44b4038016ee17af03975992d03931"}, + {file = "wrapt-2.0.1-cp39-cp39-win32.whl", hash = "sha256:1f186e26ea0a55f809f232e92cc8556a0977e00183c3ebda039a807a42be1494"}, + {file = "wrapt-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf4cb76f36be5de950ce13e22e7fdf462b35b04665a12b64f3ac5c1bbbcf3728"}, + {file = "wrapt-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:d6cc985b9c8b235bd933990cdbf0f891f8e010b65a3911f7a55179cd7b0fc57b"}, + {file = "wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca"}, + {file = "wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f"}, +] + +[package.extras] +dev = ["pytest", "setuptools"] + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<4" +content-hash = "955102f559235ec9acd3e6b47cf22a8717f032717788ff0990df892796c9af3f" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a5c97615 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,99 @@ +[tool.poetry] +name = "python-keycloak" +version = "0.0.0" +description = "python-keycloak is a Python package providing access to the Keycloak API." +license = "MIT" +readme = "README.md" +keywords = ["keycloak", "openid", "oidc"] +authors = [ + "Marcos Pereira ", + "Richard Nemeth ", +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Operating System :: MacOS", + "Operating System :: Unix", + "Operating System :: Microsoft :: Windows", + "Topic :: Utilities", +] +packages = [ + { include = "keycloak", from = "src/" }, + { include = "keycloak/**/*.py", from = "src/" }, + { include = "keycloak/py.typed", from = "src/" }, +] +include = ["LICENSE", "CHANGELOG.md", "CONTRIBUTING.md"] + +[tool.poetry.urls] +Changelog = "https://raw.githubusercontent.com/marcospereirampj/python-keycloak/master/CHANGELOG.md" +Documentation = "https://python-keycloak.readthedocs.io/en/latest/" +"Issue tracker" = "https://github.com/marcospereirampj/python-keycloak/issues" + +[tool.poetry.dependencies] +python = ">=3.10,<4" +requests = ">=2.20.0" +requests-toolbelt = ">=0.6.0" +deprecation = ">=2.1.0" +jwcrypto = ">=1.5.4" +httpx = ">=0.23.2" +aiofiles = ">=24.1.0" + +[tool.poetry.group.docs.dependencies] +alabaster = ">=0.7.0" +commonmark = ">=0.9.1" +recommonmark = ">=0.7.1" +Sphinx = ">=7.0.0" +m2r2 = ">=0.3.2" +sphinx-autoapi = ">=3.0.0" +setuptools = ">=70.0.0" +sphinx-book-theme = ">=1.1.3" + +[tool.poetry.group.dev.dependencies] +tox = ">=4.0.0" +pytest = ">=7.1.2" +pytest-cov = ">=3.0.0" +pytest-asyncio = ">=0.23.7" +wheel = ">=0.38.4" +pre-commit = ">=3.5.0" +commitizen = ">=2.28.0" +cryptography = ">=42.0.0" +codespell = ">=2.1.0" +darglint = ">=1.8.1" +twine = ">=4.0.2" +freezegun = ">=1.2.2" +docutils = "<0.21" +ruff = ">=0.9.3" +backports-asyncio-runner = { "version" = ">=1.2.0", "python" = ">=3.9,<3.11" } + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 99 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "BLE001", + "C901", + "COM812", + "D203", + "D212", + "FBT001", + "FBT002", + "FBT003", + "N818", + "PLR0912", + "PLR0913", + "PLR0915", + "TRY003", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ARG001","PLR2004", "PT011", "S101", "SLF001"] +"docs/*" = ["A001", "EXE001", "ERA001"] + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d4776e37..00000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests>=2.20.0 -httmock>=1.2.5 -python-jose>=1.4.0 -twine==1.13.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 224a7795..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 31832219..00000000 --- a/setup.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- - -from setuptools import setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name='python-keycloak', - version='0.17.6', - url='https://github.com/marcospereirampj/python-keycloak', - license='The MIT License', - author='Marcos Pereira', - author_email='marcospereira.mpj@gmail.com', - keywords='keycloak openid', - description='python-keycloak is a Python package providing access to the Keycloak API.', - long_description=long_description, - long_description_content_type="text/markdown", - packages=['keycloak', 'keycloak.authorization', 'keycloak.tests'], - install_requires=['requests>=2.20.0', 'python-jose>=1.4.0'], - tests_require=['httmock>=1.2.5'], - classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: MIT License', - 'Development Status :: 3 - Alpha', - 'Operating System :: MacOS', - 'Operating System :: Unix', - 'Operating System :: Microsoft :: Windows', - 'Topic :: Utilities' - ] -) diff --git a/src/keycloak/__init__.py b/src/keycloak/__init__.py new file mode 100644 index 00000000..8f6e3390 --- /dev/null +++ b/src/keycloak/__init__.py @@ -0,0 +1,67 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Python-Keycloak library.""" + +from ._version import __version__ +from .connection import ConnectionManager +from .exceptions import ( + KeycloakAuthenticationError, + KeycloakAuthorizationConfigError, + KeycloakConnectionError, + KeycloakDeleteError, + KeycloakDeprecationError, + KeycloakError, + KeycloakGetError, + KeycloakInvalidTokenError, + KeycloakOperationError, + KeycloakPostError, + KeycloakPutError, + KeycloakRPTNotFound, + KeycloakSecretNotFound, +) +from .keycloak_admin import KeycloakAdmin +from .keycloak_openid import KeycloakOpenID +from .keycloak_uma import KeycloakUMA +from .openid_connection import KeycloakOpenIDConnection + +__all__ = [ + "ConnectionManager", + "KeycloakAdmin", + "KeycloakAuthenticationError", + "KeycloakAuthorizationConfigError", + "KeycloakConnectionError", + "KeycloakDeleteError", + "KeycloakDeprecationError", + "KeycloakError", + "KeycloakGetError", + "KeycloakInvalidTokenError", + "KeycloakOpenID", + "KeycloakOpenIDConnection", + "KeycloakOperationError", + "KeycloakPostError", + "KeycloakPutError", + "KeycloakRPTNotFound", + "KeycloakSecretNotFound", + "KeycloakUMA", + "__version__", +] diff --git a/keycloak/__init__.py b/src/keycloak/_version.py similarity index 93% rename from keycloak/__init__.py rename to src/keycloak/_version.py index 987ce1c5..36796953 100644 --- a/keycloak/__init__.py +++ b/src/keycloak/_version.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # The MIT License (MIT) # @@ -21,5 +20,6 @@ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from .keycloak_admin import * -from .keycloak_openid import * +from importlib import metadata + +__version__ = metadata.version("python-keycloak") diff --git a/keycloak/authorization/__init__.py b/src/keycloak/authorization/__init__.py similarity index 50% rename from keycloak/authorization/__init__.py rename to src/keycloak/authorization/__init__.py index 219687bd..42efdbbc 100644 --- a/keycloak/authorization/__init__.py +++ b/src/keycloak/authorization/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # The MIT License (MIT) # @@ -21,6 +20,8 @@ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +"""Authorization module.""" + import ast import json @@ -37,57 +38,71 @@ class Authorization: """ - def __init__(self): - self._policies = {} + def __init__(self) -> None: + """Init method.""" + self.policies = {} @property - def policies(self): + def policies(self) -> dict: + """ + Get policies. + + :returns: Policies + :rtype: dict + """ return self._policies @policies.setter - def policies(self, value): + def policies(self, value: dict) -> None: self._policies = value - def load_config(self, data): + def load_config(self, data: dict) -> None: """ Load policies, roles and permissions (scope/resources). :param data: keycloak authorization data (dict) - :return: + :type data: dict """ - for pol in data['policies']: - if pol['type'] == 'role': - policy = Policy(name=pol['name'], - type=pol['type'], - logic=pol['logic'], - decision_strategy=pol['decisionStrategy']) - - config_roles = json.loads(pol['config']['roles']) + for pol in data["policies"]: + if pol["type"] == "role": + policy = Policy( + name=pol["name"], + type=pol["type"], + logic=pol["logic"], + decision_strategy=pol["decisionStrategy"], + ) + + config_roles = json.loads(pol["config"]["roles"]) for role in config_roles: - policy.add_role(Role(name=role['id'], - required=role['required'])) + policy.add_role(Role(name=role["id"], required=role["required"])) self.policies[policy.name] = policy - if pol['type'] == 'scope': - permission = Permission(name=pol['name'], - type=pol['type'], - logic=pol['logic'], - decision_strategy=pol['decisionStrategy']) + if pol["type"] == "scope": + permission = Permission( + name=pol["name"], + type=pol["type"], + logic=pol["logic"], + decision_strategy=pol["decisionStrategy"], + ) - permission.scopes = ast.literal_eval(pol['config']['scopes']) + permission.scopes = ast.literal_eval(pol["config"]["scopes"]) - for policy_name in ast.literal_eval(pol['config']['applyPolicies']): - self.policies[policy_name].add_permission(permission) + if "applyPolicies" in pol["config"]: + for policy_name in ast.literal_eval(pol["config"]["applyPolicies"]): + if self.policies.get(policy_name) is not None: + self.policies[policy_name].add_permission(permission) - if pol['type'] == 'resource': - permission = Permission(name=pol['name'], - type=pol['type'], - logic=pol['logic'], - decision_strategy=pol['decisionStrategy']) + if pol["type"] == "resource": + permission = Permission( + name=pol["name"], + type=pol["type"], + logic=pol["logic"], + decision_strategy=pol["decisionStrategy"], + ) - permission.resources = ast.literal_eval(pol['config'].get('resources', "[]")) + permission.resources = ast.literal_eval(pol["config"].get("resources", "[]")) - for policy_name in ast.literal_eval(pol['config']['applyPolicies']): + for policy_name in ast.literal_eval(pol["config"]["applyPolicies"]): if self.policies.get(policy_name) is not None: self.policies[policy_name].add_permission(permission) diff --git a/src/keycloak/authorization/permission.py b/src/keycloak/authorization/permission.py new file mode 100644 index 00000000..dfe0740c --- /dev/null +++ b/src/keycloak/authorization/permission.py @@ -0,0 +1,179 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak authorization Permission module.""" + + +class Permission: + """ + Base permission class. + + Consider this simple and very common permission: + + A permission associates the object being protected with the policies that must be evaluated to + determine whether access is granted. + + X CAN DO Y ON RESOURCE Z + + where + + - X represents one or more users, roles, or groups, or a combination of them. You can + also use claims and context here. + + - Y represents an action to be performed, for example, write, view, and so on. + + - Z represents a protected resource, for example, "/accounts". + + https://keycloak.gitbooks.io/documentation/authorization_services/topics/permission/overview.html + + :param name: Name + :type name: str + :param type: Type + :type type: str + :param logic: Logic + :type logic: str + :param decision_strategy: Decision strategy + :type decision_strategy: str + + """ + + def __init__(self, name: str, type: str, logic: str, decision_strategy: str) -> None: # noqa: A002 + """ + Init method. + + :param name: Name + :type name: str + :param type: Type + :type type: str + :param logic: Logic + :type logic: str + :param decision_strategy: Decision strategy + :type decision_strategy: str + """ + self.name = name + self.type = type + self.logic = logic + self.decision_strategy = decision_strategy + self.resources = [] + self.scopes = [] + + def __repr__(self) -> str: + """ + Repr method. + + :returns: Class representation + :rtype: str + """ + return f"" + + def __str__(self) -> str: + """ + Str method. + + :returns: Class string representation + :rtype: str + """ + return f"Permission: {self.name} ({self.type})" + + @property + def name(self) -> str: + """ + Get name. + + :returns: name + :rtype: str + """ + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + @property + def type(self) -> str: + """ + Get type. + + :returns: type + :rtype: str + """ + return self._type + + @type.setter + def type(self, value: str) -> None: + self._type = value + + @property + def logic(self) -> str: + """ + Get logic. + + :returns: Logic + :rtype: str + """ + return self._logic + + @logic.setter + def logic(self, value: str) -> None: + self._logic = value + + @property + def decision_strategy(self) -> str: + """ + Get decision strategy. + + :returns: Decision strategy + :rtype: str + """ + return self._decision_strategy + + @decision_strategy.setter + def decision_strategy(self, value: str) -> None: + self._decision_strategy = value + + @property + def resources(self) -> list: + """ + Get resources. + + :returns: Resources + :rtype: list + """ + return self._resources + + @resources.setter + def resources(self, value: list) -> None: + self._resources = value + + @property + def scopes(self) -> list: + """ + Get scopes. + + :returns: Scopes + :rtype: list + """ + return self._scopes + + @scopes.setter + def scopes(self, value: list) -> None: + self._scopes = value diff --git a/src/keycloak/authorization/policy.py b/src/keycloak/authorization/policy.py new file mode 100644 index 00000000..90b687c6 --- /dev/null +++ b/src/keycloak/authorization/policy.py @@ -0,0 +1,198 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak authorization Policy module.""" + +from keycloak.exceptions import KeycloakAuthorizationConfigError + +from .permission import Permission +from .role import Role + + +class Policy: + """ + Base policy class. + + A policy defines the conditions that must be satisfied to grant access to an object. + Unlike permissions, you do not specify the object being protected but rather the conditions + that must be satisfied for access to a given object (for example, resource, scope, or both). + Policies are strongly related to the different access control mechanisms (ACMs) that you can + use to protect your resources. With policies, you can implement strategies for attribute-based + access control (ABAC), role-based access control (RBAC), context-based access control, or any + combination of these. + + https://keycloak.gitbooks.io/documentation/authorization_services/topics/policy/overview.html + + :param name: Name + :type name: str + :param type: Type + :type type: str + :param logic: Logic + :type logic: str + :param decision_strategy: Decision strategy + :type decision_strategy: str + + """ + + def __init__(self, name: str, type: str, logic: str, decision_strategy: str) -> None: # noqa: A002 + """ + Init method. + + :param name: Name + :type name: str + :param type: Type + :type type: str + :param logic: Logic + :type logic: str + :param decision_strategy: Decision strategy + :type decision_strategy: str + """ + self.name = name + self.type = type + self.logic = logic + self.decision_strategy = decision_strategy + self.roles = [] + self.permissions = [] + + def __repr__(self) -> str: + """ + Repr method. + + :returns: Class representation + :rtype: str + """ + return f"" + + def __str__(self) -> str: + """ + Str method. + + :returns: Class string representation + :rtype: str + """ + return f"Policy: {self.name} ({self.type})" + + @property + def name(self) -> str: + """ + Get name. + + :returns: Name + :rtype: str + """ + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + @property + def type(self) -> str: + """ + Get type. + + :returns: Type + :rtype: str + """ + return self._type + + @type.setter + def type(self, value: str) -> None: + self._type = value + + @property + def logic(self) -> str: + """ + Get logic. + + :returns: Logic + :rtype: str + """ + return self._logic + + @logic.setter + def logic(self, value: str) -> None: + self._logic = value + + @property + def decision_strategy(self) -> str: + """ + Get decision strategy. + + :returns: Decision strategy + :rtype: str + """ + return self._decision_strategy + + @decision_strategy.setter + def decision_strategy(self, value: str) -> None: + self._decision_strategy = value + + @property + def roles(self) -> list: + """ + Get roles. + + :returns: Roles + :rtype: list + """ + return self._roles + + @roles.setter + def roles(self, value: list) -> None: + self._roles = value + + @property + def permissions(self) -> list: + """ + Get permissions. + + :returns: Permissions + :rtype: list + """ + return self._permissions + + @permissions.setter + def permissions(self, value: list) -> None: + self._permissions = value + + def add_role(self, role: str | Role) -> None: + """ + Add keycloak role in policy. + + :param role: Keycloak role + :type role: keycloak.authorization.Role + :raises KeycloakAuthorizationConfigError: In case of misconfigured policy type + """ + if self.type != "role": + error_msg = "Can't add role. Policy type is different of role" + raise KeycloakAuthorizationConfigError(error_msg) + self._roles.append(role) + + def add_permission(self, permission: str | Permission) -> None: + """ + Add keycloak permission in policy. + + :param permission: Keycloak permission + :type permission: keycloak.authorization.Permission + """ + self._permissions.append(permission) diff --git a/keycloak/authorization/role.py b/src/keycloak/authorization/role.py similarity index 57% rename from keycloak/authorization/role.py rename to src/keycloak/authorization/role.py index 3ff06ddb..141841a9 100644 --- a/keycloak/authorization/role.py +++ b/src/keycloak/authorization/role.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # The MIT License (MIT) # @@ -21,25 +20,70 @@ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +"""The authorization Role module.""" + +from __future__ import annotations + class Role: """ + Authorization Role base class. + Roles identify a type or category of user. Admin, user, manager, and employee are all typical roles that may exist in an organization. https://keycloak.gitbooks.io/documentation/server_admin/topics/roles.html + :param name: Name + :type name: str + :param required: Required role indicator + :type required: bool """ - def __init__(self, name, required=False): + def __init__(self, name: str, required: bool = False) -> None: + """ + Init method. + + :param name: Name + :type name: str + :param required: Required role indicator + :type required: bool + """ self.name = name self.required = required - @property - def get_name(self): + def get_name(self) -> str: + """ + Get name. + + :returns: Name + :rtype: str + """ return self.name - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + """ + Eq method. + + :param other: The other object + :type other: str + :returns: Equality bool + :rtype: bool + """ if isinstance(other, str): return self.name == other - return NotImplemented + + if isinstance(other, Role): + return self.name == other.name + + msg = f"Cannot compare Role with {type(other)}" + raise NotImplementedError(msg) + + def __hash__(self) -> int: + """ + Hash method. + + :returns: Hash value + :rtype: int + """ + return hash(f"{self.name}-{self.required}") diff --git a/src/keycloak/connection.py b/src/keycloak/connection.py new file mode 100644 index 00000000..09db614c --- /dev/null +++ b/src/keycloak/connection.py @@ -0,0 +1,611 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Connection manager module.""" + +from __future__ import annotations + +try: + from urllib.parse import urljoin +except ImportError: # pragma: no cover + from urlparse import urljoin # pyright: ignore[reportMissingImports] + +from typing import Any + +import httpx +import requests +from httpx import Response as AsyncResponse +from requests import Response +from requests.adapters import HTTPAdapter +from requests_toolbelt import MultipartEncoder + +from .exceptions import KeycloakConnectionError + + +class ConnectionManager: + """ + Represents a simple server connection. + + :param base_url: The server URL. + :type base_url: str + :param headers: The header parameters of the requests to the server. + :type headers: dict + :param timeout: Timeout to use for requests to the server. + :type timeout: int + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param proxies: The proxies servers requests is sent by. + :type proxies: dict + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of + (certificate file, key file). + :type cert: Union[str,Tuple[str,str]] + :param max_retries: The total number of times to retry HTTP requests. + :type max_retries: int + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + + def __init__( + self, + base_url: str, + headers: dict | None = None, + timeout: int | None = 60, + verify: bool | str = True, + proxies: dict | None = None, + cert: str | tuple | None = None, + max_retries: int = 1, + pool_maxsize: int | None = None, + ) -> None: + """ + Init method. + + :param base_url: The server URL. + :type base_url: str + :param headers: The header parameters of the requests to the server. + :type headers: dict + :param timeout: Timeout to use for requests to the server. + :type timeout: int + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param proxies: The proxies servers requests is sent by. + :type proxies: dict + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of + (certificate file, key file). + :type cert: Union[str,Tuple[str,str]] + :param max_retries: The total number of times to retry HTTP requests. + :type max_retries: int + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + self.base_url = base_url + self.headers = headers + self.timeout = timeout + self.verify = verify + self.proxies = proxies + self.cert = cert + self.max_retries = max_retries + self.pool_maxsize = pool_maxsize + self._s = requests.Session() + self._s.auth = lambda x: x # don't let requests add auth headers + + # retry once to reset connection with Keycloak after tomcat's ConnectionTimeout + # see https://github.com/marcospereirampj/python-keycloak/issues/36 + for protocol in ("https://", "http://"): + adapter_kwargs = {"max_retries": max_retries} + if pool_maxsize is not None: + adapter_kwargs["pool_maxsize"] = pool_maxsize + adapter = HTTPAdapter(**adapter_kwargs) # pyright: ignore[reportArgumentType] + # adds POST to retry whitelist + allowed_methods = ( + set(adapter.max_retries.allowed_methods) + if adapter.max_retries.allowed_methods + else set() + ) + allowed_methods.add("POST") + adapter.max_retries.allowed_methods = frozenset(allowed_methods) + + self._s.mount(protocol, adapter) + + if proxies: + self._s.proxies.update(proxies) + + self.async_s = httpx.AsyncClient( + verify=verify, + mounts=proxies, + cert=cert, + limits=httpx.Limits( + max_connections=100 if pool_maxsize is None else pool_maxsize, + max_keepalive_connections=20, + ), + ) + self.async_s.auth = None # pyright: ignore[reportAttributeAccessIssue] + + async def aclose(self) -> None: + """Close the async connection on delete.""" + if hasattr(self, "_s"): + await self.async_s.aclose() + + def __del__(self) -> None: + """Del method.""" + if hasattr(self, "_s"): + self._s.close() + + @property + def base_url(self) -> str | None: + """ + Return base url in use for requests to the server. + + :returns: Base URL + :rtype: str + """ + return self._base_url + + @base_url.setter + def base_url(self, value: str | None) -> None: + self._base_url = value + + @property + def timeout(self) -> int | None: + """ + Return timeout in use for request to the server. + + :returns: Timeout + :rtype: int + """ + return self._timeout + + @timeout.setter + def timeout(self, value: int | None) -> None: + self._timeout = value + + @property + def verify(self) -> bool | str: + """ + Return verify in use for request to the server. + + :returns: Verify indicator + :rtype: bool + """ + return self._verify + + @verify.setter + def verify(self, value: bool | str) -> None: + self._verify = value + + @property + 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. + + :returns: Client certificate + :rtype: Union[str,Tuple[str,str]] + """ + return self._cert + + @cert.setter + def cert(self, value: str | tuple | None) -> None: + 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 + def pool_maxsize(self) -> int | None: + """ + Return the maximum number of connections to save in the pool. + + :returns: Pool maxsize + :rtype: int or None + """ + return self._pool_maxsize + + @pool_maxsize.setter + def pool_maxsize(self, value: int | None) -> None: + self._pool_maxsize = value + + @property + def headers(self) -> dict | None: + """ + Return header request to the server. + + :returns: Request headers + :rtype: dict + """ + return self._headers + + @headers.setter + def headers(self, value: dict | None) -> None: + self._headers = value or {} + + def param_headers(self, key: str) -> str | None: + """ + Return a specific header parameter. + + :param key: Header parameters key. + :type key: str + :returns: If the header parameters exist, return its value. + :rtype: str + """ + return (self.headers or {}).get(key) + + def clean_headers(self) -> None: + """Clear header parameters.""" + self.headers = {} + + def exist_param_headers(self, key: str) -> bool: + """ + Check if the parameter exists in the header. + + :param key: Header parameters key. + :type key: str + :returns: If the header parameters exist, return True. + :rtype: bool + """ + return self.param_headers(key) is not None + + def add_param_headers(self, key: str, value: str) -> None: + """ + Add a single parameter inside the header. + + :param key: Header parameters key. + :type key: str + :param value: Value to be added. + :type value: str + """ + if self.headers is None: + self.headers = {} + + self.headers[key] = value + + def del_param_headers(self, key: str) -> None: + """ + Remove a specific parameter. + + :param key: Key of the header parameters. + :type key: str + """ + if self.headers is None: + return + + self.headers.pop(key, None) + + def raw_get(self, path: str, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Submit get request to the path. + + :param path: Path for request. + :type path: str + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + if self.base_url is None: + msg = "Unable to perform GET call with base_url missing." + raise AttributeError(msg) + try: + return self._s.get( + urljoin(self.base_url, path), + params=kwargs, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + cert=self.cert, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + def raw_post(self, path: str, data: dict | str | MultipartEncoder, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Submit post request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | str | MultipartEncoder + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :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: + return self._s.post( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + cert=self.cert, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + def raw_put(self, path: str, data: dict | str | MultipartEncoder, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Submit put request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | str | MultipartEncoder + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :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: + return self._s.put( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + cert=self.cert, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + def raw_delete(self, path: str, data: dict | None = None, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Submit delete request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | None + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + if self.base_url is None: + msg = "Unable to perform DELETE call with base_url missing." + raise AttributeError(msg) + + try: + return self._s.delete( + urljoin(self.base_url, path), + params=kwargs, + data=data or {}, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + cert=self.cert, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + async def a_raw_get(self, path: str, **kwargs: Any) -> AsyncResponse: # noqa: ANN401 + """ + Submit get request to the path. + + :param path: Path for request. + :type path: str + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + if self.base_url is None: + msg = "Unable to perform GET call with base_url missing." + raise AttributeError(msg) + + try: + return await self.async_s.get( + urljoin(self.base_url, path), + params=self._filter_query_params(kwargs), + headers=self.headers, + timeout=self.timeout, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + async def a_raw_post( + self, + path: str, + data: dict | str | MultipartEncoder, + **kwargs: Any, # noqa: ANN401 + ) -> AsyncResponse: + """ + Submit post request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | str | MultipartEncoder + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :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: + return await self.async_s.request( + method="POST", + url=urljoin(self.base_url, path), + params=self._filter_query_params(kwargs), + **self._prepare_httpx_request_content(data), + headers=self.headers, + timeout=self.timeout, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + async def a_raw_put( + self, + path: str, + data: dict | str | MultipartEncoder, + **kwargs: Any, # noqa: ANN401 + ) -> AsyncResponse: + """ + Submit put request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | str | MultipartEncoder + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :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: + return await self.async_s.put( + urljoin(self.base_url, path), + params=self._filter_query_params(kwargs), + **self._prepare_httpx_request_content(data), + headers=self.headers, + timeout=self.timeout, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + async def a_raw_delete( + self, + path: str, + data: dict | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> AsyncResponse: + """ + Submit delete request to the path. + + :param path: Path for request. + :type path: str + :param data: Payload for request. + :type data: dict | None + :param kwargs: Additional arguments + :type kwargs: dict + :returns: Response the request. + :rtype: Response + :raises KeycloakConnectionError: HttpError Can't connect to server. + """ + if self.base_url is None: + msg = "Unable to perform DELETE call with base_url missing." + raise AttributeError(msg) + + try: + return await self.async_s.request( + method="DELETE", + url=urljoin(self.base_url, path), + **self._prepare_httpx_request_content(data or {}), + params=self._filter_query_params(kwargs), + headers=self.headers, + timeout=self.timeout, + ) + except Exception as e: + msg = "Can't connect to server" + raise KeycloakConnectionError(msg) from e + + @staticmethod + def _prepare_httpx_request_content(data: dict | str | None | MultipartEncoder) -> dict: + """ + Create the correct request content kwarg to `httpx.AsyncClient.request()`. + + See https://www.python-httpx.org/compatibility/#request-content + + :param data: the request content + :type data: dict | str | None | MultipartEncoder + :returns: A dict mapping the correct kwarg to the request content + :rtype: dict + """ + if isinstance(data, MultipartEncoder): + return {"content": data.to_string()} + + if isinstance(data, str): + # Note: this could also accept bytes, Iterable[bytes], or AsyncIterable[bytes] + return {"content": data} + + return {"data": data} + + @staticmethod + def _filter_query_params(query_params: dict) -> dict: + """ + Explicitly filter query params with None values for compatibility. + + Httpx and requests differ in the way they handle query params with the value None, + requests does not include params with the value None while httpx includes them as-is. + + :param query_params: the query params + :type query_params: dict + :returns: the filtered query params + :rtype: dict + """ + return {k: v for k, v in query_params.items() if v is not None} diff --git a/src/keycloak/exceptions.py b/src/keycloak/exceptions.py new file mode 100644 index 00000000..238aafba --- /dev/null +++ b/src/keycloak/exceptions.py @@ -0,0 +1,207 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak custom exceptions module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import requests + +if TYPE_CHECKING: + from httpx import Response as AsyncResponse + +from requests import Response + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_ACCEPTED = 202 +HTTP_NO_CONTENT = 204 +HTTP_BAD_REQUEST = 400 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_NOT_ALLOWED = 405 +HTTP_CONFLICT = 409 + + +class KeycloakError(Exception): + """ + Base class for custom Keycloak errors. + + :param error_message: The error message + :type error_message: str + :param response_code: The response status code + :type response_code: int + """ + + def __init__( + self, + error_message: str | bytes = "", + response_code: int | None = None, + response_body: bytes | None = None, + ) -> None: + """ + Init method. + + :param error_message: The error message + :type error_message: str + :param response_code: The code of the response + :type response_code: int + :param response_body: Body of the response + :type response_body: bytes + """ + Exception.__init__(self, error_message) + + self.response_code = response_code + self.response_body = response_body + self.error_message = error_message + + def __str__(self) -> str: + """ + Str method. + + :returns: String representation of the object + :rtype: str + """ + if self.response_code is not None: + return f"{self.response_code}: {self.error_message}" + return f"{self.error_message}" + + +class KeycloakAuthenticationError(KeycloakError): + """Keycloak authentication error exception.""" + + +class KeycloakConnectionError(KeycloakError): + """Keycloak connection error exception.""" + + +class KeycloakOperationError(KeycloakError): + """Keycloak operation error exception.""" + + +class KeycloakDeprecationError(KeycloakError): + """Keycloak deprecation error exception.""" + + +class KeycloakGetError(KeycloakOperationError): + """Keycloak request get error exception.""" + + +class KeycloakPostError(KeycloakOperationError): + """Keycloak request post error exception.""" + + +class KeycloakPutError(KeycloakOperationError): + """Keycloak request put error exception.""" + + +class KeycloakDeleteError(KeycloakOperationError): + """Keycloak request delete error exception.""" + + +class KeycloakSecretNotFound(KeycloakOperationError): + """Keycloak secret not found exception.""" + + +class KeycloakRPTNotFound(KeycloakOperationError): + """Keycloak RPT not found exception.""" + + +class KeycloakAuthorizationConfigError(KeycloakOperationError): + """Keycloak authorization config exception.""" + + +class KeycloakInvalidTokenError(KeycloakOperationError): + """Keycloak invalid token exception.""" + + +class KeycloakPermissionFormatError(KeycloakOperationError): + """Keycloak permission format exception.""" + + +class PermissionDefinitionError(Exception): + """Keycloak permission definition exception.""" + + +def raise_error_from_response( + response: Response | AsyncResponse, + error: type[ + KeycloakGetError + | KeycloakPostError + | KeycloakDeprecationError + | KeycloakPutError + | KeycloakDeleteError + ] + | dict + | Exception, + expected_codes: list[int] | None = None, + skip_exists: bool = False, +) -> bytes | dict | list: + """ + Raise an exception for the response. + + :param response: The response object + :type response: Response + :param error: Error object to raise + :type error: dict or Exception + :param expected_codes: Set of expected codes, which should not raise the exception + :type expected_codes: Sequence[int] + :param skip_exists: Indicates whether the response on already existing object should be ignored + :type skip_exists: bool + + :returns: Content of the response message + :type: bytes or dict + :raises KeycloakError: In case of unexpected status codes + """ + if expected_codes is None: + expected_codes = [HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT] + + if response.status_code in expected_codes: + if response.status_code == requests.codes.no_content: + return {} + + try: + return response.json() + except ValueError: + return response.content + + if skip_exists and response.status_code == HTTP_CONFLICT: + return {"msg": "Already exists"} + + try: + message = response.json()["message"] + except (KeyError, ValueError): + message = response.content + + if isinstance(error, dict): + error = error.get(response.status_code, KeycloakOperationError) + elif response.status_code == HTTP_UNAUTHORIZED: + error = KeycloakAuthenticationError # pyright: ignore[reportAssignmentType] + + raise error( # pyright: ignore[reportCallIssue] + error_message=message, + response_code=response.status_code, + response_body=response.content, + ) diff --git a/src/keycloak/keycloak_admin.py b/src/keycloak/keycloak_admin.py new file mode 100644 index 00000000..6579e931 --- /dev/null +++ b/src/keycloak/keycloak_admin.py @@ -0,0 +1,14664 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# Unless otherwise stated in the comments, "id", in e.g. user_id, refers to the +# internal Keycloak server ID, usually a uuid string + +"""The keycloak admin module.""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +from requests_toolbelt import MultipartEncoder + +from . import urls_patterns +from .exceptions import ( + HTTP_ACCEPTED, + HTTP_BAD_REQUEST, + HTTP_CONFLICT, + HTTP_CREATED, + HTTP_NO_CONTENT, + HTTP_OK, + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, + raise_error_from_response, +) +from .openid_connection import KeycloakOpenIDConnection + + +class KeycloakAdmin: + """ + Keycloak Admin client. + + :param server_url: Keycloak server url + :type server_url: str + :param username: admin username + :type username: str + :param password: admin password + :type password: str + :param token: access and refresh tokens + :type token: dict + :param totp: Time based OTP + :type totp: str + :param realm_name: realm name + :type realm_name: str + :param client_id: client id + :type client_id: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param client_secret_key: client secret key + (optional, required only for access type confidential) + :type client_secret_key: str + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param user_realm_name: The realm name of the user, if different from realm_name + :type user_realm_name: str + :param timeout: connection timeout in seconds + :type timeout: int + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of + (certificate file, key file). + :type cert: Union[str,Tuple[str,str]] + :param max_retries: The total number of times to retry HTTP requests. + :type max_retries: int + :param connection: A KeycloakOpenIDConnection as an alternative to individual params. + :type connection: KeycloakOpenIDConnection + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + + PAGE_SIZE = 100 + + def __init__( + self, + server_url: str | None = None, + grant_type: str | None = None, + username: str | None = None, + password: str | None = None, + token: dict | None = None, + totp: int | None = None, + realm_name: str | None = "master", + client_id: str = "admin-cli", + verify: bool | str = True, + client_secret_key: str | None = None, + custom_headers: dict | None = None, + user_realm_name: str | None = None, + timeout: int = 60, + cert: str | tuple | None = None, + max_retries: int = 1, + connection: KeycloakOpenIDConnection | None = None, + pool_maxsize: int | None = None, + ) -> None: + """ + Init method. + + :param server_url: Keycloak server url + :type server_url: str + :param grant_type: grant type for authn + :type grant_type: str + :param username: admin username + :type username: str + :param password: admin password + :type password: str + :param token: access and refresh tokens + :type token: dict + :param totp: Time based OTP + :type totp: str + :param realm_name: realm name + :type realm_name: str + :param client_id: client id + :type client_id: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param client_secret_key: client secret key + (optional, required only for access type confidential) + :type client_secret_key: str + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param user_realm_name: The realm name of the user, if different from realm_name + :type user_realm_name: str + :param timeout: connection timeout in seconds + :type timeout: int + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of (certificate file, key file). + :type cert: Union[str,Tuple[str,str]] + :param max_retries: The total number of times to retry HTTP requests. + :type max_retries: int + :param connection: An OpenID Connection as an alternative to individual params. + :type connection: KeycloakOpenIDConnection + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + self.connection = connection or KeycloakOpenIDConnection( + server_url=server_url, + grant_type=grant_type, + username=username, + password=password, + token=token, + totp=totp, + realm_name=realm_name, + client_id=client_id, + verify=verify, + client_secret_key=client_secret_key, + user_realm_name=user_realm_name, + custom_headers=custom_headers, + timeout=timeout, + cert=cert, + max_retries=max_retries, + pool_maxsize=pool_maxsize, + ) + + @property + def connection(self) -> KeycloakOpenIDConnection: + """ + Get connection. + + :returns: Connection manager + :rtype: KeycloakOpenIDConnection + """ + return self._connection + + @connection.setter + def connection(self, value: KeycloakOpenIDConnection) -> None: + self._connection = value + + def __fetch_all(self, url: str, query: dict | None = None) -> list: + """ + Paginate over get requests. + + Wrapper function to paginate GET requests. + + :param url: The url on which the query is executed + :type url: str + :param query: Existing query parameters (optional) + :type query: dict + + :return: Combined results of paginated queries + :rtype: list + """ + results = [] + + # initialize query if it was called with None + if not query: + query = {} + + page = 0 + query["max"] = self.PAGE_SIZE + + # fetch until we can + while True: + query["first"] = page * self.PAGE_SIZE + partial_results = raise_error_from_response( + self.connection.raw_get(url, **query), + KeycloakGetError, + ) + if not partial_results: + break + results.extend(partial_results) + if len(partial_results) < query["max"]: + break + page += 1 + + return results + + def __fetch_paginated(self, url: str, query: dict | None = None) -> list: + """ + Make a specific paginated request. + + :param url: The url on which the query is executed + :type url: str + :param query: Pagination settings + :type query: dict + :returns: Response + :rtype: dict + """ + query = query or {} + res = raise_error_from_response(self.connection.raw_get(url, **query), KeycloakGetError) + if not isinstance(res, list): + msg = ( + f"Unexpected response type. Expected list, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_current_realm(self) -> str | None: + """ + Return the currently configured realm. + + :returns: Currently configured realm name + :rtype: str | None + """ + return self.connection.realm_name + + def change_current_realm(self, realm_name: str) -> None: + """ + Change the current realm. + + :param realm_name: The name of the realm to be configured as current + :type realm_name: str + """ + self.connection.realm_name = realm_name + + def import_realm(self, payload: dict) -> bytes: + """ + Import a new realm from a RealmRepresentation. + + Realm name must be unique. + + RealmRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param payload: RealmRepresentation + :type payload: dict + :return: RealmRepresentation + :rtype: bytes + """ + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALMS, + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + f"Unexpected response type. Expected bytes, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def partial_import_realm(self, realm_name: str, payload: dict) -> dict: + """ + Partial import realm configuration from PartialImportRepresentation. + + Realm partialImport is used for modifying configuration of existing realm. + + PartialImportRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_partialimportrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :param payload: PartialImportRepresentation + :type payload: dict + + :return: PartialImportResponse + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_PARTIAL_IMPORT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_OK]) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def export_realm( + self, + export_clients: bool = False, + export_groups_and_role: bool = False, + ) -> dict: + """ + Export the realm configurations in the json format. + + RealmRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_partialexport + + :param export_clients: Skip if not want to export realm clients + :type export_clients: bool + :param export_groups_and_role: Skip if not want to export realm groups and roles + :type export_groups_and_role: bool + + :return: realm configurations JSON + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "export-clients": export_clients, + "export-groups-and-roles": export_groups_and_role, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_EXPORT.format(**params_path), + data="", + exportClients=export_clients, + exportGroupsAndRoles=export_groups_and_role, + ) + res = raise_error_from_response(data_raw, KeycloakPostError) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_realms(self) -> list: + """ + List all realms in Keycloak deployment. + + :return: realms list + :rtype: list + """ + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_REALMS) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + f"Unexpected response type. Expected list, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_realm(self, realm_name: str) -> dict: + """ + Get a specific realm. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :return: RealmRepresentation + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_REALM.format(**params_path)) + res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK]) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_realm(self, payload: dict, skip_exists: bool = False) -> bytes: + """ + Create a realm. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param payload: RealmRepresentation + :type payload: dict + :param skip_exists: Skip if Realm already exist. + :type skip_exists: bool + :return: Keycloak server response (RealmRepresentation) + :rtype: dict + """ + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALMS, + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED] + + ([HTTP_BAD_REQUEST, HTTP_CONFLICT] if skip_exists else []), + ) + if isinstance(res, dict) and res in [ + {"msg": "Already exists"}, + {"errorMessage": "Realm test already exists"}, + {"errorMessage": "Conflict detected. See logs for details"}, + ]: + return json.dumps(res).encode() + + if not isinstance(res, bytes): + msg = ( + f"Unexpected response type. Expected bytes, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_realm(self, realm_name: str, payload: dict) -> dict: + """ + Update a realm. + + This will only update top level attributes and will ignore any user, + role, or client information in the payload. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :param payload: RealmRepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_NO_CONTENT], + ) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_realm(self, realm_name: str) -> dict: + """ + Delete a realm. + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_REALM.format(**params_path)) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + expected_codes=[HTTP_NO_CONTENT], + ) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_organizations(self, query: dict | None = None) -> list: + """ + Fetch all organizations. + + Returns a list of organizations, filtered according to query parameters + + OrganizationRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :return: List of organizations + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_ORGANIZATIONS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + async def a_get_organizations(self, query: dict | None = None) -> list: + """ + Fetch all organizations asynchronously. + + Returns a list of organizations, filtered according to query parameters + + OrganizationRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :return: List of organizations + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_ORGANIZATIONS.format(**params_path) + + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + def get_organization(self, organization_id: str) -> dict: + """ + Get representation of the organization. + + OrganizationRepresentation: + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + + :return: Organization details + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ORGANIZATION_BY_ID.format(**params_path) + ) + + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + async def a_get_organization(self, organization_id: str) -> dict: + """ + Get representation of the organization asynchronously. + + OrganizationRepresentation: + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + + :return: Organization details + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ORGANIZATION_BY_ID.format(**params_path) + ) + + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, dict): + msg = ( + f"Unexpected response type. Expected dict, received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_organization(self, payload: dict) -> str | None: + """ + Create a new organization. + + Organization name and alias must be unique. + + OrganizationRepresentation: + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param payload: Dictionary containing organization details + :type payload: dict + :return: org_id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_ORGANIZATIONS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + try: + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + except KeyError: + return None + + async def a_create_organization(self, payload: dict) -> str | None: + """ + Create a new organization asynchronously. + + Organization name and alias must be unique. + + OrganizationRepresentation: + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param payload: Dictionary containing organization details + :type payload: dict + :return: org_id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_ORGANIZATIONS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + try: + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + except KeyError: + return None + + def update_organization(self, organization_id: str, payload: dict) -> dict: + """ + Update an existing organization. + + OrganizationRepresentation: + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + :param payload: Dictionary with updated organization details + :type payload: dict + :return: Response from Keycloak + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_ORGANIZATION_BY_ID.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, KeycloakPutError, 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_update_organization(self, organization_id: str, payload: dict) -> dict: + """ + Update an existing organization asynchronously. + + OrganizationRepresentation: + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + :param payload: Dictionary with updated organization details + :type payload: dict + :return: Response from Keycloak + :rtype: dict | bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_ORGANIZATION_BY_ID.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, KeycloakPutError, 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 delete_organization(self, organization_id: str) -> dict: + """ + Delete an organization. + + :param organization_id: ID of the organization + :type organization_id: str + :return: Response from Keycloak + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_ORGANIZATION_BY_ID.format(**params_path) + ) + + res = raise_error_from_response( + data_raw, KeycloakDeleteError, 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_delete_organization(self, organization_id: str) -> dict: + """ + Delete an organization asynchronously. + + :param organization_id: ID of the organization + :type organization_id: str + :return: Response from Keycloak + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_ORGANIZATION_BY_ID.format(**params_path) + ) + + res = raise_error_from_response( + data_raw, KeycloakDeleteError, 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 get_organization_idps(self, organization_id: str) -> list: + """ + Get IDPs by organization id. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#IdentityProviderRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + :return: List of IDPs in the organization + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ORGANIZATION_IDPS.format(**params_path) + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_organization_idps(self, organization_id: str) -> list: + """ + Get IDPs by organization id asynchronously. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#IdentityProviderRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + :return: List of IDPs in the organization + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ORGANIZATION_IDPS.format(**params_path) + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def organization_idp_add(self, organization_id: str, idp_alias: str) -> dict: + """ + Add an IDP to an organization. + + :param organization_id: ID of the organization + :type organization_id: str + :param idp_alias: Alias of the IDP + :type idp_alias: str + :return: Response from Keycloak + :rtype: dict | bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_ORGANIZATION_IDPS.format(**params_path), data=idp_alias + ) + res = raise_error_from_response( + data_raw, KeycloakPostError, 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_organization_idp_add(self, organization_id: str, idp_alias: str) -> dict: + """ + Add an IDP to an organization asynchronously. + + :param organization_id: ID of the organization + :type organization_id: str + :param idp_alias: Alias of the IDP + :type idp_alias: str + :return: Response from Keycloak + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_ORGANIZATION_IDPS.format(**params_path), data=idp_alias + ) + res = raise_error_from_response( + data_raw, KeycloakPostError, 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 organization_idp_remove(self, organization_id: str, idp_alias: str) -> dict: + """ + Remove an IDP from an organization. + + :param organization_id: ID of the organization + :type organization_id: str + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + "idp_alias": idp_alias, + } + + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_ORGANIZATION_IDP_BY_ALIAS.format(**params_path) + ) + + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_organization_idp_remove(self, organization_id: str, idp_alias: str) -> dict: + """ + Remove an IDP from an organization asynchronously. + + :param organization_id: ID of the organization + :type organization_id: str + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + "idp_alias": idp_alias, + } + + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_ORGANIZATION_IDP_BY_ALIAS.format(**params_path) + ) + + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_user_organizations(self, user_id: str) -> list: + """ + Get organizations by user id. + + OrganizationRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param user_id: ID of the user + :type user_id: str + :return: List of organizations the user is member of + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "user_id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_ORGANIZATIONS.format(**params_path) + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_user_organizations(self, user_id: str) -> list: + """ + Get organizations by user id asynchronously. + + OrganizationRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#OrganizationRepresentation + + :param user_id: ID of the user + :type user_id: str + :return: List of organizations the user is member of + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "user_id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_ORGANIZATIONS.format(**params_path) + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_organization_members(self, organization_id: str, query: dict | None = None) -> list: + """ + Get members by organization id. + + Returns organization members, filtered according to query parameters + + MemberRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#MemberRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#_organizations) + :type query: dict + :return: List of users in the organization + :rtype: list + """ + query = query or {} + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + url = urls_patterns.URL_ADMIN_ORGANIZATION_MEMBERS.format(**params_path) + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + async def a_get_organization_members( + self, organization_id: str, query: dict | None = None + ) -> list: + """ + Get members by organization id asynchronously. + + Returns organization members, filtered according to query parameters + + MemberRepresentation + https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#MemberRepresentation + + :param organization_id: ID of the organization + :type organization_id: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/26.1.4/rest-api/index.html#_organizations) + :type query: dict + :return: List of users in the organization + :rtype: list + """ + query = query or {} + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + url = urls_patterns.URL_ADMIN_ORGANIZATION_MEMBERS.format(**params_path) + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + def get_organization_members_count(self, organization_id: str) -> int: + """ + Get the number of members in the organization. + + :param organization_id: ID of the organization + :type organization_id: str + :return: Number of members in the organization + :rtype: int + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ORGANIZATION_MEMBERS_COUNT.format(**params_path) + ) + res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK]) + if not isinstance(res, int): + msg = ( + f"Unexpected response type. Expected 'int', received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + async def a_get_organization_members_count(self, organization_id: str) -> int: + """ + Get the number of members in the organization asynchronously. + + :param organization_id: ID of the organization + :type organization_id: str + :return: Number of members in the organization + :rtype: int + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ORGANIZATION_MEMBERS_COUNT.format(**params_path) + ) + res = raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[HTTP_OK]) + if not isinstance(res, int): + msg = ( + f"Unexpected response type. Expected 'int', received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def organization_user_add(self, user_id: str, organization_id: str) -> bytes: + """ + Add a user to an organization. + + :param user_id: ID of the user to be added + :type user_id: str + :param organization_id: ID of the organization + :type organization_id: str + :return: Response from Keycloak + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_ORGANIZATION_MEMBERS.format(**params_path), data=user_id + ) + res = raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + 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_organization_user_add(self, user_id: str, organization_id: str) -> bytes: + """ + Add a user to an organization asynchronously. + + :param user_id: ID of the user to be added + :type user_id: str + :param organization_id: ID of the organization + :type organization_id: str + :return: Response from Keycloak + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + } + + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_ORGANIZATION_MEMBERS.format(**params_path), data=user_id + ) + res = raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def organization_user_remove(self, user_id: str, organization_id: str) -> dict: + """ + Remove a user from an organization. + + :param user_id: ID of the user to be removed + :type user_id: str + :param organization_id: ID of the organization + :type organization_id: str + :return: Response from Keycloak + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + "user_id": user_id, + } + + url = urls_patterns.URL_ADMIN_ORGANIZATION_DEL_MEMBER_BY_ID.format(**params_path) + data_raw = self.connection.raw_delete(url) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_organization_user_remove(self, user_id: str, organization_id: str) -> dict: + """ + Remove a user from an organization asynchronously. + + :param user_id: ID of the user to be removed + :type user_id: str + :param organization_id: ID of the organization + :type organization_id: str + :return: Response from Keycloak + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "organization_id": organization_id, + "user_id": user_id, + } + + url = urls_patterns.URL_ADMIN_ORGANIZATION_DEL_MEMBER_BY_ID.format(**params_path) + data_raw = await self.connection.a_raw_delete(url) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_users(self, query: dict | None = None) -> list: + """ + Get all users. + + Return a list of users, filtered according to query parameters + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param query: Query parameters (optional) + :type query: dict + :return: users list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_USERS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def create_idp(self, payload: dict) -> bytes: + """ + Create an ID Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :param: payload: IdentityProviderRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_IDPS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_idp(self, idp_alias: str, payload: dict) -> dict: + """ + Update an ID Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identity_providers_resource + + :param: idp_alias: alias for IdP to update + :type idp_alias: str + :param: payload: The IdentityProviderRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_IDP.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 add_mapper_to_idp(self, idp_alias: str, payload: dict) -> bytes: + """ + Create an ID Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityprovidermapperrepresentation + + :param: idp_alias: alias for Idp to add mapper in + :type idp_alias: str + :param: payload: IdentityProviderMapperRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "idp-alias": idp_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_mapper_in_idp(self, idp_alias: str, mapper_id: str, payload: dict) -> dict: + """ + Update an IdP mapper. + + IdentityProviderMapperRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_update + + :param: idp_alias: alias for Idp to fetch mappers + :type idp_alias: str + :param: mapper_id: Mapper Id to update + :type mapper_id: str + :param: payload: IdentityProviderMapperRepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "idp-alias": idp_alias, + "mapper-id": mapper_id, + } + + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_IDP_MAPPER_UPDATE.format(**params_path), + data=json.dumps(payload), + ) + + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 get_idp_mappers(self, idp_alias: str) -> list: + """ + Get IDP mappers. + + Returns a list of ID Providers mappers + + IdentityProviderMapperRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getmappers + + :param: idp_alias: alias for Idp to fetch mappers + :type idp_alias: str + :return: array IdentityProviderMapperRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "idp-alias": idp_alias} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_idps(self) -> list: + """ + Get IDPs. + + Returns a list of ID Providers, + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :return: array IdentityProviderRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_IDPS.format(**params_path)) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_idp(self, idp_alias: str) -> dict: + """ + Get IDP provider. + + Get the representation of a specific IDP Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :param: idp_alias: alias for IdP to get + :type idp_alias: str + :return: IdentityProviderRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_IDP.format(**params_path)) + 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 + + def delete_idp(self, idp_alias: str) -> dict: + """ + Delete an ID Provider. + + :param: idp_alias: idp alias name + :type idp_alias: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_IDP.format(**params_path)) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 create_user(self, payload: dict, exist_ok: bool = False) -> str: + """ + Create a new user. + + Username must be unique + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param payload: UserRepresentation + :type payload: dict + :param exist_ok: If False, raise KeycloakGetError if username already exists. + Otherwise, return existing user ID. + :type exist_ok: bool + + :return: user_id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + + if exist_ok: + exists = self.get_user_id(username=payload["username"]) + + if exists is not None: + return str(exists) + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USERS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + def users_count(self, query: dict | None = None) -> int: + """ + Count users. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_users_resource + + :param query: (dict) Query parameters for users count + :type query: dict + + :return: counter + :rtype: int + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USERS_COUNT.format(**params_path), + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, int): + msg = ( + f"Unexpected response type. Expected 'int', received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_user_id(self, username: str) -> str | None: + """ + Get internal keycloak user id from username. + + This is required for further actions against this user. + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param username: id in UserRepresentation + :type username: str + + :return: user_id + :rtype: str + """ + lower_user_name = username.lower() + users = self.get_users(query={"username": lower_user_name, "max": 1, "exact": True}) + return users[0]["id"] if len(users) == 1 else None + + def get_user(self, user_id: str, user_profile_metadata: bool = False) -> dict: + """ + Get representation of the user. + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param user_id: User id + :type user_id: str + :param user_profile_metadata: Whether to include user profile metadata in the response + :type user_profile_metadata: bool + :return: UserRepresentation + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER.format(**params_path), + userProfileMetadata=user_profile_metadata, + ) + 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 + + def get_user_groups( + self, + user_id: str, + query: dict | None = None, + brief_representation: bool = True, + ) -> list: + """ + Get user groups. + + Returns a list of groups of which the user is a member + + :param user_id: User id + :type user_id: str + :param query: Additional query options + :type query: dict + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: user groups list + :rtype: list + """ + query = query or {} + + params = {"briefRepresentation": brief_representation} + + query.update(params) + + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + + url = urls_patterns.URL_ADMIN_USER_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def update_user(self, user_id: str, payload: dict) -> dict: + """ + Update the user. + + :param user_id: User id + :type user_id: str + :param payload: UserRepresentation + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_USER.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 disable_user(self, user_id: str) -> dict: + """ + Disable the user from the realm. Disabled users can not log in. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: dict + """ + return self.update_user(user_id=user_id, payload={"enabled": False}) + + def enable_user(self, user_id: str) -> dict: + """ + Enable the user from the realm. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: dict + """ + return self.update_user(user_id=user_id, payload={"enabled": True}) + + def disable_all_users(self) -> None: + """Disable all existing users.""" + users = self.get_users() + for user in users: + user_id = user["id"] + self.disable_user(user_id=user_id) + + def enable_all_users(self) -> None: + """Disable all existing users.""" + users = self.get_users() + for user in users: + user_id = user["id"] + self.enable_user(user_id=user_id) + + def delete_user(self, user_id: str) -> dict: + """ + Delete the user. + + :param user_id: User id + :type user_id: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_USER.format(**params_path)) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 set_user_password( + self, + user_id: str, + password: str, + temporary: bool = True, + ) -> dict: + """ + Set up a password for the user. + + If temporary is True, the user will have to reset + the temporary password next time they log in. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_users_resource + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_credentialrepresentation + + :param user_id: User id + :type user_id: str + :param password: New password + :type password: str + :param temporary: True if password is temporary + :type temporary: bool + :returns: Response + :rtype: dict + """ + payload = {"type": "password", "temporary": temporary, "value": password} + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_RESET_PASSWORD.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 get_credentials(self, user_id: str) -> list: + """ + Get user credentials. + + Returns a list of credential belonging to the user. + + CredentialRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_credentialrepresentation + + :param: user_id: user id + :type user_id: str + :returns: Keycloak server response (CredentialRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_CREDENTIALS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_credential(self, user_id: str, credential_id: str) -> dict: + """ + Delete credential of the user. + + CredentialRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_credentialrepresentation + + :param: user_id: user id + :type user_id: str + :param: credential_id: credential id + :type credential_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "credential_id": credential_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_CREDENTIAL.format(**params_path), + ) + 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 user_logout(self, user_id: str) -> dict: + """ + Log out the user. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_logout + + :param user_id: User id + :type user_id: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_LOGOUT.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 user_consents(self, user_id: str) -> list: + """ + Get consents granted by the user. + + UserConsentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userconsentrepresentation + + :param user_id: User id + :type user_id: str + :returns: List of UserConsentRepresentations + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_CONSENTS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def revoke_consent(self, user_id: str, client_id: str) -> dict: + """ + Revoke consent and offline tokens for particular client from user. + + :param user_id: User id + :type user_id: str + :param client_id: Client id + :type client_id: str + + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_CONSENT.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_user_social_logins(self, user_id: str) -> list: + """ + Get user social logins. + + Returns a list of federated identities/social logins of which the user has been associated + with + :param user_id: User id + :type user_id: str + :returns: Federated identities list + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITIES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def add_user_social_login( + self, + user_id: str, + provider_id: str, + provider_userid: str, + provider_username: str, + ) -> dict: + """ + Add a federated identity / social login provider to the user. + + :param user_id: User id + :type user_id: str + :param provider_id: Social login provider id + :type provider_id: str + :param provider_userid: userid specified by the provider + :type provider_userid: str + :param provider_username: username specified by the provider + :type provider_username: str + :returns: Keycloak server response + :rtype: bytes + """ + payload = { + "identityProvider": provider_id, + "userId": provider_userid, + "userName": provider_username, + } + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "provider": provider_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED, 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 delete_user_social_login(self, user_id: str, provider_id: str) -> dict: + """ + Delete a federated identity / social login provider from the user. + + :param user_id: User id + :type user_id: str + :param provider_id: Social login provider id + :type provider_id: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "provider": provider_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 send_update_account( + self, + user_id: str, + payload: list, + client_id: str | None = None, + lifespan: int | None = None, + redirect_uri: str | None = None, + ) -> dict: + """ + Send an update account email to the user. + + An email contains a link the user can click to perform a set of required actions. + + :param user_id: User id + :type user_id: str + :param payload: A list of actions for the user to complete + :type payload: list + :param client_id: Client id (optional) + :type client_id: str + :param lifespan: Number of seconds after which the generated token expires (optional) + :type lifespan: int + :param redirect_uri: The redirect uri (optional) + :type redirect_uri: str + + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params_query = {"client_id": client_id, "lifespan": lifespan, "redirect_uri": redirect_uri} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), + data=json.dumps(payload), + **params_query, + ) + 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 + + def send_verify_email( + self, + user_id: str, + client_id: str | None = None, + redirect_uri: str | None = None, + ) -> dict: + """ + Send a update account email to the user. + + An email contains a link the user can click to perform a set of required actions. + + :param user_id: User id + :type user_id: str + :param client_id: Client id (optional) + :type client_id: str + :param redirect_uri: Redirect uri (optional) + :type redirect_uri: str + + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params_query = {"client_id": client_id, "redirect_uri": redirect_uri} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), + data={}, + **params_query, + ) + 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 + + def get_sessions(self, user_id: str) -> list: + """ + Get sessions associated with the user. + + UserSessionRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_usersessionrepresentation + + :param user_id: Id of user + :type user_id: str + :return: UserSessionRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GET_SESSIONS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_server_info(self) -> dict: + """ + Get themes, social providers, etc. on this server. + + ServerInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :return: ServerInfoRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_SERVER_INFO) + 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 + + def get_groups(self, query: dict | None = None, full_hierarchy: bool = False) -> list: + """ + Get groups. + + Returns a list of groups belonging to the realm + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + Notice that when using full_hierarchy=True, the response will be a nested structure + containing all the children groups. If used with query parameters, the full_hierarchy + will be applied to the received groups only. + + :param query: Additional query options + :type query: dict + :param full_hierarchy: If True, return all of the nested children groups, otherwise only + the first level children are returned + :type full_hierarchy: bool + :return: array GroupRepresentation + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + groups = self.__fetch_paginated(url, query) + else: + groups = self.__fetch_all(url, query) + + # For version +23.0.0 + for group in groups: + if group.get("subGroupCount"): + group["subGroups"] = self.get_group_children( + group_id=group.get("id"), + full_hierarchy=full_hierarchy, + query=query, + ) + + return groups + + def get_group( + self, group_id: str, full_hierarchy: bool = False, query: dict | None = None + ) -> dict: + """ + Get group by id. + + Returns full group details + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group_id: The group id + :type group_id: str + :param full_hierarchy: If True, return all of the nested children groups, otherwise only + the first level children are returned + :type full_hierarchy: bool + :param query: Additional query parameters passed into the subgroup fetch requests + :type query: dict | None + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + response = self.connection.raw_get(urls_patterns.URL_ADMIN_GROUP.format(**params_path)) + + if response.status_code >= HTTP_BAD_REQUEST: + raise_error_from_response(response, KeycloakGetError) + + # For version +23.0.0 + group = response.json() + if group.get("subGroupCount"): + group["subGroups"] = self.get_group_children( + group.get("id"), full_hierarchy=full_hierarchy, query=query + ) + + return group + + def get_subgroups(self, group: dict, path: str) -> dict | None: + """ + Get subgroups. + + Utility function to iterate through nested group structures + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group: group (GroupRepresentation) + :type group: dict + :param path: group path (string) + :type path: str + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + for subgroup in group["subGroups"]: + if subgroup["path"] == path: + return subgroup + if subgroup["subGroups"]: + for _subgroup in group["subGroups"]: + result = self.get_subgroups(_subgroup, path) + if result: + return result + + # went through the tree without hits + return None + + def get_group_children( + self, + group_id: str, + query: dict | None = None, + full_hierarchy: bool = False, + ) -> list: + """ + Get group children by parent id. + + Returns full group children details + + :param group_id: The parent group id + :type group_id: str + :param query: Additional query options + :type query: dict + :param full_hierarchy: If True, return all of the nested children groups + :type full_hierarchy: bool + :return: Keycloak server response (GroupRepresentation) + :rtype: list + :raises ValueError: If both query and full_hierarchy parameters are used + """ + query = query or {} + if query and full_hierarchy: + msg = "Cannot use both query and full_hierarchy parameters" + raise ValueError(msg) + + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + url = urls_patterns.URL_ADMIN_GROUP_CHILD.format(**params_path) + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + res = self.__fetch_all(url, query) + + if not full_hierarchy: + return res + + for group in res: + if group.get("subGroupCount"): + group["subGroups"] = self.get_group_children( + group_id=group.get("id"), + full_hierarchy=full_hierarchy, + query=query, + ) + + return res + + def get_group_members(self, group_id: str, query: dict | None = None) -> list: + """ + Get members by group id. + + Returns group members + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_userrepresentation + + :param group_id: The group id + :type group_id: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getmembers) + :type query: dict + :return: Keycloak server response (UserRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + url = urls_patterns.URL_ADMIN_GROUP_MEMBERS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def get_group_by_path(self, path: str) -> dict: + """ + Get group id based on name or path. + + Returns full group details for a group defined by path + + Raises an `KeycloakGetError` if the group was not found. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param path: group path + :type path: str + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "path": path} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUP_BY_PATH.format(**params_path), + ) + # PR https://github.com/marcospereirampj/python-keycloak/pull/627 + # added `HTTP_NOT_FOUND` to the `expected_codes` argument. + # This change has since been reverted, see: + # https://github.com/marcospereirampj/python-keycloak/issues/675 + 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 + + def create_group( + self, + payload: dict, + parent: str | None = None, + skip_exists: bool = False, + ) -> str | None: + """ + Create a group in the Realm. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param payload: GroupRepresentation + :type payload: dict + :param parent: parent group's id. Required to create a sub-group. + :type parent: str + :param skip_exists: If true then do not raise an error if it already exists + :type skip_exists: bool + + :return: Group id for newly created group or None for an existing group + :rtype: str + """ + if parent is None: + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUPS.format(**params_path), + data=json.dumps(payload), + ) + else: + params_path = {"realm-name": self.connection.realm_name, "id": parent} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUP_CHILD.format(**params_path), + data=json.dumps(payload), + ) + + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + try: + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + except KeyError: + return None + + def update_group(self, group_id: str, payload: dict) -> dict: + """ + Update group, ignores subgroups. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group_id: id of group + :type group_id: str + :param payload: GroupRepresentation with updated information. + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_GROUP.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 groups_count(self, query: dict | None = None) -> dict: + """ + Count groups. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_groups + + :param query: (dict) Query parameters for groups count + :type query: dict + + :return: Keycloak Server Response + :rtype: dict + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_COUNT.format(**params_path), + **query, + ) + 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 + + def group_set_permissions(self, group_id: str, enabled: bool = True) -> dict: + """ + Enable/Disable permissions for a group. + + Cannot delete group if disabled + + :param group_id: id of group + :type group_id: str + :param enabled: Enabled flag + :type enabled: bool + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), + data=json.dumps({"enabled": enabled}), + ) + 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 + + def group_user_add(self, user_id: str, group_id: str) -> dict: + """ + Add user to group (user_id and group_id). + + :param user_id: id of user + :type user_id: str + :param group_id: id of group to add to + :type group_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "group-id": group_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path), + data=None, + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 group_user_remove(self, user_id: str, group_id: str) -> dict: + """ + Remove user from group (user_id and group_id). + + :param user_id: id of user + :type user_id: str + :param group_id: id of group to remove from + :type group_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "group-id": group_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 delete_group(self, group_id: str) -> dict: + """ + Delete a group in the Realm. + + :param group_id: id of group to delete + :type group_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_GROUP.format(**params_path)) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_clients(self) -> list: + """ + Get clients. + + Returns a list of clients belonging to the realm + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :return: Keycloak server response (ClientRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_CLIENTS.format(**params_path)) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client(self, client_id: str) -> dict: + """ + Get representation of the client. + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_CLIENT.format(**params_path)) + 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 + + def get_client_id(self, client_id: str) -> str | None: + """ + Get internal keycloak client id from client-id. + + This is required for further actions against this client. + + :param client_id: clientId in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: client_id (uuid as string) + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path), + clientId=client_id, + ) + data_response = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(data_response, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(data_response)}', value '{data_response}'." + ) + raise TypeError(msg) + + for client in data_response: + if client_id == client.get("clientId"): + return client["id"] + + return None + + def get_client_authz_settings(self, client_id: str) -> dict: + """ + Get authorization json from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SETTINGS.format(**params_path), + ) + 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 + + def import_client_authz_config(self, client_id: str, payload: dict) -> dict: + """ + Import client authorization configuration. + + ResourceServerRepresentation + https://www.keycloak.org/docs-api/latest/rest-api/index.html#ResourceServerRepresentation + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceServerRepresentation + :type payload: dict + + :return: None + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_IMPORT.format(**params_path), + data=json.dumps(payload), + ) + + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 create_client_authz_resource( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create resources of client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type payload: dict + :param skip_exists: Skip the creation in case the resource exists + :type skip_exists: bool + + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + 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_authz_resource( + self, + client_id: str, + resource_id: str, + payload: dict, + ) -> dict: + """ + Update resource of client. + + Any parameter missing from the ResourceRepresentation in the payload WILL be set + to default by the Keycloak server. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_client_authz_resource(self, client_id: str, resource_id: str) -> dict: + """ + Delete a client resource. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_client_authz_resources(self, client_id: str) -> list: + """ + Get resources from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (ResourceRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), + max=-1, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_resource(self, client_id: str, resource_id: str) -> dict: + """ + Get a client resource. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response (ResourceRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + ) + 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) + + return res + + def create_client_authz_role_based_policy( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create role-based policy of client. + + Payload example:: + + payload={ + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "Policy-1", + "roles": [ + { + "id": id + } + ] + } + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: No Document + :type payload: dict + :param skip_exists: Skip creation in case the object exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: bytes + + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if not isinstance(res, dict): + msg = ( + "Unexpected response type. Expected 'dict', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_client_authz_policy( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create an authz policy of client. + + Payload example:: + + payload={ + "name": "Policy-time-based", + "type": "time", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "hourEnd": "18", + "hour": "9" + } + } + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: No Document + :type payload: dict + :param skip_exists: Skip creation in case the object exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: dict + + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path), + data=json.dumps(payload), + max=-1, + permission=False, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if not isinstance(res, dict): + msg = ( + "Unexpected response type. Expected 'dict', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_client_authz_resource_based_permission( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create resource-based permission of client. + + Payload example:: + + payload={ + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "Permission-Name", + "resources": [ + resource_id + ], + "policies": [ + policy_id + ] + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type payload: dict + :param skip_exists: Skip creation in case the object already exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: dict + + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if not isinstance(res, dict): + msg = ( + "Unexpected response type. Expected 'dict', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_scopes(self, client_id: str) -> list: + """ + Get scopes from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path), + max=-1, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_client_authz_scopes(self, client_id: str, payload: dict) -> dict: + """ + Create scopes for client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :param payload: ScopeRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_ScopeRepresentation + :type payload: dict + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 + + def get_client_authz_permissions(self, client_id: str) -> list: + """ + Get permissions from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS.format(**params_path), + max=-1, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_policies(self, client_id: str) -> list: + """ + Get policies from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path), + max=-1, + permission=False, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_client_authz_policy(self, client_id: str, policy_id: str) -> dict: + """ + Delete a policy from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_client_authz_policy(self, client_id: str, policy_id: str) -> dict: + """ + Get a policy from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path), + ) + 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 + + def get_client_service_account_user(self, client_id: str) -> dict: + """ + Get service account user from client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: UserRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER.format(**params_path), + ) + 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 + + def get_client_default_client_scopes(self, client_id: str) -> list: + """ + Get all default client scopes from client. + + :param client_id: id of the client in which the new default client scope should be added + :type client_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def add_client_default_client_scope( + self, + client_id: str, + client_scope_id: str, + payload: dict, + ) -> dict: + """ + Add a client scope to the default client scopes from client. + + Payload example:: + + payload={ + "realm":"testrealm", + "client":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "clientScopeId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + } + + :param client_id: id of the client in which the new default client scope should be added + :type client_id: str + :param client_scope_id: id of the new client scope that should be added + :type client_scope_id: str + :param payload: dictionary with realm, client and clientScopeId + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + 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 + + def delete_client_default_client_scope( + self, + client_id: str, + client_scope_id: str, + ) -> dict: + """ + Delete a client scope from the default client scopes of the client. + + :param client_id: id of the client in which the default client scope should be deleted + :type client_id: str + :param client_scope_id: id of the client scope that should be deleted + :type client_scope_id: str + + :return: list of client scopes with id and name + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path), + ) + 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 get_client_optional_client_scopes(self, client_id: str) -> list: + """ + Get all optional client scopes from client. + + :param client_id: id of the client in which the new optional client scope should be added + :type client_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def add_client_optional_client_scope( + self, + client_id: str, + client_scope_id: str, + payload: dict, + ) -> dict: + """ + Add a client scope to the optional client scopes from client. + + Payload example:: + + payload={ + "realm":"testrealm", + "client":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "clientScopeId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + } + + :param client_id: id of the client in which the new optional client scope should be added + :type client_id: str + :param client_scope_id: id of the new client scope that should be added + :type client_scope_id: str + :param payload: dictionary with realm, client and clientScopeId + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + 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 + + def delete_client_optional_client_scope( + self, + client_id: str, + client_scope_id: str, + ) -> dict: + """ + Delete a client scope from the optional client scopes of the client. + + :param client_id: id of the client in which the optional client scope should be deleted + :type client_id: str + :param client_scope_id: id of the client scope that should be deleted + :type client_scope_id: str + + :return: list of client scopes with id and name + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + ) + 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 create_initial_access_token(self, count: int = 1, expiration: int = 1) -> dict: + """ + Create an initial access token. + + :param count: Number of clients that can be registered + :type count: int + :param expiration: Days until expireation + :type expiration: int + :return: initial access token + :rtype: dict + """ + payload = {"count": count, "expiration": expiration} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_INITIAL_ACCESS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_OK]) + if not isinstance(res, dict): + msg = ( + "Unexpected response type. Expected 'dict', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_client(self, payload: dict, skip_exists: bool = False) -> str: + """ + Create a client. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param skip_exists: If true then do not raise an error if client already exists + :type skip_exists: bool + :param payload: ClientRepresentation + :type payload: dict + :return: Client ID + :rtype: str + """ + if skip_exists: + client_id = self.get_client_id(client_id=payload["clientId"]) + + if client_id is not None: + return client_id + + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + def update_client(self, client_id: str, payload: dict) -> dict: + """ + Update a client. + + :param client_id: Client id + :type client_id: str + :param payload: ClientRepresentation + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_client(self, client_id: str) -> dict: + """ + Get representation of the client. + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param client_id: keycloak client id (not oauth client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_CLIENT.format(**params_path)) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_client_installation_provider(self, client_id: str, provider_id: str) -> dict: + """ + Get content for given installation provider. + + Related documentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource + + Possible provider_id list available in the ServerInfoRepresentation#clientInstallations + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :param client_id: Client id + :type client_id: str + :param provider_id: provider id to specify response format + :type provider_id: str + :returns: Installation providers + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "provider-id": provider_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_INSTALLATION_PROVIDER.format(**params_path), + ) + 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) + + return res + + def get_realm_users_profile(self) -> dict: + """ + Get list of attributes and group for given realm. + + Related documentation: + https://www.keycloak.org/docs-api/26.0.0/rest-api/index.html#_get_adminrealmsrealmusersprofile + + Return https://www.keycloak.org/docs-api/26.0.0/rest-api/index.html#UPConfig + :returns: UPConfig + + """ + params_path = {"realm-name": self.connection.realm_name} + + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_USER_PROFILE.format(**params_path), + ) + 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) + + return res + + def get_realm_roles( + self, brief_representation: bool = True, search_text: str = "", query: dict | None = None + ) -> list: + """ + Get all roles for the realm or client. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :param search_text: optional search text to limit the returned result. + :type search_text: str + :param query: Query parameters (optional) + :type query: dict + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + params: dict[str, str | bool] = {"briefRepresentation": brief_representation} + url = urls_patterns.URL_ADMIN_REALM_ROLES.format(**params_path) + + if search_text is not None and search_text.strip() != "": + params["search"] = search_text + + if "first" in query and "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, params) + + def get_realm_role_groups( + self, + role_name: str, + query: dict | None = None, + brief_representation: bool = True, + ) -> list: + """ + Get role groups of realm by role name. + + :param role_name: Name of the role. + :type role_name: str + :param query: Additional Query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_parameters_226) + :type query: dict + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak Server Response (GroupRepresentation) + :rtype: list + """ + query = query or {} + + params = {"briefRepresentation": brief_representation} + + query.update(params) + + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + + url = urls_patterns.URL_ADMIN_REALM_ROLES_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def get_realm_role_members(self, role_name: str, query: dict | None = None) -> list: + """ + Get role members of realm by role name. + + :param role_name: Name of the role. + :type role_name: str + :param query: Additional Query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_roles_resource) + :type query: dict + :return: Keycloak Server Response (UserRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + return self.__fetch_all( + urls_patterns.URL_ADMIN_REALM_ROLES_MEMBERS.format(**params_path), + query, + ) + + def get_default_realm_role_id(self) -> str: + """ + Get the ID of the default realm role. + + :return: Realm role ID + :rtype: str + """ + all_realm_roles = self.get_realm_roles() + default_realm_roles = [ + realm_role + for realm_role in all_realm_roles + if realm_role["name"] == f"default-roles-{self.connection.realm_name}".lower() + ] + return default_realm_roles[0]["id"] + + def get_realm_default_roles(self) -> list: + """ + Get all the default realm roles. + + :return: Keycloak Server Response (UserRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": self.get_default_realm_role_id(), + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES_REALM.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def remove_realm_default_roles(self, payload: list) -> dict: + """ + Remove a set of default realm roles. + + :param payload: List of RoleRepresentations + :type payload: list + :return: Keycloak Server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": self.get_default_realm_role_id(), + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + 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 add_realm_default_roles(self, payload: list) -> dict: + """ + Add a set of default realm roles. + + :param payload: List of RoleRepresentations + :type payload: list + :return: Keycloak Server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": self.get_default_realm_role_id(), + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + 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 get_client_roles(self, client_id: str, brief_representation: bool = True) -> list: + """ + Get all roles for the client. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_role(self, client_id: str, role_name: str) -> dict: + """ + Get client role by name. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :return: Role object + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), + ) + 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 + + def get_client_role_id(self, client_id: str, role_name: str) -> str | None: + """ + Get client role id by name. + + This is required for further actions with this role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :return: role_id + :rtype: str + """ + role = self.get_client_role(client_id, role_name) + return role.get("id") + + def create_client_role( + self, + client_role_id: str, + payload: dict, + skip_exists: bool = False, + ) -> str: + """ + Create a client role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param payload: RoleRepresentation + :type payload: dict + :param skip_exists: If true then do not raise an error if client role already exists + :type skip_exists: bool + :return: Client role name + :rtype: str + """ + if skip_exists: + try: + res = self.get_client_role(client_id=client_role_id, role_name=payload["name"]) + return res["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name, "id": client_role_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + def add_composite_client_roles_to_role( + self, + client_role_id: str, + role_name: str, + roles: dict | list, + ) -> dict: + """ + Add composite roles to client role. + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be updated + :type roles: list + :return: Keycloak server response + :rtype: bytes + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 remove_composite_client_roles_from_role( + self, + client_role_id: str, + role_name: str, + roles: str | list, + ) -> dict: + """ + Remove composite roles from a client role. + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be removed + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 update_client_role(self, client_id: str, role_name: str, payload: dict) -> dict: + """ + Update a client role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :param payload: RoleRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_client_role(self, client_role_id: str, role_name: str) -> dict: + """ + Delete a client role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: role's name (not id!) + :type role_name: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 assign_client_role(self, user_id: str, client_id: str, roles: str | list) -> dict: + """ + Assign a client role to a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 get_client_role_members(self, client_id: str, role_name: str, **query: Any) -> list: # noqa: ANN401 + """ + Get members by client role. + + :param client_id: The client id + :type client_id: str + :param role_name: the name of role to be queried. + :type role_name: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource) + :type query: dict + :return: Keycloak server response (UserRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + return self.__fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_MEMBERS.format(**params_path), + query, + ) + + def get_client_role_groups(self, client_id: str, role_name: str, **query: Any) -> list: # noqa: ANN401 + """ + Get group members by client role. + + :param client_id: The client id + :type client_id: str + :param role_name: the name of role to be queried. + :type role_name: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource) + :type query: dict + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + return self.__fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_GROUPS.format(**params_path), + query, + ) + + def get_role_by_id(self, role_id: str) -> dict: + """ + Get a specific role's representation. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: id of role + :type role_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + ) + 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) + + return res + + def update_role_by_id(self, role_id: str, payload: dict) -> dict: + """ + Update the role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param payload: RoleRepresentation + :type payload: dict + :param role_id: id of role + :type role_id: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_role_by_id(self, role_id: str) -> dict: + """ + Delete a role by its id. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: id of role + :type role_id: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_role_composites_by_id(self, role_id: str, query: dict | None = None) -> list: + """ + Get all composite roles by role id. + + :param role_id: id of role + :type role_id: str + :param query: Query parameters (optional). Supported keys: 'first', 'max', 'search' + :type query: dict + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + url = urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path) + + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def create_realm_role(self, payload: dict, skip_exists: bool = False) -> str: + """ + Create a new role for the realm or client. + + :param payload: The role (use RoleRepresentation) + :type payload: dict + :param skip_exists: If true then do not raise an error if realm role already exists + :type skip_exists: bool + :return: Realm role name + :rtype: str + """ + if skip_exists: + try: + role = self.get_realm_role(role_name=payload["name"]) + return role["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + def get_realm_role(self, role_name: str) -> dict: + """ + Get realm role by role name. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_name: role's name, not id! + :type role_name: str + :return: role + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + ) + 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 + + def get_realm_role_by_id(self, role_id: str) -> dict: + """ + Get realm role by role id. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: role's id, not name! + :type role_id: str + :return: role + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + ) + 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 + + def update_realm_role(self, role_name: str, payload: dict) -> dict: + """ + Update a role for the realm by name. + + :param role_name: The name of the role to be updated + :type role_name: str + :param payload: The role (use RoleRepresentation) + :type payload: dict + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 update_realm_users_profile(self, payload: dict) -> dict: + """ + Update realm users profile for the current realm. + + :param up_config: List of attributes, groups, unmamagedAttributePolicy + + Related documentation: + https://www.keycloak.org/docs-api/26.0.0/rest-api/index.html#UPConfig + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REALM_USER_PROFILE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_OK], + ) + if not isinstance(res, dict): + msg = ( + "Unexpected response type. Expected 'dict', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_realm_role(self, role_name: str) -> dict: + """ + Delete a role for the realm by name. + + :param role_name: The role name + :type role_name: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 add_composite_realm_roles_to_role(self, role_name: str, roles: dict | list) -> dict: + """ + Add composite roles to the role. + + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be updated + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 remove_composite_realm_roles_to_role(self, role_name: str, roles: str | list) -> dict: + """ + Remove composite roles from the role. + + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be removed + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_composite_realm_roles_of_role(self, role_name: str) -> list: + """ + Get composite roles of the role. + + :param role_name: The name of the role + :type role_name: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_composite_client_roles_of_role(self, client_id: str, role_name: str) -> list: + """ + Get composite roles of the client role. + + :param client_id: The id of the client + :type client_id: str + :param role_name: The name of the role + :type role_name: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def assign_realm_roles_to_client_scope(self, client_id: str, roles: str | list) -> dict: + """ + Assign realm roles to a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 delete_realm_roles_of_client_scope(self, client_id: str, roles: str | list) -> dict: + """ + Delete realm roles of a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_realm_roles_of_client_scope(self, client_id: str) -> list: + """ + Get all realm roles for a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def assign_client_roles_to_client_scope( + self, + client_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Assign client roles to a client's dedicated scope. + + To assign roles to a client scope, use add_client_specific_roles_to_client_scope. + + :param client_id: id of client (not client-id) who is assigned the roles + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 delete_client_roles_of_client_scope( + self, + client_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles of a client's dedicated scope. + + To delete roles from a client scope, use remove_client_specific_roles_of_client_scope. + + :param client_id: id of client (not client-id) who is assigned the roles + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_client_roles_of_client_scope(self, client_id: str, client_roles_owner_id: str) -> list: + """ + Get all client roles for a client's dedicated scope. + + To get roles for a client scope, use get_client_specific_roles_of_client_scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def assign_realm_roles(self, user_id: str, roles: str | list) -> dict: + """ + Assign realm roles to a user. + + :param user_id: id of user + :type user_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 delete_realm_roles_of_user(self, user_id: str, roles: str | list) -> dict: + """ + Delete realm roles of a user. + + :param user_id: id of user + :type user_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_realm_roles_of_user(self, user_id: str) -> list: + """ + Get all realm roles for a user. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_available_realm_roles_of_user(self, user_id: str) -> list: + """ + Get all available (i.e. unassigned) realm roles for a user. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_AVAILABLE.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_composite_realm_roles_of_user( + self, + user_id: str, + brief_representation: bool = True, + ) -> list: + """ + Get all composite (i.e. implicit) realm roles for a user. + + :param user_id: id of user + :type user_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_COMPOSITE.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def assign_group_realm_roles(self, group_id: str, roles: str | list) -> dict: + """ + Assign realm roles to a group. + + :param group_id: id of group + :type group_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 delete_group_realm_roles(self, group_id: str, roles: str | list) -> dict: + """ + Delete realm roles of a group. + + :param group_id: id of group + :type group_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_group_realm_roles(self, group_id: str, brief_representation: bool = True) -> list: + """ + Get all realm roles for a group. + + :param group_id: id of the group + :type group_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def assign_group_client_roles(self, group_id: str, client_id: str, roles: str | list) -> dict: + """ + Assign client roles to a group. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 get_group_client_roles(self, group_id: str, client_id: str) -> list: + """ + Get client roles of a group. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_group_client_roles(self, group_id: str, client_id: str, roles: str | list) -> dict: + """ + Delete client roles of a group. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_all_roles_of_user(self, user_id: str) -> dict: + """ + Get all level roles for a user. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (MappingsRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_ALL_ROLES.format(**params_path), + ) + 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 + + def get_client_roles_of_user(self, user_id: str, client_id: str) -> list: + """ + Get all client roles for a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + return self._get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES, + user_id, + client_id, + ) + + def get_available_client_roles_of_user(self, user_id: str, client_id: str) -> list: + """ + Get available client role-mappings for a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + return self._get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, + user_id, + client_id, + ) + + def get_composite_client_roles_of_user( + self, + user_id: str, + client_id: str, + brief_representation: bool = False, + ) -> list: + """ + Get composite client role-mappings for a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params = {"briefRepresentation": brief_representation} + return self._get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, + user_id, + client_id, + **params, + ) + + def _get_client_roles_of_user( + self, + client_level_role_mapping_url: str, + user_id: str, + client_id: str, + **params: Any, # noqa: ANN401 + ) -> list: + """ + Get client roles of a single user helper. + + :param client_level_role_mapping_url: Url for the client role mapping + :type client_level_role_mapping_url: str + :param user_id: User id + :type user_id: str + :param client_id: Client id + :type client_id: str + :param params: Additional parameters + :type params: dict + :returns: Client roles of a user + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_get( + client_level_role_mapping_url.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_client_roles_of_user( + self, + user_id: str, + client_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles from a user. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client containing role (not client-id) + :type client_id: str + :param roles: roles list or role to delete (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_authentication_flows(self) -> list: + """ + Get authentication flows. + + Returns all flow details + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :return: Keycloak server response (AuthenticationFlowRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_FLOWS.format(**params_path)) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_authentication_flow_for_id(self, flow_id: str) -> dict: + """ + Get one authentication flow by it's id. + + Returns all flow details + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param flow_id: the id of a flow NOT it's alias + :type flow_id: str + :return: Keycloak server response (AuthenticationFlowRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "flow-id": flow_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_FLOWS_ALIAS.format(**params_path), + ) + 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 + + def create_authentication_flow(self, payload: dict, skip_exists: bool = False) -> bytes: + """ + Create a new authentication flow. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :param skip_exists: Do not raise an error if authentication flow already exists + :type skip_exists: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if isinstance(res, dict) and res == {"msg": "Already exists"}: + return json.dumps(res).encode() + + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_authentication_flow(self, flow_id: str, payload: dict) -> dict: + """ + Update an authentication flow. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param flow_id: The id of the flow + :type flow_id: str + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :return: Keycloak server response + :rtype: dict + """ + params_path = {"id": flow_id, "realm-name": self.connection.realm_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_FLOW.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_ACCEPTED, 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 copy_authentication_flow(self, payload: dict, flow_alias: str) -> bytes: + """ + Copy existing authentication flow under a new name. + + The new name is given as 'newName' attribute of the passed payload. + + :param payload: JSON containing 'newName' attribute + :type payload: dict + :param flow_alias: the flow alias + :type flow_alias: str + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_COPY.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_authentication_flow(self, flow_id: str) -> dict: + """ + Delete authentication flow. + + AuthenticationInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationinforepresentation + + :param flow_id: authentication flow id + :type flow_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": flow_id} + data_raw = self.connection.raw_delete(urls_patterns.URL_ADMIN_FLOW.format(**params_path)) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_authentication_flow_executions(self, flow_alias: str) -> list: + """ + Get authentication flow executions. + + Returns all execution steps + + :param flow_alias: the flow alias + :type flow_alias: str + :return: Response(json) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_authentication_flow_executions(self, payload: dict, flow_alias: str) -> dict: + """ + Update an authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param payload: AuthenticationExecutionInfoRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_ACCEPTED, 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 get_authentication_flow_execution(self, execution_id: str) -> dict: + """ + Get authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param execution_id: the execution ID + :type execution_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": execution_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path), + ) + 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 + + def create_authentication_flow_execution(self, payload: dict, flow_alias: str) -> bytes: + """ + Create an authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param payload: AuthenticationExecutionInfoRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_authentication_flow_execution(self, execution_id: str) -> dict: + """ + Delete authentication flow execution. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param execution_id: keycloak client id (not oauth client-id) + :type execution_id: str + :return: Keycloak server response (json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": execution_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 change_execution_priority(self, execution_id: str, diff: int) -> None: + """ + Raise or lower execution priority of diff time. + + :param execution_id: The ID of the execution + :type execution_id: str + :param diff: The difference in priority, positive to raise, negative to lower, the value + is the number of times + :type diff: int + :raises KeycloakPostError: when post requests are failed + """ + params_path = {"id": execution_id, "realm-name": self.connection.realm_name} + if diff > 0: + for _ in range(diff): + data_raw = self.connection.raw_post( + urls_patterns.URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY.format( + **params_path, + ), + data="{}", + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_NO_CONTENT], + ) + elif diff < 0: + for _ in range(-diff): + data_raw = self.connection.raw_post( + urls_patterns.URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY.format( + **params_path, + ), + data="{}", + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_NO_CONTENT], + ) + + def create_authentication_flow_subflow( + self, + payload: dict, + flow_alias: str, + skip_exists: bool = False, + ) -> bytes: + """ + Create a new sub authentication flow for a given authentication flow. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :param skip_exists: Do not raise an error if authentication flow already exists + :type skip_exists: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS_FLOW.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if isinstance(res, dict) and res == {"msg": "Already exists"}: + return json.dumps(res).encode() + + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_authenticator_providers(self) -> list: + """ + Get authenticator providers list. + + :return: Authenticator providers + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_PROVIDERS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_authenticator_provider_config_description(self, provider_id: str) -> dict: + """ + Get authenticator's provider configuration description. + + AuthenticatorConfigInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfiginforepresentation + + :param provider_id: Provider Id + :type provider_id: str + :return: AuthenticatorConfigInfoRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "provider-id": provider_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG_DESCRIPTION.format(**params_path), + ) + 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 + + def get_authenticator_config(self, config_id: str) -> dict: + """ + Get authenticator configuration. + + Returns all configuration details. + + :param config_id: Authenticator config id + :type config_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + ) + 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 + + def create_execution_config(self, execution_id: str, payload: dict) -> bytes: + """ + Update execution with new configuration. + + AuthenticatorConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfigrepresentation + + :param execution_id: The ID of the execution + :type execution_id: str + :param payload: Configuration to add to the execution + :type payload: dir + :return: Response(json) + :rtype: bytes + """ + params_path = {"id": execution_id, "realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_authenticator_config(self, payload: dict, config_id: str) -> dict: + """ + Update an authenticator configuration. + + AuthenticatorConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfigrepresentation + + :param payload: AuthenticatorConfigRepresentation + :type payload: dict + :param config_id: Authenticator config id + :type config_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_authenticator_config(self, config_id: str) -> dict: + """ + Delete a authenticator configuration. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authentication_management_resource + + :param config_id: Authenticator config id + :type config_id: str + :return: Keycloak server Response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 sync_users(self, storage_id: str, action: str) -> dict: + """ + Trigger user sync from provider. + + :param storage_id: The id of the user storage provider + :type storage_id: str + :param action: Action can be "triggerFullSync" or "triggerChangedUsersSync" + :type action: str + :return: Keycloak server response + :rtype: dict + """ + data = {"action": action} + params_query = {"action": action} + + params_path = {"realm-name": self.connection.realm_name, "id": storage_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_USER_STORAGE.format(**params_path), + data=json.dumps(data), + **params_query, + ) + 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 get_client_scopes(self) -> list: + """ + Get client scopes. + + Get representation of the client scopes for the realm where we are connected to + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :return: Keycloak server response Array of (ClientScopeRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_scope(self, client_scope_id: str) -> dict: + """ + Get client scope. + + Get representation of the client scopes for the realm where we are connected to + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :return: Keycloak server response (ClientScopeRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), + ) + 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 + + def get_client_scope_by_name(self, client_scope_name: str) -> dict | None: + """ + Get client scope by name. + + Get representation of the client scope identified by the client scope name. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + :param client_scope_name: (str) Name of the client scope + :type client_scope_name: str + :returns: ClientScopeRepresentation or None + :rtype: dict + """ + client_scopes = self.get_client_scopes() + for client_scope in client_scopes: + if client_scope["name"] == client_scope_name: + return client_scope + + return None + + def create_client_scope(self, payload: dict, skip_exists: bool = False) -> str: + """ + Create a client scope. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :param payload: ClientScopeRepresentation + :type payload: dict + :param skip_exists: If true then do not raise an error if client scope already exists + :type skip_exists: bool + :return: Client scope id + :rtype: str + """ + if skip_exists: + exists = self.get_client_scope_by_name(client_scope_name=payload["name"]) + + if exists is not None: + return exists["id"] + + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + def update_client_scope(self, client_scope_id: str, payload: dict) -> dict: + """ + Update a client scope. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_client_scopes_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param payload: ClientScopeRepresentation + :type payload: dict + :return: Keycloak server response (ClientScopeRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_client_scope(self, client_scope_id: str) -> dict: + """ + Delete existing client scope. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_client_scopes_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_mappers_from_client_scope(self, client_scope_id: str) -> list: + """ + Get a list of all mappers connected to the client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + :param client_scope_id: Client scope id + :type client_scope_id: str + :returns: Keycloak server response (ProtocolMapperRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), + ) + 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 add_mapper_to_client_scope(self, client_scope_id: str, payload: dict) -> bytes: + """ + Add a mapper to a client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_create_mapper + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_mapper_from_client_scope( + self, + client_scope_id: str, + protocol_mapper_id: str, + ) -> dict: + """ + Delete a mapper from a client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_delete_mapper + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param protocol_mapper_id: Protocol mapper id + :type protocol_mapper_id: str + :return: Keycloak server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 update_mapper_in_client_scope( + self, + client_scope_id: str, + protocol_mapper_id: str, + payload: dict, + ) -> dict: + """ + Update an existing protocol mapper in a client scope. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param protocol_mapper_id: The id of the protocol mapper which exists in the client scope + and should to be updated + :type protocol_mapper_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 get_default_default_client_scopes(self) -> list: + """ + Get default default client scopes. + + Return list of default default client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_default_default_client_scope(self, scope_id: str) -> dict: + """ + Delete default default client scope. + + :param scope_id: default default client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 add_default_default_client_scope(self, scope_id: str) -> dict: + """ + Add default default client scope. + + :param scope_id: default default client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + payload = {"realm": self.connection.realm_name, "clientScopeId": scope_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 get_default_optional_client_scopes(self) -> list: + """ + Get default optional client scopes. + + Return list of default optional client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def delete_default_optional_client_scope(self, scope_id: str) -> dict: + """ + Delete default optional client scope. + + :param scope_id: default optional client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 add_default_optional_client_scope(self, scope_id: str) -> dict: + """ + Add default optional client scope. + + :param scope_id: default optional client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + payload = {"realm": self.connection.realm_name, "clientScopeId": scope_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 add_client_specific_roles_to_client_scope( + self, + client_scope_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Assign client roles to a client scope. + + To assign roles to a client's dedicated scope, use assign_client_roles_to_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 remove_client_specific_roles_of_client_scope( + self, + client_scope_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles of a client scope. + + To delete roles from a client's dedicated scope, use delete_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_client_specific_roles_of_client_scope( + self, + client_scope_id: str, + client_roles_owner_id: str, + ) -> list: + """ + Get client roles for a client scope, for a specific client. + + To get roles for a client's dedicated scope, use get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_all_roles_of_client_scope(self, client_scope_id: str) -> dict: + """ + Get all client roles for a client scope. + + To get roles for a client's dedicated scope, + use get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS.format(**params_path), + ) + 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 + + def get_mappers_from_client(self, client_id: str) -> list: + """ + List of all client mappers. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocolmapperrepresentation + + :param client_id: Client id + :type client_id: str + :returns: KeycloakServerResponse (list of ProtocolMapperRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakPostError, 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 add_mapper_to_client(self, client_id: str, payload: dict) -> bytes: + """ + Add a mapper to a client. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_create_mapper + + :param client_id: The id of the client + :type client_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_client_mapper(self, client_id: str, mapper_id: str, payload: dict) -> dict: + """ + Update client mapper. + + :param client_id: The id of the client + :type client_id: str + :param mapper_id: The id of the mapper to be deleted + :type mapper_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "protocol-mapper-id": mapper_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 remove_client_mapper(self, client_id: str, client_mapper_id: str) -> dict: + """ + Remove a mapper from the client. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + + :param client_id: The id of the client + :type client_id: str + :param client_mapper_id: The id of the mapper to be deleted + :type client_mapper_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "protocol-mapper-id": client_mapper_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 generate_client_secrets(self, client_id: str) -> dict: + """ + Generate a new secret for the client. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_regeneratesecret + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path), + data=None, + ) + 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 get_client_secrets(self, client_id: str) -> dict: + """ + Get representation of the client secrets. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientsecret + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path), + ) + 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 + + def get_components(self, query: dict | None = None) -> list: + """ + Get components. + + Return a list of components, filtered according to query parameters + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param query: Query parameters (optional) + :type query: dict + :return: components list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), + data=None, + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def create_component(self, payload: dict) -> str: + """ + Create a new component. + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param payload: ComponentRepresentation + :type payload: dict + :return: Component id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + def get_component(self, component_id: str) -> dict: + """ + Get representation of the component. + + :param component_id: Component id + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param component_id: Id of the component + :type component_id: str + :return: ComponentRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = self.connection.raw_get(urls_patterns.URL_ADMIN_COMPONENT.format(**params_path)) + 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 + + def update_component(self, component_id: str, payload: dict) -> dict: + """ + Update the component. + + :param component_id: Component id + :type component_id: str + :param payload: ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 delete_component(self, component_id: str) -> dict: + """ + Delete the component. + + :param component_id: Component id + :type component_id: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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 get_keys(self) -> dict: + """ + Get keys. + + Return a list of keys, filtered according to query parameters + + KeysMetadataRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_key_resource + + :return: keys list + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_KEYS.format(**params_path), + data=None, + ) + 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 + + def get_admin_events(self, query: dict | None = None) -> list: + """ + Get Administrative events. + + Return a list of events, filtered according to query parameters + + AdminEvents Representation array + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getevents + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_get_adminrealmsrealmadmin_events + + :param query: Additional query parameters + :type query: dict + :return: events list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ADMIN_EVENTS.format(**params_path), + data=None, + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_events(self, query: dict | None = None) -> list: + """ + Get events. + + Return a list of events, filtered according to query parameters + + EventRepresentation array + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_eventrepresentation + + :param query: Additional query parameters + :type query: dict + :return: events list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_USER_EVENTS.format(**params_path), + data=None, + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def set_events(self, payload: dict) -> dict: + """ + Set realm events configuration. + + RealmEventsConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmeventsconfigrepresentation + + :param payload: Payload object for the events configuration + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_EVENTS_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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 get_client_all_sessions(self, client_id: str, query: dict | None = None) -> list: + """ + Get sessions associated with the client. + + UserSessionRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_usersessionrepresentation + + :param client_id: id of client + :type client_id: str + :param query: Additional query parameters + :type query: dict + :return: UserSessionRepresentation + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + url = urls_patterns.URL_ADMIN_CLIENT_ALL_SESSIONS.format(**params_path) + if "first" in query or "max" in query: + return self.__fetch_paginated(url, query) + + return self.__fetch_all(url, query) + + def get_client_sessions_stats(self) -> list: + """ + Get current session count for all clients with active sessions. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientsessionstats + + :return: Dict of clients and session count + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SESSION_STATS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_management_permissions(self, client_id: str) -> dict: + """ + Get management permissions for a client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path), + ) + 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 + + def update_client_management_permissions(self, payload: dict, client_id: str) -> dict: + """ + Update management permissions for a client. + + ManagementPermissionReference + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_managementpermissionreference + + Payload example:: + + payload={ + "enabled": true + } + + :param payload: ManagementPermissionReference + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[HTTP_OK]) + if not isinstance(res, dict): + msg = ( + "Unexpected response type. Expected 'dict', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_policy_scopes(self, client_id: str, policy_id: str) -> list: + """ + Get scopes for a given policy. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: No Document + :type policy_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_policy_resources(self, client_id: str, policy_id: str) -> list: + """ + Get resources for a given policy. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: No Document + :type policy_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_RESOURCES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_scope_permission(self, client_id: str, scope_id: str) -> dict: + """ + Get permissions for a given scope. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param scope_id: No Document + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "scope-id": scope_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + ) + 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 + + def create_client_authz_scope_permission(self, payload: dict, client_id: str) -> dict: + """ + Create permissions for a authz scope. + + Payload example:: + + payload={ + "name": "My Permission Name", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [some_scope_id], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_ADD_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 + + def update_client_authz_scope_permission( + self, + payload: dict, + client_id: str, + scope_id: str, + ) -> bytes: + """ + Update permissions for a given scope. + + Payload example:: + + payload={ + "id": scope_id, + "name": "My Permission Name", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [some_scope_id], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param scope_id: No Document + :type scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "scope-id": scope_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[HTTP_CREATED]) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_client_authz_resource_permission( + self, + payload: dict, + client_id: str, + resource_id: str, + ) -> bytes: + """ + Update permissions for a given resource. + + Payload example:: + + payload={ + "id": resource_id, + "name": "My Permission Name", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: No Document + :type resource_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[HTTP_CREATED]) + if not isinstance(res, bytes): + msg = ( + "Unexpected response type. Expected 'bytes', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_client_authz_client_policies(self, client_id: str) -> list: + """ + Get policies for a given client. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path), + ) + 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 get_client_authz_permission_associated_policies( + self, + client_id: str, + policy_id: str, + ) -> list: + """ + Get associated policies for a given client permission. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY_ASSOCIATED_POLICIES.format( + **params_path, + ), + ) + 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 create_client_authz_client_policy(self, payload: dict, client_id: str) -> dict: + """ + Create a new policy for a given client. + + Payload example:: + + payload={ + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "My Policy", + "clients": [other_client_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 + + def get_composite_client_roles_of_group( + self, + client_id: str, + group_id: str, + brief_representation: bool = True, + ) -> list: + """ + Get the composite client roles of the given group for the given client. + + :param client_id: id of the client. + :type client_id: str + :param group_id: id of the group. + :type group_id: str + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: the composite client roles of the group (list of RoleRepresentation). + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + params = {"briefRepresentation": brief_representation} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES_COMPOSITE.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def get_role_client_level_children(self, client_id: str, role_id: str) -> list: + """ + Get the child roles of which the given composite client role is composed of. + + :param client_id: id of the client. + :type client_id: str + :param role_id: id of the role. + :type role_id: str + :return: the child roles (list of RoleRepresentation). + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": role_id, + "client-id": client_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE_CHILDREN.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def upload_certificate(self, client_id: str, certcont: str) -> dict: + """ + Upload a new certificate for the client. + + :param client_id: id of the client. + :type client_id: str + :param certcont: the content of the certificate. + :type certcont: str + :return: dictionary {"certificate": ""}, + where is the content of the uploaded certificate. + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "attr": "jwt.credential", + } + m = MultipartEncoder(fields={"keystoreFormat": "Certificate PEM", "file": certcont}) + orig_headers = copy.deepcopy(self.connection.headers or {}) + new_headers = copy.deepcopy(orig_headers) + new_headers["Content-Type"] = m.content_type + self.connection.headers = new_headers + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_CERT_UPLOAD.format(**params_path), + data=m, + headers=new_headers, + ) + self.connection.headers = orig_headers + 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 get_required_action_by_alias(self, action_alias: str) -> dict | None: + """ + Get a required action by its alias. + + :param action_alias: the alias of the required action. + :type action_alias: str + :return: the required action (RequiredActionProviderRepresentation). + :rtype: dict + """ + actions = self.get_required_actions() + for a in actions: + if a["alias"] == action_alias: + return a + return None + + def get_required_actions(self) -> list: + """ + Get the required actions for the realms. + + :return: the required actions (list of RequiredActionProviderRepresentation). + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + def update_required_action(self, action_alias: str, payload: dict) -> dict: + """ + Update a required action. + + :param action_alias: the action alias. + :type action_alias: str + :param payload: the new required action (RequiredActionProviderRepresentation). + :type payload: dict + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "action-alias": action_alias} + data_raw = self.connection.raw_put( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS_ALIAS.format(**params_path), + data=json.dumps(payload), + ) + 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 + + def get_bruteforce_detection_status(self, user_id: str) -> dict: + """ + Get bruteforce detection status for user. + + :param user_id: User id + :type user_id: str + :return: Bruteforce status. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path), + ) + 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 + + def clear_bruteforce_attempts_for_user(self, user_id: str) -> dict: + """ + Clear bruteforce attempts for user. + + :param user_id: User id + :type user_id: str + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path), + ) + 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 clear_all_bruteforce_attempts(self) -> dict: + """ + Clear bruteforce attempts for all users in realm. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION.format(**params_path), + ) + 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 clear_keys_cache(self) -> dict: + """ + Clear keys cache. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLEAR_KEYS_CACHE.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 clear_realm_cache(self) -> dict: + """ + Clear realm cache. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLEAR_REALM_CACHE.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 clear_user_cache(self) -> dict: + """ + Clear user cache. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLEAR_USER_CACHE.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 functions start + async def a___fetch_all(self, url: str, query: dict | None = None) -> list: + """ + Paginate asynchronously over get requests . + + Wrapper function to paginate GET requests. + + :param url: The url on which the query is executed + :type url: str + :param query: Existing query parameters (optional) + :type query: dict + + :return: Combined results of paginated queries + :rtype: list + """ + results = [] + + # initialize query if it was called with None + if not query: + query = {} + page = 0 + query["max"] = self.PAGE_SIZE + + # fetch until we can + while True: + query["first"] = page * self.PAGE_SIZE + partial_results = raise_error_from_response( + await self.connection.a_raw_get(url, **query), + KeycloakGetError, + ) + if not partial_results: + break + results.extend(partial_results) + if len(partial_results) < query["max"]: + break + page += 1 + return results + + async def a___fetch_paginated(self, url: str, query: dict | None = None) -> list: + """ + Make a specific paginated request asynchronously. + + :param url: The url on which the query is executed + :type url: str + :param query: Pagination settings + :type query: dict + :returns: Response + :rtype: list + """ + query = query or {} + res = raise_error_from_response( + await self.connection.a_raw_get(url, **query), + KeycloakGetError, + ) + 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_get_current_realm(self) -> str | None: + """ + Return the currently configured realm asynchronously. + + :returns: Currently configured realm name + :rtype: str + """ + return self.connection.realm_name + + async def a_change_current_realm(self, realm_name: str) -> None: + """ + Change the current realm asynchronously. + + :param realm_name: The name of the realm to be configured as current + :type realm_name: str + """ + self.connection.realm_name = realm_name + + async def a_import_realm(self, payload: dict) -> bytes: + """ + Import a new realm asynchronously from a RealmRepresentation. + + Realm name must be unique. + + RealmRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param payload: RealmRepresentation + :type payload: dict + :return: RealmRepresentation + :rtype: bytes + """ + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALMS, + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_partial_import_realm(self, realm_name: str, payload: dict) -> dict: + """ + Partial import realm configuration asynchronously from PartialImportRepresentation. + + Realm partialImport is used for modifying configuration of existing realm. + + PartialImportRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_partialimportrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :param payload: PartialImportRepresentation + :type payload: dict + + :return: PartialImportResponse + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_PARTIAL_IMPORT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_OK]) + 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_export_realm( + self, + export_clients: bool = False, + export_groups_and_role: bool = False, + ) -> dict: + """ + Export the realm configurations asynchronously in the json format. + + RealmRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_partialexport + + :param export_clients: Skip if not want to export realm clients + :type export_clients: bool + :param export_groups_and_role: Skip if not want to export realm groups and roles + :type export_groups_and_role: bool + + :return: realm configurations JSON + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "export-clients": export_clients, + "export-groups-and-roles": export_groups_and_role, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_EXPORT.format(**params_path), + data="", + exportClients=export_clients, + exportGroupsAndRoles=export_groups_and_role, + ) + 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_get_realms(self) -> list: + """ + List all realms in asynchronouslyKeycloak deployment. + + :return: realms list + :rtype: list + """ + data_raw = await self.connection.a_raw_get(urls_patterns.URL_ADMIN_REALMS) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_realm(self, realm_name: str) -> dict: + """ + Get a specific realm asynchronously. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :return: RealmRepresentation + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM.format(**params_path), + ) + 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) + + return res + + async def a_create_realm(self, payload: dict, skip_exists: bool = False) -> bytes: + """ + Create a realm asynchronously. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param payload: RealmRepresentation + :type payload: dict + :param skip_exists: Skip if Realm already exist. + :type skip_exists: bool + :return: Keycloak server response (RealmRepresentation) + :rtype: bytes + """ + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALMS, + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED] + + ([HTTP_BAD_REQUEST, HTTP_CONFLICT] if skip_exists else []), + ) + if isinstance(res, dict) and res in [ + {"msg": "Already exists"}, + {"errorMessage": "Realm test already exists"}, + {"errorMessage": "Conflict detected. See logs for details"}, + ]: + return json.dumps(res).encode() + + 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_update_realm(self, realm_name: str, payload: dict) -> dict: + """ + Update a realm asynchronously. + + This will only update top level attributes and will ignore any user, + role, or client information in the payload. + + RealmRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmrepresentation + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :param payload: RealmRepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_REALM.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_update_realm_users_profile(self, payload: dict) -> dict: + """ + Update realm users profile for the current realm. + + :param up_config: List of attributes, groups, unmamagedAttributePolicy + + Related documentation: + https://www.keycloak.org/docs-api/26.0.0/rest-api/index.html#UPConfig + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_REALM_USER_PROFILE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_OK], + ) + 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_delete_realm(self, realm_name: str) -> dict: + """ + Delete a realm asynchronously. + + :param realm_name: Realm name (not the realm id) + :type realm_name: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": realm_name} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_users(self, query: dict | None = None) -> list: + """ + Get all users asynchronously. + + Return a list of users, filtered according to query parameters + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param query: Query parameters (optional) + :type query: dict + :return: users list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_USERS.format(**params_path) + + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_create_idp(self, payload: dict) -> bytes: + """ + Create an ID Provider asynchronously. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :param: payload: IdentityProviderRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_IDPS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_update_idp(self, idp_alias: str, payload: dict) -> dict: + """ + Update an ID Provider asynchronously. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identity_providers_resource + + :param: idp_alias: alias for IdP to update + :type idp_alias: str + :param: payload: The IdentityProviderRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_IDP.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_add_mapper_to_idp(self, idp_alias: str, payload: dict) -> bytes: + """ + Create an ID Provider asynchronously. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityprovidermapperrepresentation + + :param: idp_alias: alias for Idp to add mapper in + :type idp_alias: str + :param: payload: IdentityProviderMapperRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "idp-alias": idp_alias} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_update_mapper_in_idp(self, idp_alias: str, mapper_id: str, payload: dict) -> dict: + """ + Update an IdP mapper asynchronously. + + IdentityProviderMapperRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_update + + :param: idp_alias: alias for Idp to fetch mappers + :type idp_alias: str + :param: mapper_id: Mapper Id to update + :type mapper_id: str + :param: payload: IdentityProviderMapperRepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "idp-alias": idp_alias, + "mapper-id": mapper_id, + } + + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_IDP_MAPPER_UPDATE.format(**params_path), + data=json.dumps(payload), + ) + + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_get_idp_mappers(self, idp_alias: str) -> list: + """ + Get IDP mappers asynchronously. + + Returns a list of ID Providers mappers + + IdentityProviderMapperRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getmappers + + :param: idp_alias: alias for Idp to fetch mappers + :type idp_alias: str + :return: array IdentityProviderMapperRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "idp-alias": idp_alias} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_IDP_MAPPERS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_idps(self) -> list: + """ + Get IDPs asynchronously. + + Returns a list of ID Providers, + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :return: array IdentityProviderRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_IDPS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_idp(self, idp_alias: str) -> dict: + """ + Get IDP provider asynchronously. + + Get the representation of a specific IDP Provider. + + IdentityProviderRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_identityproviderrepresentation + + :param: idp_alias: alias for IdP to get + :type idp_alias: str + :return: IdentityProviderRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_IDP.format(**params_path), + ) + 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_delete_idp(self, idp_alias: str) -> dict: + """ + Delete an ID Provider asynchronously. + + :param: idp_alias: idp alias name + :type idp_alias: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "alias": idp_alias} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_IDP.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_create_user(self, payload: dict, exist_ok: bool = False) -> str: + """ + Create a new user asynchronously. + + Username must be unique + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param payload: UserRepresentation + :type payload: dict + :param exist_ok: If False, raise KeycloakGetError if username already exists. + Otherwise, return existing user ID. + :type exist_ok: bool + + :return: user_id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + + if exist_ok: + exists = await self.a_get_user_id(username=payload["username"]) + + if exists is not None: + return str(exists) + + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USERS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + async def a_users_count(self, query: dict | None = None) -> int: + """ + Count users asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_users_resource + + :param query: (dict) Query parameters for users count + :type query: dict + + :return: counter + :rtype: int + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USERS_COUNT.format(**params_path), + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(res, int): + msg = ( + f"Unexpected response type. Expected 'int', received '{type(res)}', value '{res}'." + ) + raise TypeError(msg) + + return res + + async def a_get_user_id(self, username: str) -> str | None: + """ + Get internal keycloak user id from username asynchronously. + + This is required for further actions against this user. + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param username: id in UserRepresentation + :type username: str + + :return: user_id + :rtype: str + """ + lower_user_name = username.lower() + users = await self.a_get_users( + query={"username": lower_user_name, "max": 1, "exact": True}, + ) + return users[0]["id"] if len(users) == 1 else None + + async def a_get_user(self, user_id: str, user_profile_metadata: bool = False) -> dict: + """ + Get representation of the user asynchronously. + + UserRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userrepresentation + + :param user_id: User id + :type user_id: str + :param user_profile_metadata: whether to include user profile metadata in the response + :type user_profile_metadata: bool + :return: UserRepresentation + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER.format(**params_path), + userProfileMetadata=user_profile_metadata, + ) + 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_get_user_groups( + self, + user_id: str, + query: dict | None = None, + brief_representation: bool = True, + ) -> list: + """ + Get user groups asynchronously. + + Returns a list of groups of which the user is a member + + :param user_id: User id + :type user_id: str + :param query: Additional query options + :type query: dict + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: user groups list + :rtype: list + """ + query = query or {} + params = {"briefRepresentation": brief_representation} + query.update(params) + + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + url = urls_patterns.URL_ADMIN_USER_GROUPS.format(**params_path) + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_update_user(self, user_id: str, payload: dict) -> dict: + """ + Update the user asynchronously. + + :param user_id: User id + :type user_id: str + :param payload: UserRepresentation + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_USER.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_disable_user(self, user_id: str) -> dict: + """ + Disable the user asynchronously from the realm. Disabled users can not log in. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: bytes + """ + return await self.a_update_user(user_id=user_id, payload={"enabled": False}) + + async def a_enable_user(self, user_id: str) -> dict: + """ + Enable the user from the realm asynchronously. + + :param user_id: User id + :type user_id: str + + :return: Http response + :rtype: bytes + """ + return await self.a_update_user(user_id=user_id, payload={"enabled": True}) + + async def a_disable_all_users(self) -> None: + """Disable all existing users asynchronously.""" + users = await self.a_get_users() + for user in users: + user_id = user["id"] + await self.a_disable_user(user_id=user_id) + + async def a_enable_all_users(self) -> None: + """Disable all existing users asynchronously.""" + users = await self.a_get_users() + for user in users: + user_id = user["id"] + await self.a_enable_user(user_id=user_id) + + async def a_delete_user(self, user_id: str) -> dict: + """ + Delete the user asynchronously. + + :param user_id: User id + :type user_id: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_set_user_password( + self, + user_id: str, + password: str, + temporary: bool = True, + ) -> dict: + """ + Set up a password for the user asynchronously. + + If temporary is True, the user will have to reset + the temporary password next time they log in. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_users_resource + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_credentialrepresentation + + :param user_id: User id + :type user_id: str + :param password: New password + :type password: str + :param temporary: True if password is temporary + :type temporary: bool + :returns: Response + :rtype: dict + """ + payload = {"type": "password", "temporary": temporary, "value": password} + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_RESET_PASSWORD.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_get_credentials(self, user_id: str) -> list: + """ + Get user credentials asynchronously. + + Returns a list of credential belonging to the user. + + CredentialRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_credentialrepresentation + + :param: user_id: user id + :type user_id: str + :returns: Keycloak server response (CredentialRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_CREDENTIALS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_delete_credential(self, user_id: str, credential_id: str) -> dict: + """ + Delete credential of the user asynchronously. + + CredentialRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_credentialrepresentation + + :param: user_id: user id + :type user_id: str + :param: credential_id: credential id + :type credential_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "credential_id": credential_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_CREDENTIAL.format(**params_path), + ) + 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_user_logout(self, user_id: str) -> dict: + """ + Log out the user. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_logout + + :param user_id: User id + :type user_id: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USER_LOGOUT.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_user_consents(self, user_id: str) -> list: + """ + Asynchronously get consents granted by the user. + + UserConsentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_userconsentrepresentation + + :param user_id: User id + :type user_id: str + :returns: List of UserConsentRepresentations + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_CONSENTS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_revoke_consent(self, user_id: str, client_id: str) -> dict: + """ + Asynchronously revoke consent and offline tokens for particular client from user. + + :param user_id: User id + :type user_id: str + :param client_id: Client id + :type client_id: str + :rtype: dict + + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_CONSENT.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_user_social_logins(self, user_id: str) -> list: + """ + Get user social logins asynchronously. + + Returns a list of federated identities/social logins of which the user has been associated + with + :param user_id: User id + :type user_id: str + :returns: Federated identities list + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITIES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_add_user_social_login( + self, + user_id: str, + provider_id: str, + provider_userid: str, + provider_username: str, + ) -> dict: + """ + Add a federated identity / social login provider asynchronously to the user. + + :param user_id: User id + :type user_id: str + :param provider_id: Social login provider id + :type provider_id: str + :param provider_userid: userid specified by the provider + :type provider_userid: str + :param provider_username: username specified by the provider + :type provider_username: str + :returns: Keycloak server response + :rtype: dict + """ + payload = { + "identityProvider": provider_id, + "userId": provider_userid, + "userName": provider_username, + } + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "provider": provider_id, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED, 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_delete_user_social_login(self, user_id: str, provider_id: str) -> dict: + """ + Delete a federated identity / social login provider asynchronously from the user. + + :param user_id: User id + :type user_id: str + :param provider_id: Social login provider id + :type provider_id: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "provider": provider_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_send_update_account( + self, + user_id: str, + payload: list, + client_id: str | None = None, + lifespan: int | None = None, + redirect_uri: str | None = None, + ) -> dict: + """ + Send an update account email to the user asynchronously. + + An email contains a link the user can click to perform a set of required actions. + + :param user_id: User id + :type user_id: str + :param payload: A list of actions for the user to complete + :type payload: list + :param client_id: Client id (optional) + :type client_id: str + :param lifespan: Number of seconds after which the generated token expires (optional) + :type lifespan: int + :param redirect_uri: The redirect uri (optional) + :type redirect_uri: str + + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params_query = {"client_id": client_id, "lifespan": lifespan, "redirect_uri": redirect_uri} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), + data=json.dumps(payload), + **params_query, + ) + 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_send_verify_email( + self, + user_id: str, + client_id: str | None = None, + redirect_uri: str | None = None, + ) -> dict: + """ + Send a update account email to the user asynchronously. + + An email contains a link the user can click to perform a set of required actions. + + :param user_id: User id + :type user_id: str + :param client_id: Client id (optional) + :type client_id: str + :param redirect_uri: Redirect uri (optional) + :type redirect_uri: str + + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params_query = {"client_id": client_id, "redirect_uri": redirect_uri} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), + data={}, + **params_query, + ) + 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_get_sessions(self, user_id: str) -> list: + """ + Get sessions associated with the user asynchronously. + + UserSessionRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_usersessionrepresentation + + :param user_id: Id of user + :type user_id: str + :return: UserSessionRepresentation + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GET_SESSIONS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_server_info(self) -> dict: + """ + Get themes, social providers, etc. on this server asynchronously. + + ServerInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :return: ServerInfoRepresentation + :rtype: dict + """ + data_raw = await self.connection.a_raw_get(urls_patterns.URL_ADMIN_SERVER_INFO) + 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_get_groups(self, query: dict | None = None, full_hierarchy: bool = False) -> list: + """ + Get groups asynchronously. + + Returns a list of groups belonging to the realm + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + Notice that when using full_hierarchy=True, the response will be a nested structure + containing all the children groups. If used with query parameters, the full_hierarchy + will be applied to the received groups only. + + :param query: Additional query options + :type query: dict + :param full_hierarchy: If True, return all of the nested children groups, otherwise only + the first level children are returned + :type full_hierarchy: bool + :return: array GroupRepresentation + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + url = urls_patterns.URL_ADMIN_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + groups = await self.a___fetch_paginated(url, query) + else: + groups = await self.a___fetch_all(url, query) + + # For version +23.0.0 + for group in groups: + if group.get("subGroupCount"): + group["subGroups"] = await self.a_get_group_children( + group_id=group.get("id"), full_hierarchy=full_hierarchy, query=query + ) + + return groups + + async def a_get_group( + self, group_id: str, full_hierarchy: bool = False, query: dict | None = None + ) -> dict: + """ + Get group by id asynchronously. + + Returns full group details + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group_id: The group id + :type group_id: str + :param full_hierarchy: If True, return all of the nested children groups, otherwise only + the first level children are returned + :type full_hierarchy: bool + :param query: Additional query parameters to pass into the subgroups fetch requests. + :type query: dict | None + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + response = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUP.format(**params_path), + ) + + if response.status_code >= HTTP_BAD_REQUEST: + raise_error_from_response(response, KeycloakGetError) + + # For version +23.0.0 + group = response.json() + if group.get("subGroupCount"): + group["subGroups"] = await self.a_get_group_children( + group.get("id"), full_hierarchy=full_hierarchy, query=query + ) + + return group + + async def a_get_subgroups(self, group: dict, path: str) -> dict | None: + """ + Get subgroups asynchronously. + + Utility function to iterate through nested group structures + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group: group (GroupRepresentation) + :type group: dict + :param path: group path (string) + :type path: str + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + for subgroup in group["subGroups"]: + if subgroup["path"] == path: + return subgroup + if subgroup["subGroups"]: + for _subgroup in group["subGroups"]: + result = await self.a_get_subgroups(_subgroup, path) + if result: + return result + # went through the tree without hits + return None + + async def a_get_group_children( + self, + group_id: str, + query: dict | None = None, + full_hierarchy: bool = False, + ) -> list: + """ + Get group children by parent id asynchronously. + + Returns full group children details + + :param group_id: The parent group id + :type group_id: str + :param query: Additional query options + :type query: dict + :param full_hierarchy: If True, return all of the nested children groups + :type full_hierarchy: bool + :return: Keycloak server response (GroupRepresentation) + :rtype: list + :raises ValueError: If both query and full_hierarchy parameters are used + """ + query = query or {} + if query and full_hierarchy: + msg = "Cannot use both query and full_hierarchy parameters" + raise ValueError(msg) + + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + url = urls_patterns.URL_ADMIN_GROUP_CHILD.format(**params_path) + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + res = await self.a___fetch_all(url, query) + + if not full_hierarchy: + return res + + for group in res: + if group.get("subGroupCount"): + group["subGroups"] = await self.a_get_group_children( + group_id=group.get("id"), full_hierarchy=full_hierarchy, query=query + ) + + return res + + async def a_get_group_members(self, group_id: str, query: dict | None = None) -> list: + """ + Get members by group id asynchronously. + + Returns group members + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_userrepresentation + + :param group_id: The group id + :type group_id: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getmembers) + :type query: dict + :return: Keycloak server response (UserRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + url = urls_patterns.URL_ADMIN_GROUP_MEMBERS.format(**params_path) + + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_get_group_by_path(self, path: str) -> dict: + """ + Get group id based on name or path asynchronously . + + Returns full group details for a group defined by path + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param path: group path + :type path: str + :return: Keycloak server response (GroupRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "path": path} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUP_BY_PATH.format(**params_path), + ) + 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_create_group( + self, + payload: dict, + parent: str | None = None, + skip_exists: bool = False, + ) -> str | None: + """ + Create a group in the Realm asynchronously. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param payload: GroupRepresentation + :type payload: dict + :param parent: parent group's id. Required to create a sub-group. + :type parent: str + :param skip_exists: If true then do not raise an error if it already exists + :type skip_exists: bool + + :return: Group id for newly created group or None for an existing group + :rtype: str + """ + if parent is None: + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_GROUPS.format(**params_path), + data=json.dumps(payload), + ) + else: + params_path = {"realm-name": self.connection.realm_name, "id": parent} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_GROUP_CHILD.format(**params_path), + data=json.dumps(payload), + ) + + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + try: + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + except KeyError: + return None + + async def a_update_group(self, group_id: str, payload: dict) -> dict: + """ + Update group, ignores subgroups asynchronously. + + GroupRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/#_grouprepresentation + + :param group_id: id of group + :type group_id: str + :param payload: GroupRepresentation with updated information. + :type payload: dict + + :return: Http response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_GROUP.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_groups_count(self, query: dict | None = None) -> dict: + """ + Count groups asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_groups + + :param query: (dict) Query parameters for groups count + :type query: dict + + :return: Keycloak Server Response + :rtype: dict + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_COUNT.format(**params_path), + **query, + ) + 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_group_set_permissions(self, group_id: str, enabled: bool = True) -> dict: + """ + Enable/Disable permissions for a group asynchronously. + + Cannot delete group if disabled + + :param group_id: id of group + :type group_id: str + :param enabled: Enabled flag + :type enabled: bool + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), + data=json.dumps({"enabled": enabled}), + ) + 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_group_user_add(self, user_id: str, group_id: str) -> dict: + """ + Add user to group (user_id and group_id) asynchronously. + + :param user_id: id of user + :type user_id: str + :param group_id: id of group to add to + :type group_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "group-id": group_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path), + data=None, + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_group_user_remove(self, user_id: str, group_id: str) -> dict: + """ + Remove user from group (user_id and group_id) asynchronously. + + :param user_id: id of user + :type user_id: str + :param group_id: id of group to remove from + :type group_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "group-id": group_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_GROUP.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_delete_group(self, group_id: str) -> dict: + """ + Delete a group in the Realm asynchronously. + + :param group_id: id of group to delete + :type group_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_GROUP.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_clients(self) -> list: + """ + Get clients asynchronously. + + Returns a list of clients belonging to the realm + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :return: Keycloak server response (ClientRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client(self, client_id: str) -> dict: + """ + Get representation of the client asynchronously. + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path), + ) + 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_get_client_id(self, client_id: str) -> str | None: + """ + Get internal keycloak client id from client-id asynchronously. + + This is required for further actions against this client. + + :param client_id: clientId in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: client_id (uuid as string) + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path), + clientId=client_id, + ) + data_response = raise_error_from_response(data_raw, KeycloakGetError) + if not isinstance(data_response, list): + msg = ( + "Unexpected response type. Expected 'list', received " + f"'{type(data_response)}', value '{data_response}'." + ) + raise TypeError(msg) + + for client in data_response: + if client_id == client.get("clientId"): + return client["id"] + + return None + + async def a_get_client_authz_settings(self, client_id: str) -> dict: + """ + Get authorization json from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SETTINGS.format(**params_path), + ) + 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_import_client_authz_config(self, client_id: str, payload: dict) -> dict: + """ + Import client authorization configuration asynchronously. + + ResourceServerRepresentation + https://www.keycloak.org/docs-api/latest/rest-api/index.html#ResourceServerRepresentation + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceServerRepresentation + :type payload: dict + + :return: Server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_IMPORT.format(**params_path), + data=json.dumps(payload), + ) + + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_create_client_authz_resource( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create resources of client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type payload: dict + :param skip_exists: Skip the creation in case the resource exists + :type skip_exists: bool + + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + 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_authz_resource( + self, + client_id: str, + resource_id: str, + payload: dict, + ) -> dict: + """ + Update resource of client asynchronously. + + Any parameter missing from the ResourceRepresentation in the payload WILL be set + to default by the Keycloak server. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_client_authz_resource(self, client_id: str, resource_id: str) -> dict: + """ + Delete a client resource asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_client_authz_resources(self, client_id: str) -> list: + """ + Get resources from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (ResourceRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), + max=-1, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_authz_resource(self, client_id: str, resource_id: str) -> dict: + """ + Get a client resource asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: id in ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + :type resource_id: str + + :return: Keycloak server response (ResourceRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE.format(**params_path), + ) + 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) + + return res + + async def a_create_client_authz_role_based_policy( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create role-based policy of client asynchronously. + + Payload example:: + + payload={ + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "Policy-1", + "roles": [ + { + "id": id + } + ] + } + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: No Document + :type payload: dict + :param skip_exists: Skip creation in case the object exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + 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_create_client_authz_policy( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create an authz policy of client asynchronously. + + Payload example:: + + payload={ + "name": "Policy-time-based", + "type": "time", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "hourEnd": "18", + "hour": "9" + } + } + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: No Document + :type payload: dict + :param skip_exists: Skip creation in case the object exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path), + data=json.dumps(payload), + max=-1, + permission=False, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + 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_create_client_authz_resource_based_permission( + self, + client_id: str, + payload: dict, + skip_exists: bool = False, + ) -> dict: + """ + Create resource-based permission of client asynchronously. + + Payload example:: + + payload={ + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "Permission-Name", + "resources": [ + resource_id + ], + "policies": [ + policy_id + ] + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param payload: PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type payload: dict + :param skip_exists: Skip creation in case the object already exists + :type skip_exists: bool + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + 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_get_client_authz_scopes(self, client_id: str) -> list: + """ + Get scopes from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path), + max=-1, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_create_client_authz_scopes(self, client_id: str, payload: dict) -> dict: + """ + Create scopes for client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :param payload: ScopeRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_ScopeRepresentation + :type payload: dict + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPES.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_get_client_authz_permissions(self, client_id: str) -> list: + """ + Get permissions from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS.format(**params_path), + max=-1, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_authz_policies(self, client_id: str) -> list: + """ + Get policies from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICIES.format(**params_path), + max=-1, + permission=False, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_delete_client_authz_policy(self, client_id: str, policy_id: str) -> dict: + """ + Delete a policy from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_client_authz_policy(self, client_id: str, policy_id: str) -> dict: + """ + Get a policy from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY.format(**params_path), + ) + 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_get_client_service_account_user(self, client_id: str) -> dict: + """ + Get service account user from client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: UserRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER.format(**params_path), + ) + 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_get_client_default_client_scopes(self, client_id: str) -> list: + """ + Get all default client scopes from client asynchronously. + + :param client_id: id of the client in which the new default client scope should be added + :type client_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_add_client_default_client_scope( + self, + client_id: str, + client_scope_id: str, + payload: dict, + ) -> dict: + """ + Add a client scope to the default client scopes from client asynchronously. + + Payload example:: + + payload={ + "realm":"testrealm", + "client":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "clientScopeId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + } + + :param client_id: id of the client in which the new default client scope should be added + :type client_id: str + :param client_scope_id: id of the new client scope that should be added + :type client_scope_id: str + :param payload: dictionary with realm, client and clientScopeId + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + 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_delete_client_default_client_scope( + self, + client_id: str, + client_scope_id: str, + ) -> dict: + """ + Delete a client scope from the default client scopes of the client asynchronously. + + :param client_id: id of the client in which the default client scope should be deleted + :type client_id: str + :param client_scope_id: id of the client scope that should be deleted + :type client_scope_id: str + + :return: list of client scopes with id and name + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE.format(**params_path), + ) + 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_get_client_optional_client_scopes(self, client_id: str) -> list: + """ + Get all optional client scopes from client asynchronously. + + :param client_id: id of the client in which the new optional client scope should be added + :type client_id: str + + :return: list of client scopes with id and name + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_add_client_optional_client_scope( + self, + client_id: str, + client_scope_id: str, + payload: dict, + ) -> dict: + """ + Add a client scope to the optional client scopes from client asynchronously. + + Payload example:: + + payload={ + "realm":"testrealm", + "client":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "clientScopeId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + } + + :param client_id: id of the client in which the new optional client scope should be added + :type client_id: str + :param client_scope_id: id of the new client scope that should be added + :type client_scope_id: str + :param payload: dictionary with realm, client and clientScopeId + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + 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_delete_client_optional_client_scope( + self, + client_id: str, + client_scope_id: str, + ) -> dict: + """ + Delete a client scope from the optional client scopes of the client asynchronously. + + :param client_id: id of the client in which the optional client scope should be deleted + :type client_id: str + :param client_scope_id: id of the client scope that should be deleted + :type client_scope_id: str + + :return: list of client scopes with id and name + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client_scope_id": client_scope_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + ) + 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_create_initial_access_token( + self, + count: int = 1, + expiration: int = 1, + ) -> dict: + """ + Create an initial access token asynchronously. + + :param count: Number of clients that can be registered + :type count: int + :param expiration: Days until expireation + :type expiration: int + :return: initial access token + :rtype: dict + """ + payload = {"count": count, "expiration": expiration} + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_INITIAL_ACCESS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_OK]) + 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_create_client(self, payload: dict, skip_exists: bool = False) -> str: + """ + Create a client asynchronously. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param skip_exists: If true then do not raise an error if client already exists + :type skip_exists: bool + :param payload: ClientRepresentation + :type payload: dict + :return: Client ID + :rtype: str + """ + if skip_exists: + client_id = await self.a_get_client_id(client_id=payload["clientId"]) + + if client_id is not None: + return client_id + + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENTS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + async def a_update_client(self, client_id: str, payload: dict) -> dict: + """ + Update a client asynchronously. + + :param client_id: Client id + :type client_id: str + :param payload: ClientRepresentation + :type payload: dict + + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_client(self, client_id: str) -> dict: + """ + Get representation of the client asynchronously. + + ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param client_id: keycloak client id (not oauth client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_client_installation_provider(self, client_id: str, provider_id: str) -> dict: + """ + Get content for given installation provider asynchronously. + + Related documentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource + + Possible provider_id list available in the ServerInfoRepresentation#clientInstallations + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_serverinforepresentation + + :param client_id: Client id + :type client_id: str + :param provider_id: provider id to specify response format + :type provider_id: str + :returns: Installation providers + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "provider-id": provider_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_INSTALLATION_PROVIDER.format(**params_path), + ) + 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) + + return res + + async def a_get_realm_users_profile(self) -> dict: + """ + Get list of attributes and group for given realm. + + Related documentation: + https://www.keycloak.org/docs-api/26.0.0/rest-api/index.html#_get_adminrealmsrealmusersprofile + + Return https://www.keycloak.org/docs-api/26.0.0/rest-api/index.html#UPConfig + :returns: UPConfig + :rtype: dict + + """ + params_path = {"realm-name": self.connection.realm_name} + + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_USER_PROFILE.format(**params_path), + ) + 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) + + return res + + async def a_get_realm_roles( + self, brief_representation: bool = True, search_text: str = "", query: dict | None = None + ) -> list: + """ + Get all roles for the realm or client asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :param search_text: optional search text to limit the returned result. + :type search_text: str + :param query: Query parameters (optional) + :type query: dict + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + params: dict[str, str | bool] = {"briefRepresentation": brief_representation} + url = urls_patterns.URL_ADMIN_REALM_ROLES.format(**params_path) + + if search_text is not None and search_text.strip() != "": + params["search"] = search_text + + if "first" in query and "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, params) + + async def a_get_realm_role_groups( + self, + role_name: str, + query: dict | None = None, + brief_representation: bool = True, + ) -> list: + """ + Get role groups of realm by role name asynchronously. + + :param role_name: Name of the role. + :type role_name: str + :param query: Additional Query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_parameters_226) + :type query: dict + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak Server Response (GroupRepresentation) + :rtype: list + """ + query = query or {} + params = {"briefRepresentation": brief_representation} + query.update(params) + + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + url = urls_patterns.URL_ADMIN_REALM_ROLES_GROUPS.format(**params_path) + + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_get_realm_role_members(self, role_name: str, query: dict | None = None) -> list: + """ + Get role members of realm by role name asynchronously. + + :param role_name: Name of the role. + :type role_name: str + :param query: Additional Query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_roles_resource) + :type query: dict + :return: Keycloak Server Response (UserRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + return await self.a___fetch_all( + urls_patterns.URL_ADMIN_REALM_ROLES_MEMBERS.format(**params_path), + query, + ) + + async def a_get_default_realm_role_id(self) -> str: + """ + Get the ID of the default realm role asynchronously. + + :return: Realm role ID + :rtype: str + """ + all_realm_roles = await self.a_get_realm_roles() + default_realm_roles = [ + realm_role + for realm_role in all_realm_roles + if realm_role["name"] == f"default-roles-{self.connection.realm_name}".lower() + ] + return default_realm_roles[0]["id"] + + async def a_get_realm_default_roles(self) -> list: + """ + Get all the default realm roles asyncho asynchronously. + + :return: Keycloak Server Response (UserRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": await self.a_get_default_realm_role_id(), + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES_REALM.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_remove_realm_default_roles(self, payload: list) -> dict: + """ + Remove a set of default realm roles asynchronously. + + :param payload: List of RoleRepresentations + :type payload: list + :return: Keycloak Server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": await self.a_get_default_realm_role_id(), + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + 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_add_realm_default_roles(self, payload: list) -> dict: + """ + Add a set of default realm roles asynchronously. + + :param payload: List of RoleRepresentations + :type payload: list + :return: Keycloak Server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": await self.a_get_default_realm_role_id(), + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path), + data=json.dumps(payload), + ) + 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_get_client_roles(self, client_id: str, brief_representation: bool = True) -> list: + """ + Get all roles for the client asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + params = {"briefRepresentation": brief_representation} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_role(self, client_id: str, role_name: str) -> dict: + """ + Get client role by name asynchronously. + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :return: Role object + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), + ) + 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_get_client_role_id(self, client_id: str, role_name: str) -> str | None: + """ + Get client role id by name asynchronously. + + This is required for further actions with this role. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :return: role_id + :rtype: str | None + """ + role = await self.a_get_client_role(client_id, role_name) + return role.get("id") + + async def a_create_client_role( + self, + client_role_id: str, + payload: dict, + skip_exists: bool = False, + ) -> str: + """ + Create a client role asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param payload: RoleRepresentation + :type payload: dict + :param skip_exists: If true then do not raise an error if client role already exists + :type skip_exists: bool + :return: Client role name + :rtype: str + """ + if skip_exists: + try: + res = await self.a_get_client_role( + client_id=client_role_id, + role_name=payload["name"], + ) + return res["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name, "id": client_role_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + async def a_add_composite_client_roles_to_role( + self, + client_role_id: str, + role_name: str, + roles: dict | list, + ) -> dict: + """ + Add composite roles to client role asynchronously. + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be updated + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_remove_composite_client_roles_from_role( + self, + client_role_id: str, + role_name: str, + roles: str | list, + ) -> dict: + """ + Remove composite roles from a client role asynchronously. + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be removed + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_update_client_role(self, client_id: str, role_name: str, payload: dict) -> dict: + """ + Update a client role asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_id: id of client (not client-id) + :type client_id: str + :param role_name: role's name (not id!) + :type role_name: str + :param payload: RoleRepresentation + :type payload: dict + :returns: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_client_role(self, client_role_id: str, role_name: str) -> dict: + """ + Delete a client role asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param client_role_id: id of client (not client-id) + :type client_role_id: str + :param role_name: role's name (not id!) + :type role_name: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_role_id, + "role-name": role_name, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_ROLE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_assign_client_role(self, user_id: str, client_id: str, roles: str | list) -> dict: + """ + Assign a client role to a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_get_client_role_members( + self, + client_id: str, + role_name: str, + **query: Any, # noqa: ANN401 + ) -> list: + """ + Get members by client role asynchronously. + + :param client_id: The client id + :type client_id: str + :param role_name: the name of role to be queried. + :type role_name: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource) + :type query: dict + :return: Keycloak server response (UserRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + return await self.a___fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_MEMBERS.format(**params_path), + query, + ) + + async def a_get_client_role_groups( + self, + client_id: str, + role_name: str, + **query: Any, # noqa: ANN401 + ) -> list: + """ + Get group members by client role asynchronously. + + :param client_id: The client id + :type client_id: str + :param role_name: the name of role to be queried. + :type role_name: str + :param query: Additional query parameters + (see https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clients_resource) + :type query: dict + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + return await self.a___fetch_all( + urls_patterns.URL_ADMIN_CLIENT_ROLE_GROUPS.format(**params_path), + query, + ) + + async def a_get_role_by_id(self, role_id: str) -> dict: + """ + Get a specific role's representation asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: id of role + :type role_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + ) + 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) + + return res + + async def a_update_role_by_id(self, role_id: str, payload: dict) -> dict: + """ + Update the role asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param payload: RoleRepresentation + :type payload: dict + :param role_id: id of role + :type role_id: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_role_by_id(self, role_id: str) -> dict: + """ + Delete a role by its id asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: id of role + :type role_id: str + :returns: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_role_composites_by_id(self, role_id: str, query: dict | None = None) -> list: + """ + Get all composite roles by role id asynchronously. + + :param role_id: id of role + :type role_id: str + :param query: Query parameters (optional). Supported keys: 'first', 'max', 'search' + :type query: dict + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + url = urls_patterns.URL_ADMIN_REALM_ROLE_COMPOSITES.format(**params_path) + + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_create_realm_role(self, payload: dict, skip_exists: bool = False) -> str: + """ + Create a new role for the realm or client asynchronously. + + :param payload: The role (use RoleRepresentation) + :type payload: dict + :param skip_exists: If true then do not raise an error if realm role already exists + :type skip_exists: bool + :return: Realm role name + :rtype: str + """ + if skip_exists: + try: + role = await self.a_get_realm_role(role_name=payload["name"]) + return role["name"] + except KeycloakGetError: + pass + + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + async def a_get_realm_role(self, role_name: str) -> dict: + """ + Get realm role by role name asynchronously. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_name: role's name, not id! + :type role_name: str + :return: role + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + ) + 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_get_realm_role_by_id(self, role_id: str) -> dict: + """ + Get realm role by role id. + + RoleRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_rolerepresentation + + :param role_id: role's id, not name! + :type role_id: str + :return: role + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-id": role_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_ID.format(**params_path), + ) + 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_update_realm_role(self, role_name: str, payload: dict) -> dict: + """ + Update a role for the realm by name asynchronously. + + :param role_name: The name of the role to be updated + :type role_name: str + :param payload: The role (use RoleRepresentation) + :type payload: dict + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_realm_role(self, role_name: str) -> dict: + """ + Delete a role for the realm by name asynchronously. + + :param role_name: The role name + :type role_name: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_add_composite_realm_roles_to_role( + self, + role_name: str, + roles: dict | list, + ) -> dict: + """ + Add composite roles to the role asynchronously. + + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be updated + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_remove_composite_realm_roles_to_role( + self, + role_name: str, + roles: str | list, + ) -> dict: + """ + Remove composite roles from the role asynchronously. + + :param role_name: The name of the role + :type role_name: str + :param roles: roles list or role (use RoleRepresentation) to be removed + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_composite_realm_roles_of_role(self, role_name: str) -> list: + """ + Get composite roles of the role asynchronously. + + :param role_name: The name of the role + :type role_name: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "role-name": role_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_composite_client_roles_of_role(self, client_id: str, role_name: str) -> list: + """ + Get composite roles of the client role. + + :param client_id: The id of the client + :type client_id: str + :param role_name: The name of the role + :type role_name: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "role-name": role_name, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_assign_realm_roles_to_client_scope( + self, + client_id: str, + roles: str | list, + ) -> dict: + """ + Assign realm roles to a client's scope asynchronously. + + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_delete_realm_roles_of_client_scope( + self, + client_id: str, + roles: str | list, + ) -> dict: + """ + Delete realm roles of a client's scope asynchronously. + + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_realm_roles_of_client_scope(self, client_id: str) -> list: + """ + Get all realm roles for a client's scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_assign_client_roles_to_client_scope( + self, + client_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Assign client roles to a client's dedicated scope asynchronously. + + To assign roles to a client scope, use a_add_client_specific_roles_to_client_scope. + + :param client_id: id of client (not client-id) who is assigned the roles + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_delete_client_roles_of_client_scope( + self, + client_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles of a client's dedicated scope asynchronously. + + To remove roles from a client scope, use a_remove_client_specific_roles_of_client_scope. + + :param client_id: id of client (not client-id) who is assigned the roles + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_client_roles_of_client_scope( + self, + client_id: str, + client_roles_owner_id: str, + ) -> list: + """ + Get all client roles for a client's scope asynchronously. + + To get roles from a client scope, use a_get_client_roles_of_client_scope. + + :param client_id: id of client (not client-id) + :type client_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "client": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_assign_realm_roles(self, user_id: str, roles: str | list) -> dict: + """ + Assign realm roles to a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_delete_realm_roles_of_user(self, user_id: str, roles: str | list) -> dict: + """ + Delete realm roles of a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param roles: roles list or role (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_realm_roles_of_user(self, user_id: str) -> list: + """ + Get all realm roles for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_available_realm_roles_of_user(self, user_id: str) -> list: + """ + Get all available (i.e. unassigned) realm roles for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_AVAILABLE.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_composite_realm_roles_of_user( + self, + user_id: str, + brief_representation: bool = True, + ) -> list: + """ + Get all composite (i.e. implicit) realm roles for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + params = {"briefRepresentation": brief_representation} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_REALM_ROLES_COMPOSITE.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_assign_group_realm_roles(self, group_id: str, roles: str | list) -> dict: + """ + Assign realm roles to a group asynchronously. + + :param group_id: id of group + :type group_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_delete_group_realm_roles(self, group_id: str, roles: str | list) -> dict: + """ + Delete realm roles of a group asynchronously. + + :param group_id: id of group + :type group_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_group_realm_roles( + self, + group_id: str, + brief_representation: bool = True, + ) -> list: + """ + Get all realm roles for a group asynchronously. + + :param group_id: id of the group + :type group_id: str + :param brief_representation: whether to omit role attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": group_id} + params = {"briefRepresentation": brief_representation} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_assign_group_client_roles( + self, + group_id: str, + client_id: str, + roles: str | list, + ) -> dict: + """ + Assign client roles to a group asynchronously. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_get_group_client_roles(self, group_id: str, client_id: str) -> list: + """ + Get client roles of a group asynchronously. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_delete_group_client_roles( + self, + group_id: str, + client_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles of a group asynchronously. + + :param group_id: id of group + :type group_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param roles: roles list or role (use GroupRoleRepresentation) + :type roles: list + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_all_roles_of_user(self, user_id: str) -> dict: + """ + Get all level roles for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :return: Keycloak server response (MappingsRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_ALL_ROLES.format(**params_path), + ) + 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_get_client_roles_of_user(self, user_id: str, client_id: str) -> list: + """ + Get all client roles for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + return await self.a__get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES, + user_id, + client_id, + ) + + async def a_get_available_client_roles_of_user(self, user_id: str, client_id: str) -> list: + """ + Get available client role-mappings for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + return await self.a__get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, + user_id, + client_id, + ) + + async def a_get_composite_client_roles_of_user( + self, + user_id: str, + client_id: str, + brief_representation: bool = False, + ) -> list: + """ + Get composite client role-mappings for a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client (not client-id) + :type client_id: str + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params = {"briefRepresentation": brief_representation} + return await self.a__get_client_roles_of_user( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, + user_id, + client_id, + **params, + ) + + async def a__get_client_roles_of_user( + self, + client_level_role_mapping_url: str, + user_id: str, + client_id: str, + **params: Any, # noqa: ANN401 + ) -> list: + """ + Get client roles of a single user helper asynchronously. + + :param client_level_role_mapping_url: Url for the client role mapping + :type client_level_role_mapping_url: str + :param user_id: User id + :type user_id: str + :param client_id: Client id + :type client_id: str + :param params: Additional parameters + :type params: dict + :returns: Client roles of a user + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_get( + client_level_role_mapping_url.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_delete_client_roles_of_user( + self, + user_id: str, + client_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles from a user asynchronously. + + :param user_id: id of user + :type user_id: str + :param client_id: id of client containing role (not client-id) + :type client_id: str + :param roles: roles list or role to delete (use RoleRepresentation) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "id": user_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_authentication_flows(self) -> list: + """ + Get authentication flows asynchronously. + + Returns all flow details + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :return: Keycloak server response (AuthenticationFlowRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_authentication_flow_for_id(self, flow_id: str) -> dict: + """ + Get one authentication flow by it's id asynchronously. + + Returns all flow details + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param flow_id: the id of a flow NOT it's alias + :type flow_id: str + :return: Keycloak server response (AuthenticationFlowRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "flow-id": flow_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS_ALIAS.format(**params_path), + ) + 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_create_authentication_flow( + self, + payload: dict, + skip_exists: bool = False, + ) -> bytes: + """ + Create a new authentication flow asynchronously. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :param skip_exists: Do not raise an error if authentication flow already exists + :type skip_exists: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_FLOWS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if isinstance(res, dict) and res == {"msg": "Already exists"}: + return json.dumps(res).encode() + + 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_copy_authentication_flow(self, payload: dict, flow_alias: str) -> bytes: + """ + Copy existing authentication flow under a new name asynchronously. + + The new name is given as 'newName' attribute of the passed payload. + + :param payload: JSON containing 'newName' attribute + :type payload: dict + :param flow_alias: the flow alias + :type flow_alias: str + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_FLOWS_COPY.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_delete_authentication_flow(self, flow_id: str) -> dict: + """ + Delete authentication flow asynchronously. + + AuthenticationInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationinforepresentation + + :param flow_id: authentication flow id + :type flow_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": flow_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_FLOW.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_authentication_flow_executions(self, flow_alias: str) -> list: + """ + Get authentication flow executions asynchronously. + + Returns all execution steps + + :param flow_alias: the flow alias + :type flow_alias: str + :return: Response(json) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_update_authentication_flow_executions( + self, + payload: dict, + flow_alias: str, + ) -> dict: + """ + Update an authentication flow execution asynchronously. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param payload: AuthenticationExecutionInfoRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_ACCEPTED, 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_get_authentication_flow_execution(self, execution_id: str) -> dict: + """ + Get authentication flow execution asynchronously. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param execution_id: the execution ID + :type execution_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": execution_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path), + ) + 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_create_authentication_flow_execution( + self, + payload: dict, + flow_alias: str, + ) -> bytes: + """ + Create an authentication flow execution asynchronously. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param payload: AuthenticationExecutionInfoRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_delete_authentication_flow_execution(self, execution_id: str) -> dict: + """ + Delete authentication flow execution asynchronously. + + AuthenticationExecutionInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationexecutioninforepresentation + + :param execution_id: keycloak client id (not oauth client-id) + :type execution_id: str + :return: Keycloak server response (json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": execution_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_create_authentication_flow_subflow( + self, + payload: dict, + flow_alias: str, + skip_exists: bool = False, + ) -> bytes: + """ + Create a new sub authentication flow for a given authentication flow asynchronously. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :param flow_alias: The flow alias + :type flow_alias: str + :param skip_exists: Do not raise an error if authentication flow already exists + :type skip_exists: bool + :return: Keycloak server response (RoleRepresentation) + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "flow-alias": flow_alias} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTIONS_FLOW.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + if isinstance(res, dict) and res == {"msg": "Already exists"}: + return json.dumps(res).encode() + + 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_get_authenticator_providers(self) -> list: + """ + Get authenticator providers list asynchronously. + + :return: Authenticator providers + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_PROVIDERS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_authenticator_provider_config_description(self, provider_id: str) -> dict: + """ + Get authenticator's provider configuration description asynchronously. + + AuthenticatorConfigInfoRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfiginforepresentation + + :param provider_id: Provider Id + :type provider_id: str + :return: AuthenticatorConfigInfoRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "provider-id": provider_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG_DESCRIPTION.format(**params_path), + ) + 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_get_authenticator_config(self, config_id: str) -> dict: + """ + Get authenticator configuration asynchronously. + + Returns all configuration details. + + :param config_id: Authenticator config id + :type config_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + ) + 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_update_authenticator_config(self, payload: dict, config_id: str) -> dict: + """ + Update an authenticator configuration asynchronously. + + AuthenticatorConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfigrepresentation + + :param payload: AuthenticatorConfigRepresentation + :type payload: dict + :param config_id: Authenticator config id + :type config_id: str + :return: Response(json) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_authenticator_config(self, config_id: str) -> dict: + """ + Delete a authenticator configuration asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authentication_management_resource + + :param config_id: Authenticator config id + :type config_id: str + :return: Keycloak server Response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": config_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_sync_users(self, storage_id: str, action: str) -> dict: + """ + Trigger user sync from provider asynchronously. + + :param storage_id: The id of the user storage provider + :type storage_id: str + :param action: Action can be "triggerFullSync" or "triggerChangedUsersSync" + :type action: str + :return: Keycloak server response + :rtype: dict + """ + data = {"action": action} + params_query = {"action": action} + + params_path = {"realm-name": self.connection.realm_name, "id": storage_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_USER_STORAGE.format(**params_path), + data=json.dumps(data), + **params_query, + ) + 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_get_client_scopes(self) -> list: + """ + Get client scopes asynchronously. + + Get representation of the client scopes for the realm where we are connected to + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :return: Keycloak server response Array of (ClientScopeRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_scope(self, client_scope_id: str) -> dict: + """ + Get client scope asynchronously. + + Get representation of the client scopes for the realm where we are connected to + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :return: Keycloak server response (ClientScopeRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), + ) + 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_get_client_scope_by_name(self, client_scope_name: str) -> dict | None: + """ + Get client scope by name asynchronously. + + Get representation of the client scope identified by the client scope name. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + :param client_scope_name: (str) Name of the client scope + :type client_scope_name: str + :returns: ClientScopeRepresentation or None + :rtype: dict + """ + client_scopes = await self.a_get_client_scopes() + for client_scope in client_scopes: + if client_scope["name"] == client_scope_name: + return client_scope + + return None + + async def a_create_client_scope(self, payload: dict, skip_exists: bool = False) -> str: + """ + Create a client scope asynchronously. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientscopes + + :param payload: ClientScopeRepresentation + :type payload: dict + :param skip_exists: If true then do not raise an error if client scope already exists + :type skip_exists: bool + :return: Client scope id + :rtype: str + """ + if skip_exists: + exists = await self.a_get_client_scope_by_name(client_scope_name=payload["name"]) + + if exists is not None: + return exists["id"] + + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPES.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + skip_exists=skip_exists, + ) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + async def a_update_client_scope(self, client_scope_id: str, payload: dict) -> dict: + """ + Update a client scope asynchronously. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_client_scopes_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param payload: ClientScopeRepresentation + :type payload: dict + :return: Keycloak server response (ClientScopeRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_client_scope(self, client_scope_id: str) -> dict: + """ + Delete existing client scope asynchronously. + + ClientScopeRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_client_scopes_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_mappers_from_client_scope(self, client_scope_id: str) -> list: + """ + Get a list of all mappers connected to the client scope asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + :param client_scope_id: Client scope id + :type client_scope_id: str + :returns: Keycloak server response (ProtocolMapperRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), + ) + 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_add_mapper_to_client_scope(self, client_scope_id: str, payload: dict) -> bytes: + """ + Add a mapper to a client scope asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_create_mapper + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_delete_mapper_from_client_scope( + self, + client_scope_id: str, + protocol_mapper_id: str, + ) -> dict: + """ + Delete a mapper from a client scope asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_delete_mapper + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param protocol_mapper_id: Protocol mapper id + :type protocol_mapper_id: str + :return: Keycloak server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_update_mapper_in_client_scope( + self, + client_scope_id: str, + protocol_mapper_id: str, + payload: dict, + ) -> dict: + """ + Update an existing protocol mapper in a client scope asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + + :param client_scope_id: The id of the client scope + :type client_scope_id: str + :param protocol_mapper_id: The id of the protocol mapper which exists in the client scope + and should to be updated + :type protocol_mapper_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_get_default_default_client_scopes(self) -> list: + """ + Get default default client scopes asynchronously. + + Return list of default default client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_delete_default_default_client_scope(self, scope_id: str) -> dict: + """ + Delete default default client scope asynchronously. + + :param scope_id: default default client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_add_default_default_client_scope(self, scope_id: str) -> dict: + """ + Add default default client scope asynchronously. + + :param scope_id: default default client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + payload = {"realm": self.connection.realm_name, "clientScopeId": scope_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_get_default_optional_client_scopes(self) -> list: + """ + Get default optional client scopes asynchronously. + + Return list of default optional client scopes + + :return: Keycloak server response + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_delete_default_optional_client_scope(self, scope_id: str) -> dict: + """ + Delete default optional client scope asynchronously. + + :param scope_id: default optional client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_add_default_optional_client_scope(self, scope_id: str) -> dict: + """ + Add default optional client scope asynchronously. + + :param scope_id: default optional client scope id + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": scope_id} + payload = {"realm": self.connection.realm_name, "clientScopeId": scope_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_add_client_specific_roles_to_client_scope( + self, + client_scope_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Assign client roles to a client scope asynchronously. + + To assign roles to a client's dedicated scope, use + a_assign_client_roles_to_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_remove_client_specific_roles_of_client_scope( + self, + client_scope_id: str, + client_roles_owner_id: str, + roles: str | list, + ) -> dict: + """ + Delete client roles of a client scope asynchronously. + + To delete roles from a client's dedicated scope, + use a_delete_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_client_specific_roles_of_client_scope( + self, + client_scope_id: str, + client_roles_owner_id: str, + ) -> list: + """ + Get all client roles for a client scope asynchronously. + + To get roles for a client's dedicated scope, + use a_get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_all_roles_of_client_scope(self, client_scope_id: str) -> dict: + """ + Get all client roles for a client scope. + + To get roles for a client's dedicated scope, + use a_get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "scope-id": client_scope_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS.format(**params_path), + ) + 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_get_mappers_from_client(self, client_id: str) -> list: + """ + List of all client mappers asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocolmapperrepresentation + + :param client_id: Client id + :type client_id: str + :returns: KeycloakServerResponse (list of ProtocolMapperRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), + ) + + res = raise_error_from_response(data_raw, KeycloakPostError, 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_add_mapper_to_client(self, client_id: str, payload: dict) -> bytes: + """ + Add a mapper to a client asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_create_mapper + + :param client_id: The id of the client + :type client_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server Response + :rtype: bytes + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_update_client_mapper(self, client_id: str, mapper_id: str, payload: dict) -> dict: + """ + Update client mapper asynchronously. + + :param client_id: The id of the client + :type client_id: str + :param mapper_id: The id of the mapper to be deleted + :type mapper_id: str + :param payload: ProtocolMapperRepresentation + :type payload: dict + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "protocol-mapper-id": mapper_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_remove_client_mapper(self, client_id: str, client_mapper_id: str) -> dict: + """ + Remove a mapper from the client asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_protocol_mappers_resource + + :param client_id: The id of the client + :type client_id: str + :param client_mapper_id: The id of the mapper to be deleted + :type client_mapper_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "protocol-mapper-id": client_mapper_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_generate_client_secrets(self, client_id: str) -> dict: + """ + Generate a new secret for the client asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_regeneratesecret + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path), + data=None, + ) + 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_get_client_secrets(self, client_id: str) -> dict: + """ + Get representation of the client secrets asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientsecret + + :param client_id: id of client (not client-id) + :type client_id: str + :return: Keycloak server response (ClientRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SECRETS.format(**params_path), + ) + 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_get_components(self, query: dict | None = None) -> list: + """ + Get components asynchronously. + + Return a list of components, filtered according to query parameters + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param query: Query parameters (optional) + :type query: dict + :return: components list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), + data=None, + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_create_component(self, payload: dict) -> str: + """ + Create a new component asynchronously. + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param payload: ComponentRepresentation + :type payload: dict + :return: Component id + :rtype: str + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_COMPONENTS.format(**params_path), + data=json.dumps(payload), + ) + raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[HTTP_CREATED]) + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] + + async def a_get_component(self, component_id: str) -> dict: + """ + Get representation of the component asynchronously. + + :param component_id: Component id + + ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + + :param component_id: Id of the component + :type component_id: str + :return: ComponentRepresentation + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path), + ) + 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_update_component(self, component_id: str, payload: dict) -> dict: + """ + Update the component asynchronously. + + :param component_id: Component id + :type component_id: str + :param payload: ComponentRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_componentrepresentation + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_delete_component(self, component_id: str) -> dict: + """ + Delete the component asynchronously. + + :param component_id: Component id + :type component_id: str + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "component-id": component_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_COMPONENT.format(**params_path), + ) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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_get_keys(self) -> dict: + """ + Get keys asynchronously. + + Return a list of keys, filtered according to query parameters + + KeysMetadataRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_key_resource + + :return: keys list + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_KEYS.format(**params_path), + data=None, + ) + 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_get_admin_events(self, query: dict | None = None) -> list: + """ + Get Administrative events asynchronously. + + Return a list of events, filtered according to query parameters + + AdminEvents Representation array + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getevents + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_get_adminrealmsrealmadmin_events + + :param query: Additional query parameters + :type query: dict + :return: events list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ADMIN_EVENTS.format(**params_path), + data=None, + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_events(self, query: dict | None = None) -> list: + """ + Get events asynchronously. + + Return a list of events, filtered according to query parameters + + EventRepresentation array + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_eventrepresentation + + :param query: Additional query parameters + :type query: dict + :return: events list + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_USER_EVENTS.format(**params_path), + data=None, + **query, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_set_events(self, payload: dict) -> dict: + """ + Set realm events configuration asynchronously. + + RealmEventsConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_realmeventsconfigrepresentation + + :param payload: Payload object for the events configuration + :type payload: dict + :return: Http response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_EVENTS_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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_get_client_all_sessions(self, client_id: str, query: dict | None = None) -> list: + """ + Get sessions associated with the client asynchronously. + + UserSessionRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_usersessionrepresentation + + :param client_id: id of client + :type client_id: str + :param query: Additional query parameters + :type query: dict + :return: UserSessionRepresentation + :rtype: list + """ + query = query or {} + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + url = urls_patterns.URL_ADMIN_CLIENT_ALL_SESSIONS.format(**params_path) + if "first" in query or "max" in query: + return await self.a___fetch_paginated(url, query) + + return await self.a___fetch_all(url, query) + + async def a_get_client_sessions_stats(self) -> list: + """ + Get current session count for all clients with active sessions asynchronously. + + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_getclientsessionstats + + :return: Dict of clients and session count + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SESSION_STATS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_management_permissions(self, client_id: str) -> dict: + """ + Get management permissions for a client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path), + ) + 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_update_client_management_permissions(self, payload: dict, client_id: str) -> dict: + """ + Update management permissions for a client asynchronously. + + ManagementPermissionReference + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_managementpermissionreference + + Payload example:: + + payload={ + "enabled": true + } + + :param payload: ManagementPermissionReference + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[HTTP_OK]) + 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_get_client_authz_policy_scopes(self, client_id: str, policy_id: str) -> list: + """ + Get scopes for a given policy asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: No Document + :type policy_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_SCOPES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_authz_policy_resources(self, client_id: str, policy_id: str) -> list: + """ + Get resources for a given policy asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: No Document + :type policy_id: str + :return: Keycloak server response + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_POLICY_RESOURCES.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_client_authz_scope_permission(self, client_id: str, scope_id: str) -> dict: + """ + Get permissions for a given scope asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param scope_id: No Document + :type scope_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "scope-id": scope_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + ) + 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_create_client_authz_scope_permission(self, payload: dict, client_id: str) -> dict: + """ + Create permissions for a authz scope asynchronously. + + Payload example:: + + payload={ + "name": "My Permission Name", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [some_scope_id], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_ADD_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + data=json.dumps(payload), + max=-1, + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_update_client_authz_scope_permission( + self, + payload: dict, + client_id: str, + scope_id: str, + ) -> bytes: + """ + Update permissions for a given scope asynchronously. + + Payload example:: + + payload={ + "id": scope_id, + "name": "My Permission Name", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [some_scope_id], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param scope_id: No Document + :type scope_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "scope-id": scope_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[HTTP_CREATED]) + 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_update_client_authz_resource_permission( + self, + payload: dict, + client_id: str, + resource_id: str, + ) -> bytes: + """ + Update permissions for a given resource asynchronously. + + Payload example:: + + payload={ + "id": resource_id, + "name": "My Permission Name", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [some_resource_id], + "scopes": [], + "policies": [some_policy_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param resource_id: No Document + :type resource_id: str + :return: Keycloak server response + :rtype: bytes + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "resource-id": resource_id, + } + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_RESOURCE_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[HTTP_CREATED]) + 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_get_client_authz_client_policies(self, client_id: str) -> list: + """ + Get policies for a given client asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path), + ) + 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_get_client_authz_permission_associated_policies( + self, + client_id: str, + policy_id: str, + ) -> list: + """ + Get associated policies for a given client permission asynchronously. + + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :param policy_id: id in PolicyRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + :type policy_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "policy-id": policy_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY_ASSOCIATED_POLICIES.format( + **params_path, + ), + ) + 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_create_client_authz_client_policy(self, payload: dict, client_id: str) -> dict: + """ + Create a new policy for a given client asynchronously. + + Payload example:: + + payload={ + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": "My Policy", + "clients": [other_client_id], + } + + :param payload: No Document + :type payload: dict + :param client_id: id in ClientRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + :type client_id: str + :return: Keycloak server response (RoleRepresentation) + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": client_id} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_get_composite_client_roles_of_group( + self, + client_id: str, + group_id: str, + brief_representation: bool = True, + ) -> list: + """ + Get the composite client roles of the given group for the given client asynchronously. + + :param client_id: id of the client. + :type client_id: str + :param group_id: id of the group. + :type group_id: str + :param brief_representation: whether to omit attributes in the response + :type brief_representation: bool + :return: the composite client roles of the group (list of RoleRepresentation). + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": group_id, + "client-id": client_id, + } + params = {"briefRepresentation": brief_representation} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_GROUPS_CLIENT_ROLES_COMPOSITE.format(**params_path), + **params, + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_get_role_client_level_children(self, client_id: str, role_id: str) -> list: + """ + Get the child roles async of which the given composite client role is composed of. + + :param client_id: id of the client. + :type client_id: str + :param role_id: id of the role. + :type role_id: str + :return: the child roles (list of RoleRepresentation). + :rtype: list + """ + params_path = { + "realm-name": self.connection.realm_name, + "role-id": role_id, + "client-id": client_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_ROLE_CHILDREN.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_upload_certificate(self, client_id: str, certcont: str) -> dict: + """ + Upload a new certificate for the client asynchronously. + + :param client_id: id of the client. + :type client_id: str + :param certcont: the content of the certificate. + :type certcont: str + :return: dictionary {"certificate": ""}, + where is the content of the uploaded certificate. + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "id": client_id, + "attr": "jwt.credential", + } + m = MultipartEncoder(fields={"keystoreFormat": "Certificate PEM", "file": certcont}) + orig_headers = copy.deepcopy(self.connection.headers or {}) + new_headers = copy.deepcopy(orig_headers) + new_headers["Content-Type"] = m.content_type + self.connection.headers = new_headers + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_CERT_UPLOAD.format(**params_path), + data=m, + headers=new_headers, + ) + self.connection.headers = orig_headers + 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_get_required_action_by_alias(self, action_alias: str) -> dict | None: + """ + Get a required action by its alias asynchronously. + + :param action_alias: the alias of the required action. + :type action_alias: str + :return: the required action (RequiredActionProviderRepresentation). + :rtype: dict + """ + actions = await self.a_get_required_actions() + for a in actions: + if a["alias"] == action_alias: + return a + return None + + async def a_get_required_actions(self) -> list: + """ + Get the required actions for the realms asynchronously. + + :return: the required actions (list of RequiredActionProviderRepresentation). + :rtype: list + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS.format(**params_path), + ) + res = raise_error_from_response(data_raw, KeycloakGetError) + 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_update_required_action(self, action_alias: str, payload: dict) -> dict: + """ + Update a required action asynchronously. + + :param action_alias: the action alias. + :type action_alias: str + :param payload: the new required action (RequiredActionProviderRepresentation). + :type payload: dict + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "action-alias": action_alias} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_REQUIRED_ACTIONS_ALIAS.format(**params_path), + data=json.dumps(payload), + ) + 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_get_bruteforce_detection_status(self, user_id: str) -> dict: + """ + Get bruteforce detection status for user asynchronously. + + :param user_id: User id + :type user_id: str + :return: Bruteforce status. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path), + ) + 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_clear_bruteforce_attempts_for_user(self, user_id: str) -> dict: + """ + Clear bruteforce attempts for user asynchronously. + + :param user_id: User id + :type user_id: str + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name, "id": user_id} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION_USER.format(**params_path), + ) + 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_clear_all_bruteforce_attempts(self) -> dict: + """ + Clear bruteforce attempts for all users in realm asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_ATTACK_DETECTION.format(**params_path), + ) + 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_clear_keys_cache(self) -> dict: + """ + Clear keys cache asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLEAR_KEYS_CACHE.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_clear_realm_cache(self) -> dict: + """ + Clear realm cache asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLEAR_REALM_CACHE.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_clear_user_cache(self) -> dict: + """ + Clear user cache asynchronously. + + :return: empty dictionary. + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLEAR_USER_CACHE.format(**params_path), + data="", + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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_change_execution_priority(self, execution_id: str, diff: int) -> None: + """ + Raise or lower execution priority of diff time. + + :param execution_id: The ID of the execution + :type execution_id: str + :param diff: The difference in priority, positive to raise, negative to lower, the value + is the number of times + :type diff: int + :raises KeycloakPostError: when post requests are failed + """ + params_path = {"id": execution_id, "realm-name": self.connection.realm_name} + if diff > 0: + for _ in range(diff): + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY.format( + **params_path, + ), + data="{}", + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_NO_CONTENT], + ) + elif diff < 0: + for _ in range(-diff): + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY.format( + **params_path, + ), + data="{}", + ) + raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_NO_CONTENT], + ) + + async def a_create_execution_config(self, execution_id: str, payload: dict) -> bytes: + """ + Update execution with new configuration. + + AuthenticatorConfigRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticatorconfigrepresentation + + :param execution_id: The ID of the execution + :type execution_id: str + :param payload: Configuration to add to the execution + :type payload: dir + :return: Response(json) + :rtype: bytes + """ + params_path = {"id": execution_id, "realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_FLOWS_EXECUTION_CONFIG.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + expected_codes=[HTTP_CREATED], + ) + 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_update_authentication_flow(self, flow_id: str, payload: dict) -> dict: + """ + Update an authentication flow. + + AuthenticationFlowRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_authenticationflowrepresentation + + :param flow_id: The id of the flow + :type flow_id: str + :param payload: AuthenticationFlowRepresentation + :type payload: dict + :return: Keycloak server response + :rtype: dict + """ + params_path = {"id": flow_id, "realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_put( + urls_patterns.URL_ADMIN_FLOW.format(**params_path), + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + expected_codes=[HTTP_ACCEPTED, 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 diff --git a/src/keycloak/keycloak_openid.py b/src/keycloak/keycloak_openid.py new file mode 100644 index 00000000..dcf4273b --- /dev/null +++ b/src/keycloak/keycloak_openid.py @@ -0,0 +1,2003 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +Keycloak OpenID module. + +The module contains mainly the implementation of KeycloakOpenID class, the main +class to handle authentication and token manipulation. +""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any + +import aiofiles +from jwcrypto import jwk, jwt + +from .authorization import Authorization +from .connection import ConnectionManager +from .exceptions import ( + HTTP_FORBIDDEN, + HTTP_NO_CONTENT, + HTTP_NOT_ALLOWED, + HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, + KeycloakAuthenticationError, + KeycloakAuthorizationConfigError, + KeycloakDeprecationError, + KeycloakGetError, + KeycloakInvalidTokenError, + KeycloakPostError, + KeycloakPutError, + KeycloakRPTNotFound, + raise_error_from_response, +) +from .uma_permissions import AuthStatus, build_permission_param +from .urls_patterns import ( + URL_AUTH, + URL_CERTS, + URL_CLIENT_REGISTRATION, + URL_CLIENT_UPDATE, + URL_DEVICE, + URL_ENTITLEMENT, + URL_INTROSPECT, + URL_LOGOUT, + URL_REALM, + URL_TOKEN, + URL_USERINFO, + URL_WELL_KNOWN, +) + + +class KeycloakOpenID: + """ + Keycloak OpenID client. + + :param server_url: Keycloak server url + :param client_id: client id + :param realm_name: realm name + :param client_secret_key: client secret key + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :param custom_headers: dict of custom header to pass to each HTML request + :param proxies: dict of proxies to sent the request by. + :param timeout: connection timeout in seconds + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of + (certificate file, key file). + :param max_retries: The total number of times to retry HTTP requests. + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + + def __init__( + self, + server_url: str, + realm_name: str, + client_id: str, + client_secret_key: str | None = None, + verify: bool | str = True, + custom_headers: dict | None = None, + proxies: dict | None = None, + timeout: int | None = 60, + cert: str | tuple | None = None, + max_retries: int = 1, + pool_maxsize: int | None = None, + ) -> None: + """ + Init method. + + :param server_url: Keycloak server url + :type server_url: str + :param client_id: client id + :type client_id: str + :param realm_name: realm name + :type realm_name: str + :param client_secret_key: client secret key + :type client_secret_key: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param proxies: dict of proxies to sent the request by. + :type proxies: dict + :param timeout: connection timeout in seconds + :type timeout: int + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of + (certificate file, key file). + :type cert: Union[str,Tuple[str,str]] + :param max_retries: The total number of times to retry HTTP requests. + :type max_retries: int + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + self.client_id = client_id + self.client_secret_key = client_secret_key + self.realm_name = realm_name + headers = custom_headers if custom_headers is not None else {} + self.connection = ConnectionManager( + base_url=server_url, + headers=headers, + timeout=timeout, + verify=verify, + proxies=proxies, + cert=cert, + max_retries=max_retries, + pool_maxsize=pool_maxsize, + ) + + self.authorization = Authorization() + + @property + def client_id(self) -> str: + """ + Get client id. + + :returns: Client id + :rtype: str + """ + return self._client_id + + @client_id.setter + def client_id(self, value: str) -> None: + self._client_id = value + + @property + def client_secret_key(self) -> str | None: + """ + Get the client secret key. + + :returns: Client secret key + :rtype: str + """ + return self._client_secret_key + + @client_secret_key.setter + def client_secret_key(self, value: str | None) -> None: + self._client_secret_key = value + + @property + def realm_name(self) -> str: + """ + Get the realm name. + + :returns: Realm name + :rtype: str + """ + return self._realm_name + + @realm_name.setter + def realm_name(self, value: str) -> None: + self._realm_name = value + + @property + def connection(self) -> ConnectionManager: + """ + Get connection. + + :returns: Connection manager object + :rtype: ConnectionManager + """ + return self._connection + + @connection.setter + def connection(self, value: ConnectionManager) -> None: + self._connection = value + + @property + def authorization(self) -> Authorization: + """ + Get authorization. + + :returns: The authorization manager + :rtype: Authorization + """ + return self._authorization + + @authorization.setter + def authorization(self, value: Authorization) -> None: + self._authorization = value + + def _add_secret_key(self, payload: dict) -> dict: + """ + Add secret key if exists. + + :param payload: Payload + :type payload: dict + :returns: Payload with the secret key + :rtype: dict + """ + if self.client_secret_key: + payload.update({"client_secret": self.client_secret_key}) + + return payload + + def _build_name_role(self, role: str) -> str: + """ + Build name of a role. + + :param role: Role name + :type role: str + :returns: Role path + :rtype: str + """ + return self.client_id + "/" + role + + def _token_info(self, token: str, method_token_info: str, **kwargs: Any) -> dict: # noqa: ANN401 + """ + Getter for the token data. + + :param token: Token + :type token: str + :param method_token_info: Token info method to use + :type method_token_info: str + :param kwargs: Additional keyword arguments passed to the decode_token method + :type kwargs: dict + :returns: Token info + :rtype: dict + """ + if method_token_info == "introspect": # noqa: S105 + token_info = self.introspect(token) + else: + token_info = self.decode_token(token, **kwargs) + + return token_info + + def well_known(self) -> dict: + """ + Get the well_known object. + + The most important endpoint to understand is the well-known configuration + endpoint. It lists endpoints and other configuration options relevant to + the OpenID Connect implementation in Keycloak. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path)) + 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( + self, + redirect_uri: str, + scope: str = "email", + state: str = "", + nonce: str = "", + code_challenge: str | None = None, + code_challenge_method: str | None = None, + ) -> str: + """ + Get authorization URL endpoint. + + :param redirect_uri: Redirect url to receive oauth code + :type redirect_uri: str + :param scope: Scope of authorization request, split with the blank space + :type scope: str + :param state: State will be returned to the redirect_uri + :type state: str + :param nonce: Associates a Client session with an ID Token to mitigate replay attacks + :type nonce: str + :param code_challenge: PKCE code challenge + :type code_challenge: str + :param code_challenge_method: PKCE code challenge method + :type code_challenge_method: str + :returns: Authorization URL Full Build + :rtype: str + """ + params_path = { + "authorization-endpoint": self.well_known()["authorization_endpoint"], + "client-id": self.client_id, + "redirect-uri": redirect_uri, + "scope": scope, + "state": state, + "nonce": nonce, + } + url = URL_AUTH.format(**params_path) + if code_challenge: + url += f"&code_challenge={code_challenge}" + if code_challenge_method: + url += f"&code_challenge_method={code_challenge_method}" + return url + + def token( + self, + username: str | None = "", + password: str | None = "", + grant_type: str = "password", + code: str = "", + redirect_uri: str = "", + totp: int | None = None, + scope: str = "openid", + code_verifier: str | None = None, + **extra: Any, # noqa: ANN401 + ) -> dict: + """ + Retrieve user token. + + The token endpoint is used to obtain tokens. Tokens can either be obtained by + exchanging an authorization code or by supplying credentials directly depending on + what flow is used. The token endpoint is also used to obtain new access tokens + when they expire. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param username: Username + :type username: str + :param password: Password + :type password: str + :param grant_type: Grant type + :type grant_type: str + :param code: Code + :type code: str + :param redirect_uri: Redirect URI + :type redirect_uri: str + :param totp: Time-based one-time password + :type totp: int + :param scope: Scope, defaults to openid + :type scope: str + :param code_verifier: PKCE code verifier + :type code_verifier: str + :param extra: Additional extra arguments + :type extra: dict + :returns: Keycloak token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "username": username, + "password": password, + "client_id": self.client_id, + "grant_type": grant_type, + "code": code, + "redirect_uri": redirect_uri, + "scope": scope, + } + if code_verifier: + payload["code_verifier"] = code_verifier + if extra: + payload.update(extra) + + if totp: + payload["totp"] = totp + + payload = self._add_secret_key(payload) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + 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: + """ + Refresh the user token. + + The token endpoint is used to obtain tokens. Tokens can either be obtained by + exchanging an authorization code or by supplying credentials directly depending on + what flow is used. The token endpoint is also used to obtain new access tokens + when they expire. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param refresh_token: Refresh token from Keycloak + :type refresh_token: str + :param grant_type: Grant type + :type grant_type: str + :returns: New token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "client_id": self.client_id, + "grant_type": grant_type, + "refresh_token": refresh_token, + } + payload = self._add_secret_key(payload) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + 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( + self, + token: str, + audience: str | None = None, + subject: str | None = None, + subject_token_type: str | None = None, + subject_issuer: str | None = None, + requested_issuer: str | None = None, + requested_token_type: str = "urn:ietf:params:oauth:token-type:refresh_token", # noqa: S107 + scope: str = "openid", + ) -> dict: + """ + Exchange user token. + + Use a token to obtain an entirely different token. See + https://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange + + :param token: Access token + :type token: str + :param audience: Audience + :type audience: str + :param subject: Subject + :type subject: str + :param subject_token_type: Token Type specification + :type subject_token_type: Optional[str] + :param subject_issuer: Issuer + :type subject_issuer: Optional[str] + :param requested_issuer: Issuer + :type requested_issuer: Optional[str] + :param requested_token_type: Token type specification + :type requested_token_type: str + :param scope: Scope, defaults to openid + :type scope: str + :returns: Exchanged token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "client_id": self.client_id, + "subject_token": token, + "subject_token_type": subject_token_type, + "subject_issuer": subject_issuer, + "requested_token_type": requested_token_type, + "audience": audience, + "requested_subject": subject, + "requested_issuer": requested_issuer, + "scope": scope, + } + payload = self._add_secret_key(payload) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + 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: + """ + Get the user info object. + + The userinfo endpoint returns standard claims about the authenticated user, + and is protected by a bearer token. + + http://openid.net/specs/openid-connect-core-1_0.html#UserInfo + + :param token: Access token + :type token: str + :returns: Userinfo object + :rtype: dict + """ + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + 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) + + return res + + def logout(self, refresh_token: str) -> dict: + """ + Log out the authenticated user. + + :param refresh_token: Refresh token from Keycloak + :type refresh_token: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "refresh_token": refresh_token} + payload = self._add_secret_key(payload) + data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), data=payload) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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: + """ + Get certificates. + + The certificate endpoint returns the public keys enabled by the realm, encoded as a + JSON Web Key (JWK). Depending on the realm settings there can be one or more keys enabled + for verifying tokens. + + https://tools.ietf.org/html/rfc7517 + + :returns: Certificates + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) + 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: + """ + Retrieve the public key. + + The public key is exposed by the realm page directly. + + :returns: The public key + :rtype: str + """ + params_path = {"realm-name": self.realm_name} + data_raw = self.connection.raw_get(URL_REALM.format(**params_path)) + 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: + """ + Get entitlements from the token. + + Client applications can use a specific endpoint to obtain a special security token + called a requesting party token (RPT). This token consists of all the entitlements + (or permissions) for a user as a result of the evaluation of the permissions and + authorization policies associated with the resources being requested. With an RPT, + client applications can gain access to protected resources at the resource server. + + :param token: Access token + :type token: str + :param resource_server_id: Resource server ID + :type resource_server_id: str + :returns: Entitlements + :rtype: dict + """ + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} + data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + + if data_raw.status_code in {HTTP_NOT_FOUND, HTTP_NOT_ALLOWED}: + 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 res + + def introspect( + self, + token: str, + rpt: str | None = None, + token_type_hint: str | None = None, + ) -> dict: + """ + Introspect the user token. + + The introspection endpoint is used to retrieve the active state of a token. + It is can only be invoked by confidential clients. + + https://tools.ietf.org/html/rfc7662 + + :param token: Access token + :type token: str + :param rpt: Requesting party token + :type rpt: str + :param token_type_hint: Token type hint + :type token_type_hint: str + + :returns: Token info + :rtype: dict + :raises KeycloakRPTNotFound: In case of RPT not specified + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "token": token} + + bearer_changed = False + orig_bearer = None + if token_type_hint == "requesting_party_token": # noqa: S105 + if rpt: + payload.update({"token": rpt, "token_type_hint": token_type_hint}) + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + bearer_changed = True + else: + msg = "Can't find RPT" + raise KeycloakRPTNotFound(msg) + + payload = self._add_secret_key(payload) + + data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), data=payload) + if bearer_changed: + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + + 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 + def _verify_token(token: str, key: jwk.JWK | jwk.JWKSet | None, **kwargs: Any) -> dict: # noqa: ANN401 + """ + Decode and optionally validate a token. + + :param token: The token to verify + :type token: str + :param key: Which key should be used for validation. + If not provided, the validation is not performed and the token is implicitly valid. + :type key: Union[jwk.JWK, jwk.JWKSet, None] + :param kwargs: Additional keyword arguments for jwcrypto's JWT object + :type kwargs: dict + :returns: Decoded token + """ + # keep the function free of IO + # this way it can be used by `decode_token` and `a_decode_token` + + if key is not None: + leeway = kwargs.pop("leeway", 60) + full_jwt = jwt.JWT(jwt=token, **kwargs) + full_jwt.leeway = leeway + full_jwt.validate(key) + return jwt.json_decode(full_jwt.claims) # pyright: ignore[reportAttributeAccessIssue] + + full_jwt = jwt.JWT(jwt=token, **kwargs) + full_jwt.token.objects["valid"] = True + return json.loads(full_jwt.token.payload.decode("utf-8")) + + def decode_token(self, token: str, validate: bool = True, **kwargs: Any) -> dict: # noqa: ANN401 + """ + Decode user token. + + A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data + structure that represents a cryptographic key. This specification + also defines a JWK Set JSON data structure that represents a set of + JWKs. Cryptographic algorithms and identifiers for use with this + specification are described in the separate JSON Web Algorithms (JWA) + specification and IANA registries established by that specification. + + https://tools.ietf.org/html/rfc7517 + + :param token: Keycloak token + :type token: str + :param validate: Determines whether the token should be validated with the public key. + Defaults to True. + :type validate: bool + :param kwargs: Additional keyword arguments for jwcrypto's JWT object + :type kwargs: dict + :returns: Decoded token + :rtype: dict + """ + key = kwargs.pop("key", None) + if validate: + if key is None: + key = ( + "-----BEGIN PUBLIC KEY-----\n" + + self.public_key() + + "\n-----END PUBLIC KEY-----" + ) + key = jwk.JWK.from_pem(key.encode("utf-8")) + else: + key = None + + return self._verify_token(token, key, **kwargs) + + def load_authorization_config(self, path: str) -> None: + """ + Load Keycloak settings (authorization). + + :param path: settings file (json) + :type path: str + """ + with pathlib.Path(path).open("r") as fp: + authorization_json = json.load(fp) + + self.authorization.load_config(authorization_json) + + def get_policies( + self, + token: str, + method_token_info: str = "introspect", # noqa: S107 + **kwargs: Any, # noqa: ANN401 + ) -> list | None: + """ + Get policies by user token. + + :param token: User token + :type token: str + :param method_token_info: Method for token info decoding + :type method_token_info: str + :param kwargs: Additional keyword arguments + :type kwargs: dict + :return: Policies + :rtype: dict + :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration + :raises KeycloakInvalidTokenError: In case of bad token + """ + if not self.authorization.policies: + msg = "Keycloak settings not found. Load Authorization Keycloak settings." + raise KeycloakAuthorizationConfigError(msg) + + token_info = self._token_info(token, method_token_info, **kwargs) + + if method_token_info == "introspect" and not token_info["active"]: # noqa: S105 + msg = "Token expired or invalid." + raise KeycloakInvalidTokenError(msg) + + user_resources = token_info["resource_access"].get(self.client_id) + + if not user_resources: + return None + + policies = [ + policy + for policy in self.authorization.policies.values() + for role in user_resources["roles"] + if self._build_name_role(role) in policy.roles + ] + + return list(set(policies)) + + def get_permissions( + self, + token: str, + method_token_info: str = "introspect", # noqa: S107 + **kwargs: Any, # noqa: ANN401 + ) -> list | None: + """ + Get permission by user token. + + :param token: user token + :type token: str + :param method_token_info: Decode token method + :type method_token_info: str + :param kwargs: parameters for decode + :type kwargs: dict + :returns: permissions list + :rtype: list + :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration + :raises KeycloakInvalidTokenError: In case of bad token + """ + if not self.authorization.policies: + msg = "Keycloak settings not found. Load Authorization Keycloak settings." + raise KeycloakAuthorizationConfigError(msg) + + token_info = self._token_info(token, method_token_info, **kwargs) + + if method_token_info == "introspect" and not token_info["active"]: # noqa: S105 + msg = "Token expired or invalid." + raise KeycloakInvalidTokenError(msg) + + user_resources = token_info["resource_access"].get(self.client_id) + + if not user_resources: + return None + + permissions = [] + + for policy in self.authorization.policies.values(): + for role in user_resources["roles"]: + if self._build_name_role(role) in policy.roles: + permissions += policy.permissions + + return list(set(permissions)) + + def uma_permissions( + self, + token: str, + permissions: str | list | dict | tuple | set = "", + **extra_payload: Any, # noqa: ANN401 + ) -> list: + """ + Get UMA permissions by user token with requested permissions. + + The token endpoint is used to retrieve UMA permissions from Keycloak. It can only be + invoked by confidential clients. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param token: user token + :type token: str + :param permissions: list of uma permissions list(resource:scope) requested by the user + :type permissions: str + :param extra_payload: Additional payload data + :type extra_payload: dict + :returns: Keycloak server response + :rtype: list + """ + permission = build_permission_param(permissions) + + params_path = {"realm-name": self.realm_name} + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": permission, + "response_mode": "permissions", + "audience": self.client_id, + **extra_payload, + } + + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + 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) + + return res + + def has_uma_access( + self, token: str, permissions: str | list | dict | tuple | set + ) -> AuthStatus: + """ + Determine whether user has uma permissions with specified user token. + + :param token: user token + :type token: str + :param permissions: list of uma permissions (resource:scope) + :type permissions: str + :return: Authentication status + :rtype: AuthStatus + :raises KeycloakAuthenticationError: In case of failed authentication + :raises KeycloakPostError: In case of failed request to Keycloak + """ + needed = build_permission_param(permissions) + try: + granted = self.uma_permissions(token, permissions) + except (KeycloakPostError, KeycloakAuthenticationError) as e: + if e.response_code == HTTP_FORBIDDEN: # pragma: no cover + return AuthStatus( + is_logged_in=True, + is_authorized=False, + missing_permissions=needed, + ) + if e.response_code == HTTP_UNAUTHORIZED: + return AuthStatus( + is_logged_in=False, + is_authorized=False, + missing_permissions=needed, + ) + raise + + for resource_struct in granted: + for resource in (resource_struct["rsname"], resource_struct["rsid"]): + scopes = resource_struct.get("scopes", None) + if not scopes: + needed.discard(resource) + continue + for scope in scopes: # pragma: no cover + needed.discard(f"{resource}#{scope}") + + return AuthStatus( + is_logged_in=True, + is_authorized=len(needed) == 0, + missing_permissions=needed, + ) + + def register_client(self, token: str, payload: dict) -> dict: + """ + Create a client. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param token: Initial access token + :type token: str + :param payload: ClientRepresentation + :type payload: dict + :return: Client Representation + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/json") + data_raw = self.connection.raw_post( + URL_CLIENT_REGISTRATION.format(**params_path), + data=json.dumps(payload), + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) + 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: + """ + Get device authorization grant. + + The device endpoint is used to obtain a user code verification and user authentication. + The response contains a device_code, user_code, verification_uri, + verification_uri_complete, expires_in (lifetime in seconds for device_code + and user_code), and polling interval. + Users can either follow the verification_uri and enter the user_code or + follow the verification_uri_complete. + After authenticating with valid credentials, users can obtain tokens using the + "urn:ietf:params:oauth:grant-type:device_code" grant_type and the device_code. + + https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow + https://github.com/keycloak/keycloak-community/blob/main/design/oauth2-device-authorization-grant.md#how-to-try-it + + :param scope: Scope of authorization request, split with the blank space + :type scope: str + :returns: Device Authorization Response + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "scope": scope} + + payload = self._add_secret_key(payload) + data_raw = self.connection.raw_post(URL_DEVICE.format(**params_path), data=payload) + 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) -> dict: + """ + Update a client. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param token: registration access token + :type token: str + :param client_id: Keycloak client id + :type client_id: str + :param payload: ClientRepresentation + :type payload: dict + :return: Client Representation + :rtype: bytes + """ + params_path = {"realm-name": self.realm_name, "client-id": client_id} + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/json") + + # Keycloak complains if the clientId is not set in the payload + if "clientId" not in payload: + payload["clientId"] = client_id + + data_raw = self.connection.raw_put( + URL_CLIENT_UPDATE.format(**params_path), + data=json.dumps(payload), + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) + 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: Any) -> dict: # noqa: ANN401 + """ + Asynchronous getter for the token data. + + :param token: Token + :type token: str + :param method_token_info: Token info method to use + :type method_token_info: str + :param kwargs: Additional keyword arguments passed to the decode_token method + :type kwargs: dict + :returns: Token info + :rtype: dict + """ + if method_token_info == "introspect": # noqa: S105 + token_info = await self.a_introspect(token) + else: + token_info = await self.a_decode_token(token, **kwargs) + + return token_info + + async def a_well_known(self) -> dict: + """ + Get the well_known object asynchronously. + + The most important endpoint to understand is the well-known configuration + endpoint. It lists endpoints and other configuration options relevant to + the OpenID Connect implementation in Keycloak. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + data_raw = await self.connection.a_raw_get(URL_WELL_KNOWN.format(**params_path)) + 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( + self, + redirect_uri: str, + scope: str = "email", + state: str = "", + nonce: str = "", + code_challenge: str | None = None, + code_challenge_method: str | None = None, + ) -> str: + """ + Get authorization URL endpoint asynchronously. + + :param redirect_uri: Redirect url to receive oauth code + :type redirect_uri: str + :param scope: Scope of authorization request, split with the blank space + :type scope: str + :param state: State will be returned to the redirect_uri + :type state: str + :param nonce: Associates a Client session with an ID Token to mitigate replay attacks + :type nonce: str + :param code_challenge: PKCE code challenge + :type code_challenge: str + :param code_challenge_method: PKCE code challenge method + :type code_challenge_method: str + :returns: Authorization URL Full Build + :rtype: str + """ + params_path = { + "authorization-endpoint": (await self.a_well_known())["authorization_endpoint"], + "client-id": self.client_id, + "redirect-uri": redirect_uri, + "scope": scope, + "state": state, + "nonce": nonce, + } + url = URL_AUTH.format(**params_path) + if code_challenge: + url += f"&code_challenge={code_challenge}" + if code_challenge_method: + url += f"&code_challenge_method={code_challenge_method}" + return url + + async def a_token( + self, + username: str | None = "", + password: str | None = "", + grant_type: str = "password", + code: str = "", + redirect_uri: str = "", + totp: int | None = None, + scope: str = "openid", + code_verifier: str | None = None, + **extra: Any, # noqa: ANN401 + ) -> dict: + """ + Retrieve user token asynchronously. + + The token endpoint is used to obtain tokens. Tokens can either be obtained by + exchanging an authorization code or by supplying credentials directly depending on + what flow is used. The token endpoint is also used to obtain new access tokens + when they expire. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param username: Username + :type username: str + :param password: Password + :type password: str + :param grant_type: Grant type + :type grant_type: str + :param code: Code + :type code: str + :param redirect_uri: Redirect URI + :type redirect_uri: str + :param totp: Time-based one-time password + :type totp: int + :param scope: Scope, defaults to openid + :type scope: str + :param code_verifier: PKCE code verifier + :type code_verifier: str + :param extra: Additional extra arguments + :type extra: dict + :returns: Keycloak token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "username": username, + "password": password, + "client_id": self.client_id, + "grant_type": grant_type, + "code": code, + "redirect_uri": redirect_uri, + "scope": scope, + } + if code_verifier: + payload["code_verifier"] = code_verifier + if extra: + payload.update(extra) + + if totp: + payload["totp"] = totp + + payload = self._add_secret_key(payload) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + 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: + """ + Refresh the user token asynchronously. + + The token endpoint is used to obtain tokens. Tokens can either be obtained by + exchanging an authorization code or by supplying credentials directly depending on + what flow is used. The token endpoint is also used to obtain new access tokens + when they expire. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param refresh_token: Refresh token from Keycloak + :type refresh_token: str + :param grant_type: Grant type + :type grant_type: str + :returns: New token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "client_id": self.client_id, + "grant_type": grant_type, + "refresh_token": refresh_token, + } + payload = self._add_secret_key(payload) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + 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( + self, + token: str, + audience: str | None = None, + subject: str | None = None, + subject_token_type: str | None = None, + subject_issuer: str | None = None, + requested_issuer: str | None = None, + requested_token_type: str = "urn:ietf:params:oauth:token-type:refresh_token", # noqa: S107 + scope: str = "openid", + ) -> dict: + """ + Exchange user token asynchronously. + + Use a token to obtain an entirely different token. See + https://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange + + :param token: Access token + :type token: str + :param audience: Audience + :type audience: str + :param subject: Subject + :type subject: str + :param subject_token_type: Token Type specification + :type subject_token_type: Optional[str] + :param subject_issuer: Issuer + :type subject_issuer: Optional[str] + :param requested_issuer: Issuer + :type requested_issuer: Optional[str] + :param requested_token_type: Token type specification + :type requested_token_type: str + :param scope: Scope, defaults to openid + :type scope: str + :returns: Exchanged token + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "client_id": self.client_id, + "subject_token": token, + "subject_token_type": subject_token_type, + "subject_issuer": subject_issuer, + "requested_token_type": requested_token_type, + "audience": audience, + "requested_subject": subject, + "requested_issuer": requested_issuer, + "scope": scope, + } + payload = self._add_secret_key(payload) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + 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: + """ + Get the user info object asynchronously. + + The userinfo endpoint returns standard claims about the authenticated user, + and is protected by a bearer token. + + http://openid.net/specs/openid-connect-core-1_0.html#UserInfo + + :param token: Access token + :type token: str + :returns: Userinfo object + :rtype: dict + """ + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name} + data_raw = await self.connection.a_raw_get(URL_USERINFO.format(**params_path)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + 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_logout(self, refresh_token: str) -> dict: + """ + Log out the authenticated user asynchronously. + + :param refresh_token: Refresh token from Keycloak + :type refresh_token: str + :returns: Keycloak server response + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "refresh_token": refresh_token} + payload = self._add_secret_key(payload) + data_raw = await self.connection.a_raw_post(URL_LOGOUT.format(**params_path), data=payload) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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: + """ + Get certificates asynchronously. + + The certificate endpoint returns the public keys enabled by the realm, encoded as a + JSON Web Key (JWK). Depending on the realm settings there can be one or more keys enabled + for verifying tokens. + + https://tools.ietf.org/html/rfc7517 + + :returns: Certificates + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + data_raw = await self.connection.a_raw_get(URL_CERTS.format(**params_path)) + 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: + """ + Retrieve the public key asynchronously. + + The public key is exposed by the realm page directly. + + :returns: The public key + :rtype: str + """ + params_path = {"realm-name": self.realm_name} + data_raw = await self.connection.a_raw_get(URL_REALM.format(**params_path)) + 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: + """ + Get entitlements from the token asynchronously. + + Client applications can use a specific endpoint to obtain a special security token + called a requesting party token (RPT). This token consists of all the entitlements + (or permissions) for a user as a result of the evaluation of the permissions and + authorization policies associated with the resources being requested. With an RPT, + client applications can gain access to protected resources at the resource server. + + :param token: Access token + :type token: str + :param resource_server_id: Resource server ID + :type resource_server_id: str + :returns: Entitlements + :rtype: dict + """ + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} + data_raw = await self.connection.a_raw_get(URL_ENTITLEMENT.format(**params_path)) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + + if data_raw.status_code in [HTTP_NOT_FOUND, HTTP_NOT_ALLOWED]: + 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 + + 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( + self, + token: str, + rpt: str | None = None, + token_type_hint: str | None = None, + ) -> dict: + """ + Introspect the user token asynchronously. + + The introspection endpoint is used to retrieve the active state of a token. + It is can only be invoked by confidential clients. + + https://tools.ietf.org/html/rfc7662 + + :param token: Access token + :type token: str + :param rpt: Requesting party token + :type rpt: str + :param token_type_hint: Token type hint + :type token_type_hint: str + + :returns: Token info + :rtype: dict + :raises KeycloakRPTNotFound: In case of RPT not specified + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "token": token} + + orig_bearer = None + bearer_changed = False + if token_type_hint == "requesting_party_token": # noqa: S105 + if rpt: + payload.update({"token": rpt, "token_type_hint": token_type_hint}) + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + bearer_changed = True + else: + msg = "Can't find RPT." + raise KeycloakRPTNotFound(msg) + + payload = self._add_secret_key(payload) + + data_raw = await self.connection.a_raw_post( + URL_INTROSPECT.format(**params_path), + data=payload, + ) + if bearer_changed: + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + 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_decode_token(self, token: str, validate: bool = True, **kwargs: Any) -> dict: # noqa: ANN401 + """ + Decode user token asynchronously. + + A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data + structure that represents a cryptographic key. This specification + also defines a JWK Set JSON data structure that represents a set of + JWKs. Cryptographic algorithms and identifiers for use with this + specification are described in the separate JSON Web Algorithms (JWA) + specification and IANA registries established by that specification. + + https://tools.ietf.org/html/rfc7517 + + :param token: Keycloak token + :type token: str + :param validate: Determines whether the token should be validated with the public key. + Defaults to True. + :type validate: bool + :param kwargs: Additional keyword arguments for jwcrypto's JWT object + :type kwargs: dict + :returns: Decoded token + :rtype: dict + """ + key = kwargs.pop("key", None) + if validate: + if key is None: + key = ( + "-----BEGIN PUBLIC KEY-----\n" + + await self.a_public_key() + + "\n-----END PUBLIC KEY-----" + ) + key = jwk.JWK.from_pem(key.encode("utf-8")) + else: + key = None + + return self._verify_token(token, key, **kwargs) + + async def a_load_authorization_config(self, path: str) -> None: + """ + Load Keycloak settings (authorization) asynchronously. + + :param path: settings file (json) + :type path: str + """ + async with aiofiles.open(path) as fp: + authorization_json = json.loads(await fp.read()) + + self.authorization.load_config(authorization_json) + + async def a_get_policies( + self, + token: str, + method_token_info: str = "introspect", # noqa: S107 + **kwargs: Any, # noqa: ANN401 + ) -> list | None: + """ + Get policies by user token asynchronously. + + :param token: User token + :type token: str + :param method_token_info: Method for token info decoding + :type method_token_info: str + :param kwargs: Additional keyword arguments + :type kwargs: dict + :return: Policies + :rtype: list | None + :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration + :raises KeycloakInvalidTokenError: In case of bad token + """ + if not self.authorization.policies: + msg = "Keycloak settings not found. Load Authorization Keycloak settings." + raise KeycloakAuthorizationConfigError(msg) + + token_info = await self._a_token_info(token, method_token_info, **kwargs) + + if method_token_info == "introspect" and not token_info["active"]: # noqa: S105 + msg = "Token expired or invalid." + raise KeycloakInvalidTokenError(msg) + + user_resources = token_info["resource_access"].get(self.client_id) + + if not user_resources: + return None + + policies = [ + policy + for policy in self.authorization.policies.values() + for role in user_resources["roles"] + if self._build_name_role(role) in policy.roles + ] + + return list(set(policies)) + + async def a_get_permissions( + self, + token: str, + method_token_info: str = "introspect", # noqa: S107 + **kwargs: Any, # noqa: ANN401 + ) -> list | None: + """ + Get permission by user token asynchronously. + + :param token: user token + :type token: str + :param method_token_info: Decode token method + :type method_token_info: str + :param kwargs: parameters for decode + :type kwargs: dict + :returns: permissions list + :rtype: list | None + :raises KeycloakAuthorizationConfigError: In case of bad authorization configuration + :raises KeycloakInvalidTokenError: In case of bad token + """ + if not self.authorization.policies: + msg = "Keycloak settings not found. Load Authorization Keycloak settings." + raise KeycloakAuthorizationConfigError(msg) + + token_info = await self._a_token_info(token, method_token_info, **kwargs) + + if method_token_info == "introspect" and not token_info["active"]: # noqa: S105 + msg = "Token expired or invalid." + raise KeycloakInvalidTokenError(msg) + + user_resources = token_info["resource_access"].get(self.client_id) + + if not user_resources: + return None + + permissions = [] + for policy in self.authorization.policies.values(): + for role in user_resources["roles"]: + if self._build_name_role(role) in policy.roles: + permissions += policy.permissions + + return list(set(permissions)) + + async def a_uma_permissions( + self, + token: str, + permissions: str | list | dict | tuple | set = "", + **extra_payload: Any, # noqa: ANN401 + ) -> list: + """ + Get UMA permissions by user token with requested permissions asynchronously. + + The token endpoint is used to retrieve UMA permissions from Keycloak. It can only be + invoked by confidential clients. + + http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + + :param token: user token + :type token: str + :param permissions: list of uma permissions list(resource:scope) requested by the user + :type permissions: str + :param extra_payload: Additional payload data + :type extra_payload: dict + :returns: Keycloak server response + :rtype: list + """ + permission = build_permission_param(permissions) + + params_path = {"realm-name": self.realm_name} + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": list(permission), # httpx does not handle `set` correctly + "response_mode": "permissions", + "audience": self.client_id, + **extra_payload, + } + + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") + data_raw = await self.connection.a_raw_post(URL_TOKEN.format(**params_path), data=payload) + ( + self.connection.add_param_headers("Content-Type", content_type) + if content_type + else self.connection.del_param_headers("Content-Type") + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + 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) + + return res + + async def a_has_uma_access( + self, token: str, permissions: str | list | dict | tuple | set + ) -> AuthStatus: + """ + Determine whether user has uma permissions with specified user token asynchronously. + + :param token: user token + :type token: str + :param permissions: list of uma permissions (resource:scope) + :type permissions: str + :return: Authentication status + :rtype: AuthStatus + :raises KeycloakAuthenticationError: In case of failed authentication + :raises KeycloakPostError: In case of failed request to Keycloak + """ + needed = build_permission_param(permissions) + try: + granted = await self.a_uma_permissions(token, permissions) + except (KeycloakPostError, KeycloakAuthenticationError) as e: + if e.response_code == HTTP_FORBIDDEN: # pragma: no cover + return AuthStatus( + is_logged_in=True, + is_authorized=False, + missing_permissions=needed, + ) + if e.response_code == HTTP_UNAUTHORIZED: + return AuthStatus( + is_logged_in=False, + is_authorized=False, + missing_permissions=needed, + ) + raise + + for resource_struct in granted: + for resource in (resource_struct["rsname"], resource_struct["rsid"]): + scopes = resource_struct.get("scopes", None) + if not scopes: + needed.discard(resource) + continue + for scope in scopes: # pragma: no cover + needed.discard(f"{resource}#{scope}") + + return AuthStatus( + is_logged_in=True, + is_authorized=len(needed) == 0, + missing_permissions=needed, + ) + + async def a_register_client(self, token: str, payload: dict) -> dict: + """ + Create a client asynchronously. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param token: Initial access token + :type token: str + :param payload: ClientRepresentation + :type payload: dict + :return: Client Representation + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/json") + data_raw = await self.connection.a_raw_post( + URL_CLIENT_REGISTRATION.format(**params_path), + data=json.dumps(payload), + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) + 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: + """ + Get device authorization grant asynchronously. + + The device endpoint is used to obtain a user code verification and user authentication. + The response contains a device_code, user_code, verification_uri, + verification_uri_complete, expires_in (lifetime in seconds for device_code + and user_code), and polling interval. + Users can either follow the verification_uri and enter the user_code or + follow the verification_uri_complete. + After authenticating with valid credentials, users can obtain tokens using the + "urn:ietf:params:oauth:grant-type:device_code" grant_type and the device_code. + + https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow + https://github.com/keycloak/keycloak-community/blob/main/design/oauth2-device-authorization-grant.md#how-to-try-it + + :param scope: Scope of authorization request, split with the blank space + :type scope: str + :returns: Device Authorization Response + :rtype: dict + """ + params_path = {"realm-name": self.realm_name} + payload = {"client_id": self.client_id, "scope": scope} + + payload = self._add_secret_key(payload) + data_raw = await self.connection.a_raw_post(URL_DEVICE.format(**params_path), data=payload) + 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) -> dict: + """ + Update a client asynchronously. + + ClientRepresentation: + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_clientrepresentation + + :param token: registration access token + :type token: str + :param client_id: Keycloak client id + :type client_id: str + :param payload: ClientRepresentation + :type payload: dict + :return: Client Representation + :rtype: dict + """ + params_path = {"realm-name": self.realm_name, "client-id": client_id} + orig_bearer = (self.connection.headers or {}).get("Authorization") + self.connection.add_param_headers("Authorization", "Bearer " + token) + orig_content_type = (self.connection.headers or {}).get("Content-Type") + self.connection.add_param_headers("Content-Type", "application/json") + + # Keycloak complains if the clientId is not set in the payload + if "clientId" not in payload: + payload["clientId"] = client_id + + data_raw = await self.connection.a_raw_put( + URL_CLIENT_UPDATE.format(**params_path), + data=json.dumps(payload), + ) + ( + self.connection.add_param_headers("Authorization", orig_bearer) + if orig_bearer is not None + else self.connection.del_param_headers("Authorization") + ) + ( + self.connection.add_param_headers("Content-Type", orig_content_type) + if orig_content_type is not None + else self.connection.del_param_headers("Content-Type") + ) + 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 diff --git a/src/keycloak/keycloak_uma.py b/src/keycloak/keycloak_uma.py new file mode 100644 index 00000000..6c822ed3 --- /dev/null +++ b/src/keycloak/keycloak_uma.py @@ -0,0 +1,1122 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +Keycloak UMA module. + +The module contains a UMA compatible client for keycloak: +https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-federated-authz-2.0.html +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from urllib.parse import quote_plus + +from .connection import ConnectionManager +from .exceptions import ( + HTTP_CREATED, + HTTP_NO_CONTENT, + HTTP_OK, + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, + raise_error_from_response, +) +from .urls_patterns import URL_UMA_WELL_KNOWN + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator, Iterable + + from .openid_connection import KeycloakOpenIDConnection + from .uma_permissions import UMAPermission + + +class KeycloakUMA: + """ + Keycloak UMA client. + + :param connection: OpenID connection manager + """ + + def __init__(self, connection: KeycloakOpenIDConnection) -> None: + """ + Init method. + + :param connection: OpenID connection manager + :type connection: KeycloakOpenIDConnection + """ + self.connection = connection + self._well_known = None + + def _fetch_well_known(self) -> dict: + params_path = {"realm-name": self.connection.realm_name} + data_raw = self.connection.raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) + 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 + def format_url(url: str, **kwargs: Any) -> str: # noqa: ANN401 + """ + Substitute url path parameters. + + Given a parameterized url string, returns the string after url encoding and substituting + the given params. For example, + `format_url("https://myserver/{my_resource}/{id}", my_resource="hello world", id="myid")` + would produce `https://myserver/hello+world/myid`. + + :param url: url string to format + :type url: str + :param kwargs: dict containing kwargs to substitute + :type kwargs: dict + :return: formatted string + :rtype: str + """ + return url.format(**{k: quote_plus(v) for k, v in kwargs.items()}) + + @staticmethod + async def a_format_url(url: str, **kwargs: Any) -> str: # noqa: ANN401 + """ + Substitute url path parameters. + + Given a parameterized url string, returns the string after url encoding and substituting + the given params. For example, + `format_url("https://myserver/{my_resource}/{id}", my_resource="hello world", id="myid")` + would produce `https://myserver/hello+world/myid`. + + :param url: url string to format + :type url: str + :param kwargs: dict containing kwargs to substitute + :type kwargs: dict + :return: formatted string + :rtype: str + """ + return url.format(**{k: quote_plus(v) for k, v in kwargs.items()}) + + @property + def uma_well_known(self) -> dict: + """ + Get the well_known UMA2 config. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + # per instance cache + if not self._well_known: + self._well_known = self._fetch_well_known() + + return self._well_known + + @property + async def a_uma_well_known(self) -> dict: + """ + Get the well_known UMA2 config async. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + if not self._well_known: + self._well_known = await self.a__fetch_well_known() + + return self._well_known + + def resource_set_create(self, payload: dict) -> dict: + """ + Create a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#rfc.section.2.2.1 + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param payload: ResourceRepresentation + :type payload: dict + :return: ResourceRepresentation with the _id property assigned + :rtype: dict + """ + data_raw = self.connection.raw_post( + self.uma_well_known["resource_registration_endpoint"], + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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 + + def resource_set_update(self, resource_id: str, payload: dict) -> dict: + """ + Update a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#update-resource-set + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param resource_id: id of the resource + :type resource_id: str + :param payload: ResourceRepresentation + :type payload: dict + :return: Response dict (empty) + :rtype: bytes + """ + url = self.format_url( + self.uma_well_known["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = self.connection.raw_put(url, data=json.dumps(payload)) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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: + """ + Read a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#read-resource-set + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param resource_id: id of the resource + :type resource_id: str + :return: ResourceRepresentation + :rtype: dict + """ + url = self.format_url( + self.uma_well_known["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = self.connection.raw_get(url) + 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) + + return res + + def resource_set_delete(self, resource_id: str) -> dict: + """ + Delete a resource set. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#delete-resource-set + + :param resource_id: id of the resource + :type resource_id: str + :return: Response dict (empty) + :rtype: dict + """ + url = self.format_url( + self.uma_well_known["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = self.connection.raw_delete(url) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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( + self, + name: str = "", + exact_name: bool = False, + uri: str = "", + owner: str = "", + resource_type: str = "", + scope: str = "", + matchingUri: bool = False, # noqa: N803 + first: int = 0, + maximum: int = -1, + ) -> list: + """ + Query for list of resource set ids. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets + + :param name: query resource name + :type name: str + :param exact_name: query exact match for resource name + :type exact_name: bool + :param uri: query resource uri + :type uri: str + :param owner: query resource owner + :type owner: str + :param resource_type: query resource type + :type resource_type: str + :param scope: query resource scope + :type scope: str + :param matchingUri: enable URI matching + :type matchingUri: bool + :param first: index of first matching resource to return + :type first: int + :param maximum: maximum number of resources to return (-1 for all) + :type maximum: int + :return: List of ids + :rtype: List[str] + """ + query = {} + if name: + query["name"] = name + if exact_name: + query["exactName"] = "true" + if uri: + query["uri"] = uri + if owner: + query["owner"] = owner + if resource_type: + query["type"] = resource_type + if scope: + query["scope"] = scope + if matchingUri: + query["matchingUri"] = "true" + if first > 0: + query["first"] = first + if maximum >= 0: + query["max"] = maximum + + data_raw = self.connection.raw_get( + self.uma_well_known["resource_registration_endpoint"], + **query, + ) + 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) -> Generator[dict, Any, Any]: + """ + List all resource sets. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :yields: Iterator over a list of ResourceRepresentations + :rtype: Iterator[dict] + """ + for resource_id in self.resource_set_list_ids(): + resource = self.resource_set_read(resource_id) + yield resource + + def permission_ticket_create(self, permissions: Iterable[UMAPermission]) -> dict: + """ + Create a permission ticket. + + :param permissions: Iterable of uma permissions to validate the token against + :type permissions: Iterable[UMAPermission] + :returns: Keycloak decision + :rtype: boolean + :raises KeycloakPostError: In case permission resource not found + """ + resources = {} + for permission in permissions: + resource_id = getattr(permission, "resource_id", None) + + if resource_id is None: + resource_ids = self.resource_set_list_ids( + exact_name=True, + name=permission.resource, + first=0, + maximum=1, + ) + + if not resource_ids: + msg = "Invalid resource specified" + raise KeycloakPostError(msg) + + permission.resource_id = resource_ids[0] + + resources.setdefault(resource_id, set()) + if permission.scope: + resources[resource_id].add(permission.scope) + + payload = [ + {"resource_id": resource_id, "resource_scopes": list(scopes)} + for resource_id, scopes in resources.items() + ] + + data_raw = self.connection.raw_post( + self.uma_well_known["permission_endpoint"], + data=json.dumps(payload), + ) + 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( + self, + token: str, + permissions: Iterable[UMAPermission], + **extra_payload: Any, # noqa: ANN401 + ) -> bool: + """ + Check UMA permissions by user token with requested permissions. + + The token endpoint is used to check UMA permissions from Keycloak. It can only be + invoked by confidential clients. + + https://www.keycloak.org/docs/latest/authorization_services/#_service_authorization_api + + :param token: user token + :type token: str + :param permissions: Iterable of uma permissions to validate the token against + :type permissions: Iterable[UMAPermission] + :param extra_payload: extra payload data + :type extra_payload: dict + :returns: Keycloak decision + :rtype: boolean + """ + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": ",".join(str(permission) for permission in permissions), + "response_mode": "decision", + "audience": self.connection.client_id, + **extra_payload, + } + + # Everyone always has the null set of permissions + # However keycloak cannot evaluate the null set + if len(payload["permission"]) == 0: + return True + + 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("Content-Type", "application/x-www-form-urlencoded") + data_raw = connection.raw_post(self.uma_well_known["token_endpoint"], data=payload) + try: + data = raise_error_from_response(data_raw, KeycloakPostError) + except KeycloakPostError: + return False + + 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) + + def policy_resource_create(self, resource_id: str, payload: dict) -> dict: + """ + Create permission policy for resource. + + Supports name, description, scopes, roles, groups, clients + + https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource + + :param resource_id: _id of resource + :type resource_id: str + :param payload: permission configuration + :type payload: dict + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_post( + self.uma_well_known["policy_endpoint"] + f"/{resource_id}", + data=json.dumps(payload), + ) + 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) -> bytes: + """ + Update permission policy. + + https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + + :param policy_id: id of policy permission + :type policy_id: str + :param payload: policy permission configuration + :type payload: dict + :return: PermissionRepresentation + :rtype: bytes + """ + data_raw = self.connection.raw_put( + self.uma_well_known["policy_endpoint"] + f"/{policy_id}", + data=json.dumps(payload), + ) + 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: + """ + Delete permission policy. + + https://www.keycloak.org/docs/latest/authorization_services/#removing-a-permission + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + + :param policy_id: id of permission policy + :type policy_id: str + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = self.connection.raw_delete( + self.uma_well_known["policy_endpoint"] + f"/{policy_id}", + ) + 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( + self, + resource: str = "", + name: str = "", + scope: str = "", + first: int = 0, + maximum: int = -1, + ) -> list: + """ + Query permission policies. + + https://www.keycloak.org/docs/latest/authorization_services/#querying-permission + + :param resource: query resource id + :type resource: str + :param name: query resource name + :type name: str + :param scope: query resource scope + :type scope: str + :param first: index of first matching resource to return + :type first: int + :param maximum: maximum number of resources to return (-1 for all) + :type maximum: int + :return: List of ids + :return: List of ids + :rtype: List[str] + """ + query = {} + if name: + query["name"] = name + if resource: + query["resource"] = resource + if scope: + query["scope"] = scope + if first > 0: + query["first"] = first + if maximum >= 0: + query["max"] = maximum + + data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query) + 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: + """ + Get the well_known UMA2 config async. + + :returns: It lists endpoints and other configuration options relevant + :rtype: dict + """ + params_path = {"realm-name": self.connection.realm_name} + data_raw = await self.connection.a_raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) + 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: + """ + Create a resource set asynchronously. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#rfc.section.2.2.1 + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param payload: ResourceRepresentation + :type payload: dict + :return: ResourceRepresentation with the _id property assigned + :rtype: dict + """ + data_raw = await self.connection.a_raw_post( + (await self.a_uma_well_known)["resource_registration_endpoint"], + data=json.dumps(payload), + ) + res = raise_error_from_response( + data_raw, + KeycloakPostError, + 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) -> dict: + """ + Update a resource set asynchronously. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#update-resource-set + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param resource_id: id of the resource + :type resource_id: str + :param payload: ResourceRepresentation + :type payload: dict + :return: Response dict (empty) + :rtype: dict + """ + url = self.format_url( + (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = await self.connection.a_raw_put(url, data=json.dumps(payload)) + res = raise_error_from_response( + data_raw, + KeycloakPutError, + 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: + """ + Read a resource set asynchronously. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#read-resource-set + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :param resource_id: id of the resource + :type resource_id: str + :return: ResourceRepresentation + :rtype: dict + """ + url = self.format_url( + (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = await self.connection.a_raw_get(url) + 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) + + return res + + async def a_resource_set_delete(self, resource_id: str) -> dict: + """ + Delete a resource set asynchronously. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#delete-resource-set + + :param resource_id: id of the resource + :type resource_id: str + :return: Response dict (empty) + :rtype: dict + """ + url = self.format_url( + (await self.a_uma_well_known)["resource_registration_endpoint"] + "/{id}", + id=resource_id, + ) + data_raw = await self.connection.a_raw_delete(url) + res = raise_error_from_response( + data_raw, + KeycloakDeleteError, + 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( + self, + name: str = "", + exact_name: bool = False, + uri: str = "", + owner: str = "", + resource_type: str = "", + scope: str = "", + matchingUri: bool = False, # noqa: N803 + first: int = 0, + maximum: int = -1, + ) -> list: + """ + Query for list of resource set ids asynchronously. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets + + :param name: query resource name + :type name: str + :param exact_name: query exact match for resource name + :type exact_name: bool + :param uri: query resource uri + :type uri: str + :param owner: query resource owner + :type owner: str + :param resource_type: query resource type + :type resource_type: str + :param scope: query resource scope + :type scope: str + :param first: index of first matching resource to return + :param matchingUri: enable URI matching + :type matchingUri: bool + :type first: int + :param maximum: maximum number of resources to return (-1 for all) + :type maximum: int + :return: List of ids + :rtype: List[str] + """ + query = {} + if name: + query["name"] = name + if exact_name: + query["exactName"] = "true" + if uri: + query["uri"] = uri + if owner: + query["owner"] = owner + if resource_type: + query["type"] = resource_type + if scope: + query["scope"] = scope + if matchingUri: + query["matchingUri"] = "true" + if first > 0: + query["first"] = first + if maximum >= 0: + query["max"] = maximum + + data_raw = await self.connection.a_raw_get( + (await self.a_uma_well_known)["resource_registration_endpoint"], + **query, + ) + 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) -> AsyncGenerator[dict, Any]: + """ + List all resource sets asynchronously. + + Spec + https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets + + ResourceRepresentation + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_resourcerepresentation + + :yields: Iterator over a list of ResourceRepresentations + :rtype: Iterator[dict] + """ + for resource_id in await self.a_resource_set_list_ids(): + resource = await self.a_resource_set_read(resource_id) + yield resource + + async def a_permission_ticket_create(self, permissions: Iterable[UMAPermission]) -> dict: + """ + Create a permission ticket asynchronously. + + :param permissions: Iterable of uma permissions to validate the token against + :type permissions: Iterable[UMAPermission] + :returns: Keycloak decision + :rtype: boolean + :raises KeycloakPostError: In case permission resource not found + """ + resources = {} + for permission in permissions: + resource_id = getattr(permission, "resource_id", None) + + if resource_id is None: + resource_ids = await self.a_resource_set_list_ids( + exact_name=True, + name=permission.resource, + first=0, + maximum=1, + ) + + if not resource_ids: + msg = "Invalid resource specified" + raise KeycloakPostError(msg) + + permission.resource_id = resource_ids[0] + + resources.setdefault(resource_id, set()) + if permission.scope: + resources[resource_id].add(permission.scope) + + payload = [ + {"resource_id": resource_id, "resource_scopes": list(scopes)} + for resource_id, scopes in resources.items() + ] + + data_raw = await self.connection.a_raw_post( + (await self.a_uma_well_known)["permission_endpoint"], + data=json.dumps(payload), + ) + 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( + self, + token: str, + permissions: Iterable[UMAPermission], + **extra_payload: Any, # noqa: ANN401 + ) -> bool: + """ + Check UMA permissions by user token with requested permissions asynchronously. + + The token endpoint is used to check UMA permissions from Keycloak. It can only be + invoked by confidential clients. + + https://www.keycloak.org/docs/latest/authorization_services/#_service_authorization_api + + :param token: user token + :type token: str + :param permissions: Iterable of uma permissions to validate the token against + :type permissions: Iterable[UMAPermission] + :param extra_payload: extra payload data + :type extra_payload: dict + :returns: Keycloak decision + :rtype: boolean + """ + payload = { + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": ",".join(str(permission) for permission in permissions), + "response_mode": "decision", + "audience": self.connection.client_id, + **extra_payload, + } + + # Everyone always has the null set of permissions + # However keycloak cannot evaluate the null set + if len(payload["permission"]) == 0: + return True + + 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("Content-Type", "application/x-www-form-urlencoded") + data_raw = await connection.a_raw_post( + (await self.a_uma_well_known)["token_endpoint"], + data=payload, + ) + try: + data = raise_error_from_response(data_raw, KeycloakPostError) + except KeycloakPostError: + return False + + 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) + + async def a_policy_resource_create(self, resource_id: str, payload: dict) -> dict: + """ + Create permission policy for resource asynchronously. + + Supports name, description, scopes, roles, groups, clients + + https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource + + :param resource_id: _id of resource + :type resource_id: str + :param payload: permission configuration + :type payload: dict + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = await self.connection.a_raw_post( + (await self.a_uma_well_known)["policy_endpoint"] + f"/{resource_id}", + data=json.dumps(payload), + ) + 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) -> bytes: + """ + Update permission policy asynchronously. + + https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + + :param policy_id: id of policy permission + :type policy_id: str + :param payload: policy permission configuration + :type payload: dict + :return: PermissionRepresentation + :rtype: bytes + """ + data_raw = await self.connection.a_raw_put( + (await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}", + data=json.dumps(payload), + ) + 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: + """ + Delete permission policy asynchronously. + + https://www.keycloak.org/docs/latest/authorization_services/#removing-a-permission + https://www.keycloak.org/docs-api/24.0.2/rest-api/index.html#_policyrepresentation + + :param policy_id: id of permission policy + :type policy_id: str + :return: PermissionRepresentation + :rtype: dict + """ + data_raw = await self.connection.a_raw_delete( + (await self.a_uma_well_known)["policy_endpoint"] + f"/{policy_id}", + ) + 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( + self, + resource: str = "", + name: str = "", + scope: str = "", + first: int = 0, + maximum: int = -1, + ) -> list: + """ + Query permission policies asynchronously. + + https://www.keycloak.org/docs/latest/authorization_services/#querying-permission + + :param resource: query resource id + :type resource: str + :param name: query resource name + :type name: str + :param scope: query resource scope + :type scope: str + :param first: index of first matching resource to return + :type first: int + :param maximum: maximum number of resources to return (-1 for all) + :type maximum: int + :return: List of ids + :return: List of ids + :rtype: List[str] + """ + query = {} + if name: + query["name"] = name + if resource: + query["resource"] = resource + if scope: + query["scope"] = scope + if first > 0: + query["first"] = first + if maximum >= 0: + query["max"] = maximum + + data_raw = await self.connection.a_raw_get( + (await self.a_uma_well_known)["policy_endpoint"], + **query, + ) + 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 diff --git a/src/keycloak/openid_connection.py b/src/keycloak/openid_connection.py new file mode 100644 index 00000000..a2a42991 --- /dev/null +++ b/src/keycloak/openid_connection.py @@ -0,0 +1,643 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +Keycloak OpenID Connection Manager module. + +The module contains mainly the implementation of KeycloakOpenIDConnection class. +This is an extension of the ConnectionManager class, and handles the automatic refresh +of openid tokens when required. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from httpx import Response as AsyncResponse + from requests import Response + +from .connection import ConnectionManager +from .exceptions import HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, KeycloakPostError +from .keycloak_openid import KeycloakOpenID + + +class KeycloakOpenIDConnection(ConnectionManager): + """ + A class to help with OpenID connections which can auto refresh tokens. + + :param object: _description_ + :type object: _type_ + """ + + _server_url = None + _grant_type = None + _username = None + _password = None + _totp = None + _realm_name = None + _client_id = None + _verify = None + _client_secret_key = None + _connection = None + _custom_headers = None + _user_realm_name = None + _expires_at = None + _keycloak_openid = None + + def __init__( + self, + server_url: str | None = None, + grant_type: str | None = None, + username: str | None = None, + password: str | None = None, + token: dict | None = None, + totp: int | None = None, + realm_name: str | None = "master", + client_id: str = "admin-cli", + verify: str | bool = True, + client_secret_key: str | None = None, + custom_headers: dict | None = None, + user_realm_name: str | None = None, + timeout: int | None = 60, + cert: str | tuple | None = None, + max_retries: int = 1, + pool_maxsize: int | None = None, + ) -> None: + """ + Init method. + + :param server_url: Keycloak server url + :type server_url: str + :param grant_type: grant type for authn + :type grant_type: str + :param username: admin username + :type username: str + :param password: admin password + :type password: str + :param token: access and refresh tokens + :type token: dict + :param totp: Time based OTP + :type totp: str + :param realm_name: realm name + :type realm_name: str + :param client_id: client id + :type client_id: str + :param verify: Boolean value to enable or disable certificate validation or a string + containing a path to a CA bundle to use + :type verify: Union[bool,str] + :param client_secret_key: client secret key + (optional, required only for access type confidential) + :type client_secret_key: str + :param custom_headers: dict of custom header to pass to each HTML request + :type custom_headers: dict + :param user_realm_name: The realm name of the user, if different from realm_name + :type user_realm_name: str + :param timeout: connection timeout in seconds + :type timeout: int + :param cert: An SSL certificate used by the requested host to authenticate the client. + Either a path to an SSL certificate file, or two-tuple of + (certificate file, key file). + :type cert: Union[str,Tuple[str,str]] + :param max_retries: The total number of times to retry HTTP requests. + :type max_retries: int + :param pool_maxsize: The maximum number of connections to save in the pool. + :type pool_maxsize: int + """ + # token is renewed when it hits 90% of its lifetime. This is to account for any possible + # clock skew. + self.token_lifetime_fraction = 0.9 + self.headers = {} + self.server_url = server_url + self.grant_type = grant_type + self.username = username + self.password = password + self.token = token + self.totp = totp + self.realm_name = realm_name + self.client_id = client_id + self.verify = verify + self.client_secret_key = client_secret_key + self.user_realm_name = user_realm_name + self.timeout = timeout + self.custom_headers = custom_headers + self.headers = {**self.headers, "Content-Type": "application/json"} + self.cert = cert + + if not self.grant_type: + if username and password: + self.grant_type = "password" + elif client_secret_key: + self.grant_type = "client_credentials" + + if self.server_url is None: + msg = "Unable to initialize KeycloakOpenIDConnection without server_url." + raise ValueError(msg) + + super().__init__( + base_url=self.server_url, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + cert=cert, + max_retries=max_retries, + pool_maxsize=pool_maxsize, + ) + + @property + def server_url(self) -> str | None: + """ + Get server url. + + :returns: Keycloak server url + :rtype: str + """ + return self.base_url + + @server_url.setter + def server_url(self, value: str | None) -> None: + self.base_url = value + + @property + def grant_type(self) -> str | None: + """ + Get grant type. + + :returns: Grant type + :rtype: str + """ + return self._grant_type + + @grant_type.setter + def grant_type(self, value: str | None) -> None: + self._grant_type = value + + @property + def realm_name(self) -> str | None: + """ + Get realm name. + + :returns: Realm name + :rtype: str + """ + return self._realm_name + + @realm_name.setter + def realm_name(self, value: str | None) -> None: + self._realm_name = value + + @property + def client_id(self) -> str | None: + """ + Get client id. + + :returns: Client id + :rtype: str + """ + return self._client_id + + @client_id.setter + def client_id(self, value: str | None) -> None: + self._client_id = value + + @property + def client_secret_key(self) -> str | None: + """ + Get client secret key. + + :returns: Client secret key + :rtype: str + """ + return self._client_secret_key + + @client_secret_key.setter + def client_secret_key(self, value: str | None) -> None: + self._client_secret_key = value + + @property + def username(self) -> str | None: + """ + Get username. + + :returns: Admin username + :rtype: str + """ + return self._username + + @username.setter + def username(self, value: str | None) -> None: + self._username = value + + @property + def password(self) -> str | None: + """ + Get password. + + :returns: Admin password + :rtype: str + """ + return self._password + + @password.setter + def password(self, value: str | None) -> None: + self._password = value + + @property + def totp(self) -> int | None: + """ + Get totp. + + :returns: TOTP + :rtype: str + """ + return self._totp + + @totp.setter + def totp(self, value: int | None) -> None: + self._totp = value + + @property + def token(self) -> dict | None: + """ + Get token. + + :returns: Access and refresh token + :rtype: dict + """ + return self._token + + @token.setter + def token(self, value: dict | None) -> None: + self._token = value + self._expires_at = datetime.now(tz=timezone.utc) + timedelta( + seconds=int(self.token_lifetime_fraction * value["expires_in"] if value else 0), + ) + if value is not None: + self.add_param_headers("Authorization", "Bearer " + value["access_token"]) + + @property + def expires_at(self) -> datetime | None: + """ + Get token expiry time. + + :returns: Datetime at which the current token will expire + :rtype: datetime + """ + return self._expires_at + + @property + def user_realm_name(self) -> str | None: + """ + Get user realm name. + + :returns: User realm name + :rtype: str + """ + return self._user_realm_name + + @user_realm_name.setter + def user_realm_name(self, value: str | None) -> None: + self._user_realm_name = value + + @property + def custom_headers(self) -> dict | None: + """ + Get custom headers. + + :returns: Custom headers + :rtype: dict + """ + return self._custom_headers + + @custom_headers.setter + def custom_headers(self, value: dict | None) -> None: + self._custom_headers = value + if self.custom_headers is not None and self.headers is not None: + # merge custom headers to main headers + self.headers.update(self.custom_headers) + + @property + def keycloak_openid(self) -> KeycloakOpenID: + """ + Get the KeycloakOpenID object. + + The KeycloakOpenID is used to refresh tokens + + :returns: KeycloakOpenID + :rtype: KeycloakOpenID + """ + if self._keycloak_openid is None: + if self.user_realm_name: + token_realm_name = self.user_realm_name + elif self.realm_name: + token_realm_name = self.realm_name + else: + token_realm_name = "master" # 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( + server_url=self.server_url, + client_id=self.client_id, + realm_name=token_realm_name, + verify=self.verify, + client_secret_key=self.client_secret_key, + timeout=self.timeout, + custom_headers=self.custom_headers, + cert=self.cert, + ) + + return self._keycloak_openid + + def get_token(self) -> None: + """ + Get admin token. + + The admin token is then set in the `token` attribute. + """ + if self.grant_type: + self.token = self.keycloak_openid.token( + self.username, + self.password, + grant_type=self.grant_type, + totp=self.totp, + ) + else: + self.token = None + + def refresh_token(self) -> None: + """ + Refresh the token. + + :raises KeycloakPostError: In case the refresh token request failed. + """ + refresh_token = self.token.get("refresh_token", None) if self.token else None + if refresh_token is None: + self.get_token() + else: + try: + self.token = self.keycloak_openid.refresh_token(refresh_token) + except KeycloakPostError as e: + list_errors = [ + b"Refresh token expired", + b"Token is not active", + b"Session not active", + ] + if e.response_code == HTTP_BAD_REQUEST and any( + err in (e.response_body or b"") for err in list_errors + ): + self.get_token() + else: + raise + + def _refresh_if_required(self) -> None: + if self.expires_at is not None and datetime.now(tz=timezone.utc) >= self.expires_at: + self.refresh_token() + + def raw_get(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Call connection.raw_get. + + If auto_refresh is set for *get* and *access_token* is expired, it will refresh the token + and try *get* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_get(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + self.refresh_token() + r = super().raw_get(*args, **kwargs) + + return r + + def raw_post(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Call connection.raw_post. + + If auto_refresh is set for *post* and *access_token* is expired, it will refresh the token + and try *post* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_post(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + self.refresh_token() + r = super().raw_post(*args, **kwargs) + + return r + + def raw_put(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Call connection.raw_put. + + If auto_refresh is set for *put* and *access_token* is expired, it will refresh the token + and try *put* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_put(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + self.refresh_token() + r = super().raw_put(*args, **kwargs) + + return r + + def raw_delete(self, *args: Any, **kwargs: Any) -> Response: # noqa: ANN401 + """ + Call connection.raw_delete. + + If auto_refresh is set for *delete* and *access_token* is expired, + it will refresh the token and try *delete* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + self._refresh_if_required() + r = super().raw_delete(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + self.refresh_token() + r = super().raw_delete(*args, **kwargs) + + return r + + async def a_get_token(self) -> None: + """ + Get admin token. + + The admin token is then set in the `token` attribute. + """ + if self.grant_type: + self.token = await self.keycloak_openid.a_token( + self.username, + self.password, + grant_type=self.grant_type, + totp=self.totp, + ) + else: + self.token = None + + async def a_refresh_token(self) -> None: + """ + Refresh the token. + + :raises KeycloakPostError: In case the refresh token request failed. + """ + refresh_token = self.token.get("refresh_token", None) if self.token else None + if refresh_token is None: + await self.a_get_token() + else: + try: + self.token = await self.keycloak_openid.a_refresh_token(refresh_token) + except KeycloakPostError as e: + list_errors = [ + b"Refresh token expired", + b"Token is not active", + b"Session not active", + ] + if e.response_code == HTTP_BAD_REQUEST and any( + err in (e.response_body or b"") for err in list_errors + ): + await self.a_get_token() + else: + raise + + async def a__refresh_if_required(self) -> None: + """Refresh the token if it is expired.""" + if self.expires_at is not None and datetime.now(tz=timezone.utc) >= self.expires_at: + await self.a_refresh_token() + + async def a_raw_get(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401 + """ + Call connection.raw_get. + + If auto_refresh is set for *get* and *access_token* is expired, it will refresh the token + and try *get* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + await self.a__refresh_if_required() + r = await super().a_raw_get(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + await self.a_refresh_token() + r = await super().a_raw_get(*args, **kwargs) + + return r + + async def a_raw_post(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401 + """ + Call connection.raw_post. + + If auto_refresh is set for *post* and *access_token* is expired, it will refresh the token + and try *post* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + await self.a__refresh_if_required() + r = await super().a_raw_post(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + await self.a_refresh_token() + r = await super().a_raw_post(*args, **kwargs) + + return r + + async def a_raw_put(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401 + """ + Call connection.raw_put. + + If auto_refresh is set for *put* and *access_token* is expired, it will refresh the token + and try *put* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + await self.a__refresh_if_required() + r = await super().a_raw_put(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + await self.a_refresh_token() + r = await super().a_raw_put(*args, **kwargs) + + return r + + async def a_raw_delete(self, *args: Any, **kwargs: Any) -> AsyncResponse: # noqa: ANN401 + """ + Call connection.raw_delete. + + If auto_refresh is set for *delete* and *access_token* is expired, + it will refresh the token and try *delete* once more. + + :param args: Additional arguments + :type args: tuple + :param kwargs: Additional keyword arguments + :type kwargs: dict + :returns: Response + :rtype: Response + """ + await self.a__refresh_if_required() + r = await super().a_raw_delete(*args, **kwargs) + if r.status_code == HTTP_UNAUTHORIZED: + await self.a_refresh_token() + r = await super().a_raw_delete(*args, **kwargs) + + return r diff --git a/src/keycloak/pkce_utils.py b/src/keycloak/pkce_utils.py new file mode 100644 index 00000000..015bb3fa --- /dev/null +++ b/src/keycloak/pkce_utils.py @@ -0,0 +1,55 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +"""PKCE utility functions for code verifier and code challenge generation.""" + +import base64 +import hashlib +import os + + +def generate_code_verifier(length: int = 128) -> str: + """ + Generate a high-entropy cryptographic random string for PKCE code_verifier. + + RFC 7636 recommends a length between 43 and 128 characters. + """ + return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode("utf-8")[:length] + + +def generate_code_challenge(code_verifier: str, method: str = "S256") -> tuple[str, str]: + """ + Generate a code_challenge from the code_verifier using the specified method. + + Supported methods: "S256" (default), "plain". + Returns (code_challenge, code_challenge_method). + """ + if method == "S256": + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest()) + .rstrip(b"=") + .decode("utf-8") + ) + return code_challenge, "S256" + if method == "plain": + return code_verifier, "plain" + error_msg = f"Unsupported PKCE method: {method}" + raise ValueError(error_msg) diff --git a/keycloak/tests/__init__.py b/src/keycloak/py.typed similarity index 100% rename from keycloak/tests/__init__.py rename to src/keycloak/py.typed diff --git a/src/keycloak/uma_permissions.py b/src/keycloak/uma_permissions.py new file mode 100644 index 00000000..54ab6196 --- /dev/null +++ b/src/keycloak/uma_permissions.py @@ -0,0 +1,301 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""User-managed access permissions module.""" + +from __future__ import annotations + +from keycloak.exceptions import KeycloakPermissionFormatError + + +class UMAPermission: + """ + A class to conveniently assemble permissions. + + The class itself is callable, and will return the assembled permission. + + Usage example: + + >>> r = Resource("Users") + >>> s = Scope("delete") + >>> permission = r(s) + >>> print(permission) + 'Users#delete' + + :param permission: Permission + :type permission: UMAPermission + :param resource: Resource + :type resource: str + :param scope: Scope + :type scope: str + """ + + def __init__( + self, + permission: UMAPermission | None = None, + resource: str = "", + scope: str = "", + ) -> None: + """ + Init method. + + :param permission: Permission + :type permission: UMAPermission + :param resource: Resource + :type resource: str + :param scope: Scope + :type scope: str + :raises PermissionDefinitionError: In case bad permission definition + """ + self.resource = resource + self.scope = scope + self.resource_id = None + + if permission is not None: + if permission.resource: + self.resource = str(permission.resource) + if permission.scope: + self.scope = str(permission.scope) + + def __str__(self) -> str: + """ + Str method. + + :returns: String representation + :rtype: str + """ + scope = self.scope + if scope: + scope = "#" + scope + return f"{self.resource}{scope}" + + def __eq__(self, other: object) -> bool: + """ + Eq method. + + :param __o: The other object + :type __o: object + :returns: Equality boolean + :rtype: bool + """ + return str(self) == str(other) + + def __repr__(self) -> str: + """ + Repr method. + + :returns: The object representation + :rtype: str + """ + return self.__str__() + + def __hash__(self) -> int: + """ + Hash method. + + :returns: Hash of the object + :rtype: int + """ + return hash(str(self)) + + def __call__( + self, + permission: UMAPermission | None = None, + resource: str = "", + scope: str = "", + ) -> UMAPermission: + """ + Call method. + + :param permission: Permission + :type permission: UMAPermission + :param resource: Resource + :type resource: str + :param scope: Scope + :type scope: str + :returns: The combined UMA permission + :rtype: UMAPermission + :raises PermissionDefinitionError: In case bad permission definition + """ + result_resource = self.resource + result_scope = self.scope + + if resource: + result_resource = str(resource) + if scope: + result_scope = str(scope) + + if permission is not None: + if permission.resource: + result_resource = str(permission.resource) + if permission.scope: + result_scope = str(permission.scope) + + return UMAPermission(resource=result_resource, scope=result_scope) + + +class Resource(UMAPermission): + """ + A UMAPermission Resource class to conveniently assemble permissions. + + The class itself is callable, and will return the assembled permission. + + :param resource: Resource + :type resource: str + """ + + def __init__(self, resource: str) -> None: + """ + Init method. + + :param resource: Resource + :type resource: str + """ + super().__init__(resource=resource) + + +class Scope(UMAPermission): + """ + A UMAPermission Scope class to conveniently assemble permissions. + + The class itself is callable, and will return the assembled permission. + + :param scope: Scope + :type scope: str + """ + + def __init__(self, scope: str) -> None: + """ + Init method. + + :param scope: Scope + :type scope: str + """ + super().__init__(scope=scope) + + +class AuthStatus: + """ + A class that represents the authorization/login status of a user associated with a token. + + This has to evaluate to True if and only if the user is properly authorized + for the requested resource. + + :param is_logged_in: Is logged in indicator + :type is_logged_in: bool + :param is_authorized: Is authorized indicator + :type is_authorized: bool + :param missing_permissions: Missing permissions + :type missing_permissions: set + """ + + def __init__( + self, is_logged_in: bool, is_authorized: bool, missing_permissions: set | str + ) -> None: + """ + Init method. + + :param is_logged_in: Is logged in indicator + :type is_logged_in: bool + :param is_authorized: Is authorized indicator + :type is_authorized: bool + :param missing_permissions: Missing permissions + :type missing_permissions: set + """ + self.is_logged_in = is_logged_in + self.is_authorized = is_authorized + self.missing_permissions = missing_permissions + + def __bool__(self) -> bool: + """ + Bool method. + + :returns: Boolean representation + :rtype: bool + """ + return self.is_authorized + + def __repr__(self) -> str: + """ + Repr method. + + :returns: The object representation + :rtype: str + """ + return ( + f"AuthStatus(" + f"is_authorized={self.is_authorized}, " + f"is_logged_in={self.is_logged_in}, " + f"missing_permissions={self.missing_permissions})" + ) + + +def build_permission_param( + permissions: str | list | dict | UMAPermission | None | tuple | set, +) -> set: + """ + Transform permissions to a set, so they are usable for requests. + + :param permissions: Permissions + :type permissions: str | Iterable[str] | dict[str, str] | dict[str, Iterabble[str]] + :returns: Permission parameters + :rtype: set + :raises KeycloakPermissionFormatError: In case of bad permission format + """ + if permissions is None or permissions == "": + return set() + if isinstance(permissions, str): + return {permissions} + if isinstance(permissions, UMAPermission): + return {str(permissions)} + if isinstance(permissions, (list, tuple, set)): + return set(permissions) + + try: # treat as dictionary of permissions + result = set() + for resource, scopes in permissions.items(): + if scopes is None: + result.add(resource) + elif isinstance(scopes, str): + result.add(f"{resource}#{scopes}") + else: + try: + for scope in scopes: + if not isinstance(scope, str): + msg = f"misbuilt permission {permissions}" + raise KeycloakPermissionFormatError(msg) + result.add(f"{resource}#{scope}") + except TypeError as e: + msg = f"misbuilt permission {permissions}" + raise KeycloakPermissionFormatError(msg) from e + except AttributeError: + pass + else: + return result + + result = set() + for permission in permissions: + if not isinstance(permission, (str, UMAPermission)): + msg = f"misbuilt permission {permissions}" + raise KeycloakPermissionFormatError(msg) + result.add(str(permission)) + return result diff --git a/src/keycloak/urls_patterns.py b/src/keycloak/urls_patterns.py new file mode 100644 index 00000000..4dc3054a --- /dev/null +++ b/src/keycloak/urls_patterns.py @@ -0,0 +1,252 @@ +# +# The MIT License (MIT) +# +# Copyright (C) 2017 Marcos Pereira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Keycloak URL patterns.""" + +# OPENID URLS +URL_REALM = "realms/{realm-name}" +URL_WELL_KNOWN_BASE = "realms/{realm-name}/.well-known" +URL_WELL_KNOWN = URL_WELL_KNOWN_BASE + "/openid-configuration" +URL_TOKEN = "realms/{realm-name}/protocol/openid-connect/token" # noqa: S105 +URL_USERINFO = "realms/{realm-name}/protocol/openid-connect/userinfo" +URL_LOGOUT = "realms/{realm-name}/protocol/openid-connect/logout" +URL_CERTS = "realms/{realm-name}/protocol/openid-connect/certs" +URL_INTROSPECT = "realms/{realm-name}/protocol/openid-connect/token/introspect" +URL_ENTITLEMENT = "realms/{realm-name}/authz/entitlement/{resource-server-id}" +URL_AUTH = ( + "{authorization-endpoint}?client_id={client-id}&response_type=code&redirect_uri={redirect-uri}" + "&scope={scope}&state={state}&nonce={nonce}" +) +URL_DEVICE = "realms/{realm-name}/protocol/openid-connect/auth/device" + +URL_CLIENT_REGISTRATION = URL_REALM + "/clients-registrations/default" +URL_CLIENT_UPDATE = URL_CLIENT_REGISTRATION + "/{client-id}" + +# ADMIN URLS +URL_ADMIN_USERS = "admin/realms/{realm-name}/users" +URL_ADMIN_USERS_COUNT = "admin/realms/{realm-name}/users/count" +URL_ADMIN_USER = "admin/realms/{realm-name}/users/{id}" +URL_ADMIN_USER_CONSENTS = "admin/realms/{realm-name}/users/{id}/consents" +URL_ADMIN_USER_CONSENT = URL_ADMIN_USER_CONSENTS + "/{client-id}" +URL_ADMIN_SEND_UPDATE_ACCOUNT = "admin/realms/{realm-name}/users/{id}/execute-actions-email" +URL_ADMIN_SEND_VERIFY_EMAIL = "admin/realms/{realm-name}/users/{id}/send-verify-email" +URL_ADMIN_RESET_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password" # noqa: S105 +URL_ADMIN_GET_SESSIONS = "admin/realms/{realm-name}/users/{id}/sessions" +URL_ADMIN_USER_ALL_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings" +URL_ADMIN_USER_CLIENT_ROLES = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}" +) +URL_ADMIN_USER_REALM_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/realm" +URL_ADMIN_USER_REALM_ROLES_AVAILABLE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/realm/available" +) +URL_ADMIN_USER_REALM_ROLES_COMPOSITE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/realm/composite" +) +URL_ADMIN_GROUPS_REALM_ROLES = "admin/realms/{realm-name}/groups/{id}/role-mappings/realm" +URL_ADMIN_GROUPS_CLIENT_ROLES = ( + "admin/realms/{realm-name}/groups/{id}/role-mappings/clients/{client-id}" +) +URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/available" +) +URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/composite" +) +URL_ADMIN_USER_GROUP = "admin/realms/{realm-name}/users/{id}/groups/{group-id}" +URL_ADMIN_USER_GROUPS = "admin/realms/{realm-name}/users/{id}/groups" +URL_ADMIN_USER_CREDENTIALS = "admin/realms/{realm-name}/users/{id}/credentials" +URL_ADMIN_USER_CREDENTIAL = "admin/realms/{realm-name}/users/{id}/credentials/{credential_id}" +URL_ADMIN_USER_LOGOUT = "admin/realms/{realm-name}/users/{id}/logout" +URL_ADMIN_USER_STORAGE = "admin/realms/{realm-name}/user-storage/{id}/sync" + +URL_ADMIN_SERVER_INFO = "admin/serverinfo" + +URL_ADMIN_GROUPS = "admin/realms/{realm-name}/groups" +URL_ADMIN_GROUPS_COUNT = "admin/realms/{realm-name}/groups/count" +URL_ADMIN_GROUP = "admin/realms/{realm-name}/groups/{id}" +URL_ADMIN_GROUP_BY_PATH = "admin/realms/{realm-name}/group-by-path/{path}" +URL_ADMIN_GROUP_CHILD = "admin/realms/{realm-name}/groups/{id}/children" +URL_ADMIN_GROUP_PERMISSIONS = "admin/realms/{realm-name}/groups/{id}/management/permissions" +URL_ADMIN_GROUP_MEMBERS = "admin/realms/{realm-name}/groups/{id}/members" + +URL_ADMIN_CLIENT_INITIAL_ACCESS = "admin/realms/{realm-name}/clients-initial-access" +URL_ADMIN_CLIENTS = "admin/realms/{realm-name}/clients" +URL_ADMIN_CLIENT = URL_ADMIN_CLIENTS + "/{id}" +URL_ADMIN_CLIENT_ALL_SESSIONS = URL_ADMIN_CLIENT + "/user-sessions" +URL_ADMIN_CLIENT_SECRETS = URL_ADMIN_CLIENT + "/client-secret" +URL_ADMIN_CLIENT_ROLES = URL_ADMIN_CLIENT + "/roles" +URL_ADMIN_CLIENT_ROLE = URL_ADMIN_CLIENT + "/roles/{role-name}" +URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE = URL_ADMIN_CLIENT_ROLE + "/composites" +URL_ADMIN_CLIENT_ROLE_MEMBERS = URL_ADMIN_CLIENT + "/roles/{role-name}/users" +URL_ADMIN_CLIENT_ROLE_GROUPS = URL_ADMIN_CLIENT + "/roles/{role-name}/groups" +URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS = URL_ADMIN_CLIENT + "/management/permissions" +URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES = URL_ADMIN_CLIENT + "/scope-mappings/realm" +URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES = ( + URL_ADMIN_CLIENT + "/scope-mappings/clients/{client}" +) +URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES = URL_ADMIN_CLIENT + "/optional-client-scopes" +URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE = ( + URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES + "/{client_scope_id}" +) +URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES = URL_ADMIN_CLIENT + "/default-client-scopes" +URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE = ( + URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES + "/{client_scope_id}" +) + +URL_ADMIN_CLIENT_AUTHZ = URL_ADMIN_CLIENT + "/authz/resource-server" +URL_ADMIN_CLIENT_AUTHZ_SETTINGS = URL_ADMIN_CLIENT_AUTHZ + "/settings" +URL_ADMIN_CLIENT_AUTHZ_IMPORT = URL_ADMIN_CLIENT_AUTHZ + "/import" +URL_ADMIN_CLIENT_AUTHZ_RESOURCE = URL_ADMIN_CLIENT_AUTHZ + "/resource/{resource-id}" +URL_ADMIN_CLIENT_AUTHZ_RESOURCES = URL_ADMIN_CLIENT_AUTHZ + "/resource" +URL_ADMIN_CLIENT_AUTHZ_SCOPES = URL_ADMIN_CLIENT_AUTHZ + "/scope" +URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS = URL_ADMIN_CLIENT_AUTHZ + "/permission" +URL_ADMIN_CLIENT_AUTHZ_POLICIES = URL_ADMIN_CLIENT_AUTHZ + "/policy" +URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY = URL_ADMIN_CLIENT_AUTHZ + "/policy/role" +URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION = URL_ADMIN_CLIENT_AUTHZ + "/permission/resource" +URL_ADMIN_CLIENT_AUTHZ_POLICY = URL_ADMIN_CLIENT_AUTHZ + "/policy/{policy-id}" +URL_ADMIN_CLIENT_AUTHZ_POLICY_SCOPES = URL_ADMIN_CLIENT_AUTHZ_POLICY + "/scopes" +URL_ADMIN_CLIENT_AUTHZ_POLICY_RESOURCES = URL_ADMIN_CLIENT_AUTHZ_POLICY + "/resources" +URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION = URL_ADMIN_CLIENT_AUTHZ + "/permission/scope/{scope-id}" +URL_ADMIN_CLIENT_AUTHZ_RESOURCE_PERMISSION = ( + URL_ADMIN_CLIENT_AUTHZ + "/permission/resource/{resource-id}" +) +URL_ADMIN_ADD_CLIENT_AUTHZ_SCOPE_PERMISSION = URL_ADMIN_CLIENT_AUTHZ + "/permission/scope" +URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY = URL_ADMIN_CLIENT_AUTHZ + "/policy/client" +URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY_ASSOCIATED_POLICIES = ( + URL_ADMIN_CLIENT_AUTHZ + "/policy/{policy-id}/associatedPolicies" +) + +URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER = URL_ADMIN_CLIENT + "/service-account-user" +URL_ADMIN_CLIENT_CERTS = URL_ADMIN_CLIENT + "/certificates/{attr}" +URL_ADMIN_CLIENT_INSTALLATION_PROVIDER = URL_ADMIN_CLIENT + "/installation/providers/{provider-id}" +URL_ADMIN_CLIENT_PROTOCOL_MAPPERS = URL_ADMIN_CLIENT + "/protocol-mappers/models" +URL_ADMIN_CLIENT_PROTOCOL_MAPPER = URL_ADMIN_CLIENT_PROTOCOL_MAPPERS + "/{protocol-mapper-id}" + +URL_ADMIN_CLIENT_SCOPES = "admin/realms/{realm-name}/client-scopes" +URL_ADMIN_CLIENT_SCOPE = URL_ADMIN_CLIENT_SCOPES + "/{scope-id}" +URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER = URL_ADMIN_CLIENT_SCOPE + "/protocol-mappers/models" +URL_ADMIN_CLIENT_SCOPES_MAPPERS = URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER + "/{protocol-mapper-id}" +URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS = URL_ADMIN_CLIENT_SCOPE + "/scope-mappings" +URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_REALM = URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS + "/realm" +URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT = ( + URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS + "/clients/{client-id}" +) + +URL_ADMIN_REALM_ROLES = "admin/realms/{realm-name}/roles" +URL_ADMIN_REALM_ROLES_MEMBERS = URL_ADMIN_REALM_ROLES + "/{role-name}/users" +URL_ADMIN_REALM_ROLES_GROUPS = URL_ADMIN_REALM_ROLES + "/{role-name}/groups" +URL_ADMIN_REALM_USER_PROFILE = "admin/realms/{realm-name}/users/profile" +URL_ADMIN_REALMS = "admin/realms" +URL_ADMIN_REALM = "admin/realms/{realm-name}" +URL_ADMIN_IDPS = "admin/realms/{realm-name}/identity-provider/instances" +URL_ADMIN_IDP_MAPPERS = "admin/realms/{realm-name}/identity-provider/instances/{idp-alias}/mappers" +URL_ADMIN_IDP_MAPPER_UPDATE = URL_ADMIN_IDP_MAPPERS + "/{mapper-id}" +URL_ADMIN_IDP = "admin/realms/{realm-name}/identity-provider/instances/{alias}" +URL_ADMIN_REALM_ROLES_ROLE_BY_ID = URL_ADMIN_REALM + "/roles-by-id/{role-id}" +URL_ADMIN_REALM_ROLES_ROLE_BY_NAME = "admin/realms/{realm-name}/roles/{role-name}" +URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE = ( + "admin/realms/{realm-name}/roles/{role-name}/composites" +) +URL_ADMIN_REALM_EXPORT = "admin/realms/{realm-name}/partial-export" + +URL_ADMIN_REALM_PARTIAL_IMPORT = "admin/realms/{realm-name}/partialImport" + +URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES = URL_ADMIN_REALM + "/default-default-client-scopes" +URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE = URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES + "/{id}" +URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES = URL_ADMIN_REALM + "/default-optional-client-scopes" +URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE = URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES + "/{id}" + +URL_ADMIN_FLOWS = "admin/realms/{realm-name}/authentication/flows" +URL_ADMIN_FLOW = URL_ADMIN_FLOWS + "/{id}" +URL_ADMIN_FLOWS_ALIAS = "admin/realms/{realm-name}/authentication/flows/{flow-id}" +URL_ADMIN_FLOWS_COPY = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/copy" +URL_ADMIN_FLOWS_EXECUTIONS = ( + "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions" +) +URL_ADMIN_FLOWS_EXECUTION = "admin/realms/{realm-name}/authentication/executions/{id}" +URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION = ( + "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/execution" +) +URL_ADMIN_FLOWS_EXECUTIONS_FLOW = ( + "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/flow" +) +URL_ADMIN_AUTHENTICATOR_PROVIDERS = ( + "admin/realms/{realm-name}/authentication/authenticator-providers" +) +URL_ADMIN_AUTHENTICATOR_CONFIG_DESCRIPTION = ( + "admin/realms/{realm-name}/authentication/config-description/{provider-id}" +) +URL_ADMIN_AUTHENTICATOR_CONFIG = "admin/realms/{realm-name}/authentication/config/{id}" + +URL_ADMIN_COMPONENTS = "admin/realms/{realm-name}/components" +URL_ADMIN_COMPONENT = "admin/realms/{realm-name}/components/{component-id}" +URL_ADMIN_KEYS = "admin/realms/{realm-name}/keys" + +URL_ADMIN_USER_FEDERATED_IDENTITIES = "admin/realms/{realm-name}/users/{id}/federated-identity" +URL_ADMIN_USER_FEDERATED_IDENTITY = ( + "admin/realms/{realm-name}/users/{id}/federated-identity/{provider}" +) + +URL_ADMIN_USER_EVENTS = "admin/realms/{realm-name}/events" +URL_ADMIN_ADMIN_EVENTS = "admin/realms/{realm-name}/admin-events" +URL_ADMIN_EVENTS_CONFIG = URL_ADMIN_USER_EVENTS + "/config" +URL_ADMIN_CLIENT_SESSION_STATS = "admin/realms/{realm-name}/client-session-stats" + +URL_ADMIN_GROUPS_CLIENT_ROLES_COMPOSITE = URL_ADMIN_GROUPS_CLIENT_ROLES + "/composite" +URL_ADMIN_REALM_ROLE_COMPOSITES = "admin/realms/{realm-name}/roles-by-id/{role-id}/composites" +URL_ADMIN_REALM_ROLE_COMPOSITES_REALM = URL_ADMIN_REALM_ROLE_COMPOSITES + "/realm" +URL_ADMIN_CLIENT_ROLE_CHILDREN = URL_ADMIN_REALM_ROLE_COMPOSITES + "/clients/{client-id}" +URL_ADMIN_CLIENT_CERT_UPLOAD = URL_ADMIN_CLIENT_CERTS + "/upload-certificate" +URL_ADMIN_REQUIRED_ACTIONS = URL_ADMIN_REALM + "/authentication/required-actions" +URL_ADMIN_REQUIRED_ACTIONS_ALIAS = URL_ADMIN_REQUIRED_ACTIONS + "/{action-alias}" + +URL_ADMIN_ATTACK_DETECTION = "admin/realms/{realm-name}/attack-detection/brute-force/users" +URL_ADMIN_ATTACK_DETECTION_USER = ( + "admin/realms/{realm-name}/attack-detection/brute-force/users/{id}" +) + +URL_ADMIN_CLEAR_KEYS_CACHE = URL_ADMIN_REALM + "/clear-keys-cache" +URL_ADMIN_CLEAR_REALM_CACHE = URL_ADMIN_REALM + "/clear-realm-cache" +URL_ADMIN_CLEAR_USER_CACHE = URL_ADMIN_REALM + "/clear-user-cache" + +# UMA URLS +URL_UMA_WELL_KNOWN = URL_WELL_KNOWN_BASE + "/uma2-configuration" + +URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY = ( + "admin/realms/{realm-name}/authentication/executions/{id}/raise-priority" +) +URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY = ( + "admin/realms/{realm-name}/authentication/executions/{id}/lower-priority" +) + +URL_ADMIN_FLOWS_EXECUTION_CONFIG = URL_ADMIN_FLOWS_EXECUTION + "/config" + +# Organization API Endpoints +URL_ADMIN_ORGANIZATIONS = URL_ADMIN_REALM + "/organizations" +URL_ADMIN_ORGANIZATION_BY_ID = URL_ADMIN_ORGANIZATIONS + "/{organization_id}" +URL_ADMIN_ORGANIZATION_MEMBERS = URL_ADMIN_ORGANIZATION_BY_ID + "/members" +URL_ADMIN_ORGANIZATION_MEMBERS_COUNT = URL_ADMIN_ORGANIZATION_MEMBERS + "/count" +URL_ADMIN_ORGANIZATION_DEL_MEMBER_BY_ID = URL_ADMIN_ORGANIZATION_MEMBERS + "/{user_id}" +URL_ADMIN_ORGANIZATION_IDPS = URL_ADMIN_ORGANIZATION_BY_ID + "/identity-providers" +URL_ADMIN_ORGANIZATION_IDP_BY_ALIAS = URL_ADMIN_ORGANIZATION_IDPS + "/{idp_alias}" +URL_ADMIN_USER_ORGANIZATIONS = URL_ADMIN_ORGANIZATIONS + "/members/{user_id}/organizations" diff --git a/test_keycloak_init.sh b/test_keycloak_init.sh new file mode 100755 index 00000000..457197ca --- /dev/null +++ b/test_keycloak_init.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +CMD_ARGS=$1 +KEYCLOAK_DOCKER_IMAGE_TAG="${KEYCLOAK_DOCKER_IMAGE_TAG:-latest}" +KEYCLOAK_DOCKER_IMAGE="quay.io/keycloak/keycloak:$KEYCLOAK_DOCKER_IMAGE_TAG" + +function keycloak_stop() { + 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 + fi +} + +function keycloak_start() { + echo "Starting keycloak docker container" + PWD=$(pwd) + if [[ "$KEYCLOAK_DOCKER_IMAGE_TAG" == "22.0" || "$KEYCLOAK_DOCKER_IMAGE_TAG" == "23.0" ]]; then + KEYCLOAK_FEATURES="admin-fine-grained-authz,token-exchange" + else + KEYCLOAK_FEATURES="admin-fine-grained-authz:v1,token-exchange:v1" + fi + 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 + until curl --silent --output /dev/null localhost:$KEYCLOAK_PORT; do + sleep 5 + if [ ${SECONDS} -gt 180 ]; then + echo "Timeout exceeded" + exit 1 + fi + done +} + +# Ensuring that keycloak is stopped in case of CTRL-C +trap keycloak_stop err exit + +keycloak_stop # In case it did not shut down correctly last time. +keycloak_start + +eval ${CMD_ARGS} +RETURN_VALUE=$? + +exit ${RETURN_VALUE} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..f1b390f8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests module.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b31a01f3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,665 @@ +"""Fixtures for tests.""" + +import ipaddress +import os +import uuid +from collections.abc import Generator +from datetime import datetime, timedelta, timezone + +import freezegun +import pytest +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from keycloak import KeycloakAdmin, KeycloakOpenID, KeycloakOpenIDConnection, KeycloakUMA + + +class KeycloakTestEnv: + """ + Wrapper for test Keycloak connection configuration. + + :param host: Hostname + :type host: str + :param port: Port + :type port: str + :param username: Admin username + :type username: str + :param password: Admin password + :type password: str + """ + + def __init__( + self, + host: str = os.environ["KEYCLOAK_HOST"], + port: str = os.environ["KEYCLOAK_PORT"], + username: str = os.environ["KEYCLOAK_ADMIN"], + password: str = os.environ["KEYCLOAK_ADMIN_PASSWORD"], + ) -> None: + """ + Init method. + + :param host: Hostname + :type host: str + :param port: Port + :type port: str + :param username: Admin username + :type username: str + :param password: Admin password + :type password: str + """ + self.keycloak_host = host + self.keycloak_port = port + self.keycloak_admin = username + self.keycloak_admin_password = password + + @property + def keycloak_host(self) -> str: + """ + Hostname getter. + + :returns: Keycloak host + :rtype: str + """ + return self._keycloak_host + + @keycloak_host.setter + def keycloak_host(self, value: str) -> None: + """ + Hostname setter. + + :param value: Keycloak host + :type value: str + """ + self._keycloak_host = value + + @property + def keycloak_port(self) -> str: + """ + Port getter. + + :returns: Keycloak port + :rtype: str + """ + return self._keycloak_port + + @keycloak_port.setter + def keycloak_port(self, value: str) -> None: + """ + Port setter. + + :param value: Keycloak port + :type value: str + """ + self._keycloak_port = value + + @property + def keycloak_admin(self) -> str: + """ + Admin username getter. + + :returns: Admin username + :rtype: str + """ + return self._keycloak_admin + + @keycloak_admin.setter + def keycloak_admin(self, value: str) -> None: + """ + Admin username setter. + + :param value: Admin username + :type value: str + """ + self._keycloak_admin = value + + @property + def keycloak_admin_password(self) -> str: + """ + Admin password getter. + + :returns: Admin password + :rtype: str + """ + return self._keycloak_admin_password + + @keycloak_admin_password.setter + def keycloak_admin_password(self, value: str) -> None: + """ + Admin password setter. + + :param value: Admin password + :type value: str + """ + self._keycloak_admin_password = value + + +@pytest.fixture +def env() -> KeycloakTestEnv: + """ + Fixture for getting the test environment configuration object. + + :returns: Keycloak test environment object + :rtype: KeycloakTestEnv + """ + return KeycloakTestEnv() + + +@pytest.fixture +def admin(env: KeycloakTestEnv) -> KeycloakAdmin: + """ + Fixture for initialized KeycloakAdmin class. + + :param env: Keycloak test environment + :type env: KeycloakTestEnv + :returns: Keycloak admin + :rtype: KeycloakAdmin + """ + return KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=env.keycloak_admin, + password=env.keycloak_admin_password, + ) + + +@pytest.fixture +@freezegun.freeze_time("2023-02-25 10:00:00") +def admin_frozen(env: KeycloakTestEnv) -> KeycloakAdmin: + """ + Fixture for initialized KeycloakAdmin class, with time frozen. + + :param env: Keycloak test environment + :type env: KeycloakTestEnv + :returns: Keycloak admin + :rtype: KeycloakAdmin + """ + return KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=env.keycloak_admin, + password=env.keycloak_admin_password, + ) + + +@pytest.fixture +def oid( + env: KeycloakTestEnv, + realm: str, + admin: KeycloakAdmin, +) -> Generator[KeycloakOpenID, None, None]: + """ + Fixture for initialized KeycloakOpenID class. + + :param env: Keycloak test environment + :type env: KeycloakTestEnv + :param realm: Keycloak realm + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :yields: Keycloak OpenID client + :rtype: KeycloakOpenID + """ + # Set the realm + admin.change_current_realm(realm) + # Create client + client = str(uuid.uuid4()) + client_id = admin.create_client( + payload={ + "name": client, + "clientId": client, + "enabled": True, + "publicClient": True, + "protocol": "openid-connect", + }, + ) + # Return OID + yield KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name=realm, + client_id=client, + ) + # Cleanup + admin.delete_client(client_id=client_id) + + +@pytest.fixture +def oid_with_credentials( + env: KeycloakTestEnv, + realm: str, + admin: KeycloakAdmin, +) -> Generator[tuple[KeycloakOpenID, str, str], None, None]: + """ + Fixture for an initialized KeycloakOpenID class and a random user credentials. + + :param env: Keycloak test environment + :type env: KeycloakTestEnv + :param realm: Keycloak realm + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :yields: Keycloak OpenID client with user credentials + :rtype: Tuple[KeycloakOpenID, str, str] + """ + # Set the realm + admin.change_current_realm(realm) + # Create client + client = str(uuid.uuid4()) + secret = str(uuid.uuid4()) + client_id = admin.create_client( + payload={ + "name": client, + "clientId": client, + "enabled": True, + "publicClient": False, + "protocol": "openid-connect", + "secret": secret, + "clientAuthenticatorType": "client-secret", + }, + ) + # Create user + username = str(uuid.uuid4()) + password = str(uuid.uuid4()) + user_id = admin.create_user( + payload={ + "username": username, + "email": f"{username}@test.test", + "enabled": True, + "firstName": "first", + "lastName": "last", + "emailVerified": True, + "requiredActions": [], + "credentials": [{"type": "password", "value": password, "temporary": False}], + }, + ) + + yield ( + KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name=realm, + client_id=client, + client_secret_key=secret, + ), + username, + password, + ) + + # Cleanup + admin.delete_client(client_id=client_id) + admin.delete_user(user_id=user_id) + + +@pytest.fixture +def oid_with_credentials_authz( + env: KeycloakTestEnv, + realm: str, + admin: KeycloakAdmin, +) -> Generator[tuple[KeycloakOpenID, str, str], None, None]: + """ + Fixture for an initialized KeycloakOpenID class and a random user credentials. + + :param env: Keycloak test environment + :type env: KeycloakTestEnv + :param realm: Keycloak realm + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :yields: Keycloak OpenID client configured as an authorization server with client credentials + :rtype: Tuple[KeycloakOpenID, str, str] + """ + # Set the realm + admin.change_current_realm(realm) + # Create client + client = str(uuid.uuid4()) + secret = str(uuid.uuid4()) + client_id = admin.create_client( + payload={ + "name": client, + "clientId": client, + "enabled": True, + "publicClient": False, + "protocol": "openid-connect", + "secret": secret, + "clientAuthenticatorType": "client-secret", + "authorizationServicesEnabled": True, + "serviceAccountsEnabled": True, + }, + ) + admin.create_client_authz_resource( + client_id=client_id, + payload={"name": "Default Resource", "uris": ["/*"], "type": "urn.resource"}, + skip_exists=True, + ) + admin.create_client_authz_role_based_policy( + client_id=client_id, + payload={ + "name": "test-authz-rb-policy", + "roles": [{"id": admin.get_realm_role(role_name="offline_access")["id"]}], + }, + ) + admin.create_client_authz_resource_based_permission( + client_id=client_id, + payload={ + "name": "default-resource-permission", + "resources": ["Default Resource"], + "policies": ["test-authz-rb-policy"], + "decisionStrategy": "UNANIMOUS", + }, + ) + # Create user + username = str(uuid.uuid4()) + password = str(uuid.uuid4()) + user_id = admin.create_user( + payload={ + "username": username, + "email": f"{username}@test.test", + "enabled": True, + "emailVerified": True, + "firstName": "first", + "lastName": "last", + "requiredActions": [], + "credentials": [{"type": "password", "value": password, "temporary": False}], + }, + ) + + yield ( + KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name=realm, + client_id=client, + client_secret_key=secret, + ), + username, + password, + ) + + # Cleanup + admin.delete_client(client_id=client_id) + admin.delete_user(user_id=user_id) + + +@pytest.fixture +def oid_with_credentials_device( + env: KeycloakTestEnv, + realm: str, + admin: KeycloakAdmin, +) -> Generator[tuple[KeycloakOpenID, str, str], None, None]: + """ + Fixture for an initialized KeycloakOpenID class and a random user credentials. + + :param env: Keycloak test environment + :type env: KeycloakTestEnv + :param realm: Keycloak realm + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :yields: Keycloak OpenID client with user credentials + :rtype: Tuple[KeycloakOpenID, str, str] + """ + # Set the realm + admin.change_current_realm(realm) + # Create client + client = str(uuid.uuid4()) + secret = str(uuid.uuid4()) + client_id = admin.create_client( + payload={ + "name": client, + "clientId": client, + "enabled": True, + "publicClient": False, + "protocol": "openid-connect", + "secret": secret, + "clientAuthenticatorType": "client-secret", + "attributes": {"oauth2.device.authorization.grant.enabled": True}, + }, + ) + # Create user + username = str(uuid.uuid4()) + password = str(uuid.uuid4()) + user_id = admin.create_user( + payload={ + "username": username, + "email": f"{username}@test.test", + "enabled": True, + "firstName": "first", + "lastName": "last", + "emailVerified": True, + "requiredActions": [], + "credentials": [{"type": "password", "value": password, "temporary": False}], + }, + ) + + yield ( + KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name=realm, + client_id=client, + client_secret_key=secret, + ), + username, + password, + ) + + # Cleanup + admin.delete_client(client_id=client_id) + admin.delete_user(user_id=user_id) + + +@pytest.fixture +def realm(admin: KeycloakAdmin) -> Generator[str, None, None]: + """ + Fixture for a new random realm. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :yields: Keycloak realm + :rtype: str + """ + realm_name = str(uuid.uuid4()) + admin.create_realm(payload={"realm": realm_name, "enabled": True}) + yield realm_name + admin.delete_realm(realm_name=realm_name) + + +@pytest.fixture +def user(admin: KeycloakAdmin, realm: str) -> Generator[str, None, None]: + """ + Fixture for a new random user. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :yields: Keycloak user + :rtype: str + """ + admin.change_current_realm(realm) + username = str(uuid.uuid4()) + user_id = admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) + yield user_id + admin.delete_user(user_id=user_id) + + +@pytest.fixture +def group(admin: KeycloakAdmin, realm: str) -> Generator[str, None, None]: + """ + Fixture for a new random group. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :yields: Keycloak group + :rtype: str + """ + admin.change_current_realm(realm) + group_name = str(uuid.uuid4()) + group_id = admin.create_group(payload={"name": group_name}) + assert group_id is not None + yield group_id + admin.delete_group(group_id=group_id) + + +@pytest.fixture +def client(admin: KeycloakAdmin, realm: str) -> Generator[str, None, None]: + """ + Fixture for a new random client. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :yields: Keycloak client id + :rtype: str + """ + admin.change_current_realm(realm) + client = str(uuid.uuid4()) + client_id = admin.create_client(payload={"name": client, "clientId": client}) + yield client_id + admin.delete_client(client_id=client_id) + + +@pytest.fixture +def client_role(admin: KeycloakAdmin, realm: str, client: str) -> Generator[str, None, None]: + """ + Fixture for a new random client role. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :yields: Keycloak client role + :rtype: str + """ + admin.change_current_realm(realm) + role = str(uuid.uuid4()) + admin.create_client_role(client, {"name": role, "composite": False}) + yield role + admin.delete_client_role(client, role) + + +@pytest.fixture +def composite_client_role( + admin: KeycloakAdmin, + realm: str, + client: str, + client_role: str, +) -> Generator[str, None, None]: + """ + Fixture for a new random composite client role. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param client_role: Keycloak client role + :type client_role: str + :yields: Composite client role + :rtype: str + """ + admin.change_current_realm(realm) + role = str(uuid.uuid4()) + admin.create_client_role(client, {"name": role, "composite": True}) + role_repr = admin.get_client_role(client, client_role) + admin.add_composite_client_roles_to_role(client, role, roles=[role_repr]) + yield role + admin.delete_client_role(client, role) + + +@pytest.fixture +def selfsigned_cert() -> tuple[bytes, bytes]: + """ + Generate self signed certificate for a hostname, and optional IP addresses. + + :returns: Selfsigned certificate + :rtype: Tuple[str, str] + """ + hostname = "testcert" + ip_addresses: None | list = [] + key = None + # Generate our key + if key is None: + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) + alt_names: list = [x509.DNSName(hostname)] + + # allow addressing by IP, for when you don't have real DNS (common in most testing scenarios + if ip_addresses: + for addr in ip_addresses: + # openssl wants DNSnames for ips... + alt_names.append(x509.DNSName(addr)) + # ... whereas golang's crypto/tls is stricter, and needs IPAddresses + # note: older versions of cryptography do not understand ip_address objects + alt_names.append(x509.IPAddress(ipaddress.ip_address(addr))) + + san = x509.SubjectAlternativeName(alt_names) + + # path_len=0 means this cert can only sign itself, not other certs. + basic_contraints = x509.BasicConstraints(ca=True, path_length=0) + now = datetime.now(tz=timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(1000) + .not_valid_before(now) + .not_valid_after(now + timedelta(days=10 * 365)) + .add_extension(basic_contraints, False) + .add_extension(san, False) + .sign(key, hashes.SHA256(), default_backend()) + ) + cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + return cert_pem, key_pem + + +@pytest.fixture +def oid_connection_with_authz( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], +) -> KeycloakOpenIDConnection: + """ + Fixture for initialized KeycloakUMA class. + + :param oid_with_credentials_authz: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :yields: Keycloak OpenID connection manager + :rtype: KeycloakOpenIDConnection + """ + oid, _, _ = oid_with_credentials_authz + return KeycloakOpenIDConnection( + server_url=oid.connection.base_url, + realm_name=oid.realm_name, + client_id=oid.client_id, + client_secret_key=oid.client_secret_key, + timeout=60, + ) + + +@pytest.fixture +def uma(oid_connection_with_authz: KeycloakOpenIDConnection) -> KeycloakUMA: + """ + Fixture for initialized KeycloakUMA class. + + :param oid_connection_with_authz: Keycloak open id connection with pre-configured authz client + :type oid_connection_with_authz: KeycloakOpenIDConnection + :yields: Keycloak OpenID client + :rtype: KeycloakOpenID + """ + connection = oid_connection_with_authz + # Return UMA + return KeycloakUMA(connection=connection) diff --git a/tests/data/authz_settings.json b/tests/data/authz_settings.json new file mode 100644 index 00000000..8f111988 --- /dev/null +++ b/tests/data/authz_settings.json @@ -0,0 +1,45 @@ +{ + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "policies": [ + { + "name": "Default Policy", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "name": "test-authz-rb-policy", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "roles": "[{\"id\":\"offline_access\",\"required\":false}]" + } + }, + { + "name": "Default Permission", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "applyPolicies": "[\"test-authz-rb-policy\"]" + } + }, + { + "name": "Test scope", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "scopes": "[]", + "applyPolicies": "[\"test-authz-rb-policy\"]" + } + } + ], + "scopes": [], + "decisionStrategy": "UNANIMOUS" +} diff --git a/tests/providers/asm-7.3.1.jar b/tests/providers/asm-7.3.1.jar new file mode 100644 index 00000000..8a502662 Binary files /dev/null and b/tests/providers/asm-7.3.1.jar differ diff --git a/tests/providers/asm-commons-7.3.1.jar b/tests/providers/asm-commons-7.3.1.jar new file mode 100644 index 00000000..65fb30e7 Binary files /dev/null and b/tests/providers/asm-commons-7.3.1.jar differ diff --git a/tests/providers/asm-tree-7.3.1.jar b/tests/providers/asm-tree-7.3.1.jar new file mode 100644 index 00000000..28858f4e Binary files /dev/null and b/tests/providers/asm-tree-7.3.1.jar differ diff --git a/tests/providers/asm-util-7.3.1.jar b/tests/providers/asm-util-7.3.1.jar new file mode 100644 index 00000000..4fe6c527 Binary files /dev/null and b/tests/providers/asm-util-7.3.1.jar differ diff --git a/tests/providers/nashorn-core-15.4.jar b/tests/providers/nashorn-core-15.4.jar new file mode 100644 index 00000000..b4726606 Binary files /dev/null and b/tests/providers/nashorn-core-15.4.jar differ diff --git a/tests/test_authorization.py b/tests/test_authorization.py new file mode 100644 index 00000000..bc30f8fb --- /dev/null +++ b/tests/test_authorization.py @@ -0,0 +1,49 @@ +"""Test authorization module.""" + +import pytest + +from keycloak.authorization import Permission, Policy, Role +from keycloak.exceptions import KeycloakAuthorizationConfigError + + +def test_authorization_objects() -> None: + """Test authorization objects.""" + # Test permission + p = Permission(name="test", type="test", logic="test", decision_strategy="test") + assert p.name == "test" + assert p.type == "test" + assert p.logic == "test" + assert p.decision_strategy == "test" + p.resources = ["test"] + assert p.resources == ["test"] + p.scopes = ["test"] + assert p.scopes == ["test"] + + # Test policy + p = Policy(name="test", type="test", logic="test", decision_strategy="test") + assert p.name == "test" + assert p.type == "test" + assert p.logic == "test" + assert p.decision_strategy == "test" + p.roles = ["test"] + assert p.roles == ["test"] + p.permissions = ["test"] + assert p.permissions == ["test"] + p.add_permission(permission="test2") + assert p.permissions == ["test", "test2"] + with pytest.raises(KeycloakAuthorizationConfigError): + p.add_role(role="test2") + + # Test role + r = Role(name="test") + assert r.name == "test" + assert not r.required + assert r.get_name() == "test" + assert r == r # noqa: PLR0124 + assert hash(r) == hash("test-False") + assert r == "test" + + with pytest.raises(NotImplementedError) as err: + assert r == 1 + + assert str(err.value) == "Cannot compare Role with " diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 00000000..7bcdf0a4 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,97 @@ +"""Connection test module.""" + +from inspect import iscoroutinefunction, signature + +import pytest + +from keycloak.connection import ConnectionManager +from keycloak.exceptions import KeycloakConnectionError + + +def test_connection_proxy() -> None: + """Test proxies of connection manager.""" + cm = ConnectionManager( + base_url="http://test.test", + proxies={"http://test.test": "http://localhost:8080"}, + ) + assert cm._s.proxies == {"http://test.test": "http://localhost:8080"} + + +def test_headers() -> None: + """Test headers manipulation.""" + cm = ConnectionManager(base_url="http://test.test", headers={"H": "A"}) + assert cm.param_headers(key="H") == "A" + assert cm.param_headers(key="A") is None + cm.clean_headers() + assert cm.headers == {} + cm.add_param_headers(key="H", value="B") + assert cm.exist_param_headers(key="H") + assert not cm.exist_param_headers(key="B") + cm.del_param_headers(key="H") + assert not cm.exist_param_headers(key="H") + + +def test_bad_connection() -> None: + """Test bad connection.""" + cm = ConnectionManager(base_url="http://not.real.domain") + with pytest.raises(KeycloakConnectionError): + cm.raw_get(path="bad") + with pytest.raises(KeycloakConnectionError): + cm.raw_delete(path="bad") + with pytest.raises(KeycloakConnectionError): + cm.raw_post(path="bad", data={}) + with pytest.raises(KeycloakConnectionError): + cm.raw_put(path="bad", data={}) + + +@pytest.mark.asyncio +async def a_test_bad_connection() -> None: + """Test bad connection.""" + cm = ConnectionManager(base_url="http://not.real.domain") + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_get(path="bad") + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_delete(path="bad") + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_post(path="bad", data={}) + with pytest.raises(KeycloakConnectionError): + await cm.a_raw_put(path="bad", data={}) + + +def test_counter_part() -> None: + """Test that each function has its async counter part.""" + con_methods = [ + func for func in dir(ConnectionManager) if callable(getattr(ConnectionManager, func)) + ] + sync_methods = [ + method + for method in con_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in con_methods if iscoroutinefunction(getattr(ConnectionManager, method)) + ] + + for method in sync_methods: + if method in [ + "aclose", + "add_param_headers", + "del_param_headers", + "clean_headers", + "exist_param_headers", + "param_headers", + ]: + continue + async_method = f"a_{method}" + assert (async_method in con_methods) is True + sync_sign = signature(getattr(ConnectionManager, method)) + async_sign = signature(getattr(ConnectionManager, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method in ["aclose"]: + continue + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..4b1663f2 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,23 @@ +"""Test the exceptions module.""" + +from unittest.mock import Mock + +import pytest + +from keycloak.exceptions import KeycloakOperationError, raise_error_from_response + + +def test_raise_error_from_response_from_dict() -> None: + """Test raise error from response using a dictionary.""" + response = Mock() + response.json.return_value = {"key": "value"} + response.status_code = 408 + response.content = "Error" + + with pytest.raises(KeycloakOperationError): + raise_error_from_response( + response=response, + error={}, + expected_codes=[200], + skip_exists=False, + ) diff --git a/tests/test_keycloak_admin.py b/tests/test_keycloak_admin.py new file mode 100644 index 00000000..a6f8a5ac --- /dev/null +++ b/tests/test_keycloak_admin.py @@ -0,0 +1,7723 @@ +"""Test the keycloak admin object.""" + +import contextlib +import copy +import json +import os +import uuid +from inspect import iscoroutinefunction, signature +from unittest.mock import ANY, patch + +import freezegun +import pytest +from dateutil import parser as datetime_parser +from packaging.version import Version + +import keycloak +from keycloak import ( + KeycloakAdmin, + KeycloakConnectionError, + KeycloakOpenID, + KeycloakOpenIDConnection, +) +from keycloak.connection import ConnectionManager +from keycloak.exceptions import ( + KeycloakAuthenticationError, + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, +) +from tests.conftest import KeycloakTestEnv + +CLIENT_NOT_FOUND_REGEX = '404: b\'{"error":"Client not found".*}\'' +CLIENT_SCOPE_NOT_FOUND_REGEX = '404: b\'{"error":"Client scope not found".*}\'' +CONSENT_NOT_FOUND_REGEX = '404: b\'{"error":"Consent nor offline token not found".*}\'' +COULD_NOT_FIND_ROLE_REGEX = '404: b\'{"error":"Could not find role".*}\'' +COULD_NOT_FIND_ROLE_WITH_ID_REGEX = '404: b\'{"error":"Could not find role with id".*}\'' +HTTP_404_REGEX = '404: b\'{"error":"HTTP 404 Not Found".*}\'' +ILLEGAL_EXECUTION_REGEX = '404: b\'{"error":"Illegal execution".*}\'' +NO_CLIENT_SCOPE_REGEX = '404: b\'{"error":"Could not find client scope".*}\'' +UNKOWN_ERROR_REGEX = 'b\'{"error":"unknown_error".*}\'' +USER_NOT_FOUND_REGEX = '404: b\'{"error":"User not found".*}\'' + + +def test_keycloak_version() -> None: + """Test version.""" + assert keycloak.__version__, keycloak.__version__ + + +def test_keycloak_admin_init(env: KeycloakTestEnv) -> None: + """ + Test keycloak admin init. + + :param env: Environment fixture + :type env: KeycloakTestEnv + """ + admin = KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=env.keycloak_admin, + password=env.keycloak_admin_password, + pool_maxsize=5, + ) + assert admin.connection.server_url == f"http://{env.keycloak_host}:{env.keycloak_port}", ( + admin.connection.server_url + ) + assert admin.connection.realm_name == "master", admin.connection.realm_name + assert isinstance(admin.connection, ConnectionManager), type(admin.connection) + assert admin.connection.client_id == "admin-cli", admin.connection.client_id + assert admin.connection.client_secret_key is None, admin.connection.client_secret_key + assert admin.connection.verify, admin.connection.verify + assert admin.connection.username == env.keycloak_admin, admin.connection.username + assert admin.connection.password == env.keycloak_admin_password, admin.connection.password + assert admin.connection.totp is None, admin.connection.totp + assert admin.connection.token is None, admin.connection.token + assert admin.connection.user_realm_name is None, admin.connection.user_realm_name + assert admin.connection.custom_headers is None, admin.connection.custom_headers + assert admin.connection.pool_maxsize == 5, admin.connection.pool_maxsize + + admin = KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=env.keycloak_admin, + password=env.keycloak_admin_password, + realm_name=None, + user_realm_name="master", + ) + assert admin.connection.token is None + admin = KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=env.keycloak_admin, + password=env.keycloak_admin_password, + realm_name=None, + user_realm_name=None, + ) + assert admin.connection.token is None + + admin.get_realms() + token = admin.connection.token + admin = KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + token=token, + realm_name=None, + user_realm_name=None, + ) + assert admin.connection.token == token + + admin.create_realm(payload={"realm": "authz", "enabled": True}) + admin.connection.realm_name = "authz" + admin.create_client( + payload={ + "name": "authz-client", + "clientId": "authz-client", + "authorizationServicesEnabled": True, + "serviceAccountsEnabled": True, + "clientAuthenticatorType": "client-secret", + "directAccessGrantsEnabled": False, + "enabled": True, + "implicitFlowEnabled": False, + "publicClient": False, + }, + ) + client_id = admin.get_client_id("authz-client") + assert client_id is not None + secret = admin.generate_client_secrets(client_id=client_id) + admin_auth = KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + user_realm_name="authz", + client_id="authz-client", + client_secret_key=secret["value"], + ) + admin_auth.connection.refresh_token() + assert admin_auth.connection.token is not None + admin.delete_realm(realm_name="authz") + + assert ( + KeycloakAdmin( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=None, + password=None, + client_secret_key=None, + custom_headers={"custom": "header"}, + ).connection.token + is None + ) + + keycloak_connection = KeycloakOpenIDConnection( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + username=env.keycloak_admin, + password=env.keycloak_admin_password, + realm_name="master", + client_id="admin-cli", + verify=True, + ) + keycloak_admin = KeycloakAdmin(connection=keycloak_connection) + keycloak_admin.connection.get_token() + assert keycloak_admin.connection.token + + +def test_realms(admin: KeycloakAdmin) -> None: + """ + Test realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Get realms + realms = admin.get_realms() + assert len(realms) == 1, realms + assert realms[0]["realm"] == "master" + + # Create a test realm + res = admin.create_realm(payload={"realm": "test"}) + assert res == b"", res + + # Create the same realm, should fail + with pytest.raises(KeycloakPostError) as err: + res = admin.create_realm(payload={"realm": "test"}) + + assert isinstance(err.value.error_message, bytes) + assert ( + b"Realm test already exists" in err.value.error_message + or b"Conflict detected" in err.value.error_message + ) + + # Create the same realm, skip_exists true + res = admin.create_realm(payload={"realm": "test"}, skip_exists=True) + assert res in [ + json.dumps({"errorMessage": "Realm test already exists"}).encode(), + json.dumps({"msg": "Already exists"}).encode(), + json.dumps({"errorMessage": "Conflict detected. See logs for details"}).encode(), + ], res + + # Get a single realm + res = admin.get_realm(realm_name="test") + assert res["realm"] == "test" + + # Get non-existing realm + with pytest.raises(KeycloakGetError) as err: + admin.get_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found.".*\'') + + # Update realm + res = admin.update_realm(realm_name="test", payload={"accountTheme": "test"}) + assert res == {}, res + + # Check that the update worked + res = admin.get_realm(realm_name="test") + assert res["realm"] == "test" + assert res["accountTheme"] == "test" + + # Update wrong payload + with pytest.raises(KeycloakPutError) as err: + admin.update_realm(realm_name="test", payload={"wrong": "payload"}) + assert err.match("Unrecognized field") + + # Check that get realms returns both realms + realms = admin.get_realms() + realm_names = [x["realm"] for x in realms] + assert len(realms) == 2, realms + assert "master" in realm_names, realm_names + assert "test" in realm_names, realm_names + + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] == "latest" or Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"], + ) >= Version("24"): + # Get users profile, add an attribute + user_profile = admin.get_realm_users_profile() + assert "attributes" in user_profile + + new_attribute = { + "name": "surname", + "displayName": "", + "validations": {}, + "annotations": {}, + "permissions": {"view": [], "edit": ["admin"]}, + "multivalued": False, + } + user_profile["attributes"].append(new_attribute) + + res = admin.update_realm_users_profile(user_profile) + # Check for new attribute in result + assert "surname" in [x["name"] for x in res["attributes"]] + + # Delete the realm + res = admin.delete_realm(realm_name="test") + assert res == {}, res + + # Check that the realm does not exist anymore + with pytest.raises(KeycloakGetError) as err: + admin.get_realm(realm_name="test") + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + + # Delete non-existing realm + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + + +def test_changing_of_realms(admin: KeycloakAdmin, realm: str) -> None: + """ + Test changing of realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + assert admin.get_current_realm() == "master" + admin.change_current_realm(realm) + assert admin.get_current_realm() == realm + + +def test_import_export_realms(admin: KeycloakAdmin, realm: str) -> None: + """ + Test import and export of realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + realm_export = admin.export_realm(export_clients=True, export_groups_and_role=True) + assert realm_export != {}, realm_export + + admin.delete_realm(realm_name=realm) + admin.change_current_realm("master") + res = admin.import_realm(payload=realm_export) + assert res == b"", res + + # Test bad import + with pytest.raises(KeycloakPostError) as err: + admin.import_realm(payload={}) + assert err.match( + '500: b\'{"error":"unknown_error"}\'|400: b\'{"errorMessage":"Realm name cannot be empty"}\'', # noqa: E501 + ) + + +def test_partial_import_realm(admin: KeycloakAdmin, realm: str) -> None: + """ + Test partial import of realm configuration. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + test_realm_role = str(uuid.uuid4()) + test_user = str(uuid.uuid4()) + test_client = str(uuid.uuid4()) + + admin.change_current_realm(realm) + client_id = admin.create_client(payload={"name": test_client, "clientId": test_client}) + + realm_export = admin.export_realm(export_clients=True, export_groups_and_role=False) + + client_config = next( + client_entry for client_entry in realm_export["clients"] if client_entry["id"] == client_id + ) + + # delete before partial import + admin.delete_client(client_id) + + payload = { + "ifResourceExists": "SKIP", + "id": realm_export["id"], + "realm": realm, + "clients": [client_config], + "roles": {"realm": [{"name": test_realm_role}]}, + "users": [{"username": test_user, "email": f"{test_user}@test.test"}], + } + + # check add + res = admin.partial_import_realm(realm_name=realm, payload=payload) + assert res["added"] == 3 + + # check skip + res = admin.partial_import_realm(realm_name=realm, payload=payload) + assert res["skipped"] == 3 + + # check overwrite + payload["ifResourceExists"] = "OVERWRITE" + res = admin.partial_import_realm(realm_name=realm, payload=payload) + assert res["overwritten"] == 3 + + +def test_organizations(admin: KeycloakAdmin, realm: str) -> None: + """ + Test organizations. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + # Organizations was only release in KeyCloak 26, so disable these checks + # for older KeyCloak releases + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] != "latest" and Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] + ) < Version("26"): + return + + admin.change_current_realm(realm) + admin.update_realm(realm_name=realm, payload={"organizationsEnabled": True}) + + org_payload = {"name": "test-org01", "alias": "test-org01", "domains": [{"name": "org1.com"}]} + org_id = admin.create_organization(payload=org_payload) + assert org_id is not None, org_id + + org = admin.get_organization(org_id) + assert org["name"] == "test-org01", org["name"] + assert org["alias"] == "test-org01", org["alias"] + assert org["domains"][0]["name"] == "org1.com", org["domains"][0]["name"] + + org["name"] = "test-org01-u" + org_update_res = admin.update_organization(org_id, payload=org) + assert org_update_res == {} + org["name"] = "test-org01" + admin.update_organization(org_id, payload=org) + + orgs = admin.get_organizations() + assert len(orgs) == 1, orgs + assert orgs[0]["name"] == "test-org01", orgs[0]["name"] + + user_id = admin.create_user(payload={"username": "test", "email": "test@test.test"}) + org_user_add_res = admin.organization_user_add(user_id, org_id) + assert org_user_add_res == b"" + + users = admin.get_organization_members(org_id) + assert len(users) == 1, users + assert users[0]["id"] == user_id, users[0]["id"] + num_users = admin.get_organization_members_count(org_id) + assert num_users == 1, num_users + + user_orgs = admin.get_user_organizations(user_id) + assert len(user_orgs) == 1, user_orgs + assert user_orgs[0]["name"] == "test-org01", user_orgs[0]["name"] + + org_user_remove_res = admin.organization_user_remove(user_id, org_id) + assert org_user_remove_res == {} + users = admin.get_organization_members(org_id) + assert len(users) == 0, users + num_users = admin.get_organization_members_count(org_id) + assert num_users == 0, num_users + + for i in range(admin.PAGE_SIZE + 50): + user_id = admin.create_user( + payload={"username": f"test-user{i:02d}", "email": f"test-user{i:02d}@test.test"} + ) + + admin.organization_user_add(user_id, org_id) + + users = admin.get_organization_members(org_id) + assert len(users) == admin.PAGE_SIZE + 50, users + + users = admin.get_organization_members(org_id, query={"first": 100, "max": -1, "search": ""}) + assert len(users) == 50, len(users) + + users = admin.get_organization_members(org_id, query={"max": 20, "first": -1, "search": ""}) + assert len(users) == 20, len(users) + + _ = admin.create_idp( + payload={ + "providerId": "github", + "alias": "github", + "config": {"clientId": "test-client-id", "clientSecret": "test-client-secret"}, + } + ) + + idp_add_res = admin.organization_idp_add(org_id, "github") + assert idp_add_res == {} + + idps = admin.get_organization_idps(org_id) + assert len(idps) == 1, idps + assert idps[0]["alias"] == "github", idps[0]["alias"] + + idp_remove_res = admin.organization_idp_remove(org_id, "github") + assert idp_remove_res == {} + idps = admin.get_organization_idps(org_id) + assert len(idps) == 0, idps + + delete_org_res = admin.delete_organization(org_id) + assert delete_org_res == {} + orgs = admin.get_organizations() + assert len(orgs) == 0, orgs + + for i in range(admin.PAGE_SIZE + 50): + admin.create_organization( + payload={ + "name": f"test-org{i:02d}", + "alias": f"org{i:02d}", + "domains": [{"name": f"org{i:02d}.com"}], + } + ) + + orgs = admin.get_organizations() + assert len(orgs) == admin.PAGE_SIZE + 50, len(orgs) + + orgs = admin.get_organizations(query={"first": 100, "max": -1, "search": ""}) + assert len(orgs) == 50, len(orgs) + + orgs = admin.get_organizations(query={"first": -1, "max": 20, "search": ""}) + assert len(orgs) == 20, len(orgs) + + +def test_users(admin: KeycloakAdmin, realm: str) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Check no users present + users = admin.get_users() + assert users == [], users + + # Test create user + user_id = admin.create_user(payload={"username": "test", "email": "test@test.test"}) + assert user_id is not None, user_id + + # Test create the same user + with pytest.raises(KeycloakPostError) as err: + admin.create_user(payload={"username": "test", "email": "test@test.test"}) + assert err.match(".*User exists with same.*") + + # Test create the same user, exists_ok true + user_id_2 = admin.create_user( + payload={"username": "test", "email": "test@test.test"}, + exist_ok=True, + ) + assert user_id == user_id_2 + + # Test get user + user = admin.get_user(user_id=user_id) + assert user["username"] == "test", user["username"] + assert user["email"] == "test@test.test", user["email"] + + # Test update user + res = admin.update_user(user_id=user_id, payload={"firstName": "Test"}) + assert res == {}, res + user = admin.get_user(user_id=user_id) + assert user["firstName"] == "Test" + + # Test update user fail + with pytest.raises(KeycloakPutError) as err: + admin.update_user(user_id=user_id, payload={"wrong": "payload"}) + assert err.match("Unrecognized field") + + # Test disable user + res = admin.disable_user(user_id=user_id) + assert res == {}, res + assert not admin.get_user(user_id=user_id)["enabled"] + + # Test enable user + res = admin.enable_user(user_id=user_id) + assert res == {}, res + assert admin.get_user(user_id=user_id)["enabled"] + + # Test get users again + users = admin.get_users() + usernames = [x["username"] for x in users] + assert "test" in usernames + + # Test users counts + count = admin.users_count() + assert count == 1, count + + # Test users count with query + count = admin.users_count(query={"username": "notpresent"}) + assert count == 0 + + # Test user groups + groups = admin.get_user_groups(user_id=user["id"]) + assert len(groups) == 0 + + # Test user groups bad id + with pytest.raises(KeycloakGetError) as err: + admin.get_user_groups(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test logout + res = admin.user_logout(user_id=user["id"]) + assert res == {}, res + + # Test logout fail + with pytest.raises(KeycloakPostError) as err: + admin.user_logout(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test consents + res = admin.user_consents(user_id=user["id"]) + assert len(res) == 0, res + + # Test consents fail + with pytest.raises(KeycloakGetError) as err: + admin.user_consents(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test delete user + res = admin.delete_user(user_id=user_id) + assert res == {}, res + with pytest.raises(KeycloakGetError) as err: + admin.get_user(user_id=user_id) + err.match(USER_NOT_FOUND_REGEX) + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_user(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + +def test_enable_disable_all_users(admin: KeycloakAdmin, realm: str) -> None: + """ + Test enable and disable all users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + user_id_1 = admin.create_user( + payload={"username": "test", "email": "test@test.test", "enabled": True}, + ) + user_id_2 = admin.create_user( + payload={"username": "test2", "email": "test2@test.test", "enabled": True}, + ) + user_id_3 = admin.create_user( + payload={"username": "test3", "email": "test3@test.test", "enabled": True}, + ) + + assert admin.get_user(user_id_1)["enabled"] + assert admin.get_user(user_id_2)["enabled"] + assert admin.get_user(user_id_3)["enabled"] + + admin.disable_all_users() + + assert not admin.get_user(user_id_1)["enabled"] + assert not admin.get_user(user_id_2)["enabled"] + assert not admin.get_user(user_id_3)["enabled"] + + admin.enable_all_users() + + assert admin.get_user(user_id_1)["enabled"] + assert admin.get_user(user_id_2)["enabled"] + assert admin.get_user(user_id_3)["enabled"] + + +def test_users_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test users roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + user_id = admin.create_user(payload={"username": "test", "email": "test@test.test"}) + + # Test all level user roles + client_id = admin.create_client(payload={"name": "test-client", "clientId": "test-client"}) + admin.create_client_role(client_role_id=client_id, payload={"name": "test-role"}) + admin.assign_client_role( + client_id=client_id, + user_id=user_id, + roles=[admin.get_client_role(client_id=client_id, role_name="test-role")], + ) + all_roles = admin.get_all_roles_of_user(user_id=user_id) + realm_roles = all_roles["realmMappings"] + assert len(realm_roles) == 1, realm_roles + client_roles = all_roles["clientMappings"] + assert len(client_roles) == 1, client_roles + + # Test all level user roles fail + with pytest.raises(KeycloakGetError) as err: + admin.get_all_roles_of_user(user_id="non-existent-id") + err.match('404: b\'{"error":"User not found"') + + admin.delete_user(user_id) + admin.delete_client(client_id) + + +def test_users_pagination(admin: KeycloakAdmin, realm: str) -> None: + """ + Test user pagination. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + for ind in range(admin.PAGE_SIZE + 50): + username = f"user_{ind}" + admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) + + users = admin.get_users() + assert len(users) == admin.PAGE_SIZE + 50, len(users) + + users = admin.get_users(query={"first": 100}) + assert len(users) == 50, len(users) + + users = admin.get_users(query={"max": 20}) + assert len(users) == 20, len(users) + + +def test_user_groups_pagination(admin: KeycloakAdmin, realm: str) -> None: + """ + Test user groups pagination. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + user_id = admin.create_user( + payload={"username": "username_1", "email": "username_1@test.test"}, + ) + + for ind in range(admin.PAGE_SIZE + 50): + group_name = f"group_{ind}" + group_id = admin.create_group(payload={"name": group_name}) + assert group_id is not None + admin.group_user_add(user_id=user_id, group_id=group_id) + + groups = admin.get_user_groups(user_id=user_id) + assert len(groups) == admin.PAGE_SIZE + 50, len(groups) + + groups = admin.get_user_groups(user_id=user_id, query={"first": 100, "max": -1, "search": ""}) + assert len(groups) == 50, len(groups) + + groups = admin.get_user_groups(user_id=user_id, query={"max": 20, "first": -1, "search": ""}) + assert len(groups) == 20, len(groups) + + +def test_idps(admin: KeycloakAdmin, realm: str) -> None: + """ + Test IDPs. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Create IDP + res = admin.create_idp( + payload={ + "providerId": "github", + "alias": "github", + "config": {"clientId": "test", "clientSecret": "test"}, + }, + ) + assert res == b"", res + + # Test create idp fail + with pytest.raises(KeycloakPostError) as err: + admin.create_idp(payload={"providerId": "does-not-exist", "alias": "something"}) + assert err.match("Invalid identity provider id"), err + + # Test listing + idps = admin.get_idps() + assert len(idps) == 1 + assert idps[0]["alias"] == "github" + + # Test get idp + idp = admin.get_idp("github") + assert idp["alias"] == "github" + assert idp.get("config") + assert idp["config"]["clientId"] == "test" + assert idp["config"]["clientSecret"] == "**********" + + # Test get idp fail + with pytest.raises(KeycloakGetError) as err: + admin.get_idp("does-not-exist") + assert err.match(HTTP_404_REGEX) + + # Test IdP update + res = admin.update_idp(idp_alias="github", payload=idps[0]) + + assert res == {}, res + + # Test adding a mapper + res = admin.add_mapper_to_idp( + idp_alias="github", + payload={ + "identityProviderAlias": "github", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + }, + ) + assert res == b"", res + + # Test mapper fail + with pytest.raises(KeycloakPostError) as err: + admin.add_mapper_to_idp(idp_alias="does-no-texist", payload={}) + assert err.match(HTTP_404_REGEX) + + # Test IdP mappers listing + idp_mappers = admin.get_idp_mappers(idp_alias="github") + assert len(idp_mappers) == 1 + + # Test IdP mapper update + res = admin.update_mapper_in_idp( + idp_alias="github", + mapper_id=idp_mappers[0]["id"], + # For an obscure reason, keycloak expect all fields + payload={ + "id": idp_mappers[0]["id"], + "identityProviderAlias": "github-alias", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + "config": idp_mappers[0]["config"], + }, + ) + assert res == {}, res + + # Test delete + res = admin.delete_idp(idp_alias="github") + assert res == {}, res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_idp(idp_alias="does-not-exist") + assert err.match(HTTP_404_REGEX) + + +def test_user_credentials(admin: KeycloakAdmin, user: str) -> None: + """ + Test user credentials. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + res = admin.set_user_password(user_id=user, password="booya", temporary=True) # noqa: S106 + assert res == {}, res + + # Test user password set fail + with pytest.raises(KeycloakPutError) as err: + admin.set_user_password(user_id="does-not-exist", password="") + assert err.match(USER_NOT_FOUND_REGEX) + + credentials = admin.get_credentials(user_id=user) + assert len(credentials) == 1 + assert credentials[0]["type"] == "password", credentials + + # Test get credentials fail + with pytest.raises(KeycloakGetError) as err: + admin.get_credentials(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + res = admin.delete_credential(user_id=user, credential_id=credentials[0]["id"]) + assert res == {}, res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_credential(user_id=user, credential_id="does-not-exist") + assert err.match('404: b\'{"error":"Credential not found".*}\'') + + +def test_social_logins(admin: KeycloakAdmin, user: str) -> None: + """ + Test social logins. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + res = admin.add_user_social_login( + user_id=user, + provider_id="gitlab", + provider_userid="test", + provider_username="test", + ) + assert res == {}, res + admin.add_user_social_login( + user_id=user, + provider_id="github", + provider_userid="test", + provider_username="test", + ) + assert res == {}, res + + # Test add social login fail + with pytest.raises(KeycloakPostError) as err: + admin.add_user_social_login( + user_id="does-not-exist", + provider_id="does-not-exist", + provider_userid="test", + provider_username="test", + ) + assert err.match(USER_NOT_FOUND_REGEX) + + res = admin.get_user_social_logins(user_id=user) + assert res == [], res + + # Test get social logins fail + with pytest.raises(KeycloakGetError) as err: + admin.get_user_social_logins(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + res = admin.delete_user_social_login(user_id=user, provider_id="gitlab") + assert res == {}, res + + res = admin.delete_user_social_login(user_id=user, provider_id="github") + assert res == {}, res + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_user_social_login(user_id=user, provider_id="instagram") + assert err.match('404: b\'{"error":"Link not found".*}\''), err + + +def test_server_info(admin: KeycloakAdmin) -> None: + """ + Test server info. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + info = admin.get_server_info() + keys = info.keys() + assert set(keys).issubset( + { + "systemInfo", + "memoryInfo", + "profileInfo", + "features", + "themes", + "socialProviders", + "identityProviders", + "providers", + "protocolMapperTypes", + "builtinProtocolMappers", + "clientInstallations", + "componentTypes", + "passwordPolicies", + "enums", + "cryptoInfo", + "cpuInfo", + }, + ) + + +def test_groups(admin: KeycloakAdmin, user: str) -> None: + """ + Test groups. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + # Test get groups + groups = admin.get_groups() + assert len(groups) == 0 + + # Test create group + group_id = admin.create_group(payload={"name": "main-group"}) + assert group_id is not None, group_id + + # Test group count + count = admin.groups_count() + assert count.get("count") == 1, count + + # Test group count with query + count = admin.groups_count(query={"search": "notpresent"}) + assert count.get("count") == 0 + + # Test create subgroups + subgroup_id_1 = admin.create_group(payload={"name": "subgroup-1"}, parent=group_id) + subgroup_id_2 = admin.create_group(payload={"name": "subgroup-2"}, parent=group_id) + + # Test create group fail + with pytest.raises(KeycloakPostError) as err: + admin.create_group(payload={"name": "subgroup-1"}, parent=group_id) + assert err.match("409"), err + + # Test skip exists OK + subgroup_id_1_eq = admin.create_group( + payload={"name": "subgroup-1"}, + parent=group_id, + skip_exists=True, + ) + assert subgroup_id_1_eq is None + + # Test get groups again + groups = admin.get_groups() + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups[0]["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get groups query + groups = admin.get_groups(query={"max": 10}) + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups[0]["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get group + assert subgroup_id_1 is not None + res = admin.get_group(group_id=subgroup_id_1) + assert res["id"] == subgroup_id_1, res + assert res["name"] == "subgroup-1" + assert res["path"] == "/main-group/subgroup-1" + + # Test get group fail + with pytest.raises(KeycloakGetError) as err: + admin.get_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + # Create 1 more subgroup + subsubgroup_id_1 = admin.create_group(payload={"name": "subsubgroup-1"}, parent=subgroup_id_2) + main_group = admin.get_group(group_id=group_id) + + # Test nested searches + assert subgroup_id_2 is not None + subgroup_2 = admin.get_group(group_id=subgroup_id_2) + res = admin.get_subgroups(group=subgroup_2, path="/main-group/subgroup-2/subsubgroup-1") + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + # Test nested search from main group + res = admin.get_subgroups( + group=admin.get_group(group_id=group_id, full_hierarchy=True), + path="/main-group/subgroup-2/subsubgroup-1", + ) + assert isinstance(res, dict) + assert res["id"] == subsubgroup_id_1 + + # Test nested search from all groups + res = admin.get_groups(full_hierarchy=True) + assert len(res) == 1 + assert len(res[0]["subGroups"]) == 2 + assert len(next(x for x in res[0]["subGroups"] if x["id"] == subgroup_id_1)["subGroups"]) == 0 + assert len(next(x for x in res[0]["subGroups"] if x["id"] == subgroup_id_2)["subGroups"]) == 1 + + # Test that query params are not allowed for full hierarchy + with pytest.raises(ValueError) as err: + admin.get_group_children(group_id=group_id, full_hierarchy=True, query={"max": 10}) + + # Test that query params are passed + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] == "latest" or Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"], + ) >= Version("23"): + res = admin.get_group_children(group_id=group_id, query={"max": 1}) + assert len(res) == 1 + + assert err.match("Cannot use both query and full_hierarchy parameters") + + main_group_id_2 = admin.create_group(payload={"name": "main-group-2"}) + assert len(admin.get_groups(full_hierarchy=True)) == 2 + + # Test empty search + res = admin.get_subgroups(group=main_group, path="/none") + assert res is None, res + + # Test get group by path + res = admin.get_group_by_path(path="/main-group/subgroup-1") + assert res is not None, res + assert res["id"] == subgroup_id_1, res + + # See https://github.com/marcospereirampj/python-keycloak/issues/675 + with pytest.raises(KeycloakGetError) as err: + admin.get_group_by_path(path="/main-group/subgroup-2/subsubgroup-1/does-not-exist") + assert err.match('404: b\'{"error":"Group path does not exist".*}\'') + + res = admin.get_group_by_path(path="/main-group/subgroup-2/subsubgroup-1") + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + res = admin.get_group_by_path(path="/main-group") + assert res is not None, res + assert res["id"] == group_id, res + + # Test group members + res = admin.get_group_members(group_id=subgroup_id_2) + assert len(res) == 0, res + + # Test fail group members + with pytest.raises(KeycloakGetError) as err: + admin.get_group_members(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\'') + + res = admin.group_user_add(user_id=user, group_id=subgroup_id_2) + assert res == {}, res + + res = admin.get_group_members(group_id=subgroup_id_2) + assert len(res) == 1, res + assert res[0]["id"] == user + + # Test get group members query + res = admin.get_group_members(group_id=subgroup_id_2, query={"max": 10}) + assert len(res) == 1, res + assert res[0]["id"] == user + + with pytest.raises(KeycloakDeleteError) as err: + admin.group_user_remove(user_id="does-not-exist", group_id=subgroup_id_2) + assert err.match(USER_NOT_FOUND_REGEX), err + + res = admin.group_user_remove(user_id=user, group_id=subgroup_id_2) + assert res == {}, res + + # Test set permissions + res = admin.group_set_permissions(group_id=subgroup_id_2, enabled=True) + assert res["enabled"], res + res = admin.group_set_permissions(group_id=subgroup_id_2, enabled=False) + assert not res["enabled"], res + with pytest.raises(KeycloakPutError) as err: + admin.group_set_permissions(group_id=subgroup_id_2, enabled="blah") # pyright: ignore[reportArgumentType] + assert err.match(UNKOWN_ERROR_REGEX), err + + # Test update group + res = admin.update_group(group_id=subgroup_id_2, payload={"name": "new-subgroup-2"}) + assert res == {}, res + assert admin.get_group(group_id=subgroup_id_2)["name"] == "new-subgroup-2" + + # test update fail + with pytest.raises(KeycloakPutError) as err: + admin.update_group(group_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + # Test delete + res = admin.delete_group(group_id=group_id) + assert res == {}, res + assert main_group_id_2 is not None + res = admin.delete_group(group_id=main_group_id_2) + assert res == {}, res + assert len(admin.get_groups()) == 0 + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + +def test_clients(admin: KeycloakAdmin, realm: str) -> None: + """ + Test clients. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test get clients + clients = admin.get_clients() + assert len(clients) == 6, clients + assert {x["name"] for x in clients} == { + "${client_admin-cli}", + "${client_security-admin-console}", + "${client_account-console}", + "${client_broker}", + "${client_account}", + "${client_realm-management}", + }, clients + + # Test create client + client_id = admin.create_client(payload={"name": "test-client", "clientId": "test-client"}) + assert client_id, client_id + + with pytest.raises(KeycloakPostError) as err: + admin.create_client(payload={"name": "test-client", "clientId": "test-client"}) + assert err.match('409: b\'{"errorMessage":"Client test-client already exists"}\''), err + + client_id_2 = admin.create_client( + payload={"name": "test-client", "clientId": "test-client"}, + skip_exists=True, + ) + assert client_id == client_id_2, client_id_2 + + # Test get client + res = admin.get_client(client_id=client_id) + assert res["clientId"] == "test-client", res + assert res["name"] == "test-client", res + assert res["id"] == client_id, res + + with pytest.raises(KeycloakGetError) as err: + admin.get_client(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + assert len(admin.get_clients()) == 7 + + # Test get client id + assert admin.get_client_id(client_id="test-client") == client_id + assert admin.get_client_id(client_id="does-not-exist") is None + + # Test update client + res = admin.update_client(client_id=client_id, payload={"name": "test-client-change"}) + assert res == {}, res + + with pytest.raises(KeycloakPutError) as err: + admin.update_client(client_id="does-not-exist", payload={"name": "test-client-change"}) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test client mappers + res = admin.get_mappers_from_client(client_id=client_id) + assert len(res) == 0 + + with pytest.raises(KeycloakPostError) as err: + admin.add_mapper_to_client(client_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = admin.add_mapper_to_client( + client_id=client_id, + payload={ + "name": "test-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + }, + ) + assert res == b"" + assert len(admin.get_mappers_from_client(client_id=client_id)) == 1 + + mapper = admin.get_mappers_from_client(client_id=client_id)[0] + with pytest.raises(KeycloakPutError) as err: + admin.update_client_mapper(client_id=client_id, mapper_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Model not found".*}\'') + mapper["config"]["user.attribute"] = "test" + res = admin.update_client_mapper(client_id=client_id, mapper_id=mapper["id"], payload=mapper) + assert res == {} + + res = admin.remove_client_mapper(client_id=client_id, client_mapper_id=mapper["id"]) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_client_mapper(client_id=client_id, client_mapper_id=mapper["id"]) + assert err.match('404: b\'{"error":"Model not found".*}\'') + + # Test client sessions + with pytest.raises(KeycloakGetError) as err: + admin.get_client_all_sessions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + assert admin.get_client_all_sessions(client_id=client_id) == [] + assert admin.get_client_sessions_stats() == [] + + # Test authz + auth_client_id = admin.create_client( + payload={ + "name": "authz-client", + "clientId": "authz-client", + "authorizationServicesEnabled": True, + "serviceAccountsEnabled": True, + }, + ) + res = admin.get_client_authz_settings(client_id=auth_client_id) + assert res["allowRemoteResourceManagement"] + assert res["decisionStrategy"] == "UNANIMOUS" + assert len(res["policies"]) >= 0 + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_settings(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + # Authz resources + res = admin.get_client_authz_resources(client_id=auth_client_id) + assert len(res) in [0, 1] + if len(res) == 1: + assert res[0]["name"] == "Default Resource" + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_resources(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + res = admin.create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "test-resource"}, + ) + assert res["name"] == "test-resource", res + test_resource_id = res["_id"] + + res = admin.get_client_authz_resource(client_id=auth_client_id, resource_id=test_resource_id) + assert res["_id"] == test_resource_id, res + assert res["name"] == "test-resource", res + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "test-resource"}, + ) + assert err.match('409: b\'{"error":"invalid_request"') + assert admin.create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "test-resource"}, + skip_exists=True, + ) == {"msg": "Already exists"} + + res = admin.get_client_authz_resources(client_id=auth_client_id) + assert len(res) in [1, 2] + assert {x["name"] for x in res}.issubset({"Default Resource", "test-resource"}) + + res = admin.create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "temp-resource"}, + ) + assert res["name"] == "temp-resource", res + temp_resource_id: str = res["_id"] + # Test update authz resources + cz_res = admin.update_client_authz_resource( + client_id=auth_client_id, + resource_id=temp_resource_id, + payload={"name": "temp-updated-resource"}, + ) + assert cz_res == {} + res = admin.get_client_authz_resource(client_id=auth_client_id, resource_id=temp_resource_id) + assert res["name"] == "temp-updated-resource", res + with pytest.raises(KeycloakPutError) as err: + admin.update_client_authz_resource( + client_id=auth_client_id, + resource_id="invalid_resource_id", + payload={"name": "temp-updated-resource"}, + ) + assert err.match("404: b''"), err + cz_res = admin.delete_client_authz_resource( + client_id=auth_client_id, resource_id=temp_resource_id + ) + assert cz_res == {} + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_resource(client_id=auth_client_id, resource_id=temp_resource_id) + assert err.match("404: b''") + + # Authz policies + res = admin.get_client_authz_policies(client_id=auth_client_id) + assert len(res) in [0, 1], res + if len(res) == 1: + assert res[0]["name"] == "Default Policy" + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_policies(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + role_id = admin.get_realm_role(role_name="offline_access")["id"] + res = admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert res["name"] == "test-authz-rb-policy", res + role_based_policy_id = res["id"] + role_based_policy_name = res["name"] + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(admin.get_client_authz_policies(client_id=auth_client_id)) in [1, 2] + + res = admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy-delete", "roles": [{"id": role_id}]}, + ) + res2 = admin.get_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert res["id"] == res2["id"] + cz_res = admin.delete_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert cz_res == {} + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert err.match("404: b''") + + res = admin.create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + ) + assert res["name"] == "test-authz-policy", res + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert admin.create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(admin.get_client_authz_policies(client_id=auth_client_id)) in [2, 3] + + # Test authz permissions + res = admin.get_client_authz_permissions(client_id=auth_client_id) + assert len(res) in [0, 1], res + if len(res) == 1: + assert res[0]["name"] == "Default Permission" + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_permissions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = admin.create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert res, res + assert res["name"] == "test-permission-rb" + assert res["resources"] == [test_resource_id] + resource_based_permission_id = res["id"] + resource_based_permission_name = res["name"] + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert admin.create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(admin.get_client_authz_permissions(client_id=auth_client_id)) in [1, 2] + + # Test associating client policy with resource based permission + res = admin.update_client_authz_resource_permission( + client_id=auth_client_id, + resource_id=resource_based_permission_id, + payload={ + "id": resource_based_permission_id, + "name": resource_based_permission_name, + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [test_resource_id], + "scopes": [], + "policies": [role_based_policy_id], + }, + ) + assert res == b"" + + # Test getting associated policies for a permission + associated_policies = admin.get_client_authz_permission_associated_policies( + client_id=auth_client_id, + policy_id=resource_based_permission_id, + ) + assert len(associated_policies) == 1 + assert associated_policies[0]["name"].startswith(role_based_policy_name) + + # Test authz scopes + res = admin.get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 0, res + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_scopes(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + res = admin.create_client_authz_scopes( + client_id=auth_client_id, + payload={"name": "test-authz-scope"}, + ) + assert res["name"] == "test-authz-scope", res + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_scopes( + client_id="invalid_client_id", + payload={"name": "test-authz-scope"}, + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + assert admin.create_client_authz_scopes( + client_id=auth_client_id, + payload={"name": "test-authz-scope"}, + ) + + res = admin.get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 1 + assert {x["name"] for x in res} == {"test-authz-scope"} + + # Test service account user + res = admin.get_client_service_account_user(client_id=auth_client_id) + assert res["username"] == "service-account-authz-client", res + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_service_account_user(client_id=client_id) + + assert ('b\'{"error":"Service account not enabled for the client' in str(err)) or err.match( + UNKOWN_ERROR_REGEX, + ) + + # Test import authz + authz_config = admin.get_client_authz_settings(client_id=auth_client_id) + + authz_config["resources"] = [{"name": "test-import-resource"}] + authz_config["policies"] = [ + { + "name": "test-import-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + } + ] + cz_res = admin.import_client_authz_config(client_id=auth_client_id, payload=authz_config) + assert cz_res == {} + exported = admin.get_client_authz_settings(client_id=auth_client_id) + assert ( + len( + [ + resource + for resource in exported["resources"] + if resource["name"] == "test-import-resource" + ] + ) + == 1 + ) + + assert ( + len( + [ + resource + for resource in exported["policies"] + if resource["name"] == "test-import-policy" + ] + ) + == 1 + ) + + # Test delete client + res = admin.delete_client(client_id=auth_client_id) + assert res == {}, res + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client(client_id=auth_client_id) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test client credentials + admin.create_client( + payload={ + "name": "test-confidential", + "enabled": True, + "protocol": "openid-connect", + "publicClient": False, + "redirectUris": ["http://localhost/*"], + "webOrigins": ["+"], + "clientId": "test-confidential", + "secret": "test-secret", + "clientAuthenticatorType": "client-secret", + }, + ) + with pytest.raises(KeycloakGetError) as err: + admin.get_client_secrets(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + client_id = admin.get_client_id(client_id="test-confidential") + assert client_id is not None + secrets = admin.get_client_secrets(client_id=client_id) + assert secrets == {"type": "secret", "value": "test-secret"} + + with pytest.raises(KeycloakPostError) as err: + admin.generate_client_secrets(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + client_id = admin.get_client_id(client_id="test-confidential") + assert client_id is not None + res = admin.generate_client_secrets(client_id=client_id) + assert res + client_id = admin.get_client_id(client_id="test-confidential") + assert client_id is not None + assert admin.get_client_secrets(client_id=client_id) == res + + +def test_realm_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test realm roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test get realm roles + roles = admin.get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # Test get realm roles with search text + searched_roles = admin.get_realm_roles(search_text="uma_a") + searched_role_names = [x["name"] for x in searched_roles] + assert "uma_authorization" in searched_role_names, searched_role_names + assert "offline_access" not in searched_role_names, searched_role_names + + # Test empty members + with pytest.raises(KeycloakGetError) as err: + admin.get_realm_role_members(role_name="does-not-exist") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + members = admin.get_realm_role_members(role_name="offline_access") + assert members == [], members + + # Test create realm role + role_id = admin.create_realm_role(payload={"name": "test-realm-role"}, skip_exists=True) + assert role_id, role_id + with pytest.raises(KeycloakPostError) as err: + admin.create_realm_role(payload={"name": "test-realm-role"}) + assert err.match('409: b\'{"errorMessage":"Role with name test-realm-role already exists"}\'') + role_id_2 = admin.create_realm_role(payload={"name": "test-realm-role"}, skip_exists=True) + assert role_id == role_id_2 + + # Test get realm role by its id + role_id = admin.get_realm_role(role_name="test-realm-role")["id"] + res = admin.get_realm_role_by_id(role_id) + assert res["name"] == "test-realm-role" + + # Test update realm role + res = admin.update_realm_role( + role_name="test-realm-role", + payload={"name": "test-realm-role-update"}, + ) + assert res == {}, res + with pytest.raises(KeycloakPutError) as err: + admin.update_realm_role( + role_name="test-realm-role", + payload={"name": "test-realm-role-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test realm role user assignment + user_id = admin.create_user(payload={"username": "role-testing", "email": "test@test.test"}) + with pytest.raises(KeycloakPostError) as err: + admin.assign_realm_roles(user_id=user_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.assign_realm_roles( + user_id=user_id, + roles=[ + admin.get_realm_role(role_name="offline_access"), + admin.get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == {}, res + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] for x in admin.get_realm_role_members(role_name="offline_access") + ] + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] for x in admin.get_realm_role_members(role_name="test-realm-role-update") + ] + + roles = admin.get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 3 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_roles_of_user(user_id=user_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.delete_realm_roles_of_user( + user_id=user_id, + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == {}, res + assert admin.get_realm_role_members(role_name="offline_access") == [] + roles = admin.get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" not in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + roles = admin.get_available_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "uma_authorization" in [x["name"] for x in roles] + + # Test realm role group assignment + group_id = admin.create_group(payload={"name": "test-group"}) + assert group_id is not None + with pytest.raises(KeycloakPostError) as err: + admin.assign_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.assign_group_realm_roles( + group_id=group_id, + roles=[ + admin.get_realm_role(role_name="offline_access"), + admin.get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == {}, res + + roles = admin.get_group_realm_roles(group_id=group_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX) + res = admin.delete_group_realm_roles( + group_id=group_id, + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == {}, res + roles = admin.get_group_realm_roles(group_id=group_id) + assert len(roles) == 1 + assert "test-realm-role-update" in [x["name"] for x in roles] + + # Test composite realm roles + composite_role = admin.create_realm_role(payload={"name": "test-composite-role"}) + with pytest.raises(KeycloakPostError) as err: + admin.add_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.add_composite_realm_roles_to_role( + role_name=composite_role, + roles=[admin.get_realm_role(role_name="test-realm-role-update")], + ) + assert res == {}, res + + res = admin.get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 1 + assert "test-realm-role-update" in res[0]["name"] + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_realm_roles_of_role(role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + res = admin.get_composite_realm_roles_of_user(user_id=user_id) + assert len(res) == 4 + assert "offline_access" in {x["name"] for x in res} + assert "test-realm-role-update" in {x["name"] for x in res} + assert "uma_authorization" in {x["name"] for x in res} + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_realm_roles_of_user(user_id="bad") + assert err.match(USER_NOT_FOUND_REGEX), err + + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.remove_composite_realm_roles_to_role( + role_name=composite_role, + roles=[admin.get_realm_role(role_name="test-realm-role-update")], + ) + assert res == {}, res + + res = admin.get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 0 + + # Test realm role group list + res = admin.get_realm_role_groups(role_name="test-realm-role-update") + assert len(res) == 1 + assert res[0]["id"] == group_id + with pytest.raises(KeycloakGetError) as err: + admin.get_realm_role_groups(role_name="non-existent-role") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test with query params + res = admin.get_realm_role_groups(role_name="test-realm-role-update", query={"max": 1}) + assert len(res) == 1 + + # Test delete realm role + res = admin.delete_realm_role(role_name=composite_role) + assert res == {}, res + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_role(role_name=composite_role) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + +def test_realm_roles_pagination(admin: KeycloakAdmin, realm: str) -> None: + """ + Test realm roles pagination. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + for ind in range(admin.PAGE_SIZE + 50 - 3): + role_name = f"role_{ind:03}" + admin.create_realm_role(payload={"name": role_name}) + + roles = admin.get_realm_roles() + assert len(roles) == admin.PAGE_SIZE + 50, len(roles) + + roles = admin.get_realm_roles(query={"first": 100, "max": 20}) + assert len(roles) == 20, len(roles) + + roles = admin.get_realm_roles(query={"first": 120, "max": 50}) + assert len(roles) == 30, len(roles) + + +@pytest.mark.parametrize( + ("testcase", "arg_brief_repr", "includes_attributes"), + [ + ("brief True", {"brief_representation": True}, False), + ("brief False", {"brief_representation": False}, True), + ("default", {}, False), + ], +) +def test_role_attributes( + admin: KeycloakAdmin, + realm: str, + client: str, + arg_brief_repr: dict, + includes_attributes: bool, + testcase: str, +) -> None: + """ + Test getting role attributes for bulk calls. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param arg_brief_repr: Brief representation + :type arg_brief_repr: dict + :param includes_attributes: Indicator whether to include attributes + :type includes_attributes: bool + :param testcase: Test case + :type testcase: str + """ + # setup + attribute_role = "test-realm-role-w-attr" + test_attrs = {"attr1": ["val1"], "attr2": ["val2-1", "val2-2"]} + role_id = admin.create_realm_role( + payload={"name": attribute_role, "attributes": test_attrs}, + skip_exists=True, + ) + assert role_id, role_id + + cli_role_id = admin.create_client_role( + client, + payload={"name": attribute_role, "attributes": test_attrs}, + skip_exists=True, + ) + assert cli_role_id, cli_role_id + + if not includes_attributes: + test_attrs = None + + # tests + roles = admin.get_realm_roles(**arg_brief_repr) + roles_filtered = [role for role in roles if role["name"] == role_id] + assert roles_filtered, roles_filtered + role = roles_filtered[0] + assert role.get("attributes") == test_attrs, testcase + + roles = admin.get_client_roles(client, **arg_brief_repr) + roles_filtered = [role for role in roles if role["name"] == cli_role_id] + assert roles_filtered, roles_filtered + role = roles_filtered[0] + assert role.get("attributes") == test_attrs, testcase + + # cleanup + res = admin.delete_realm_role(role_name=attribute_role) + assert res == {}, res + + res = admin.delete_client_role(client, role_name=attribute_role) + assert res == {}, res + + +def test_client_scope_realm_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test client realm roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test get realm roles + roles = admin.get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # create realm role for test + role_id = admin.create_realm_role(payload={"name": "test-realm-role"}, skip_exists=True) + assert role_id, role_id + + # Test realm role client assignment + client_id = admin.create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + with pytest.raises(KeycloakPostError) as err: + admin.assign_realm_roles_to_client_scope(client_id=client_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.assign_realm_roles_to_client_scope( + client_id=client_id, + roles=[ + admin.get_realm_role(role_name="offline_access"), + admin.get_realm_role(role_name="test-realm-role"), + ], + ) + assert res == {}, res + + roles = admin.get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 2 + client_role_names = [x["name"] for x in roles] + assert "offline_access" in client_role_names, client_role_names + assert "test-realm-role" in client_role_names, client_role_names + assert "uma_authorization" not in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_roles_of_client_scope(client_id=client_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.delete_realm_roles_of_client_scope( + client_id=client_id, + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == {}, res + roles = admin.get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 1 + assert "test-realm-role" in [x["name"] for x in roles] + + res = admin.delete_realm_roles_of_client_scope( + client_id=client_id, + roles=[admin.get_realm_role(role_name="test-realm-role")], + ) + assert res == {}, res + roles = admin.get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 0 + + +def test_client_scope_client_roles(admin: KeycloakAdmin, realm: str, client: str) -> None: + """ + Test client assignment of other client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + admin.change_current_realm(realm) + + client_id = admin.create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + + # Test get client roles + roles = admin.get_client_roles_of_client_scope(client_id, client) + assert len(roles) == 0, roles + + # create client role for test + client_role_id = admin.create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + skip_exists=True, + ) + assert client_role_id, client_role_id + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + admin.assign_client_roles_to_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.assign_client_roles_to_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test")], + ) + assert res == {}, res + + roles = admin.get_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + ) + assert len(roles) == 1 + client_role_names = [x["name"] for x in roles] + assert "client-role-test" in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.delete_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test")], + ) + assert res == {}, res + roles = admin.get_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + ) + assert len(roles) == 0 + + +def test_client_scope_mapping_client_roles(admin: KeycloakAdmin, realm: str, client: str) -> None: + """ + Test client scope assignment of client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client owning roles + :type client: str + """ + _client_role_name = "some-client-role" + + admin.change_current_realm(realm) + + client_name = admin.get_client(client)["name"] + + client_scope = { + "name": "test_client_scope", + "description": "Test Client Scope", + "protocol": "openid-connect", + "attributes": {}, + } + client_scope_id = admin.create_client_scope(client_scope, skip_exists=False) + + # Test get client roles + client_specific_roles = admin.get_client_specific_roles_of_client_scope( + client_scope_id, + client, + ) + assert len(client_specific_roles) == 0, client_specific_roles + all_roles = admin.get_all_roles_of_client_scope(client_scope_id) + assert len(all_roles) == 0, all_roles + + # create client role for test + client_role_name = admin.create_client_role( + client_role_id=client, + payload={"name": _client_role_name}, + skip_exists=True, + ) + assert client_role_name, client_role_name + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + admin.add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = admin.add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[admin.get_client_role(client_id=client, role_name=_client_role_name)], + ) + assert res == {}, res + + # Test when getting roles for the specific owner client + client_specific_roles = admin.get_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + ) + assert len(client_specific_roles) == 1 + client_role_names = [x["name"] for x in client_specific_roles] + assert _client_role_name in client_role_names, client_role_names + + # Test when getting all roles for the client scope + all_roles = admin.get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert "clientMappings" in all_roles, all_roles + all_roles_clients = all_roles["clientMappings"] + assert client_name in all_roles_clients, all_roles_clients + mappings = all_roles_clients[client_name]["mappings"] + client_role_names = [x["name"] for x in mappings] + assert _client_role_name in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = admin.remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[admin.get_client_role(client_id=client, role_name=_client_role_name)], + ) + assert res == {}, res + + all_roles = admin.get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert len(all_roles) == 0 + + +def test_client_default_client_scopes(admin: KeycloakAdmin, realm: str, client: str) -> None: + """ + Test client assignment of default client scopes. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + admin.change_current_realm(realm) + + client_id = admin.create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + # Test get client default scopes + # keycloak default roles: web-origins, acr, profile, roles, email + default_client_scopes = admin.get_client_default_client_scopes(client_id) + assert len(default_client_scopes) in [6, 5], default_client_scopes + + # Test add a client scope to client default scopes + default_client_scope = "test-client-default-scope" + new_client_scope = { + "name": default_client_scope, + "description": f"Test Client Scope: {default_client_scope}", + "protocol": "openid-connect", + "attributes": {}, + } + new_client_scope_id = admin.create_client_scope(new_client_scope, skip_exists=False) + new_default_client_scope_data = { + "realm": realm, + "client": client_id, + "clientScopeId": new_client_scope_id, + } + dcs_res = admin.add_client_default_client_scope( + client_id, + new_client_scope_id, + new_default_client_scope_data, + ) + assert dcs_res == {} + default_client_scopes = admin.get_client_default_client_scopes(client_id) + assert len(default_client_scopes) in [6, 7], default_client_scopes + + # Test remove a client default scope + dcs_res = admin.delete_client_default_client_scope(client_id, new_client_scope_id) + assert dcs_res == {} + default_client_scopes = admin.get_client_default_client_scopes(client_id) + assert len(default_client_scopes) in [5, 6], default_client_scopes + + +def test_client_optional_client_scopes(admin: KeycloakAdmin, realm: str, client: str) -> None: + """ + Test client assignment of optional client scopes. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + admin.change_current_realm(realm) + + client_id = admin.create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + # Test get client optional scopes + # keycloak optional roles: microprofile-jwt, offline_access, address, --> for versions < 26.0.0 + # starting with Keycloak version 26.0.0 a new optional role is added: organization + optional_client_scopes = admin.get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) in [4, 5], optional_client_scopes + + # Test add a client scope to client optional scopes + optional_client_scope = "test-client-optional-scope" + new_client_scope = { + "name": optional_client_scope, + "description": f"Test Client Scope: {optional_client_scope}", + "protocol": "openid-connect", + "attributes": {}, + } + new_client_scope_id = admin.create_client_scope(new_client_scope, skip_exists=False) + new_optional_client_scope_data = { + "realm": realm, + "client": client_id, + "clientScopeId": new_client_scope_id, + } + ocs_res = admin.add_client_optional_client_scope( + client_id, + new_client_scope_id, + new_optional_client_scope_data, + ) + assert ocs_res == {} + optional_client_scopes = admin.get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) in [5, 6], optional_client_scopes + + # Test remove a client optional scope + ocs_res = admin.delete_client_optional_client_scope(client_id, new_client_scope_id) + assert ocs_res == {} + optional_client_scopes = admin.get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) in [4, 5], optional_client_scopes + + +def test_client_roles(admin: KeycloakAdmin, client: str) -> None: + """ + Test client roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param client: Keycloak client + :type client: str + """ + # Test get client roles + res = admin.get_client_roles(client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_client_roles(client_id="bad") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test create client role + client_role_id = admin.create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + skip_exists=True, + ) + with pytest.raises(KeycloakPostError) as err: + admin.create_client_role(client_role_id=client, payload={"name": "client-role-test"}) + assert err.match('409: b\'{"errorMessage":"Role with name client-role-test already exists"}\'') + client_role_id_2 = admin.create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + skip_exists=True, + ) + assert client_role_id == client_role_id_2 + + # Test get client role + res = admin.get_client_role(client_id=client, role_name="client-role-test") + assert res["name"] == client_role_id + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + res_ = admin.get_client_role_id(client_id=client, role_name="client-role-test") + assert res_ == res["id"] + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role_id(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + assert len(admin.get_client_roles(client_id=client)) == 1 + + # Test update client role + res = admin.update_client_role( + client_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert res == {} + with pytest.raises(KeycloakPutError) as err: + res = admin.update_client_role( + client_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test user with client role + res = admin.get_client_role_members(client_id=client, role_name="client-role-test-update") + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role_members(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + user_id = admin.create_user(payload={"username": "test", "email": "test@test.test"}) + with pytest.raises(KeycloakPostError) as err: + admin.assign_client_role(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.assign_client_role( + user_id=user_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert res == {} + assert ( + len(admin.get_client_role_members(client_id=client, role_name="client-role-test-update")) + == 1 + ) + + roles = admin.get_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + admin.get_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + roles = admin.get_composite_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + roles = admin.get_available_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 0, roles + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client_roles_of_user(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + dcr_res = admin.delete_client_roles_of_user( + user_id=user_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert dcr_res == {} + assert len(admin.get_client_roles_of_user(user_id=user_id, client_id=client)) == 0 + + # Test groups and client roles + res = admin.get_client_role_groups(client_id=client, role_name="client-role-test-update") + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role_groups(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + group_id = admin.create_group(payload={"name": "test-group"}) + assert group_id is not None + res = admin.get_group_client_roles(group_id=group_id, client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_group_client_roles(group_id=group_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakPostError) as err: + admin.assign_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.assign_group_client_roles( + group_id=group_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert res == {} + assert ( + len(admin.get_client_role_groups(client_id=client, role_name="client-role-test-update")) + == 1 + ) + assert len(admin.get_group_client_roles(group_id=group_id, client_id=client)) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.delete_group_client_roles( + group_id=group_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert res == {} + + # Test get composite client roles of role before adding + res = admin.get_composite_client_roles_of_role( + client_id=client, role_name="client-role-test-update" + ) + assert len(res) == 0 + + # Test add composite client roles to role + with pytest.raises(KeycloakPostError) as err: + admin.add_composite_client_roles_to_role( + client_role_id=client, + role_name="client-role-test-update", + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.add_composite_client_roles_to_role( + client_role_id=client, + role_name="client-role-test-update", + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == {} + assert admin.get_client_role(client_id=client, role_name="client-role-test-update")[ + "composite" + ] + + # Test get composite client roles of role after adding + res = admin.get_composite_client_roles_of_role( + client_id=client, role_name="client-role-test-update" + ) + assert len(res) == 1 + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_client_roles_of_role(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test removal of composite client roles + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_composite_client_roles_from_role( + client_role_id=client, + role_name="client-role-test-update", + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = admin.remove_composite_client_roles_from_role( + client_role_id=client, + role_name="client-role-test-update", + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == {} + assert not admin.get_client_role(client_id=client, role_name="client-role-test-update")[ + "composite" + ] + + # Test delete of client role + res = admin.delete_client_role(client_role_id=client, role_name="client-role-test-update") + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client_role(client_role_id=client, role_name="client-role-test-update") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test of roles by id - Get role + admin.create_client_role( + client_role_id=client, + payload={"name": "client-role-by-id-test"}, + skip_exists=True, + ) + role = admin.get_client_role(client_id=client, role_name="client-role-by-id-test") + res = admin.get_role_by_id(role_id=role["id"]) + assert res["name"] == "client-role-by-id-test" + with pytest.raises(KeycloakGetError) as err: + admin.get_role_by_id(role_id="bad") + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + # Test of roles by id - Update role + res = admin.update_role_by_id( + role_id=role["id"], + payload={"name": "client-role-by-id-test-update"}, + ) + assert res == {} + with pytest.raises(KeycloakPutError) as err: + res = admin.update_role_by_id( + role_id="bad", + payload={"name": "client-role-by-id-test-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + # Test of roles by id - Delete role + res = admin.delete_role_by_id(role_id=role["id"]) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_role_by_id(role_id="bad") + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + +def test_enable_token_exchange(admin: KeycloakAdmin, realm: str) -> None: + """ + Test enable token exchange. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :raises AssertionError: In case of bad configuration + """ + # Test enabling token exchange between two confidential clients + admin.change_current_realm(realm) + + # Create test clients + source_client_id = admin.create_client( + payload={"name": "Source Client", "clientId": "source-client"}, + ) + target_client_id = admin.create_client( + payload={"name": "Target Client", "clientId": "target-client"}, + ) + for c in admin.get_clients(): + if c["clientId"] == "realm-management": + realm_management_id = c["id"] + break + else: + pytest.fail("Missing realm management client") + + # Enable permissions on the Superset client + update_res = admin.update_client_management_permissions( + payload={"enabled": True}, + client_id=target_client_id, + ) + assert isinstance(update_res, dict) + + # Fetch various IDs and strings needed when creating the permission + token_exchange_permission_id = admin.get_client_management_permissions( + client_id=target_client_id, + )["scopePermissions"]["token-exchange"] + scopes = admin.get_client_authz_policy_scopes( + client_id=realm_management_id, + policy_id=token_exchange_permission_id, + ) + + for s in scopes: + if s["name"] == "token-exchange": + token_exchange_scope_id = s["id"] + break + else: + pytest.fail("Missing token-exchange scope") + + resources = admin.get_client_authz_policy_resources( + client_id=realm_management_id, + policy_id=token_exchange_permission_id, + ) + for r in resources: + if r["name"] == f"client.resource.{target_client_id}": + token_exchange_resource_id = r["_id"] + break + else: + pytest.fail("Missing client resource") + + # Create a client policy for source client + policy_name = "Exchange source client token with target client token" + client_policy_id = admin.create_client_authz_client_policy( + payload={ + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": policy_name, + "clients": [source_client_id], + }, + client_id=realm_management_id, + )["id"] + policies = admin.get_client_authz_client_policies(client_id=realm_management_id) + for policy in policies: + if policy["name"] == policy_name: + assert policy["clients"] == [source_client_id] + break + else: + pytest.fail("Missing client policy") + + # Update permissions on the target client to reference this policy + permission_name = admin.get_client_authz_scope_permission( + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + )["name"] + per_update_res = admin.update_client_authz_scope_permission( + payload={ + "id": token_exchange_permission_id, + "name": permission_name, + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [token_exchange_resource_id], + "scopes": [token_exchange_scope_id], + "policies": [client_policy_id], + }, + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + ) + assert per_update_res == b"" + + # Create permissions on the target client to reference this policy + per_create_res = admin.create_client_authz_scope_permission( + payload={ + "id": "some-id", + "name": "test-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [token_exchange_resource_id], + "scopes": [token_exchange_scope_id], + "policies": [client_policy_id], + }, + client_id=realm_management_id, + ) + assert isinstance(per_create_res, dict) + permission_name = admin.get_client_authz_scope_permission( + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + )["name"] + assert permission_name.startswith("token-exchange.permission.client.") + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_scope_permission( + payload={"name": "test-permission", "scopes": [token_exchange_scope_id]}, + client_id="realm_management_id", + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + +def test_email(admin: KeycloakAdmin, user: str) -> None: + """ + Test email. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + admin.enable_user(user) + # Emails will fail as we don't have SMTP test setup + with pytest.raises(KeycloakPutError) as err: + admin.send_update_account(user_id=user, payload=[]) + assert err.match('500: b\'{"errorMessage":"Failed to send execute actions email.*'), err + + admin.update_user(user_id=user, payload={"enabled": True}) + with pytest.raises(KeycloakPutError) as err: + admin.send_verify_email(user_id=user) + assert err.match('500: b\'{"errorMessage":"Failed to send .*"}\'') + + +def test_get_sessions(admin: KeycloakAdmin) -> None: + """ + Test get sessions. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + assert admin.connection.username is not None + user_id = admin.get_user_id(username=admin.connection.username) + assert user_id is not None + sessions = admin.get_sessions(user_id=user_id) + assert len(sessions) >= 1 + with pytest.raises(KeycloakGetError) as err: + admin.get_sessions(user_id="bad") + assert err.match(USER_NOT_FOUND_REGEX) + + +def test_get_client_installation_provider(admin: KeycloakAdmin, client: str) -> None: + """ + Test get client installation provider. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param client: Keycloak client + :type client: str + """ + with pytest.raises(KeycloakGetError) as err: + admin.get_client_installation_provider(client_id=client, provider_id="bad") + assert err.match('404: b\'{"error":"Unknown Provider".*}\'') + + installation = admin.get_client_installation_provider( + client_id=client, + provider_id="keycloak-oidc-keycloak-json", + ) + assert set(installation.keys()) == { + "auth-server-url", + "confidential-port", + "credentials", + "realm", + "resource", + "ssl-required", + } + + +def test_auth_flows(admin: KeycloakAdmin, realm: str) -> None: + """ + Test auth flows. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + res = admin.get_authentication_flows() + assert len(res) <= 8, res + default_flows = len(res) + assert {x["alias"] for x in res}.issubset( + { + "reset credentials", + "browser", + "registration", + "http challenge", + "docker auth", + "direct grant", + "first broker login", + "clients", + }, + ) + assert set(res[0].keys()) == { + "alias", + "authenticationExecutions", + "builtIn", + "description", + "id", + "providerId", + "topLevel", + } + assert {x["alias"] for x in res}.issubset( + { + "reset credentials", + "browser", + "registration", + "docker auth", + "direct grant", + "first broker login", + "clients", + "http challenge", + }, + ) + + with pytest.raises(KeycloakGetError) as err: + admin.get_authentication_flow_for_id(flow_id="bad") + assert err.match('404: b\'{"error":"Could not find flow with id".*}\'') + browser_flow_id = next(x for x in res if x["alias"] == "browser")["id"] + res = admin.get_authentication_flow_for_id(flow_id=browser_flow_id) + assert res["alias"] == "browser" + + # Test copying + with pytest.raises(KeycloakPostError) as err: + admin.copy_authentication_flow(payload={}, flow_alias="bad") + assert ('b\'{"error":"Flow not found"' in str(err)) or err.match("404: b''") + + res = admin.copy_authentication_flow(payload={"newName": "test-browser"}, flow_alias="browser") + assert res == b"", res + assert len(admin.get_authentication_flows()) == (default_flows + 1) + + # Test create + res = admin.create_authentication_flow( + payload={"alias": "test-create", "providerId": "basic-flow"}, + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + admin.create_authentication_flow(payload={"alias": "test-create", "builtIn": False}) + assert err.match('409: b\'{"errorMessage":"Flow test-create already exists"}\'') + assert ( + admin.create_authentication_flow(payload={"alias": "test-create"}, skip_exists=True) + == json.dumps({"msg": "Already exists"}).encode() + ) + + # Update + res = admin.get_authentication_flows() + browser_flow_id = next(x for x in res if x["alias"] == "browser")["id"] + flow = admin.get_authentication_flow_for_id(flow_id=browser_flow_id) + del flow["authenticationExecutions"] + del flow["id"] + flow["description"] = "test description" + res = admin.update_authentication_flow(flow_id=browser_flow_id, payload=flow) + assert isinstance(res, dict) + res = admin.get_authentication_flow_for_id(flow_id=browser_flow_id) + assert res["description"] == "test description" + + # Test flow executions + res = admin.get_authentication_flow_executions(flow_alias="browser") + assert len(res) in [8, 12, 14, 15], res + + with pytest.raises(KeycloakGetError) as err: + admin.get_authentication_flow_executions(flow_alias="bad") + assert ('b\'{"error":"Flow not found"' in str(err)) or err.match("404: b''") + exec_id = res[0]["id"] + + res = admin.get_authentication_flow_execution(execution_id=exec_id) + assert set(res.keys()).issubset( + { + "alternative", + "authenticator", + "authenticatorFlow", + "autheticatorFlow", + "conditional", + "disabled", + "enabled", + "id", + "parentFlow", + "priority", + "required", + "requirement", + }, + ), res.keys() + with pytest.raises(KeycloakGetError) as err: + admin.get_authentication_flow_execution(execution_id="bad") + assert err.match(ILLEGAL_EXECUTION_REGEX) + + with pytest.raises(KeycloakPostError) as err: + admin.create_authentication_flow_execution(payload={}, flow_alias="browser") + assert err.match('400: b\'{"error":"It is illegal to add execution to a built in flow".*}\'') + + res = admin.create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, + flow_alias="test-create", + ) + assert res == b"" + assert len(admin.get_authentication_flow_executions(flow_alias="test-create")) == 1 + + with pytest.raises(KeycloakPutError) as err: + admin.update_authentication_flow_executions( + payload={"required": "yes"}, + flow_alias="test-create", + ) + assert err.match("Unrecognized field") + payload = admin.get_authentication_flow_executions(flow_alias="test-create")[0] + payload["displayName"] = "test" + res = admin.update_authentication_flow_executions(payload=payload, flow_alias="test-create") + assert isinstance(res, dict) + + exec_id = admin.get_authentication_flow_executions(flow_alias="test-create")[0]["id"] + res = admin.delete_authentication_flow_execution(execution_id=exec_id) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_authentication_flow_execution(execution_id=exec_id) + assert err.match(ILLEGAL_EXECUTION_REGEX) + + # Test subflows + res = admin.create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-browser", + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + admin.create_authentication_flow_subflow( + payload={"alias": "test-subflow", "providerId": "basic-flow"}, + flow_alias="test-browser", + ) + assert err.match('409: b\'{"errorMessage":"New flow alias name already exists"}\'') + res = admin.create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-create", + skip_exists=True, + ) + assert res == json.dumps({"msg": "Already exists"}).encode() + + # Test delete auth flow + flow_id = next(x for x in admin.get_authentication_flows() if x["alias"] == "test-browser")[ + "id" + ] + res = admin.delete_authentication_flow(flow_id=flow_id) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_authentication_flow(flow_id=flow_id) + assert ('b\'{"error":"Could not find flow with id"' in str(err)) or ( + 'b\'{"error":"Flow not found"' in str(err) + ) + + +def test_auth_flow_execution_priority(admin: KeycloakAdmin, realm: str) -> None: + """ + Test execution priority. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + _ = admin.create_authentication_flow( + payload={"alias": "test-create", "providerId": "basic-flow"}, + ) + _ = admin.create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, + flow_alias="test-create", + ) + _ = admin.create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, + flow_alias="test-create", + ) + executions = admin.get_authentication_flow_executions(flow_alias="test-create") + priority_list = [ex["id"] for ex in executions] + _ = admin.change_execution_priority(priority_list[1], 1) + new_executions = admin.get_authentication_flow_executions(flow_alias="test-create") + assert executions != new_executions + _ = admin.change_execution_priority(priority_list[1], -1) + new_executions = admin.get_authentication_flow_executions(flow_alias="test-create") + assert executions == new_executions + + +def test_authentication_configs(admin: KeycloakAdmin, realm: str) -> None: + """ + Test authentication configs. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test list of auth providers + res = admin.get_authenticator_providers() + assert len(res) <= 42 + + res = admin.get_authenticator_provider_config_description(provider_id="auth-cookie") + assert res == { + "helpText": "Validates the SSO cookie set by the auth server.", + "name": "Cookie", + "properties": [], + "providerId": "auth-cookie", + } + # Test authenticator config + executions = admin.get_authentication_flow_executions(flow_alias="browser") + execution = next(ex for ex in executions if ex["configurable"]) + res = admin.create_execution_config( + execution["id"], + { + "alias": "test.provisioning.property", + "config": {"test.provisioning.property": "value2"}, + }, + ) + assert res == b"" + executions = admin.get_authentication_flow_executions(flow_alias="browser") + execution_config_id = next(ex for ex in executions if ex.get("id") == execution["id"])[ + "authenticationConfig" + ] + res = admin.get_authenticator_config(config_id=execution_config_id) + assert res["config"]["test.provisioning.property"] == "value2" + + with pytest.raises(KeycloakGetError) as err: + admin.get_authenticator_config(config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + with pytest.raises(KeycloakPutError) as err: + admin.update_authenticator_config(payload={}, config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + res = admin.update_authenticator_config(payload={}, config_id=execution_config_id) + assert res == {} + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_authenticator_config(config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + res = admin.delete_authenticator_config(config_id=execution_config_id) + assert res == {} + + +def test_sync_users(admin: KeycloakAdmin, realm: str) -> None: + """ + Test sync users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Only testing the error message + with pytest.raises(KeycloakPostError) as err: + admin.sync_users(storage_id="does-not-exist", action="triggerFullSync") + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + +def test_client_scopes(admin: KeycloakAdmin, realm: str) -> None: + """ + Test client scopes. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test get client scopes + res = admin.get_client_scopes() + scope_names = {x["name"] for x in res} + assert len(res) in [10, 11, 13, 14] + assert "email" in scope_names + assert "profile" in scope_names + assert "offline_access" in scope_names + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_scope(client_scope_id="does-not-exist") + assert err.match(NO_CLIENT_SCOPE_REGEX) + + scope = admin.get_client_scope(client_scope_id=res[0]["id"]) + assert res[0] == scope + + scope = admin.get_client_scope_by_name(client_scope_name=res[0]["name"]) + assert res[0] == scope + + # Test create client scope + res = admin.create_client_scope( + payload={"name": "test-scope", "protocol": "openid-connect"}, + skip_exists=True, + ) + assert res + res2 = admin.create_client_scope( + payload={"name": "test-scope", "protocol": "openid-connect"}, + skip_exists=True, + ) + assert res == res2 + with pytest.raises(KeycloakPostError) as err: + admin.create_client_scope( + payload={"name": "test-scope", "protocol": "openid-connect"}, + skip_exists=False, + ) + assert err.match('409: b\'{"errorMessage":"Client Scope test-scope already exists"}\'') + + # Test update client scope + with pytest.raises(KeycloakPutError) as err: + admin.update_client_scope(client_scope_id="does-not-exist", payload={}) + assert err.match(NO_CLIENT_SCOPE_REGEX) + + res_update = admin.update_client_scope( + client_scope_id=res, + payload={"name": "test-scope-update"}, + ) + assert res_update == {} + assert admin.get_client_scope(client_scope_id=res)["name"] == "test-scope-update" + + # Test get mappers + mappers = admin.get_mappers_from_client_scope(client_scope_id=res) + assert mappers == [] + + # Test add mapper + with pytest.raises(KeycloakPostError) as err: + admin.add_mapper_to_client_scope(client_scope_id=res, payload={}) + assert err.match('404: b\'{"error":"ProtocolMapper provider not found".*}\'') + + res_add = admin.add_mapper_to_client_scope( + client_scope_id=res, + payload={ + "name": "test-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + }, + ) + assert res_add == b"" + assert len(admin.get_mappers_from_client_scope(client_scope_id=res)) == 1 + + # Test update mapper + test_mapper = admin.get_mappers_from_client_scope(client_scope_id=res)[0] + with pytest.raises(KeycloakPutError) as err: + admin.update_mapper_in_client_scope( + client_scope_id="does-not-exist", + protocol_mapper_id=test_mapper["id"], + payload={}, + ) + assert err.match(NO_CLIENT_SCOPE_REGEX) + test_mapper["config"]["user.attribute"] = "test" + res_update = admin.update_mapper_in_client_scope( + client_scope_id=res, + protocol_mapper_id=test_mapper["id"], + payload=test_mapper, + ) + assert res_update == {} + assert ( + admin.get_mappers_from_client_scope(client_scope_id=res)[0]["config"]["user.attribute"] + == "test" + ) + + # Test delete mapper + res_del = admin.delete_mapper_from_client_scope( + client_scope_id=res, + protocol_mapper_id=test_mapper["id"], + ) + assert res_del == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_mapper_from_client_scope( + client_scope_id=res, + protocol_mapper_id=test_mapper["id"], + ) + assert err.match('404: b\'{"error":"Model not found".*}\'') + + # Test default default scopes + res_defaults = admin.get_default_default_client_scopes() + assert len(res_defaults) in [6, 7, 8] + + with pytest.raises(KeycloakPutError) as err: + admin.add_default_default_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_add = admin.add_default_default_client_scope(scope_id=res) + assert res_add == {} + assert len(admin.get_default_default_client_scopes()) in [7, 8, 9] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_default_default_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_del = admin.delete_default_default_client_scope(scope_id=res) + assert res_del == {} + assert len(admin.get_default_default_client_scopes()) in [6, 7, 8] + + # Test default optional scopes + res_defaults = admin.get_default_optional_client_scopes() + assert len(res_defaults) in [4, 5] + + with pytest.raises(KeycloakPutError) as err: + admin.add_default_optional_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_add = admin.add_default_optional_client_scope(scope_id=res) + assert res_add == {} + assert len(admin.get_default_optional_client_scopes()) in [5, 6] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_default_optional_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_del = admin.delete_default_optional_client_scope(scope_id=res) + assert res_del == {} + assert len(admin.get_default_optional_client_scopes()) in [4, 5] + + # Test client scope delete + res_del = admin.delete_client_scope(client_scope_id=res) + assert res_del == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client_scope(client_scope_id=res) + assert err.match(NO_CLIENT_SCOPE_REGEX) + + +def test_components(admin: KeycloakAdmin, realm: str) -> None: + """ + Test components. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test get components + res = admin.get_components() + assert len(res) in [12, 14] + + with pytest.raises(KeycloakGetError) as err: + admin.get_component(component_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + res_get = admin.get_component(component_id=res[0]["id"]) + assert res_get == res[0] + + # Test create component + with pytest.raises(KeycloakPostError) as err: + admin.create_component(payload={"bad": "dict"}) + assert err.match("Unrecognized field") + + res = admin.create_component( + payload={ + "name": "Test Component", + "providerId": "max-clients", + "providerType": "org.keycloak.services.clientregistration.policy." + "ClientRegistrationPolicy", + "config": {"max-clients": ["1000"]}, + }, + ) + assert res + assert admin.get_component(component_id=res)["name"] == "Test Component" + + # Test update component + component = admin.get_component(component_id=res) + component["name"] = "Test Component Update" + + with pytest.raises(KeycloakPutError) as err: + admin.update_component(component_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Could not find component".*}\'') + res_upd = admin.update_component(component_id=res, payload=component) + assert res_upd == {} + assert admin.get_component(component_id=res)["name"] == "Test Component Update" + + # Test delete component + res_del = admin.delete_component(component_id=res) + assert res_del == {} + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_component(component_id=res) + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + +def test_keys(admin: KeycloakAdmin, realm: str) -> None: + """ + Test keys. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + assert set(admin.get_keys()["active"].keys()) == {"AES", "HS256", "RS256", "RSA-OAEP"} or set( + admin.get_keys()["active"].keys(), + ) == {"RSA-OAEP", "RS256", "HS512", "AES"} + assert {k["algorithm"] for k in admin.get_keys()["keys"]} == { + "HS256", + "RSA-OAEP", + "AES", + "RS256", + } or {k["algorithm"] for k in admin.get_keys()["keys"]} == { + "HS512", + "RSA-OAEP", + "AES", + "RS256", + } + + +def test_admin_events(admin: KeycloakAdmin, realm: str) -> None: + """ + Test events. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + admin.create_client(payload={"name": "test", "clientId": "test"}) + events = admin.get_admin_events() + assert events == [] + + +def test_user_events(admin: KeycloakAdmin, realm: str) -> None: + """ + Test events. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + events = admin.get_events() + assert events == [] + + with pytest.raises(KeycloakPutError) as err: + admin.set_events(payload={"bad": "conf"}) + assert err.match("Unrecognized field") + + res = admin.set_events(payload={"adminEventsDetailsEnabled": True, "adminEventsEnabled": True}) + assert res == {} + + admin.create_client(payload={"name": "test", "clientId": "test"}) + + events = admin.get_events() + assert events == [] + + +@freezegun.freeze_time("2023-02-25 10:00:00") +def test_auto_refresh(admin_frozen: KeycloakAdmin, realm: str) -> None: + """ + Test auto refresh token. + + :param admin_frozen: Keycloak Admin client with time frozen in place + :type admin_frozen: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin = admin_frozen + admin.get_realm(realm_name=realm) + # Test get refresh + admin.connection.custom_headers = { + "Authorization": "Bearer bad", + "Content-Type": "application/json", + } + + res = admin.get_realm(realm_name=realm) + assert res["realm"] == realm + + # Freeze time to simulate the access token expiring + assert admin.connection.expires_at is not None + with freezegun.freeze_time("2023-02-25 10:05:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:05:00Z") + assert admin.get_realm(realm_name=realm) + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:05:00Z") + + # Test bad refresh token, but first make sure access token has expired again + assert admin.connection.token is not None + with freezegun.freeze_time("2023-02-25 10:10:00"): + admin.connection.custom_headers = {"Content-Type": "application/json"} + admin.connection.token["refresh_token"] = "bad" # noqa: S105 + with pytest.raises(KeycloakPostError) as err: + admin.get_realm(realm_name="test-refresh") + assert err.match( + '400: b\'{"error":"invalid_grant","error_description":"Invalid refresh token"}\'', + ) + admin.connection.get_token() + + # Test post refresh + with freezegun.freeze_time("2023-02-25 10:15:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:15:00Z") + admin.connection.token = None + assert admin.create_realm(payload={"realm": "test-refresh"}) == b"" + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:15:00Z") + + # Test update refresh + with freezegun.freeze_time("2023-02-25 10:25:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:25:00Z") + admin.connection.token = None + assert ( + admin.update_realm(realm_name="test-refresh", payload={"accountTheme": "test"}) == {} + ) + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:25:00Z") + + # Test delete refresh + with freezegun.freeze_time("2023-02-25 10:35:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:35:00Z") + admin.connection.token = None + assert admin.delete_realm(realm_name="test-refresh") == {} + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:35:00Z") + + +def test_get_required_actions(admin: KeycloakAdmin, realm: str) -> None: + """ + Test required actions. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + ractions = admin.get_required_actions() + assert isinstance(ractions, list) + for ra in ractions: + for key in [ + "alias", + "name", + "providerId", + "enabled", + "defaultAction", + "priority", + "config", + ]: + assert key in ra + + +def test_get_required_action_by_alias(admin: KeycloakAdmin, realm: str) -> None: + """ + Test get required action by alias. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + ractions = admin.get_required_actions() + ra = admin.get_required_action_by_alias("UPDATE_PASSWORD") + assert ra is not None + assert ra in ractions + assert ra["alias"] == "UPDATE_PASSWORD" + assert admin.get_required_action_by_alias("does-not-exist") is None + + +def test_update_required_action(admin: KeycloakAdmin, realm: str) -> None: + """ + Test update required action. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + ra = admin.get_required_action_by_alias("UPDATE_PASSWORD") + assert ra is not None + old = copy.deepcopy(ra) + ra["enabled"] = False + res = admin.update_required_action("UPDATE_PASSWORD", ra) + assert res == {} + newra = admin.get_required_action_by_alias("UPDATE_PASSWORD") + assert newra is not None + assert old != newra + assert newra["enabled"] is False + + +def test_get_composite_client_roles_of_group( + admin: KeycloakAdmin, + realm: str, + client: str, + group: str, + composite_client_role: str, +) -> None: + """ + Test get composite client roles of group. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param group: Keycloak group + :type group: str + :param composite_client_role: Composite client role + :type composite_client_role: str + """ + admin.change_current_realm(realm) + role = admin.get_client_role(client, composite_client_role) + admin.assign_group_client_roles(group_id=group, client_id=client, roles=[role]) + result = admin.get_composite_client_roles_of_group(client, group) + assert role["id"] in [x["id"] for x in result] + + +def test_get_role_client_level_children( + admin: KeycloakAdmin, + realm: str, + client: str, + composite_client_role: str, + client_role: str, +) -> None: + """ + Test get children of composite client role. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param composite_client_role: Composite client role + :type composite_client_role: str + :param client_role: Client role + :type client_role: str + """ + admin.change_current_realm(realm) + child = admin.get_client_role(client, client_role) + parent = admin.get_client_role(client, composite_client_role) + res = admin.get_role_client_level_children(client, parent["id"]) + assert child["id"] in [x["id"] for x in res] + + +def test_get_role_composites_by_id( + admin: KeycloakAdmin, + realm: str, + client: str, + composite_client_role: str, + client_role: str, +) -> None: + """ + Test get role's children by role ID. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param composite_client_role: Composite client role + :type composite_client_role: str + :param client_role: Client role + :type client_role: str + """ + admin.change_current_realm(realm) + + parent_role = admin.get_client_role(client, composite_client_role) + child_role = admin.get_client_role(client, client_role) + + composites = admin.get_role_composites_by_id(parent_role["id"]) + assert len(composites) > 0 + assert child_role["id"] in [x["id"] for x in composites] + + composites_paginated = admin.get_role_composites_by_id( + parent_role["id"], query={"first": 0, "max": 10} + ) + assert len(composites_paginated) > 0 + assert child_role["id"] in [x["id"] for x in composites_paginated] + + composites_searched = admin.get_role_composites_by_id( + parent_role["id"], query={"search": client_role[:3]} + ) + assert len(composites_searched) > 0 + + +def test_upload_certificate( + admin: KeycloakAdmin, + realm: str, + client: str, + selfsigned_cert: tuple[bytes, bytes], +) -> None: + """ + Test upload certificate. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param selfsigned_cert: Selfsigned certificates + :type selfsigned_cert: tuple + """ + admin.change_current_realm(realm) + cert, _ = selfsigned_cert + cert = cert.decode("utf-8").strip() + res = admin.upload_certificate(client, cert) + assert "certificate" in res + cl = admin.get_client(client) + assert cl["attributes"]["jwt.credential.certificate"] == "".join(cert.splitlines()[1:-1]) + + +def test_get_bruteforce_status_for_user( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], + realm: str, +) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, _ = oid_with_credentials + admin.change_current_realm(realm) + + # Turn on bruteforce protection + res = admin.update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = admin.get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + with contextlib.suppress(KeycloakAuthenticationError): + oid.token(username=username, password="wrongpassword") # noqa: S106 + + user_id = admin.get_user_id(username) + assert user_id is not None + bruteforce_status = admin.get_bruteforce_detection_status(user_id) + + assert bruteforce_status["numFailures"] == 1 + + # Cleanup + res = admin.update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = admin.get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +def test_clear_bruteforce_attempts_for_user( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], + realm: str, +) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, _ = oid_with_credentials + admin.change_current_realm(realm) + + # Turn on bruteforce protection + res = admin.update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = admin.get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + with contextlib.suppress(KeycloakAuthenticationError): + oid.token(username=username, password="wrongpassword") # noqa: S106 + + user_id = admin.get_user_id(username) + assert user_id is not None + bruteforce_status = admin.get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 1 + + res = admin.clear_bruteforce_attempts_for_user(user_id) + assert res == {} + bruteforce_status = admin.get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 0 + + # Cleanup + res = admin.update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = admin.get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +def test_clear_bruteforce_attempts_for_all_users( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], + realm: str, +) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, _ = oid_with_credentials + admin.change_current_realm(realm) + + # Turn on bruteforce protection + res = admin.update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = admin.get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + with contextlib.suppress(KeycloakAuthenticationError): + oid.token(username=username, password="wrongpassword") # noqa: S106 + + user_id = admin.get_user_id(username) + assert user_id is not None + bruteforce_status = admin.get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 1 + + res = admin.clear_all_bruteforce_attempts() + assert res == {} + bruteforce_status = admin.get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 0 + + # Cleanup + res = admin.update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = admin.get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +def test_default_realm_role_present(realm: str, admin: KeycloakAdmin) -> None: + """ + Test that the default realm role is present in a brand new realm. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.change_current_realm(realm) + assert f"default-roles-{realm}" in [x["name"] for x in admin.get_realm_roles()] + assert ( + len([x["name"] for x in admin.get_realm_roles() if x["name"] == f"default-roles-{realm}"]) + == 1 + ) + + +def test_get_default_realm_role_id(realm: str, admin: KeycloakAdmin) -> None: + """ + Test getter for the ID of the default realm role. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.change_current_realm(realm) + assert admin.get_default_realm_role_id() == next( + x["id"] for x in admin.get_realm_roles() if x["name"] == f"default-roles-{realm}" + ) + + +def test_realm_default_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test getting, adding and deleting default realm roles. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.change_current_realm(realm) + + # Test listing all default realm roles + roles = admin.get_realm_default_roles() + assert len(roles) == 2 + assert {x["name"] for x in roles} == {"offline_access", "uma_authorization"} + + admin.change_current_realm("doesnotexist") + with pytest.raises(KeycloakGetError) as err: + admin.get_realm_default_roles() + + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + admin.change_current_realm(realm) + + # Test removing a default realm role + res = admin.remove_realm_default_roles(payload=[roles[0]]) + assert res == {} + assert roles[0] not in admin.get_realm_default_roles() + assert len(admin.get_realm_default_roles()) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_realm_default_roles(payload=[{"id": "bad id"}]) + assert err.match('404: b\'{"error":"Could not find composite role".*}\'') + + # Test adding a default realm role + res = admin.add_realm_default_roles(payload=[roles[0]]) + assert res == {} + assert roles[0] in admin.get_realm_default_roles() + assert len(admin.get_realm_default_roles()) == 2 + + with pytest.raises(KeycloakPostError) as err: + admin.add_realm_default_roles(payload=[{"id": "bad id"}]) + assert err.match('404: b\'{"error":"Could not find composite role".*}\'') + + +def test_clear_keys_cache(realm: str, admin: KeycloakAdmin) -> None: + """ + Test clearing the keys cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.change_current_realm(realm) + res = admin.clear_keys_cache() + assert res == {} + + +def test_clear_realm_cache(realm: str, admin: KeycloakAdmin) -> None: + """ + Test clearing the realm cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.change_current_realm(realm) + res = admin.clear_realm_cache() + assert res == {} + + +def test_clear_user_cache(realm: str, admin: KeycloakAdmin) -> None: + """ + Test clearing the user cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.change_current_realm(realm) + res = admin.clear_user_cache() + assert res == {} + + +def test_initial_access_token( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test initial access token and client creation. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + res = admin.create_initial_access_token(2, 3) + assert "token" in res + assert res["count"] == 2 + assert res["expiration"] == 3 + + oid, _, _ = oid_with_credentials + + client = str(uuid.uuid4()) + secret = str(uuid.uuid4()) + + res = oid.register_client( + token=res["token"], + payload={ + "name": "DynamicRegisteredClient", + "clientId": client, + "enabled": True, + "publicClient": False, + "protocol": "openid-connect", + "secret": secret, + "clientAuthenticatorType": "client-secret", + }, + ) + assert res["clientId"] == client + + new_secret = str(uuid.uuid4()) + res = oid.update_client(res["registrationAccessToken"], client, payload={"secret": new_secret}) + assert res["secret"] == new_secret + + +def test_refresh_token(admin: KeycloakAdmin) -> None: + """ + Test refresh token on connection even if it is expired. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.get_realms() + assert admin.connection.token is not None + assert admin.connection.username is not None + user_id = admin.get_user_id(admin.connection.username) + assert user_id is not None + admin.user_logout(user_id=user_id) + admin.connection.refresh_token() + + +def test_consents( + admin: KeycloakAdmin, oid_with_credentials: tuple[KeycloakOpenID, str, str] +) -> None: + """ + Test getting and revoking offline access via the consents API. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + + # Use offline access as ersatz consent + offline_token = oid.token(username, password, scope="offline_access") + decoded_access_token = oid.decode_token(token=offline_token["access_token"]) + user_id = decoded_access_token["sub"] + + # Test get consents/offline access + res = admin.user_consents(user_id=user_id) + assert len(res) == 1, res + assert "additionalGrants" in res[0], res[0] + assert res[0]["additionalGrants"][0].get("key") == "Offline Token", res[0] + + # Test get consents fail + with pytest.raises(KeycloakGetError) as err: + admin.user_consents(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test revoke fails + with pytest.raises(KeycloakDeleteError) as err: + admin.revoke_consent(user_id="non-existent-id", client_id=oid.client_id) + assert err.match(USER_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakDeleteError) as err: + admin.revoke_consent(user_id=user_id, client_id="non-existent-client") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + # Test revoke offline access + res = admin.revoke_consent(user_id=user_id, client_id=oid.client_id) + assert res == {}, res + + res = admin.user_consents(user_id=user_id) + assert len(res) == 0, res + + # Test re-revoke fails + with pytest.raises(KeycloakDeleteError) as err: + admin.revoke_consent(user_id=user_id, client_id=oid.client_id) + assert err.match(CONSENT_NOT_FOUND_REGEX) + + +# async function start + + +@pytest.mark.asyncio +async def test_a_realms(admin: KeycloakAdmin) -> None: + """ + Test realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Get realms + realms = await admin.a_get_realms() + assert len(realms) == 1, realms + assert realms[0]["realm"] == "master" + + # Create a test realm + res = await admin.a_create_realm(payload={"realm": "test"}) + assert res == b"", res + + # Create the same realm, should fail + with pytest.raises(KeycloakPostError) as err: + res = await admin.a_create_realm(payload={"realm": "test"}) + + assert isinstance(err.value.error_message, bytes) + assert ( + b"Realm test already exists" in err.value.error_message + or b"Conflict detected" in err.value.error_message + ) + + # Create the same realm, skip_exists true + res = await admin.a_create_realm(payload={"realm": "test"}, skip_exists=True) + assert res in [ + json.dumps({"errorMessage": "Realm test already exists"}).encode(), + json.dumps({"msg": "Already exists"}).encode(), + json.dumps({"errorMessage": "Conflict detected. See logs for details"}).encode(), + ], res + + # Get a single realm + res = await admin.a_get_realm(realm_name="test") + assert res["realm"] == "test" + + # Get non-existing realm + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found.".*\'') + + # Update realm + res = await admin.a_update_realm(realm_name="test", payload={"accountTheme": "test"}) + assert res == {}, res + + # Check that the update worked + res = await admin.a_get_realm(realm_name="test") + assert res["realm"] == "test" + assert res["accountTheme"] == "test" + + # Update wrong payload + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_realm(realm_name="test", payload={"wrong": "payload"}) + assert err.match("Unrecognized field") + + # Check that get realms returns both realms + realms = await admin.a_get_realms() + realm_names = [x["realm"] for x in realms] + assert len(realms) == 2, realms + assert "master" in realm_names, realm_names + assert "test" in realm_names, realm_names + + # Get users profile, add an attribute and check + user_profile = await admin.a_get_realm_users_profile() + assert "attributes" in user_profile + + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] == "latest" or Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"], + ) >= Version("24"): + new_attribute = { + "name": "nickname", + "displayName": "", + "validations": {}, + "annotations": {}, + "permissions": {"view": [], "edit": ["admin"]}, + "multivalued": False, + } + + user_profile["attributes"].append(new_attribute) + + res = await admin.a_update_realm_users_profile(user_profile) + # Check for new attribute in result + assert "nickname" in [x["name"] for x in res["attributes"]] + + # Delete the realm + res = await admin.a_delete_realm(realm_name="test") + assert res == {}, res + + # Check that the realm does not exist anymore + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm(realm_name="test") + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + + # Delete non-existing realm + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + + +@pytest.mark.asyncio +async def test_a_changing_of_realms(admin: KeycloakAdmin, realm: str) -> None: + """ + Test changing of realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + assert await admin.a_get_current_realm() == "master" + await admin.a_change_current_realm(realm) + assert await admin.a_get_current_realm() == realm + + +@pytest.mark.asyncio +async def test_a_import_export_realms(admin: KeycloakAdmin, realm: str) -> None: + """ + Test import and export of realms. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + realm_export = await admin.a_export_realm(export_clients=True, export_groups_and_role=True) + assert realm_export != {}, realm_export + + await admin.a_delete_realm(realm_name=realm) + admin.change_current_realm("master") + res = await admin.a_import_realm(payload=realm_export) + assert res == b"", res + + # Test bad import + with pytest.raises(KeycloakPostError) as err: + await admin.a_import_realm(payload={}) + assert err.match( + '500: b\'{"error":"unknown_error"}\'|400: b\'{"errorMessage":"Realm name cannot be empty"}\'', # noqa: E501 + ) + + +@pytest.mark.asyncio +async def test_a_partial_import_realm(admin: KeycloakAdmin, realm: str) -> None: + """ + Test partial import of realm configuration. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + test_realm_role = str(uuid.uuid4()) + test_user = str(uuid.uuid4()) + test_client = str(uuid.uuid4()) + + await admin.a_change_current_realm(realm) + client_id = await admin.a_create_client(payload={"name": test_client, "clientId": test_client}) + + realm_export = await admin.a_export_realm(export_clients=True, export_groups_and_role=False) + + client_config = next( + client_entry for client_entry in realm_export["clients"] if client_entry["id"] == client_id + ) + + # delete before partial import + await admin.a_delete_client(client_id) + + payload = { + "ifResourceExists": "SKIP", + "id": realm_export["id"], + "realm": realm, + "clients": [client_config], + "roles": {"realm": [{"name": test_realm_role}]}, + "users": [{"username": test_user, "email": f"{test_user}@test.test"}], + } + + # check add + res = await admin.a_partial_import_realm(realm_name=realm, payload=payload) + assert res["added"] == 3 + + # check skip + res = await admin.a_partial_import_realm(realm_name=realm, payload=payload) + assert res["skipped"] == 3 + + # check overwrite + payload["ifResourceExists"] = "OVERWRITE" + res = await admin.a_partial_import_realm(realm_name=realm, payload=payload) + assert res["overwritten"] == 3 + + +@pytest.mark.asyncio +async def a_test_organizations(admin: KeycloakAdmin, realm: str) -> None: + """ + Test organizations. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + # Organizations was only release in KeyCloak 26, so disable these checks + # for older KeyCloak releases + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] != "latest" and Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] + ) < Version("26"): + return + + await admin.a_change_current_realm(realm) + await admin.a_update_realm(realm_name=realm, payload={"organizationsEnabled": True}) + + org_payload = {"name": "test-org01", "alias": "test-org01", "domains": [{"name": "org1.com"}]} + org_id = await admin.a_create_organization(payload=org_payload) + assert org_id is not None, org_id + + org = await admin.a_get_organization(org_id) + assert org["name"] == "test-org01", org["name"] + assert org["alias"] == "test-org01", org["alias"] + assert org["domains"][0]["name"] == "org1.com", org["domains"][0]["name"] + + org["name"] = "test-org01-u" + org_update_res = await admin.a_update_organization(org_id, org) + assert org_update_res == {} + org["name"] = "test-org01" + await admin.a_update_organization(org_id, org) + + orgs = await admin.a_get_organizations() + assert len(orgs) == 1, orgs + assert orgs[0]["name"] == "test-org01", orgs[0]["name"] + + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + org_user_add_res = await admin.a_organization_user_add(user_id, org_id) + assert org_user_add_res == b"" + + users = await admin.a_get_organization_members(org_id) + assert len(users) == 1, users + assert users[0]["id"] == user_id, users[0]["id"] + num_users = await admin.a_get_organization_members_count(org_id) + assert num_users == 1, num_users + + user_orgs = await admin.a_get_user_organizations(user_id) + assert len(user_orgs) == 1, user_orgs + assert user_orgs[0]["name"] == "test-org01", user_orgs[0]["name"] + + org_user_remove_res = await admin.a_organization_user_remove(user_id, org_id) + assert org_user_remove_res == {} + users = await admin.a_get_organization_members(org_id) + assert len(users) == 0, users + num_users = await admin.a_get_organization_members_count(org_id) + assert num_users == 0, num_users + + for i in range(admin.PAGE_SIZE + 50): + user_id = await admin.a_create_user( + payload={"username": f"test-user{i:02d}", "email": f"test-user{i:02d}@test.test"} + ) + + await admin.a_organization_user_add(user_id, org_id) + + users = await admin.a_get_organization_members(org_id) + assert len(users) == admin.PAGE_SIZE + 50, users + + users = await admin.a_get_organization_members( + org_id, query={"first": 100, "max": -1, "search": ""} + ) + assert len(users) == 50, len(users) + + users = await admin.a_get_organization_members( + org_id, query={"max": 20, "first": -1, "search": ""} + ) + assert len(users) == 20, len(users) + + _ = await admin.a_create_idp( + payload={ + "providerId": "github", + "alias": "github", + "config": {"clientId": "test-client-id", "clientSecret": "test-client-secret"}, + } + ) + + idp_add_res = await admin.a_organization_idp_add(org_id, "github") + assert idp_add_res == {} + + idps = await admin.a_get_organization_idps(org_id) + assert len(idps) == 1, idps + assert idps[0]["alias"] == "github", idps[0]["alias"] + + idp_remove_res = await admin.a_organization_idp_remove(org_id, "github") + assert idp_remove_res == {} + idps = await admin.a_get_organization_idps(org_id) + assert len(idps) == 0, idps + + org_delete_res = await admin.a_delete_organization(org_id) + assert org_delete_res == {} + orgs = await admin.a_get_organizations() + assert len(orgs) == 0, orgs + + for i in range(admin.PAGE_SIZE + 50): + await admin.a_create_organization( + payload={ + "name": f"test-org{i:02d}", + "alias": f"org{i:02d}", + "domains": [{"name": f"org{i:02d}.com"}], + } + ) + + orgs = await admin.a_get_organizations() + assert len(orgs) == admin.PAGE_SIZE + 50, len(orgs) + + orgs = await admin.a_get_organizations(query={"first": 100, "max": -1, "search": ""}) + assert len(orgs) == 50, len(orgs) + + orgs = await admin.a_get_organizations(query={"first": -1, "max": 20, "search": ""}) + assert len(orgs) == 20, len(orgs) + + +@pytest.mark.asyncio +async def test_a_users(admin: KeycloakAdmin, realm: str) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Check no users present + users = await admin.a_get_users() + assert users == [], users + + # Test create user + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + assert user_id is not None, user_id + + # Test create the same user + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + assert err.match(".*User exists with same.*") + + # Test create the same user, exists_ok true + user_id_2 = await admin.a_create_user( + payload={"username": "test", "email": "test@test.test"}, + exist_ok=True, + ) + assert user_id == user_id_2 + + # Test get user + user = await admin.a_get_user(user_id=user_id) + assert user["username"] == "test", user["username"] + assert user["email"] == "test@test.test", user["email"] + + # Test update user + res = await admin.a_update_user(user_id=user_id, payload={"firstName": "Test"}) + assert res == {}, res + user = await admin.a_get_user(user_id=user_id) + assert user["firstName"] == "Test" + + # Test update user fail + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_user(user_id=user_id, payload={"wrong": "payload"}) + assert err.match("Unrecognized field") + + # Test disable user + res = await admin.a_disable_user(user_id=user_id) + assert res == {}, res + assert not (await admin.a_get_user(user_id=user_id))["enabled"] + + # Test enable user + res = await admin.a_enable_user(user_id=user_id) + assert res == {}, res + assert (await admin.a_get_user(user_id=user_id))["enabled"] + + # Test get users again + users = await admin.a_get_users() + usernames = [x["username"] for x in users] + assert "test" in usernames + + # Test users counts + count = await admin.a_users_count() + assert count == 1, count + + # Test users count with query + count = await admin.a_users_count(query={"username": "notpresent"}) + assert count == 0 + + # Test user groups + groups = await admin.a_get_user_groups(user_id=user["id"]) + assert len(groups) == 0 + + # Test user groups bad id + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_user_groups(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test logout + res = await admin.a_user_logout(user_id=user["id"]) + assert res == {}, res + + # Test logout fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_user_logout(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test consents + res = await admin.a_user_consents(user_id=user["id"]) + assert len(res) == 0, res + + # Test consents fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_user_consents(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test delete user + res = await admin.a_delete_user(user_id=user_id) + assert res == {}, res + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_user(user_id=user_id) + err.match(USER_NOT_FOUND_REGEX) + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_user(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + +@pytest.mark.asyncio +async def test_a_enable_disable_all_users(admin: KeycloakAdmin, realm: str) -> None: + """ + Test enable and disable all users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + user_id_1 = await admin.a_create_user( + payload={"username": "test", "email": "test@test.test", "enabled": True}, + ) + user_id_2 = await admin.a_create_user( + payload={"username": "test2", "email": "test2@test.test", "enabled": True}, + ) + user_id_3 = await admin.a_create_user( + payload={"username": "test3", "email": "test3@test.test", "enabled": True}, + ) + + assert (await admin.a_get_user(user_id_1))["enabled"] + assert (await admin.a_get_user(user_id_2))["enabled"] + assert (await admin.a_get_user(user_id_3))["enabled"] + + await admin.a_disable_all_users() + + assert not (await admin.a_get_user(user_id_1))["enabled"] + assert not (await admin.a_get_user(user_id_2))["enabled"] + assert not (await admin.a_get_user(user_id_3))["enabled"] + + await admin.a_enable_all_users() + + assert (await admin.a_get_user(user_id_1))["enabled"] + assert (await admin.a_get_user(user_id_2))["enabled"] + assert (await admin.a_get_user(user_id_3))["enabled"] + + +@pytest.mark.asyncio +async def test_a_users_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test users roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + + # Test all level user roles + client_id = await admin.a_create_client( + payload={"name": "test-client", "clientId": "test-client"}, + ) + await admin.a_create_client_role(client_role_id=client_id, payload={"name": "test-role"}) + await admin.a_assign_client_role( + client_id=client_id, + user_id=user_id, + roles=[admin.get_client_role(client_id=client_id, role_name="test-role")], + ) + all_roles = await admin.a_get_all_roles_of_user(user_id=user_id) + realm_roles = all_roles["realmMappings"] + assert len(realm_roles) == 1, realm_roles + client_roles = all_roles["clientMappings"] + assert len(client_roles) == 1, client_roles + + # Test all level user roles fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_all_roles_of_user(user_id="non-existent-id") + err.match('404: b\'{"error":"User not found"') + + await admin.a_delete_user(user_id) + await admin.a_delete_client(client_id) + + +@pytest.mark.asyncio +async def test_a_users_pagination(admin: KeycloakAdmin, realm: str) -> None: + """ + Test user pagination. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + for ind in range(admin.PAGE_SIZE + 50): + username = f"user_{ind}" + admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) + + users = await admin.a_get_users() + assert len(users) == admin.PAGE_SIZE + 50, len(users) + + users = await admin.a_get_users(query={"first": 100}) + assert len(users) == 50, len(users) + + users = await admin.a_get_users(query={"max": 20}) + assert len(users) == 20, len(users) + + +@pytest.mark.asyncio +async def test_a_user_groups_pagination(admin: KeycloakAdmin, realm: str) -> None: + """ + Test user groups pagination. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + user_id = await admin.a_create_user( + payload={"username": "username_1", "email": "username_1@test.test"}, + ) + + for ind in range(admin.PAGE_SIZE + 50): + group_name = f"group_{ind}" + group_id = await admin.a_create_group(payload={"name": group_name}) + assert group_id is not None + await admin.a_group_user_add(user_id=user_id, group_id=group_id) + + groups = await admin.a_get_user_groups(user_id=user_id) + assert len(groups) == admin.PAGE_SIZE + 50, len(groups) + + groups = await admin.a_get_user_groups( + user_id=user_id, + query={"first": 100, "max": -1, "search": ""}, + ) + assert len(groups) == 50, len(groups) + + groups = await admin.a_get_user_groups( + user_id=user_id, + query={"max": 20, "first": -1, "search": ""}, + ) + assert len(groups) == 20, len(groups) + + +@pytest.mark.asyncio +async def test_a_idps(admin: KeycloakAdmin, realm: str) -> None: + """ + Test IDPs. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Create IDP + res = await admin.a_create_idp( + payload={ + "providerId": "github", + "alias": "github", + "config": {"clientId": "test", "clientSecret": "test"}, + }, + ) + assert res == b"", res + + # Test create idp fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_idp(payload={"providerId": "does-not-exist", "alias": "something"}) + assert err.match("Invalid identity provider id"), err + + # Test listing + idps = await admin.a_get_idps() + assert len(idps) == 1 + assert idps[0]["alias"] == "github" + + # Test get idp + idp = await admin.a_get_idp("github") + assert idp["alias"] == "github" + assert idp.get("config") + assert idp["config"]["clientId"] == "test" + assert idp["config"]["clientSecret"] == "**********" + + # Test get idp fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_idp("does-not-exist") + assert err.match(HTTP_404_REGEX) + + # Test IdP update + res = await admin.a_update_idp(idp_alias="github", payload=idps[0]) + + assert res == {}, res + + # Test adding a mapper + res = await admin.a_add_mapper_to_idp( + idp_alias="github", + payload={ + "identityProviderAlias": "github", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + }, + ) + assert res == b"", res + + # Test mapper fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_mapper_to_idp(idp_alias="does-no-texist", payload={}) + assert err.match(HTTP_404_REGEX) + + # Test IdP mappers listing + idp_mappers = await admin.a_get_idp_mappers(idp_alias="github") + assert len(idp_mappers) == 1 + + # Test IdP mapper update + res = await admin.a_update_mapper_in_idp( + idp_alias="github", + mapper_id=idp_mappers[0]["id"], + # For an obscure reason, keycloak expect all fields + payload={ + "id": idp_mappers[0]["id"], + "identityProviderAlias": "github-alias", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + "config": idp_mappers[0]["config"], + }, + ) + assert res == {}, res + + # Test delete + res = await admin.a_delete_idp(idp_alias="github") + assert res == {}, res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_idp(idp_alias="does-not-exist") + assert err.match(HTTP_404_REGEX) + + +@pytest.mark.asyncio +async def test_a_user_credentials(admin: KeycloakAdmin, user: str) -> None: + """ + Test user credentials. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + res = await admin.a_set_user_password(user_id=user, password="booya", temporary=True) # noqa: S106 + assert res == {}, res + + # Test user password set fail + with pytest.raises(KeycloakPutError) as err: + await admin.a_set_user_password(user_id="does-not-exist", password="") + assert err.match(USER_NOT_FOUND_REGEX) + + credentials = await admin.a_get_credentials(user_id=user) + assert len(credentials) == 1 + assert credentials[0]["type"] == "password", credentials + + # Test get credentials fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_credentials(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + res = await admin.a_delete_credential(user_id=user, credential_id=credentials[0]["id"]) + assert res == {}, res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_credential(user_id=user, credential_id="does-not-exist") + assert err.match('404: b\'{"error":"Credential not found".*}\'') + + +@pytest.mark.asyncio +async def test_a_social_logins(admin: KeycloakAdmin, user: str) -> None: + """ + Test social logins. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + res = await admin.a_add_user_social_login( + user_id=user, + provider_id="gitlab", + provider_userid="test", + provider_username="test", + ) + assert res == {}, res + await admin.a_add_user_social_login( + user_id=user, + provider_id="github", + provider_userid="test", + provider_username="test", + ) + assert res == {}, res + + # Test add social login fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_user_social_login( + user_id="does-not-exist", + provider_id="does-not-exist", + provider_userid="test", + provider_username="test", + ) + assert err.match(USER_NOT_FOUND_REGEX) + + res = await admin.a_get_user_social_logins(user_id=user) + assert res == [], res + + # Test get social logins fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_user_social_logins(user_id="does-not-exist") + assert err.match(USER_NOT_FOUND_REGEX) + + res = await admin.a_delete_user_social_login(user_id=user, provider_id="gitlab") + assert res == {}, res + + res = await admin.a_delete_user_social_login(user_id=user, provider_id="github") + assert res == {}, res + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_user_social_login(user_id=user, provider_id="instagram") + assert err.match('404: b\'{"error":"Link not found".*}\''), err + + +@pytest.mark.asyncio +async def test_a_server_info(admin: KeycloakAdmin) -> None: + """ + Test server info. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + info = await admin.a_get_server_info() + keys = info.keys() + assert set(keys).issubset( + { + "systemInfo", + "memoryInfo", + "profileInfo", + "features", + "themes", + "socialProviders", + "identityProviders", + "providers", + "protocolMapperTypes", + "builtinProtocolMappers", + "clientInstallations", + "componentTypes", + "passwordPolicies", + "enums", + "cryptoInfo", + "cpuInfo", + }, + ) + + +@pytest.mark.asyncio +async def test_a_groups(admin: KeycloakAdmin, user: str) -> None: + """ + Test groups. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + # Test get groups + groups = await admin.a_get_groups() + assert len(groups) == 0 + + # Test create group + group_id = await admin.a_create_group(payload={"name": "main-group"}) + assert group_id is not None, group_id + + # Test group count + count = await admin.a_groups_count() + assert count.get("count") == 1, count + + # Test group count with query + count = await admin.a_groups_count(query={"search": "notpresent"}) + assert count.get("count") == 0 + + # Test create subgroups + subgroup_id_1 = await admin.a_create_group(payload={"name": "subgroup-1"}, parent=group_id) + subgroup_id_2 = await admin.a_create_group(payload={"name": "subgroup-2"}, parent=group_id) + + # Test create group fail + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_group(payload={"name": "subgroup-1"}, parent=group_id) + assert err.match("409"), err + + # Test skip exists OK + subgroup_id_1_eq = await admin.a_create_group( + payload={"name": "subgroup-1"}, + parent=group_id, + skip_exists=True, + ) + assert subgroup_id_1_eq is None + + # Test get groups again + groups = await admin.a_get_groups() + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups[0]["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get groups query + groups = await admin.a_get_groups(query={"max": 10}) + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups[0]["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get group + assert subgroup_id_1 is not None + res = await admin.a_get_group(group_id=subgroup_id_1) + assert res["id"] == subgroup_id_1, res + assert res["name"] == "subgroup-1" + assert res["path"] == "/main-group/subgroup-1" + + # Test get group fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + # Create 1 more subgroup + subsubgroup_id_1 = await admin.a_create_group( + payload={"name": "subsubgroup-1"}, + parent=subgroup_id_2, + ) + main_group = await admin.a_get_group(group_id=group_id) + + # Test nested searches + assert subgroup_id_2 is not None + subgroup_2 = await admin.a_get_group(group_id=subgroup_id_2) + res = await admin.a_get_subgroups( + group=subgroup_2, + path="/main-group/subgroup-2/subsubgroup-1", + ) + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + # Test nested search from main group + res = await admin.a_get_subgroups( + group=await admin.a_get_group(group_id=group_id, full_hierarchy=True), + path="/main-group/subgroup-2/subsubgroup-1", + ) + assert res is not None + assert res["id"] == subsubgroup_id_1 + + # Test nested search from all groups + res = await admin.a_get_groups(full_hierarchy=True) + assert len(res) == 1 + assert len(res[0]["subGroups"]) == 2 + assert len(next(x for x in res[0]["subGroups"] if x["id"] == subgroup_id_1)["subGroups"]) == 0 + assert len(next(x for x in res[0]["subGroups"] if x["id"] == subgroup_id_2)["subGroups"]) == 1 + + # Test that query params are not allowed for full hierarchy + with pytest.raises(ValueError) as err: + await admin.a_get_group_children(group_id=group_id, full_hierarchy=True, query={"max": 10}) + + # Test that query params are passed + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] == "latest" or Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"], + ) >= Version("23"): + res = await admin.a_get_group_children(group_id=group_id, query={"max": 1}) + assert len(res) == 1 + + assert err.match("Cannot use both query and full_hierarchy parameters") + + main_group_id_2 = await admin.a_create_group(payload={"name": "main-group-2"}) + assert len(await admin.a_get_groups(full_hierarchy=True)) == 2 + + # Test empty search + res = await admin.a_get_subgroups(group=main_group, path="/none") + assert res is None, res + + # Test get group by path + res = await admin.a_get_group_by_path(path="/main-group/subgroup-1") + assert res is not None, res + assert res["id"] == subgroup_id_1, res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group_by_path(path="/main-group/subgroup-2/subsubgroup-1/test") + assert err.match('404: b\'{"error":"Group path does not exist.*\'') + + res = await admin.a_get_group_by_path(path="/main-group/subgroup-2/subsubgroup-1") + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + res = await admin.a_get_group_by_path(path="/main-group") + assert res is not None, res + assert res["id"] == group_id, res + + # Test group members + res = await admin.a_get_group_members(group_id=subgroup_id_2) + assert len(res) == 0, res + + # Test fail group members + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group_members(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\'') + + res = await admin.a_group_user_add(user_id=user, group_id=subgroup_id_2) + assert res == {}, res + + res = await admin.a_get_group_members(group_id=subgroup_id_2) + assert len(res) == 1, res + assert res[0]["id"] == user + + # Test get group members query + res = await admin.a_get_group_members(group_id=subgroup_id_2, query={"max": 10}) + assert len(res) == 1, res + assert res[0]["id"] == user + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_group_user_remove(user_id="does-not-exist", group_id=subgroup_id_2) + assert err.match(USER_NOT_FOUND_REGEX), err + + res = await admin.a_group_user_remove(user_id=user, group_id=subgroup_id_2) + assert res == {}, res + + # Test set permissions + res = await admin.a_group_set_permissions(group_id=subgroup_id_2, enabled=True) + assert res["enabled"], res + res = await admin.a_group_set_permissions(group_id=subgroup_id_2, enabled=False) + assert not res["enabled"], res + with pytest.raises(KeycloakPutError) as err: + await admin.a_group_set_permissions(group_id=subgroup_id_2, enabled="blah") # pyright: ignore[reportArgumentType] + assert err.match(UNKOWN_ERROR_REGEX), err + + # Test update group + res = await admin.a_update_group(group_id=subgroup_id_2, payload={"name": "new-subgroup-2"}) + assert res == {}, res + assert (await admin.a_get_group(group_id=subgroup_id_2))["name"] == "new-subgroup-2" + + # test update fail + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_group(group_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + # Test delete + res = await admin.a_delete_group(group_id=group_id) + assert res == {}, res + assert main_group_id_2 is not None + res = await admin.a_delete_group(group_id=main_group_id_2) + assert res == {}, res + assert len(await admin.a_get_groups()) == 0 + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id".*}\''), err + + +@pytest.mark.asyncio +async def test_a_clients(admin: KeycloakAdmin, realm: str) -> None: + """ + Test clients. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get clients + clients = await admin.a_get_clients() + assert len(clients) == 6, clients + assert {x["name"] for x in clients} == { + "${client_admin-cli}", + "${client_security-admin-console}", + "${client_account-console}", + "${client_broker}", + "${client_account}", + "${client_realm-management}", + }, clients + + # Test create client + client_id = await admin.a_create_client( + payload={"name": "test-client", "clientId": "test-client"}, + ) + assert client_id, client_id + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client(payload={"name": "test-client", "clientId": "test-client"}) + assert err.match('409: b\'{"errorMessage":"Client test-client already exists"}\''), err + + client_id_2 = await admin.a_create_client( + payload={"name": "test-client", "clientId": "test-client"}, + skip_exists=True, + ) + assert client_id == client_id_2, client_id_2 + + # Test get client + res = await admin.a_get_client(client_id=client_id) + assert res["clientId"] == "test-client", res + assert res["name"] == "test-client", res + assert res["id"] == client_id, res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + assert len(await admin.a_get_clients()) == 7 + + # Test get client id + assert await admin.a_get_client_id(client_id="test-client") == client_id + assert await admin.a_get_client_id(client_id="does-not-exist") is None + + # Test update client + res = await admin.a_update_client(client_id=client_id, payload={"name": "test-client-change"}) + assert res == {}, res + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client( + client_id="does-not-exist", + payload={"name": "test-client-change"}, + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test client mappers + res = await admin.a_get_mappers_from_client(client_id=client_id) + assert len(res) == 0 + + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_mapper_to_client(client_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = await admin.a_add_mapper_to_client( + client_id=client_id, + payload={ + "name": "test-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + }, + ) + assert res == b"" + assert len(await admin.a_get_mappers_from_client(client_id=client_id)) == 1 + + mapper = (await admin.a_get_mappers_from_client(client_id=client_id))[0] + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client_mapper( + client_id=client_id, + mapper_id="does-not-exist", + payload={}, + ) + assert err.match('404: b\'{"error":"Model not found".*}\'') + mapper["config"]["user.attribute"] = "test" + res = await admin.a_update_client_mapper( + client_id=client_id, + mapper_id=mapper["id"], + payload=mapper, + ) + assert res == {} + + res = await admin.a_remove_client_mapper(client_id=client_id, client_mapper_id=mapper["id"]) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_client_mapper(client_id=client_id, client_mapper_id=mapper["id"]) + assert err.match('404: b\'{"error":"Model not found".*}\'') + + # Test client sessions + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_all_sessions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + assert await admin.a_get_client_all_sessions(client_id=client_id) == [] + assert await admin.a_get_client_sessions_stats() == [] + + # Test authz + auth_client_id = await admin.a_create_client( + payload={ + "name": "authz-client", + "clientId": "authz-client", + "authorizationServicesEnabled": True, + "serviceAccountsEnabled": True, + }, + ) + res = await admin.a_get_client_authz_settings(client_id=auth_client_id) + assert res["allowRemoteResourceManagement"] + assert res["decisionStrategy"] == "UNANIMOUS" + assert len(res["policies"]) >= 0 + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_settings(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + # Authz resources + res = await admin.a_get_client_authz_resources(client_id=auth_client_id) + assert len(res) in [0, 1] + if len(res) == 1: + assert res[0]["name"] == "Default Resource" + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_resources(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + res = await admin.a_create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "test-resource"}, + ) + assert res["name"] == "test-resource", res + test_resource_id = res["_id"] + + res = await admin.a_get_client_authz_resource( + client_id=auth_client_id, + resource_id=test_resource_id, + ) + assert res["_id"] == test_resource_id, res + assert res["name"] == "test-resource", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "test-resource"}, + ) + assert err.match('409: b\'{"error":"invalid_request"') + assert await admin.a_create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "test-resource"}, + skip_exists=True, + ) == {"msg": "Already exists"} + + res = await admin.a_get_client_authz_resources(client_id=auth_client_id) + assert len(res) in [1, 2] + assert {x["name"] for x in res}.issubset({"Default Resource", "test-resource"}) + + res = await admin.a_create_client_authz_resource( + client_id=auth_client_id, + payload={"name": "temp-resource"}, + ) + assert res["name"] == "temp-resource", res + temp_resource_id: str = res["_id"] + # Test update authz resources + res = await admin.a_update_client_authz_resource( + client_id=auth_client_id, + resource_id=temp_resource_id, + payload={"name": "temp-updated-resource"}, + ) + assert res == {} + res = await admin.a_get_client_authz_resource( + client_id=auth_client_id, + resource_id=temp_resource_id, + ) + assert res["name"] == "temp-updated-resource", res + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client_authz_resource( + client_id=auth_client_id, + resource_id="invalid_resource_id", + payload={"name": "temp-updated-resource"}, + ) + assert err.match("404: b''"), err + res = await admin.a_delete_client_authz_resource( + client_id=auth_client_id, + resource_id=temp_resource_id, + ) + assert res == {} + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_resource( + client_id=auth_client_id, + resource_id=temp_resource_id, + ) + assert err.match("404: b''") + + # Authz policies + res = await admin.a_get_client_authz_policies(client_id=auth_client_id) + assert len(res) in [0, 1], res + if len(res) == 1: + assert res[0]["name"] == "Default Policy" + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_policies(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + role_id = (await admin.a_get_realm_role(role_name="offline_access"))["id"] + res = await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert res["name"] == "test-authz-rb-policy", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(await admin.a_get_client_authz_policies(client_id=auth_client_id)) in [1, 2] + role_based_policy_id = res["id"] + role_based_policy_name = res["name"] + + res = await admin.a_create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy-delete", "roles": [{"id": role_id}]}, + ) + res2 = await admin.a_get_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert res["id"] == res2["id"] + res3 = await admin.a_delete_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert res3 == {} + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_policy(client_id=auth_client_id, policy_id=res["id"]) + assert err.match("404: b''") + + res = await admin.a_create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + ) + assert res["name"] == "test-authz-policy", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert await admin.a_create_client_authz_policy( + client_id=auth_client_id, + payload={ + "name": "test-authz-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + }, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(await admin.a_get_client_authz_policies(client_id=auth_client_id)) in [2, 3] + + # Test authz permissions + res = await admin.a_get_client_authz_permissions(client_id=auth_client_id) + assert len(res) in [0, 1], res + if len(res) == 1: + assert res[0]["name"] == "Default Permission" + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_permissions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + res = await admin.a_create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert res, res + assert res["name"] == "test-permission-rb" + assert res["resources"] == [test_resource_id] + resource_based_permission_id = res["id"] + resource_based_permission_name = res["name"] + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert await admin.a_create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(await admin.a_get_client_authz_permissions(client_id=auth_client_id)) in [1, 2] + + # Test associating client policy with resource based permission + res = await admin.a_update_client_authz_resource_permission( + client_id=auth_client_id, + resource_id=resource_based_permission_id, + payload={ + "id": resource_based_permission_id, + "name": resource_based_permission_name, + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [test_resource_id], + "scopes": [], + "policies": [role_based_policy_id], + }, + ) + assert res == b"" + + # Test getting associated policies for a permission + associated_policies = await admin.a_get_client_authz_permission_associated_policies( + client_id=auth_client_id, + policy_id=resource_based_permission_id, + ) + assert len(associated_policies) == 1 + assert associated_policies[0]["name"].startswith(role_based_policy_name) + + # Test authz scopes + res = await admin.a_get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 0, res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_authz_scopes(client_id=client_id) + assert err.match(HTTP_404_REGEX) + + res = await admin.a_create_client_authz_scopes( + client_id=auth_client_id, + payload={"name": "test-authz-scope"}, + ) + assert res["name"] == "test-authz-scope", res + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_scopes( + client_id="invalid_client_id", + payload={"name": "test-authz-scope"}, + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + assert await admin.a_create_client_authz_scopes( + client_id=auth_client_id, + payload={"name": "test-authz-scope"}, + ) + + res = await admin.a_get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 1 + assert {x["name"] for x in res} == {"test-authz-scope"} + + # Test service account user + res = await admin.a_get_client_service_account_user(client_id=auth_client_id) + assert res["username"] == "service-account-authz-client", res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_service_account_user(client_id=client_id) + assert ('b\'{"error":"Service account not enabled for the client' in str(err)) or err.match( + UNKOWN_ERROR_REGEX, + ) + + # Test async import authz + authz_config = await admin.a_get_client_authz_settings(client_id=auth_client_id) + + authz_config["resources"] = [{"name": "test-import-resource"}] + authz_config["policies"] = [ + { + "name": "test-import-policy", + "type": "time", + "config": {"hourEnd": "18", "hour": "9"}, + } + ] + res = await admin.a_import_client_authz_config(client_id=auth_client_id, payload=authz_config) + assert res == {} + exported = await admin.a_get_client_authz_settings(client_id=auth_client_id) + assert ( + len( + [ + resource + for resource in exported["resources"] + if resource["name"] == "test-import-resource" + ] + ) + == 1 + ) + + assert ( + len( + [ + resource + for resource in exported["policies"] + if resource["name"] == "test-import-policy" + ] + ) + == 1 + ) + + # Test delete client + res = await admin.a_delete_client(client_id=auth_client_id) + assert res == {}, res + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client(client_id=auth_client_id) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test client credentials + await admin.a_create_client( + payload={ + "name": "test-confidential", + "enabled": True, + "protocol": "openid-connect", + "publicClient": False, + "redirectUris": ["http://localhost/*"], + "webOrigins": ["+"], + "clientId": "test-confidential", + "secret": "test-secret", + "clientAuthenticatorType": "client-secret", + }, + ) + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_secrets(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + client_id = await admin.a_get_client_id(client_id="test-confidential") + assert client_id is not None + + secrets = await admin.a_get_client_secrets(client_id=client_id) + assert secrets == {"type": "secret", "value": "test-secret"} + + with pytest.raises(KeycloakPostError) as err: + await admin.a_generate_client_secrets(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + client_id = await admin.a_get_client_id(client_id="test-confidential") + assert client_id is not None + + res = await admin.a_generate_client_secrets(client_id=client_id) + assert res + client_id = await admin.a_get_client_id(client_id="test-confidential") + assert client_id is not None + assert await admin.a_get_client_secrets(client_id=client_id) == res + + +@pytest.mark.asyncio +async def test_a_realm_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test realm roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get realm roles + roles = await admin.a_get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # Test get realm roles with search text + searched_roles = await admin.a_get_realm_roles(search_text="uma_a") + searched_role_names = [x["name"] for x in searched_roles] + assert "uma_authorization" in searched_role_names, searched_role_names + assert "offline_access" not in searched_role_names, searched_role_names + + # Test empty members + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm_role_members(role_name="does-not-exist") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + members = await admin.a_get_realm_role_members(role_name="offline_access") + assert members == [], members + + # Test create realm role + role_id = await admin.a_create_realm_role( + payload={"name": "test-realm-role"}, + skip_exists=True, + ) + assert role_id, role_id + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_realm_role(payload={"name": "test-realm-role"}) + assert err.match('409: b\'{"errorMessage":"Role with name test-realm-role already exists"}\'') + role_id_2 = await admin.a_create_realm_role( + payload={"name": "test-realm-role"}, + skip_exists=True, + ) + assert role_id == role_id_2 + + # Test get realm role by its id + role_id = (await admin.a_get_realm_role(role_name="test-realm-role"))["id"] + res = await admin.a_get_realm_role_by_id(role_id) + assert res["name"] == "test-realm-role" + + # Test update realm role + res = await admin.a_update_realm_role( + role_name="test-realm-role", + payload={"name": "test-realm-role-update"}, + ) + assert res == {}, res + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_realm_role( + role_name="test-realm-role", + payload={"name": "test-realm-role-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test realm role user assignment + user_id = await admin.a_create_user( + payload={"username": "role-testing", "email": "test@test.test"}, + ) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_realm_roles(user_id=user_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_realm_roles( + user_id=user_id, + roles=[ + await admin.a_get_realm_role(role_name="offline_access"), + await admin.a_get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == {}, res + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] for x in await admin.a_get_realm_role_members(role_name="offline_access") + ] + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] + for x in await admin.a_get_realm_role_members(role_name="test-realm-role-update") + ] + + roles = await admin.a_get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 3 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_roles_of_user(user_id=user_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_realm_roles_of_user( + user_id=user_id, + roles=[await admin.a_get_realm_role(role_name="offline_access")], + ) + assert res == {}, res + assert await admin.a_get_realm_role_members(role_name="offline_access") == [] + roles = await admin.a_get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" not in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + roles = await admin.a_get_available_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "uma_authorization" in [x["name"] for x in roles] + + # Test realm role group assignment + group_id = await admin.a_create_group(payload={"name": "test-group"}) + assert group_id is not None + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_group_realm_roles( + group_id=group_id, + roles=[ + await admin.a_get_realm_role(role_name="offline_access"), + await admin.a_get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == {}, res + + roles = await admin.a_get_group_realm_roles(group_id=group_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX) + res = await admin.a_delete_group_realm_roles( + group_id=group_id, + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == {}, res + roles = await admin.a_get_group_realm_roles(group_id=group_id) + assert len(roles) == 1 + assert "test-realm-role-update" in [x["name"] for x in roles] + + # Test composite realm roles + composite_role = await admin.a_create_realm_role(payload={"name": "test-composite-role"}) + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_add_composite_realm_roles_to_role( + role_name=composite_role, + roles=[admin.get_realm_role(role_name="test-realm-role-update")], + ) + assert res == {}, res + + res = await admin.a_get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 1 + assert "test-realm-role-update" in res[0]["name"] + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_realm_roles_of_role(role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + res = await admin.a_get_composite_realm_roles_of_user(user_id=user_id) + assert len(res) == 4 + assert "offline_access" in {x["name"] for x in res} + assert "test-realm-role-update" in {x["name"] for x in res} + assert "uma_authorization" in {x["name"] for x in res} + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_realm_roles_of_user(user_id="bad") + assert err.match(USER_NOT_FOUND_REGEX), err + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_remove_composite_realm_roles_to_role( + role_name=composite_role, + roles=[admin.get_realm_role(role_name="test-realm-role-update")], + ) + assert res == {}, res + + res = await admin.a_get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 0 + + # Test realm role group list + res = await admin.a_get_realm_role_groups(role_name="test-realm-role-update") + assert len(res) == 1 + assert res[0]["id"] == group_id + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm_role_groups(role_name="non-existent-role") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test with query params + res = await admin.a_get_realm_role_groups(role_name="test-realm-role-update", query={"max": 1}) + assert len(res) == 1 + + # Test delete realm role + res = await admin.a_delete_realm_role(role_name=composite_role) + assert res == {}, res + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_realm_role(role_name=composite_role) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + +@pytest.mark.asyncio +async def test_a_realm_roles_pagination(admin: KeycloakAdmin, realm: str) -> None: + """ + Test realm roles pagination. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + for ind in range(admin.PAGE_SIZE + 50 - 3): + role_name = f"role_{ind:03}" + admin.create_realm_role(payload={"name": role_name}) + + roles = await admin.a_get_realm_roles() + assert len(roles) == admin.PAGE_SIZE + 50, len(roles) + + roles = await admin.a_get_realm_roles(query={"first": 100, "max": 20}) + assert len(roles) == 20, len(roles) + + roles = await admin.a_get_realm_roles(query={"first": 120, "max": 50}) + assert len(roles) == 30, len(roles) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("testcase", "arg_brief_repr", "includes_attributes"), + [ + ("brief True", {"brief_representation": True}, False), + ("brief False", {"brief_representation": False}, True), + ("default", {}, False), + ], +) +async def test_a_role_attributes( + admin: KeycloakAdmin, + realm: str, + client: str, + arg_brief_repr: dict, + includes_attributes: bool, + testcase: str, +) -> None: + """ + Test getting role attributes for bulk calls. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param arg_brief_repr: Brief representation + :type arg_brief_repr: dict + :param includes_attributes: Indicator whether to include attributes + :type includes_attributes: bool + :param testcase: Test case + :type testcase: str + """ + # setup + attribute_role = "test-realm-role-w-attr" + test_attrs = {"attr1": ["val1"], "attr2": ["val2-1", "val2-2"]} + role_id = await admin.a_create_realm_role( + payload={"name": attribute_role, "attributes": test_attrs}, + skip_exists=True, + ) + assert role_id, role_id + + cli_role_id = await admin.a_create_client_role( + client, + payload={"name": attribute_role, "attributes": test_attrs}, + skip_exists=True, + ) + assert cli_role_id, cli_role_id + + if not includes_attributes: + test_attrs = None + + # tests + roles = await admin.a_get_realm_roles(**arg_brief_repr) + roles_filtered = [role for role in roles if role["name"] == role_id] + assert roles_filtered, roles_filtered + role = roles_filtered[0] + assert role.get("attributes") == test_attrs, testcase + + roles = await admin.a_get_client_roles(client, **arg_brief_repr) + roles_filtered = [role for role in roles if role["name"] == cli_role_id] + assert roles_filtered, roles_filtered + role = roles_filtered[0] + assert role.get("attributes") == test_attrs, testcase + + # cleanup + res = await admin.a_delete_realm_role(role_name=attribute_role) + assert res == {}, res + + res = await admin.a_delete_client_role(client, role_name=attribute_role) + assert res == {}, res + + +@pytest.mark.asyncio +async def test_a_client_scope_realm_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test client realm roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get realm roles + roles = await admin.a_get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # create realm role for test + role_id = await admin.a_create_realm_role( + payload={"name": "test-realm-role"}, + skip_exists=True, + ) + assert role_id, role_id + + # Test realm role client assignment + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_realm_roles_to_client_scope(client_id=client_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_realm_roles_to_client_scope( + client_id=client_id, + roles=[ + await admin.a_get_realm_role(role_name="offline_access"), + await admin.a_get_realm_role(role_name="test-realm-role"), + ], + ) + assert res == {}, res + + roles = await admin.a_get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 2 + client_role_names = [x["name"] for x in roles] + assert "offline_access" in client_role_names, client_role_names + assert "test-realm-role" in client_role_names, client_role_names + assert "uma_authorization" not in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_realm_roles_of_client_scope(client_id=client_id, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_realm_roles_of_client_scope( + client_id=client_id, + roles=[await admin.a_get_realm_role(role_name="offline_access")], + ) + assert res == {}, res + roles = await admin.a_get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 1 + assert "test-realm-role" in [x["name"] for x in roles] + + res = await admin.a_delete_realm_roles_of_client_scope( + client_id=client_id, + roles=[await admin.a_get_realm_role(role_name="test-realm-role")], + ) + assert res == {}, res + roles = await admin.a_get_realm_roles_of_client_scope(client_id=client_id) + assert len(roles) == 0 + + +@pytest.mark.asyncio +async def test_a_client_scope_client_roles(admin: KeycloakAdmin, realm: str, client: str) -> None: + """ + Test client assignment of other client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + await admin.a_change_current_realm(realm) + + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + + # Test get client roles + roles = await admin.a_get_client_roles_of_client_scope(client_id, client) + assert len(roles) == 0, roles + + # create client role for test + client_role_id = await admin.a_create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + skip_exists=True, + ) + assert client_role_id, client_role_id + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_client_roles_to_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_client_roles_to_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name="client-role-test")], + ) + assert res == {}, res + + roles = await admin.a_get_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + ) + assert len(roles) == 1 + client_role_names = [x["name"] for x in roles] + assert "client-role-test" in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name="client-role-test")], + ) + assert res == {}, res + roles = await admin.a_get_client_roles_of_client_scope( + client_id=client_id, + client_roles_owner_id=client, + ) + assert len(roles) == 0 + + +@pytest.mark.asyncio +async def test_a_client_scope_mapping_client_roles( + admin: KeycloakAdmin, + realm: str, + client: str, +) -> None: + """ + Test client scope assignment of client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client owning roles + :type client: str + """ + _client_role_name = "some-client-role" + + await admin.a_change_current_realm(realm) + + client_obj = await admin.a_get_client(client) + client_name = client_obj["name"] + + client_scope = { + "name": "test_client_scope", + "description": "Test Client Scope", + "protocol": "openid-connect", + "attributes": {}, + } + client_scope_id = await admin.a_create_client_scope(client_scope, skip_exists=False) + + # Test get client roles + client_specific_roles = await admin.a_get_client_specific_roles_of_client_scope( + client_scope_id, + client, + ) + assert len(client_specific_roles) == 0, client_specific_roles + all_roles = await admin.a_get_all_roles_of_client_scope(client_scope_id) + assert len(all_roles) == 0, all_roles + + # create client role for test + client_role_name = await admin.a_create_client_role( + client_role_id=client, + payload={"name": _client_role_name}, + skip_exists=True, + ) + assert client_role_name, client_role_name + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = await admin.a_add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name=_client_role_name)], + ) + assert res == {}, res + + # Test when getting roles for the specific owner client + client_specific_roles = await admin.a_get_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + ) + assert len(client_specific_roles) == 1 + client_role_names = [x["name"] for x in client_specific_roles] + assert _client_role_name in client_role_names, client_role_names + + # Test when getting all roles for the client scope + all_roles = await admin.a_get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert "clientMappings" in all_roles, all_roles + all_roles_clients = all_roles["clientMappings"] + assert client_name in all_roles_clients, all_roles_clients + mappings = all_roles_clients[client_name]["mappings"] + client_role_names = [x["name"] for x in mappings] + assert _client_role_name in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = await admin.a_remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name=_client_role_name)], + ) + assert res == {}, res + + all_roles = await admin.a_get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert len(all_roles) == 0 + + +@pytest.mark.asyncio +async def test_a_client_default_client_scopes( + admin: KeycloakAdmin, + realm: str, + client: str, +) -> None: + """ + Test client assignment of default client scopes. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + await admin.a_change_current_realm(realm) + + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + # Test get client default scopes + # keycloak default roles: web-origins, acr, profile, roles, email + default_client_scopes = await admin.a_get_client_default_client_scopes(client_id) + assert len(default_client_scopes) in [6, 5], default_client_scopes + + # Test add a client scope to client default scopes + default_client_scope = "test-client-default-scope" + new_client_scope = { + "name": default_client_scope, + "description": f"Test Client Scope: {default_client_scope}", + "protocol": "openid-connect", + "attributes": {}, + } + new_client_scope_id = await admin.a_create_client_scope(new_client_scope, skip_exists=False) + new_default_client_scope_data = { + "realm": realm, + "client": client_id, + "clientScopeId": new_client_scope_id, + } + res = await admin.a_add_client_default_client_scope( + client_id, + new_client_scope_id, + new_default_client_scope_data, + ) + assert res == {} + default_client_scopes = await admin.a_get_client_default_client_scopes(client_id) + assert len(default_client_scopes) in [6, 7], default_client_scopes + + # Test remove a client default scope + res = await admin.a_delete_client_default_client_scope(client_id, new_client_scope_id) + assert res == {} + default_client_scopes = await admin.a_get_client_default_client_scopes(client_id) + assert len(default_client_scopes) in [5, 6], default_client_scopes + + +@pytest.mark.asyncio +async def test_a_client_optional_client_scopes( + admin: KeycloakAdmin, + realm: str, + client: str, +) -> None: + """ + Test client assignment of optional client scopes. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + """ + await admin.a_change_current_realm(realm) + + client_id = await admin.a_create_client( + payload={"name": "role-testing-client", "clientId": "role-testing-client"}, + ) + # Test get client optional scopes + # keycloak optional roles: microprofile-jwt, offline_access, address, --> for versions < 26.0.0 + # starting with Keycloak version 26.0.0 a new optional role is added: organization + optional_client_scopes = await admin.a_get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) in [4, 5], optional_client_scopes + + # Test add a client scope to client optional scopes + optional_client_scope = "test-client-optional-scope" + new_client_scope = { + "name": optional_client_scope, + "description": f"Test Client Scope: {optional_client_scope}", + "protocol": "openid-connect", + "attributes": {}, + } + new_client_scope_id = await admin.a_create_client_scope(new_client_scope, skip_exists=False) + new_optional_client_scope_data = { + "realm": realm, + "client": client_id, + "clientScopeId": new_client_scope_id, + } + res = await admin.a_add_client_optional_client_scope( + client_id, + new_client_scope_id, + new_optional_client_scope_data, + ) + assert res == {} + optional_client_scopes = await admin.a_get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) in [5, 6], optional_client_scopes + + # Test remove a client optional scope + res = await admin.a_delete_client_optional_client_scope(client_id, new_client_scope_id) + assert res == {} + optional_client_scopes = await admin.a_get_client_optional_client_scopes(client_id) + assert len(optional_client_scopes) in [4, 5], optional_client_scopes + + +@pytest.mark.asyncio +async def test_a_client_roles(admin: KeycloakAdmin, client: str) -> None: + """ + Test client roles. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param client: Keycloak client + :type client: str + """ + # Test get client roles + res = await admin.a_get_client_roles(client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_roles(client_id="bad") + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + # Test create client role + client_role_id = await admin.a_create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + skip_exists=True, + ) + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + ) + assert err.match('409: b\'{"errorMessage":"Role with name client-role-test already exists"}\'') + client_role_id_2 = await admin.a_create_client_role( + client_role_id=client, + payload={"name": "client-role-test"}, + skip_exists=True, + ) + assert client_role_id == client_role_id_2 + + # Test get client role + res = await admin.a_get_client_role(client_id=client, role_name="client-role-test") + assert res["name"] == client_role_id + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + res_ = await admin.a_get_client_role_id(client_id=client, role_name="client-role-test") + assert res_ == res["id"] + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role_id(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + assert len(await admin.a_get_client_roles(client_id=client)) == 1 + + # Test update client role + res = await admin.a_update_client_role( + client_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert res == {} + with pytest.raises(KeycloakPutError) as err: + res = await admin.a_update_client_role( + client_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test user with client role + res = await admin.a_get_client_role_members( + client_id=client, + role_name="client-role-test-update", + ) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role_members(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + user_id = await admin.a_create_user(payload={"username": "test", "email": "test@test.test"}) + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_client_role(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_client_role( + user_id=user_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update"), + ], + ) + assert res == {} + assert ( + len( + await admin.a_get_client_role_members( + client_id=client, + role_name="client-role-test-update", + ), + ) + == 1 + ) + + roles = await admin.a_get_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + roles = await admin.a_get_composite_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + roles = await admin.a_get_available_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 0, roles + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_roles_of_user(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_client_roles_of_user( + user_id=user_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update"), + ], + ) + assert res == {} + assert len(await admin.a_get_client_roles_of_user(user_id=user_id, client_id=client)) == 0 + + # Test groups and client roles + res = await admin.a_get_client_role_groups( + client_id=client, + role_name="client-role-test-update", + ) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_role_groups(client_id=client, role_name="bad") + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + group_id = await admin.a_create_group(payload={"name": "test-group"}) + assert group_id is not None + res = await admin.a_get_group_client_roles(group_id=group_id, client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_group_client_roles(group_id=group_id, client_id="bad") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakPostError) as err: + await admin.a_assign_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_assign_group_client_roles( + group_id=group_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update"), + ], + ) + assert res == {} + assert ( + len( + await admin.a_get_client_role_groups( + client_id=client, + role_name="client-role-test-update", + ), + ) + == 1 + ) + assert len(await admin.a_get_group_client_roles(group_id=group_id, client_id=client)) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_delete_group_client_roles( + group_id=group_id, + client_id=client, + roles=[ + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update"), + ], + ) + assert res == {} + + # Test get composite client roles of role before adding + res = await admin.a_get_composite_client_roles_of_role( + client_id=client, role_name="client-role-test-update" + ) + assert len(res) == 0 + + # Test add composite client roles to role + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_composite_client_roles_to_role( + client_role_id=client, + role_name="client-role-test-update", + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_add_composite_client_roles_to_role( + client_role_id=client, + role_name="client-role-test-update", + roles=[await admin.a_get_realm_role(role_name="offline_access")], + ) + assert res == {} + assert (await admin.a_get_client_role(client_id=client, role_name="client-role-test-update"))[ + "composite" + ] + + # Test get composite client roles of role after adding + res = await admin.a_get_composite_client_roles_of_role( + client_id=client, role_name="client-role-test-update" + ) + assert len(res) == 1 + + # Test removal of composite client roles + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_composite_client_roles_from_role( + client_role_id=client, + role_name="client-role-test-update", + roles=["bad"], + ) + assert err.match(UNKOWN_ERROR_REGEX), err + res = await admin.a_remove_composite_client_roles_from_role( + client_role_id=client, + role_name="client-role-test-update", + roles=[await admin.a_get_realm_role(role_name="offline_access")], + ) + assert res == {} + assert not ( + await admin.a_get_client_role(client_id=client, role_name="client-role-test-update") + )["composite"] + + # Test delete of client role + res = await admin.a_delete_client_role( + client_role_id=client, + role_name="client-role-test-update", + ) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_role( + client_role_id=client, + role_name="client-role-test-update", + ) + assert err.match(COULD_NOT_FIND_ROLE_REGEX) + + # Test of roles by id - Get role + await admin.a_create_client_role( + client_role_id=client, + payload={"name": "client-role-by-id-test"}, + skip_exists=True, + ) + role = await admin.a_get_client_role(client_id=client, role_name="client-role-by-id-test") + res = await admin.a_get_role_by_id(role_id=role["id"]) + assert res["name"] == "client-role-by-id-test" + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_role_by_id(role_id="bad") + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + # Test of roles by id - Update role + res = await admin.a_update_role_by_id( + role_id=role["id"], + payload={"name": "client-role-by-id-test-update"}, + ) + assert res == {} + with pytest.raises(KeycloakPutError) as err: + res = await admin.a_update_role_by_id( + role_id="bad", + payload={"name": "client-role-by-id-test-update"}, + ) + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + # Test of roles by id - Delete role + res = await admin.a_delete_role_by_id(role_id=role["id"]) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_role_by_id(role_id="bad") + assert err.match(COULD_NOT_FIND_ROLE_WITH_ID_REGEX) + + +@pytest.mark.asyncio +async def test_a_enable_token_exchange(admin: KeycloakAdmin, realm: str) -> None: + """ + Test enable token exchange. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :raises AssertionError: In case of bad configuration + """ + # Test enabling token exchange between two confidential clients + await admin.a_change_current_realm(realm) + + # Create test clients + source_client_id = await admin.a_create_client( + payload={"name": "Source Client", "clientId": "source-client"}, + ) + target_client_id = await admin.a_create_client( + payload={"name": "Target Client", "clientId": "target-client"}, + ) + for c in await admin.a_get_clients(): + if c["clientId"] == "realm-management": + realm_management_id = c["id"] + break + else: + pytest.fail("Missing realm management client") + + # Enable permissions on the Superset client + res = await admin.a_update_client_management_permissions( + payload={"enabled": True}, + client_id=target_client_id, + ) + assert isinstance(res, dict) + + # Fetch various IDs and strings needed when creating the permission + token_exchange_permission_id = ( + await admin.a_get_client_management_permissions(client_id=target_client_id) + )["scopePermissions"]["token-exchange"] + scopes = await admin.a_get_client_authz_policy_scopes( + client_id=realm_management_id, + policy_id=token_exchange_permission_id, + ) + + for s in scopes: + if s["name"] == "token-exchange": + token_exchange_scope_id = s["id"] + break + else: + pytest.fail("Missing token-exchange scope") + + resources = await admin.a_get_client_authz_policy_resources( + client_id=realm_management_id, + policy_id=token_exchange_permission_id, + ) + for r in resources: + if r["name"] == f"client.resource.{target_client_id}": + token_exchange_resource_id = r["_id"] + break + else: + pytest.fail("Missing client resource") + + # Create a client policy for source client + policy_name = "Exchange source client token with target client token" + client_policy_id = ( + await admin.a_create_client_authz_client_policy( + payload={ + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "name": policy_name, + "clients": [source_client_id], + }, + client_id=realm_management_id, + ) + )["id"] + policies = await admin.a_get_client_authz_client_policies(client_id=realm_management_id) + for policy in policies: + if policy["name"] == policy_name: + assert policy["clients"] == [source_client_id] + break + else: + pytest.fail("Missing client policy") + + # Update permissions on the target client to reference this policy + permission_name = ( + await admin.a_get_client_authz_scope_permission( + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + ) + )["name"] + res = await admin.a_update_client_authz_scope_permission( + payload={ + "id": token_exchange_permission_id, + "name": permission_name, + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [token_exchange_resource_id], + "scopes": [token_exchange_scope_id], + "policies": [client_policy_id], + }, + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + ) + assert res == b"" + + # Create permissions on the target client to reference this policy + res = await admin.a_create_client_authz_scope_permission( + payload={ + "id": "some-id", + "name": "test-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "resources": [token_exchange_resource_id], + "scopes": [token_exchange_scope_id], + "policies": [client_policy_id], + }, + client_id=realm_management_id, + ) + assert isinstance(res, dict) + permission_name = ( + await admin.a_get_client_authz_scope_permission( + client_id=realm_management_id, + scope_id=token_exchange_permission_id, + ) + )["name"] + assert permission_name.startswith("token-exchange.permission.client.") + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_authz_scope_permission( + payload={"name": "test-permission", "scopes": [token_exchange_scope_id]}, + client_id="realm_management_id", + ) + assert err.match('404: b\'{"error":"Could not find client".*}\'') + + +@pytest.mark.asyncio +async def test_a_email(admin: KeycloakAdmin, user: str) -> None: + """ + Test email. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + admin.enable_user(user) + # Emails will fail as we don't have SMTP test setup + with pytest.raises(KeycloakPutError) as err: + await admin.a_send_update_account(user_id=user, payload=[]) + assert err.match('500: b\'{"errorMessage":"Failed to send execute actions email.*'), err + + admin.update_user(user_id=user, payload={"enabled": True}) + with pytest.raises(KeycloakPutError) as err: + await admin.a_send_verify_email(user_id=user) + assert err.match('500: b\'{"errorMessage":"Failed to send .*"}\'') + + +@pytest.mark.asyncio +async def test_a_email_query_param_handling(admin: KeycloakAdmin, user: str) -> None: + """ + Test that the optional parameters are correctly transformed into query params. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param user: Keycloak user + :type user: str + """ + with ( + patch.object( + admin.connection.async_s, + "put", + side_effect=Exception("An expected error"), + ) as mock_put, + pytest.raises(KeycloakConnectionError), + ): + await admin.a_send_update_account( + user_id=user, + payload=["UPDATE_PASSWORD"], + client_id="update-account-client-id", + redirect_uri="https://example.com", + ) + + mock_put.assert_awaited_once_with( + ANY, + content='["UPDATE_PASSWORD"]', + params={"client_id": "update-account-client-id", "redirect_uri": "https://example.com"}, + headers=ANY, + timeout=60, + ) + + with ( + patch.object( + admin.connection.async_s, + "put", + side_effect=Exception("An expected error"), + ) as mock_put, + pytest.raises(KeycloakConnectionError), + ): + await admin.a_send_verify_email( + user_id=user, + client_id="verify-client-id", + redirect_uri="https://example.com", + ) + + mock_put.assert_awaited_once_with( + ANY, + data=ANY, + params={"client_id": "verify-client-id", "redirect_uri": "https://example.com"}, + headers=ANY, + timeout=60, + ) + + +@pytest.mark.asyncio +async def test_a_get_sessions(admin: KeycloakAdmin) -> None: + """ + Test get sessions. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + assert admin.connection.username is not None + user_id = admin.get_user_id(username=admin.connection.username) + assert user_id is not None + + sessions = await admin.a_get_sessions(user_id=user_id) + assert len(sessions) >= 1 + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_sessions(user_id="bad") + assert err.match(USER_NOT_FOUND_REGEX) + + +@pytest.mark.asyncio +async def test_a_get_client_installation_provider(admin: KeycloakAdmin, client: str) -> None: + """ + Test get client installation provider. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param client: Keycloak client + :type client: str + """ + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_installation_provider(client_id=client, provider_id="bad") + assert err.match('404: b\'{"error":"Unknown Provider".*}\'') + + installation = await admin.a_get_client_installation_provider( + client_id=client, + provider_id="keycloak-oidc-keycloak-json", + ) + assert set(installation.keys()) == { + "auth-server-url", + "confidential-port", + "credentials", + "realm", + "resource", + "ssl-required", + } + + +@pytest.mark.asyncio +async def test_a_auth_flows(admin: KeycloakAdmin, realm: str) -> None: + """ + Test auth flows. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + res = await admin.a_get_authentication_flows() + assert len(res) <= 8, res + default_flows = len(res) + assert {x["alias"] for x in res}.issubset( + { + "reset credentials", + "browser", + "registration", + "http challenge", + "docker auth", + "direct grant", + "first broker login", + "clients", + }, + ) + assert set(res[0].keys()) == { + "alias", + "authenticationExecutions", + "builtIn", + "description", + "id", + "providerId", + "topLevel", + } + assert {x["alias"] for x in res}.issubset( + { + "reset credentials", + "browser", + "registration", + "docker auth", + "direct grant", + "first broker login", + "clients", + "http challenge", + }, + ) + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authentication_flow_for_id(flow_id="bad") + assert err.match('404: b\'{"error":"Could not find flow with id".*}\'') + browser_flow_id = next(x for x in res if x["alias"] == "browser")["id"] + res = await admin.a_get_authentication_flow_for_id(flow_id=browser_flow_id) + assert res["alias"] == "browser" + + # Test copying + with pytest.raises(KeycloakPostError) as err: + await admin.a_copy_authentication_flow(payload={}, flow_alias="bad") + assert ('b\'{"error":"Flow not found"' in str(err)) or err.match("404: b''") + + res = await admin.a_copy_authentication_flow( + payload={"newName": "test-browser"}, + flow_alias="browser", + ) + assert res == b"", res + assert len(await admin.a_get_authentication_flows()) == (default_flows + 1) + + # Test create + res = await admin.a_create_authentication_flow( + payload={"alias": "test-create", "providerId": "basic-flow"}, + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_authentication_flow( + payload={"alias": "test-create", "builtIn": False}, + ) + assert err.match('409: b\'{"errorMessage":"Flow test-create already exists"}\'') + assert ( + await admin.a_create_authentication_flow( + payload={"alias": "test-create"}, + skip_exists=True, + ) + == json.dumps({"msg": "Already exists"}).encode() + ) + + # Update + res = await admin.a_get_authentication_flows() + browser_flow_id = next(x for x in res if x["alias"] == "browser")["id"] + flow = await admin.a_get_authentication_flow_for_id(flow_id=browser_flow_id) + del flow["authenticationExecutions"] + del flow["id"] + flow["description"] = "test description" + res = await admin.a_update_authentication_flow(flow_id=browser_flow_id, payload=flow) + assert isinstance(res, dict) + res = await admin.a_get_authentication_flow_for_id(flow_id=browser_flow_id) + assert res["description"] == "test description" + + # Test flow executions + res = await admin.a_get_authentication_flow_executions(flow_alias="browser") + assert len(res) in [8, 12, 14, 15], res + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authentication_flow_executions(flow_alias="bad") + assert ('b\'{"error":"Flow not found"' in str(err)) or err.match("404: b''") + exec_id = res[0]["id"] + + res = await admin.a_get_authentication_flow_execution(execution_id=exec_id) + assert set(res.keys()).issubset( + { + "alternative", + "authenticator", + "authenticatorFlow", + "autheticatorFlow", + "conditional", + "disabled", + "enabled", + "id", + "parentFlow", + "priority", + "required", + "requirement", + }, + ), res.keys() + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authentication_flow_execution(execution_id="bad") + assert err.match(ILLEGAL_EXECUTION_REGEX) + + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_authentication_flow_execution(payload={}, flow_alias="browser") + assert err.match('400: b\'{"error":"It is illegal to add execution to a built in flow".*}\'') + + res = await admin.a_create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, + flow_alias="test-create", + ) + assert res == b"" + assert len(await admin.a_get_authentication_flow_executions(flow_alias="test-create")) == 1 + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_authentication_flow_executions( + payload={"required": "yes"}, + flow_alias="test-create", + ) + assert err.match("Unrecognized field") + payload = (await admin.a_get_authentication_flow_executions(flow_alias="test-create"))[0] + payload["displayName"] = "test" + res = await admin.a_update_authentication_flow_executions( + payload=payload, + flow_alias="test-create", + ) + assert isinstance(res, dict) + + exec_id = (await admin.a_get_authentication_flow_executions(flow_alias="test-create"))[0]["id"] + res = await admin.a_delete_authentication_flow_execution(execution_id=exec_id) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_authentication_flow_execution(execution_id=exec_id) + assert err.match(ILLEGAL_EXECUTION_REGEX) + + # Test subflows + res = await admin.a_create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-browser", + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_authentication_flow_subflow( + payload={"alias": "test-subflow", "providerId": "basic-flow"}, + flow_alias="test-browser", + ) + assert err.match('409: b\'{"errorMessage":"New flow alias name already exists"}\'') + res = await admin.a_create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-create", + skip_exists=True, + ) + assert res == json.dumps({"msg": "Already exists"}).encode() + + # Test delete auth flow + flow_id = next( + x for x in await admin.a_get_authentication_flows() if x["alias"] == "test-browser" + )["id"] + res = await admin.a_delete_authentication_flow(flow_id=flow_id) + assert res == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_authentication_flow(flow_id=flow_id) + assert ('b\'{"error":"Could not find flow with id"' in str(err)) or ( + 'b\'{"error":"Flow not found"' in str(err) + ) + + +@pytest.mark.asyncio +async def test_a_auth_flow_execution_priority(admin: KeycloakAdmin, realm: str) -> None: + """ + Test execution priority. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + _ = await admin.a_create_authentication_flow( + payload={"alias": "test-create", "providerId": "basic-flow"}, + ) + _ = await admin.a_create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, + flow_alias="test-create", + ) + _ = await admin.a_create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, + flow_alias="test-create", + ) + executions = await admin.a_get_authentication_flow_executions(flow_alias="test-create") + priority_list = [ex["id"] for ex in executions] + _ = await admin.a_change_execution_priority(priority_list[1], 1) + new_executions = await admin.a_get_authentication_flow_executions(flow_alias="test-create") + assert executions != new_executions + _ = await admin.a_change_execution_priority(priority_list[1], -1) + new_executions = await admin.a_get_authentication_flow_executions(flow_alias="test-create") + assert executions == new_executions + + +@pytest.mark.asyncio +async def test_a_authentication_configs(admin: KeycloakAdmin, realm: str) -> None: + """ + Test authentication configs. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin.change_current_realm(realm) + + # Test list of auth providers + res = await admin.a_get_authenticator_providers() + assert len(res) <= 42 + + res = await admin.a_get_authenticator_provider_config_description(provider_id="auth-cookie") + assert res == { + "helpText": "Validates the SSO cookie set by the auth server.", + "name": "Cookie", + "properties": [], + "providerId": "auth-cookie", + } + + # Test authenticator config + executions = await admin.a_get_authentication_flow_executions(flow_alias="browser") + execution = next(ex for ex in executions if ex["configurable"]) + res = await admin.a_create_execution_config( + execution["id"], + { + "alias": "test.provisioning.property", + "config": {"test.provisioning.property": "value2"}, + }, + ) + assert res == b"" + executions = await admin.a_get_authentication_flow_executions(flow_alias="browser") + execution_config_id = next(ex for ex in executions if ex.get("id") == execution["id"])[ + "authenticationConfig" + ] + res = await admin.a_get_authenticator_config(config_id=execution_config_id) + assert res["config"]["test.provisioning.property"] == "value2" + + # Test authenticator config + # Currently unable to find a sustainable way to fetch the config id, + # therefore testing only failures + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_authenticator_config(config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_authenticator_config(payload={}, config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + res = await admin.a_update_authenticator_config(payload={}, config_id=execution_config_id) + assert res == {} + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_authenticator_config(config_id="bad") + assert err.match('404: b\'{"error":"Could not find authenticator config".*}\'') + res = await admin.a_delete_authenticator_config(config_id=execution_config_id) + assert res == {} + + +@pytest.mark.asyncio +async def test_a_sync_users(admin: KeycloakAdmin, realm: str) -> None: + """ + Test sync users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Only testing the error message + with pytest.raises(KeycloakPostError) as err: + await admin.a_sync_users(storage_id="does-not-exist", action="triggerFullSync") + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + +@pytest.mark.asyncio +async def test_a_client_scopes(admin: KeycloakAdmin, realm: str) -> None: + """ + Test client scopes. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get client scopes + res = await admin.a_get_client_scopes() + scope_names = {x["name"] for x in res} + assert len(res) in [10, 11, 13, 14] + assert "email" in scope_names + assert "profile" in scope_names + assert "offline_access" in scope_names + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_client_scope(client_scope_id="does-not-exist") + assert err.match(NO_CLIENT_SCOPE_REGEX) + + scope = await admin.a_get_client_scope(client_scope_id=res[0]["id"]) + assert res[0] == scope + + scope = await admin.a_get_client_scope_by_name(client_scope_name=res[0]["name"]) + assert res[0] == scope + + # Test create client scope + res = await admin.a_create_client_scope( + payload={"name": "test-scope", "protocol": "openid-connect"}, + skip_exists=True, + ) + assert res + res2 = await admin.a_create_client_scope( + payload={"name": "test-scope", "protocol": "openid-connect"}, + skip_exists=True, + ) + assert res == res2 + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_client_scope( + payload={"name": "test-scope", "protocol": "openid-connect"}, + skip_exists=False, + ) + assert err.match('409: b\'{"errorMessage":"Client Scope test-scope already exists"}\'') + + # Test update client scope + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_client_scope(client_scope_id="does-not-exist", payload={}) + assert err.match(NO_CLIENT_SCOPE_REGEX) + + res_update = await admin.a_update_client_scope( + client_scope_id=res, + payload={"name": "test-scope-update"}, + ) + assert res_update == {} + assert (await admin.a_get_client_scope(client_scope_id=res))["name"] == "test-scope-update" + + # Test get mappers + mappers = await admin.a_get_mappers_from_client_scope(client_scope_id=res) + assert mappers == [] + + # Test add mapper + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_mapper_to_client_scope(client_scope_id=res, payload={}) + assert err.match('404: b\'{"error":"ProtocolMapper provider not found".*}\'') + + res_add = await admin.a_add_mapper_to_client_scope( + client_scope_id=res, + payload={ + "name": "test-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + }, + ) + assert res_add == b"" + assert len(await admin.a_get_mappers_from_client_scope(client_scope_id=res)) == 1 + + # Test update mapper + test_mapper = (await admin.a_get_mappers_from_client_scope(client_scope_id=res))[0] + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_mapper_in_client_scope( + client_scope_id="does-not-exist", + protocol_mapper_id=test_mapper["id"], + payload={}, + ) + assert err.match(NO_CLIENT_SCOPE_REGEX) + test_mapper["config"]["user.attribute"] = "test" + res_update = await admin.a_update_mapper_in_client_scope( + client_scope_id=res, + protocol_mapper_id=test_mapper["id"], + payload=test_mapper, + ) + assert res_update == {} + assert (await admin.a_get_mappers_from_client_scope(client_scope_id=res))[0]["config"][ + "user.attribute" + ] == "test" + + # Test delete mapper + res_del = await admin.a_delete_mapper_from_client_scope( + client_scope_id=res, + protocol_mapper_id=test_mapper["id"], + ) + assert res_del == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_mapper_from_client_scope( + client_scope_id=res, + protocol_mapper_id=test_mapper["id"], + ) + assert err.match('404: b\'{"error":"Model not found".*}\'') + + # Test default default scopes + res_defaults = await admin.a_get_default_default_client_scopes() + assert len(res_defaults) in [6, 7, 8] + + with pytest.raises(KeycloakPutError) as err: + await admin.a_add_default_default_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_add = await admin.a_add_default_default_client_scope(scope_id=res) + assert res_add == {} + assert len(admin.get_default_default_client_scopes()) in [7, 8, 9] + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_default_default_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_del = await admin.a_delete_default_default_client_scope(scope_id=res) + assert res_del == {} + assert len(admin.get_default_default_client_scopes()) in [6, 7, 8] + + # Test default optional scopes + res_defaults = await admin.a_get_default_optional_client_scopes() + assert len(res_defaults) in [4, 5] + + with pytest.raises(KeycloakPutError) as err: + await admin.a_add_default_optional_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_add = await admin.a_add_default_optional_client_scope(scope_id=res) + assert res_add == {} + assert len(await admin.a_get_default_optional_client_scopes()) in [5, 6] + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_default_optional_client_scope(scope_id="does-not-exist") + assert err.match(CLIENT_SCOPE_NOT_FOUND_REGEX) + + res_del = await admin.a_delete_default_optional_client_scope(scope_id=res) + assert res_del == {} + assert len(await admin.a_get_default_optional_client_scopes()) in [4, 5] + + # Test client scope delete + res_del = await admin.a_delete_client_scope(client_scope_id=res) + assert res_del == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_client_scope(client_scope_id=res) + assert err.match(NO_CLIENT_SCOPE_REGEX) + + +@pytest.mark.asyncio +async def test_a_components(admin: KeycloakAdmin, realm: str) -> None: + """ + Test components. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + # Test get components + res = await admin.a_get_components() + assert len(res) in [12, 14] + + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_component(component_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + res_get = await admin.a_get_component(component_id=res[0]["id"]) + assert res_get == res[0] + + # Test create component + with pytest.raises(KeycloakPostError) as err: + await admin.a_create_component(payload={"bad": "dict"}) + assert err.match("Unrecognized field") + + res = await admin.a_create_component( + payload={ + "name": "Test Component", + "providerId": "max-clients", + "providerType": "org.keycloak.services.clientregistration." + "policy.ClientRegistrationPolicy", + "config": {"max-clients": ["1000"]}, + }, + ) + assert res + assert (await admin.a_get_component(component_id=res))["name"] == "Test Component" + + # Test update component + component = await admin.a_get_component(component_id=res) + component["name"] = "Test Component Update" + + with pytest.raises(KeycloakPutError) as err: + await admin.a_update_component(component_id="does-not-exist", payload={}) + assert err.match('404: b\'{"error":"Could not find component".*}\'') + res_upd = await admin.a_update_component(component_id=res, payload=component) + assert res_upd == {} + assert (await admin.a_get_component(component_id=res))["name"] == "Test Component Update" + + # Test delete component + res_del = await admin.a_delete_component(component_id=res) + assert res_del == {} + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_delete_component(component_id=res) + assert err.match('404: b\'{"error":"Could not find component".*}\'') + + +@pytest.mark.asyncio +async def test_a_keys(admin: KeycloakAdmin, realm: str) -> None: + """ + Test keys. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + assert set((await admin.a_get_keys())["active"].keys()) == { + "AES", + "HS256", + "RS256", + "RSA-OAEP", + } or set((await admin.a_get_keys())["active"].keys()) == {"RSA-OAEP", "RS256", "HS512", "AES"} + assert {k["algorithm"] for k in (await admin.a_get_keys())["keys"]} == { + "HS256", + "RSA-OAEP", + "AES", + "RS256", + } or {k["algorithm"] for k in (await admin.a_get_keys())["keys"]} == { + "HS512", + "RSA-OAEP", + "AES", + "RS256", + } + + +@pytest.mark.asyncio +async def test_a_admin_events(admin: KeycloakAdmin, realm: str) -> None: + """ + Test events. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + await admin.a_create_client(payload={"name": "test", "clientId": "test"}) + events = await admin.a_get_admin_events() + assert events == [] + + +@pytest.mark.asyncio +async def test_a_user_events(admin: KeycloakAdmin, realm: str) -> None: + """ + Test events. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + + events = await admin.a_get_events() + assert events == [] + + with pytest.raises(KeycloakPutError) as err: + await admin.a_set_events(payload={"bad": "conf"}) + assert err.match("Unrecognized field") + + res = await admin.a_set_events( + payload={"adminEventsDetailsEnabled": True, "adminEventsEnabled": True}, + ) + assert res == {} + + await admin.a_create_client(payload={"name": "test", "clientId": "test"}) + + events = await admin.a_get_events() + assert events == [] + + +@pytest.mark.asyncio +@freezegun.freeze_time("2023-02-25 10:00:00") +async def test_a_auto_refresh(admin_frozen: KeycloakAdmin, realm: str) -> None: + """ + Test auto refresh token. + + :param admin_frozen: Keycloak Admin client with time frozen in place + :type admin_frozen: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + admin = admin_frozen + admin.get_realm(realm) + # Test get refresh + admin.connection.custom_headers = { + "Authorization": "Bearer bad", + "Content-Type": "application/json", + } + + res = await admin.a_get_realm(realm_name=realm) + assert res["realm"] == realm + + # Freeze time to simulate the access token expiring + assert admin.connection.expires_at is not None + with freezegun.freeze_time("2023-02-25 10:05:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:05:00Z") + assert await admin.a_get_realm(realm_name=realm) + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:05:00Z") + + # Test bad refresh token, but first make sure access token has expired again + assert admin.connection.token is not None + with freezegun.freeze_time("2023-02-25 10:10:00"): + admin.connection.custom_headers = {"Content-Type": "application/json"} + admin.connection.token["refresh_token"] = "bad" # noqa: S105 + with pytest.raises(KeycloakPostError) as err: + await admin.a_get_realm(realm_name="test-refresh") + assert err.match( + '400: b\'{"error":"invalid_grant","error_description":"Invalid refresh token"}\'', + ) + admin.connection.get_token() + + # Test post refresh + with freezegun.freeze_time("2023-02-25 10:15:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:15:00Z") + admin.connection.token = None + assert await admin.a_create_realm(payload={"realm": "test-refresh"}) == b"" + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:15:00Z") + + # Test update refresh + with freezegun.freeze_time("2023-02-25 10:25:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:25:00Z") + admin.connection.token = None + assert ( + await admin.a_update_realm(realm_name="test-refresh", payload={"accountTheme": "test"}) + == {} + ) + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:25:00Z") + + # Test delete refresh + with freezegun.freeze_time("2023-02-25 10:35:00"): + assert admin.connection.expires_at < datetime_parser.parse("2023-02-25T10:35:00Z") + admin.connection.token = None + assert await admin.a_delete_realm(realm_name="test-refresh") == {} + assert admin.connection.expires_at > datetime_parser.parse("2023-02-25T10:35:00Z") + + +@pytest.mark.asyncio +async def test_a_get_required_actions(admin: KeycloakAdmin, realm: str) -> None: + """ + Test required actions. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + ractions = await admin.a_get_required_actions() + assert isinstance(ractions, list) + for ra in ractions: + for key in [ + "alias", + "name", + "providerId", + "enabled", + "defaultAction", + "priority", + "config", + ]: + assert key in ra + + +@pytest.mark.asyncio +async def test_a_get_required_action_by_alias(admin: KeycloakAdmin, realm: str) -> None: + """ + Test get required action by alias. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + ractions = await admin.a_get_required_actions() + ra = await admin.a_get_required_action_by_alias("UPDATE_PASSWORD") + assert ra is not None + assert ra in ractions + assert ra["alias"] == "UPDATE_PASSWORD" + assert await admin.a_get_required_action_by_alias("does-not-exist") is None + + +@pytest.mark.asyncio +async def test_a_update_required_action(admin: KeycloakAdmin, realm: str) -> None: + """ + Test update required action. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + """ + await admin.a_change_current_realm(realm) + ra = await admin.a_get_required_action_by_alias("UPDATE_PASSWORD") + old = copy.deepcopy(ra) + assert ra is not None + ra["enabled"] = False + res = await admin.a_update_required_action("UPDATE_PASSWORD", ra) + assert res == {} + newra = await admin.a_get_required_action_by_alias("UPDATE_PASSWORD") + assert newra is not None + assert old != newra + assert newra["enabled"] is False + + +@pytest.mark.asyncio +async def test_a_get_composite_client_roles_of_group( + admin: KeycloakAdmin, + realm: str, + client: str, + group: str, + composite_client_role: str, +) -> None: + """ + Test get composite client roles of group. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param group: Keycloak group + :type group: str + :param composite_client_role: Composite client role + :type composite_client_role: str + """ + await admin.a_change_current_realm(realm) + role = await admin.a_get_client_role(client, composite_client_role) + await admin.a_assign_group_client_roles(group_id=group, client_id=client, roles=[role]) + result = await admin.a_get_composite_client_roles_of_group(client, group) + assert role["id"] in [x["id"] for x in result] + + +@pytest.mark.asyncio +async def test_a_get_role_client_level_children( + admin: KeycloakAdmin, + realm: str, + client: str, + composite_client_role: str, + client_role: str, +) -> None: + """ + Test get children of composite client role. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param composite_client_role: Composite client role + :type composite_client_role: str + :param client_role: Client role + :type client_role: str + """ + await admin.a_change_current_realm(realm) + child = await admin.a_get_client_role(client, client_role) + parent = await admin.a_get_client_role(client, composite_client_role) + res = await admin.a_get_role_client_level_children(client, parent["id"]) + assert child["id"] in [x["id"] for x in res] + + +@pytest.mark.asyncio +async def test_a_get_role_composites_by_id( + admin: KeycloakAdmin, + realm: str, + client: str, + composite_client_role: str, + client_role: str, +) -> None: + """ + Test get all composite roles by role id asynchronously. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param composite_client_role: Composite client role + :type composite_client_role: str + :param client_role: Client role + :type client_role: str + """ + await admin.a_change_current_realm(realm) + + parent_role = await admin.a_get_client_role(client, composite_client_role) + child_role = await admin.a_get_client_role(client, client_role) + + composites = await admin.a_get_role_composites_by_id(parent_role["id"]) + assert len(composites) > 0 + assert child_role["id"] in [x["id"] for x in composites] + + composites_paginated = await admin.a_get_role_composites_by_id( + parent_role["id"], query={"first": 0, "max": 10} + ) + assert len(composites_paginated) > 0 + assert child_role["id"] in [x["id"] for x in composites_paginated] + + composites_searched = await admin.a_get_role_composites_by_id( + parent_role["id"], query={"search": client_role[:3]} + ) + assert len(composites_searched) > 0 + + +@pytest.mark.asyncio +async def test_a_upload_certificate( + admin: KeycloakAdmin, + realm: str, + client: str, + selfsigned_cert: tuple, +) -> None: + """ + Test upload certificate. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client + :type client: str + :param selfsigned_cert: Selfsigned certificates + :type selfsigned_cert: tuple + """ + await admin.a_change_current_realm(realm) + cert, _ = selfsigned_cert + cert = cert.decode("utf-8").strip() + res = await admin.a_upload_certificate(client, cert) + assert isinstance(res, dict) + assert "certificate" in res + cl = await admin.a_get_client(client) + assert cl["attributes"]["jwt.credential.certificate"] == "".join(cert.splitlines()[1:-1]) + + +@pytest.mark.asyncio +async def test_a_get_bruteforce_status_for_user( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], + realm: str, +) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, _ = oid_with_credentials + await admin.a_change_current_realm(realm) + + # Turn on bruteforce protection + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + with contextlib.suppress(KeycloakAuthenticationError): + oid.token(username=username, password="wrongpassword") # noqa: S106 + + user_id = await admin.a_get_user_id(username) + assert user_id is not None + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + + assert bruteforce_status["numFailures"] == 1 + + # Cleanup + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +@pytest.mark.asyncio +async def test_a_clear_bruteforce_attempts_for_user( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], + realm: str, +) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, _ = oid_with_credentials + await admin.a_change_current_realm(realm) + + # Turn on bruteforce protection + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + with contextlib.suppress(KeycloakAuthenticationError): + oid.token(username=username, password="wrongpassword") # noqa: S106 + + user_id = await admin.a_get_user_id(username) + assert user_id is not None + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 1 + + res = await admin.a_clear_bruteforce_attempts_for_user(user_id) + assert res == {} + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 0 + + # Cleanup + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +@pytest.mark.asyncio +async def test_a_clear_bruteforce_attempts_for_all_users( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], + realm: str, +) -> None: + """ + Test users. + + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param realm: Keycloak realm + :type realm: str + """ + oid, username, _ = oid_with_credentials + await admin.a_change_current_realm(realm) + + # Turn on bruteforce protection + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": True}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is True + + # Test login user with wrong credentials + with contextlib.suppress(KeycloakAuthenticationError): + oid.token(username=username, password="wrongpassword") # noqa: S106 + + user_id = await admin.a_get_user_id(username) + assert user_id is not None + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 1 + + res = await admin.a_clear_all_bruteforce_attempts() + assert res == {} + bruteforce_status = await admin.a_get_bruteforce_detection_status(user_id) + assert bruteforce_status["numFailures"] == 0 + + # Cleanup + res = await admin.a_update_realm(realm_name=realm, payload={"bruteForceProtected": False}) + res = await admin.a_get_realm(realm_name=realm) + assert res["bruteForceProtected"] is False + + +@pytest.mark.asyncio +async def test_a_default_realm_role_present(realm: str, admin: KeycloakAdmin) -> None: + """ + Test that the default realm role is present in a brand new realm. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + assert f"default-roles-{realm}" in [x["name"] for x in admin.get_realm_roles()] + assert ( + len( + [ + x["name"] + for x in await admin.a_get_realm_roles() + if x["name"] == f"default-roles-{realm}" + ], + ) + == 1 + ) + + +@pytest.mark.asyncio +async def test_a_get_default_realm_role_id(realm: str, admin: KeycloakAdmin) -> None: + """ + Test getter for the ID of the default realm role. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + assert await admin.a_get_default_realm_role_id() == next( + x["id"] for x in await admin.a_get_realm_roles() if x["name"] == f"default-roles-{realm}" + ) + + +@pytest.mark.asyncio +async def test_a_realm_default_roles(admin: KeycloakAdmin, realm: str) -> None: + """ + Test getting, adding and deleting default realm roles. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + + # Test listing all default realm roles + roles = await admin.a_get_realm_default_roles() + assert len(roles) == 2 + assert {x["name"] for x in roles} == {"offline_access", "uma_authorization"} + + await admin.a_change_current_realm("doesnotexist") + with pytest.raises(KeycloakGetError) as err: + await admin.a_get_realm_default_roles() + + assert err.match('404: b\'{"error":"Realm not found.".*}\'') + await admin.a_change_current_realm(realm) + + # Test removing a default realm role + res = await admin.a_remove_realm_default_roles(payload=[roles[0]]) + assert res == {} + assert roles[0] not in await admin.a_get_realm_default_roles() + assert len(await admin.a_get_realm_default_roles()) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_realm_default_roles(payload=[{"id": "bad id"}]) + assert err.match('404: b\'{"error":"Could not find composite role".*}\'') + + # Test adding a default realm role + res = await admin.a_add_realm_default_roles(payload=[roles[0]]) + assert res == {} + assert roles[0] in await admin.a_get_realm_default_roles() + assert len(await admin.a_get_realm_default_roles()) == 2 + + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_realm_default_roles(payload=[{"id": "bad id"}]) + assert err.match('404: b\'{"error":"Could not find composite role".*}\'') + + +@pytest.mark.asyncio +async def test_a_clear_keys_cache(realm: str, admin: KeycloakAdmin) -> None: + """ + Test clearing the keys cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + res = await admin.a_clear_keys_cache() + assert res == {} + + +@pytest.mark.asyncio +async def test_a_clear_realm_cache(realm: str, admin: KeycloakAdmin) -> None: + """ + Test clearing the realm cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + res = await admin.a_clear_realm_cache() + assert res == {} + + +@pytest.mark.asyncio +async def test_a_clear_user_cache(realm: str, admin: KeycloakAdmin) -> None: + """ + Test clearing the user cache. + + :param realm: Realm name + :type realm: str + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + await admin.a_change_current_realm(realm) + res = await admin.a_clear_user_cache() + assert res == {} + + +@pytest.mark.asyncio +async def test_a_initial_access_token( + admin: KeycloakAdmin, + oid_with_credentials: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test initial access token and client creation. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + res = await admin.a_create_initial_access_token(2, 3) + assert "token" in res + assert res["count"] == 2 + assert res["expiration"] == 3 + + oid, _, _ = oid_with_credentials + + client = str(uuid.uuid4()) + secret = str(uuid.uuid4()) + + res = await oid.a_register_client( + token=res["token"], + payload={ + "name": "DynamicRegisteredClient", + "clientId": client, + "enabled": True, + "publicClient": False, + "protocol": "openid-connect", + "secret": secret, + "clientAuthenticatorType": "client-secret", + }, + ) + assert res["clientId"] == client + + new_secret = str(uuid.uuid4()) + res = await oid.a_update_client( + res["registrationAccessToken"], + client, + payload={"secret": new_secret}, + ) + assert res["secret"] == new_secret + + +@pytest.mark.asyncio +async def test_a_refresh_token(admin: KeycloakAdmin) -> None: + """ + Test refresh token on connection even if it is expired. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + """ + admin.get_realms() + assert admin.connection.token is not None + assert admin.connection.username is not None + user_id = await admin.a_get_user_id(admin.connection.username) + assert user_id is not None + await admin.a_user_logout(user_id=user_id) + admin.connection.refresh_token() + + +@pytest.mark.asyncio +async def test_a_consents( + admin: KeycloakAdmin, oid_with_credentials: tuple[KeycloakOpenID, str, str] +) -> None: + """ + Test getting and revoking offline access via the consents API. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + + # Use offline access as ersatz consent + offline_token = await oid.a_token(username, password, scope="offline_access") + decoded_access_token = await oid.a_decode_token(token=offline_token["access_token"]) + user_id = decoded_access_token["sub"] + + # Test get consents/offline access + res = await admin.a_user_consents(user_id=user_id) + assert len(res) == 1, res + assert "additionalGrants" in res[0], res[0] + assert res[0]["additionalGrants"][0].get("key") == "Offline Token", res[0] + + # Test get consents fail + with pytest.raises(KeycloakGetError) as err: + await admin.a_user_consents(user_id="non-existent-id") + assert err.match(USER_NOT_FOUND_REGEX) + + # Test revoke fails + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_revoke_consent(user_id="non-existent-id", client_id=oid.client_id) + assert err.match(USER_NOT_FOUND_REGEX) + + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_revoke_consent(user_id=user_id, client_id="non-existent-client") + assert err.match(CLIENT_NOT_FOUND_REGEX) + + # Test revoke offline access + res = await admin.a_revoke_consent(user_id=user_id, client_id=oid.client_id) + assert res == {}, res + + res = await admin.a_user_consents(user_id=user_id) + assert len(res) == 0, res + + # Test re-revoke fails + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_revoke_consent(user_id=user_id, client_id=oid.client_id) + assert err.match(CONSENT_NOT_FOUND_REGEX) + + +def test_counter_part() -> None: + """Test that each function has its async counter part.""" + admin_methods = [func for func in dir(KeycloakAdmin) if callable(getattr(KeycloakAdmin, func))] + sync_methods = [ + method + for method in admin_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in admin_methods if iscoroutinefunction(getattr(KeycloakAdmin, method)) + ] + + for method in sync_methods: + async_method = f"a_{method}" + assert async_method in admin_methods + sync_sign = signature(getattr(KeycloakAdmin, method)) + async_sign = signature(getattr(KeycloakAdmin, async_method)) + assert sync_sign.parameters == async_sign.parameters, f"Parameters mismatch for {method}" + + for async_method in async_methods: + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_keycloak_openid.py b/tests/test_keycloak_openid.py new file mode 100644 index 00000000..83386a3f --- /dev/null +++ b/tests/test_keycloak_openid.py @@ -0,0 +1,1184 @@ +"""Test module for KeycloakOpenID.""" + +from inspect import iscoroutinefunction, signature +from unittest import mock + +import jwcrypto.jwk +import jwcrypto.jws +import pytest + +from keycloak import KeycloakAdmin, KeycloakOpenID +from keycloak.authorization import Authorization +from keycloak.authorization.permission import Permission +from keycloak.authorization.policy import Policy +from keycloak.authorization.role import Role +from keycloak.connection import ConnectionManager +from keycloak.exceptions import ( + KeycloakAuthenticationError, + KeycloakAuthorizationConfigError, + KeycloakDeprecationError, + KeycloakInvalidTokenError, + KeycloakPostError, + KeycloakRPTNotFound, +) +from tests.conftest import KeycloakTestEnv + + +def test_keycloak_openid_init(env: KeycloakTestEnv) -> None: + """ + Test KeycloakOpenId's init method. + + :param env: Environment fixture + :type env: KeycloakTestEnv + """ + oid = KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name="master", + client_id="admin-cli", + pool_maxsize=5, + ) + + assert oid.client_id == "admin-cli" + assert oid.client_secret_key is None + assert oid.realm_name == "master" + assert isinstance(oid.connection, ConnectionManager) + assert isinstance(oid.authorization, Authorization) + assert oid.connection.pool_maxsize == 5 + + oid_default = KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name="master", + client_id="admin-cli", + ) + assert oid_default.connection.pool_maxsize is None + + +def test_well_known(oid: KeycloakOpenID) -> None: + """ + Test the well_known method. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + res = oid.well_known() + assert res is not None + assert res != {} + for key in [ + "acr_values_supported", + "authorization_encryption_alg_values_supported", + "authorization_encryption_enc_values_supported", + "authorization_endpoint", + "authorization_signing_alg_values_supported", + "backchannel_authentication_endpoint", + "backchannel_authentication_request_signing_alg_values_supported", + "backchannel_logout_session_supported", + "backchannel_logout_supported", + "backchannel_token_delivery_modes_supported", + "check_session_iframe", + "claim_types_supported", + "claims_parameter_supported", + "claims_supported", + "code_challenge_methods_supported", + "device_authorization_endpoint", + "end_session_endpoint", + "frontchannel_logout_session_supported", + "frontchannel_logout_supported", + "grant_types_supported", + "id_token_encryption_alg_values_supported", + "id_token_encryption_enc_values_supported", + "id_token_signing_alg_values_supported", + "introspection_endpoint", + "introspection_endpoint_auth_methods_supported", + "introspection_endpoint_auth_signing_alg_values_supported", + "issuer", + "jwks_uri", + "mtls_endpoint_aliases", + "pushed_authorization_request_endpoint", + "registration_endpoint", + "request_object_encryption_alg_values_supported", + "request_object_encryption_enc_values_supported", + "request_object_signing_alg_values_supported", + "request_parameter_supported", + "request_uri_parameter_supported", + "require_pushed_authorization_requests", + "require_request_uri_registration", + "response_modes_supported", + "response_types_supported", + "revocation_endpoint", + "revocation_endpoint_auth_methods_supported", + "revocation_endpoint_auth_signing_alg_values_supported", + "scopes_supported", + "subject_types_supported", + "tls_client_certificate_bound_access_tokens", + "token_endpoint", + "token_endpoint_auth_methods_supported", + "token_endpoint_auth_signing_alg_values_supported", + "userinfo_encryption_alg_values_supported", + "userinfo_encryption_enc_values_supported", + "userinfo_endpoint", + "userinfo_signing_alg_values_supported", + ]: + assert key in res + + +def test_auth_url(env: KeycloakTestEnv, oid: KeycloakOpenID) -> None: + """ + Test the auth_url method. + + :param env: Environment fixture + :type env: KeycloakTestEnv + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + res = oid.auth_url(redirect_uri="http://test.test/*") + assert ( + res == f"http://{env.keycloak_host}:{env.keycloak_port}/realms/{oid.realm_name}" + f"/protocol/openid-connect/auth?client_id={oid.client_id}&response_type=code" + "&redirect_uri=http://test.test/*&scope=email&state=&nonce=" + ) + + +def test_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test the token method. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = oid.token(username=username, password=password) + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + # Test with dummy totp + token = oid.token(username=username, password=password, totp=123456) + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + # Test with extra param + token = oid.token(username=username, password=password, extra_param="foo") + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + +def test_exchange_token( + oid_with_credentials: tuple[KeycloakOpenID, str, str], + admin: KeycloakAdmin, +) -> None: + """ + Test the exchange token method. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Verify existing user + oid, username, password = oid_with_credentials + + # Allow impersonation + 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( + 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) + assert oid.userinfo(token=token["access_token"]) == { + "email": f"{username}@test.test", + "email_verified": True, + "family_name": "last", + "given_name": "first", + "name": "first last", + "preferred_username": username, + "sub": mock.ANY, + } + + # Exchange token with the new user + new_token = oid.exchange_token( + token=token["access_token"], + audience=oid.client_id, + subject=username, + ) + assert oid.userinfo(token=new_token["access_token"]) == { + "email": f"{username}@test.test", + "email_verified": True, + "family_name": "last", + "given_name": "first", + "name": "first last", + "preferred_username": username, + "sub": mock.ANY, + } + assert token != new_token + + +def test_logout(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test logout. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + + token = oid.token(username=username, password=password) + assert oid.userinfo(token=token["access_token"]) != {} + assert oid.logout(refresh_token=token["refresh_token"]) == {} + + with pytest.raises(KeycloakAuthenticationError): + oid.userinfo(token=token["access_token"]) + + +def test_certs(oid: KeycloakOpenID) -> None: + """ + Test certificates. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + assert len(oid.certs()["keys"]) == 2 + + +def test_public_key(oid: KeycloakOpenID) -> None: + """ + Test public key. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + assert oid.public_key() is not None + + +def test_entitlement( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], + admin: KeycloakAdmin, +) -> None: + """ + Test entitlement. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + oid, username, password = oid_with_credentials_authz + token = oid.token(username=username, password=password) + client_id = admin.get_client_id(oid.client_id) + assert client_id is not None + assert admin.connection.realm_name == oid.realm_name + resource_server_id = admin.get_client_authz_resources(client_id=client_id)[0]["_id"] + + with pytest.raises(KeycloakDeprecationError): + oid.entitlement(token=token["access_token"], resource_server_id=resource_server_id) + + +def test_introspect(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test introspect. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = oid.token(username=username, password=password) + + assert oid.introspect(token=token["access_token"])["active"] + assert oid.introspect( + token=token["access_token"], + rpt="some", + token_type_hint="requesting_party_token", # noqa: S106 + ) == {"active": False} + + with pytest.raises(KeycloakRPTNotFound): + oid.introspect(token=token["access_token"], token_type_hint="requesting_party_token") # noqa: S106 + + +def test_decode_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test decode token. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = oid.token(username=username, password=password) + decoded_access_token = oid.decode_token(token=token["access_token"]) + decoded_access_token_2 = oid.decode_token(token=token["access_token"], validate=False) + decoded_refresh_token = oid.decode_token(token=token["refresh_token"], validate=False) + + assert decoded_access_token == decoded_access_token_2 + assert decoded_access_token["preferred_username"] == username, decoded_access_token + assert decoded_refresh_token["typ"] == "Refresh", decoded_refresh_token + + +def test_decode_token_invalid_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test decode token with an invalid token. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = oid.token(username=username, password=password) + access_token = token["access_token"] + decoded_access_token = oid.decode_token(token=access_token) + + key = oid.public_key() + key = "-----BEGIN PUBLIC KEY-----\n" + key + "\n-----END PUBLIC KEY-----" + key = jwcrypto.jwk.JWK.from_pem(key.encode("utf-8")) + + invalid_access_token = access_token + "a" + with pytest.raises(jwcrypto.jws.InvalidJWSSignature): + decoded_invalid_access_token = oid.decode_token(token=invalid_access_token, validate=True) + + with pytest.raises(jwcrypto.jws.InvalidJWSSignature): + decoded_invalid_access_token = oid.decode_token( + token=invalid_access_token, + validate=True, + key=key, + ) + + decoded_invalid_access_token = oid.decode_token(token=invalid_access_token, validate=False) + assert decoded_access_token == decoded_invalid_access_token + + decoded_invalid_access_token = oid.decode_token( + token=invalid_access_token, + validate=False, + key=key, + ) + assert decoded_access_token == decoded_invalid_access_token + + +def test_load_authorization_config( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test load authorization config. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, _, _ = oid_with_credentials_authz + + oid.load_authorization_config(path="tests/data/authz_settings.json") + assert "test-authz-rb-policy" in oid.authorization.policies + assert isinstance(oid.authorization.policies["test-authz-rb-policy"], Policy) + assert len(oid.authorization.policies["test-authz-rb-policy"].roles) == 1 + assert isinstance(oid.authorization.policies["test-authz-rb-policy"].roles[0], Role) + assert len(oid.authorization.policies["test-authz-rb-policy"].permissions) == 2 + assert isinstance( + oid.authorization.policies["test-authz-rb-policy"].permissions[0], + Permission, + ) + + +def test_get_policies(oid_with_credentials_authz: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test get policies. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = oid.token(username=username, password=password) + + with pytest.raises(KeycloakAuthorizationConfigError): + oid.get_policies(token=token["access_token"]) + + oid.load_authorization_config(path="tests/data/authz_settings.json") + assert oid.get_policies(token=token["access_token"]) is None + + orig_client_id = oid.client_id + oid.client_id = "account" + assert oid.get_policies(token=token["access_token"], method_token_info="decode") == [] # noqa: S106 + policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") + policy.add_role(role="account/view-profile") + oid.authorization.policies["test"] = policy + assert [ + str(x) + for x in (oid.get_policies(token=token["access_token"], method_token_info="decode") or []) # noqa: S106 + ] == ["Policy: test (role)"] + assert [ + repr(x) + for x in (oid.get_policies(token=token["access_token"], method_token_info="decode") or []) # noqa: S106 + ] == [""] + oid.client_id = orig_client_id + + oid.logout(refresh_token=token["refresh_token"]) + with pytest.raises(KeycloakInvalidTokenError): + oid.get_policies(token=token["access_token"]) + + +def test_get_permissions(oid_with_credentials_authz: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test get policies. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = oid.token(username=username, password=password) + + with pytest.raises(KeycloakAuthorizationConfigError): + oid.get_permissions(token=token["access_token"]) + + oid.load_authorization_config(path="tests/data/authz_settings.json") + assert oid.get_permissions(token=token["access_token"]) is None + + orig_client_id = oid.client_id + oid.client_id = "account" + assert oid.get_permissions(token=token["access_token"], method_token_info="decode") == [] # noqa: S106 + policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") + policy.add_role(role="account/view-profile") + policy.add_permission( + permission=Permission( + name="test-perm", + type="resource", + logic="POSITIVE", + decision_strategy="UNANIMOUS", + ), + ) + oid.authorization.policies["test"] = policy + assert [ + str(x) + for x in ( + oid.get_permissions(token=token["access_token"], method_token_info="decode") or [] # noqa: S106 + ) + ] == ["Permission: test-perm (resource)"] + assert [ + repr(x) + for x in ( + oid.get_permissions(token=token["access_token"], method_token_info="decode") or [] # noqa: S106 + ) + ] == [""] + oid.client_id = orig_client_id + + oid.logout(refresh_token=token["refresh_token"]) + with pytest.raises(KeycloakInvalidTokenError): + oid.get_permissions(token=token["access_token"]) + + +def test_uma_permissions(oid_with_credentials_authz: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test UMA permissions. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = oid.token(username=username, password=password) + + assert len(oid.uma_permissions(token=token["access_token"])) == 1 + assert oid.uma_permissions(token=token["access_token"])[0]["rsname"] == "Default Resource" + + +def test_has_uma_access( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], + admin: KeycloakAdmin, +) -> None: + """ + Test has UMA access. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + oid, username, password = oid_with_credentials_authz + token = oid.token(username=username, password=password) + + assert ( + str(oid.has_uma_access(token=token["access_token"], permissions="")) + == "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" + ) + assert ( + str(oid.has_uma_access(token=token["access_token"], permissions="Default Resource")) + == "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" + ) + + with pytest.raises(KeycloakPostError): + oid.has_uma_access(token=token["access_token"], permissions="Does not exist") + + oid.logout(refresh_token=token["refresh_token"]) + assert ( + str(oid.has_uma_access(token=token["access_token"], permissions="")) + == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())" + ) + assert admin.connection.token is not None + assert ( + str( + oid.has_uma_access( + token=admin.connection.token["access_token"], + permissions="Default Resource", + ), + ) + == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=" + "{'Default Resource'})" + ) + + +def test_device(oid_with_credentials_device: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test device authorization flow. + + :param oid_with_credentials_device: Keycloak OpenID client with pre-configured user + credentials and device authorization flow enabled + :type oid_with_credentials_device: Tuple[KeycloakOpenID, str, str] + """ + oid, _, _ = oid_with_credentials_device + res = oid.device() + assert res == { + "device_code": mock.ANY, + "user_code": mock.ANY, + "verification_uri": f"http://localhost:8081/realms/{oid.realm_name}/device", + "verification_uri_complete": f"http://localhost:8081/realms/{oid.realm_name}/" + f"device?user_code={res['user_code']}", + "expires_in": 600, + "interval": 5, + } + + +# async function start + + +@pytest.mark.asyncio +async def test_a_well_known(oid: KeycloakOpenID) -> None: + """ + Test the well_known method. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + res = await oid.a_well_known() + assert res is not None + assert res != {} + for key in [ + "acr_values_supported", + "authorization_encryption_alg_values_supported", + "authorization_encryption_enc_values_supported", + "authorization_endpoint", + "authorization_signing_alg_values_supported", + "backchannel_authentication_endpoint", + "backchannel_authentication_request_signing_alg_values_supported", + "backchannel_logout_session_supported", + "backchannel_logout_supported", + "backchannel_token_delivery_modes_supported", + "check_session_iframe", + "claim_types_supported", + "claims_parameter_supported", + "claims_supported", + "code_challenge_methods_supported", + "device_authorization_endpoint", + "end_session_endpoint", + "frontchannel_logout_session_supported", + "frontchannel_logout_supported", + "grant_types_supported", + "id_token_encryption_alg_values_supported", + "id_token_encryption_enc_values_supported", + "id_token_signing_alg_values_supported", + "introspection_endpoint", + "introspection_endpoint_auth_methods_supported", + "introspection_endpoint_auth_signing_alg_values_supported", + "issuer", + "jwks_uri", + "mtls_endpoint_aliases", + "pushed_authorization_request_endpoint", + "registration_endpoint", + "request_object_encryption_alg_values_supported", + "request_object_encryption_enc_values_supported", + "request_object_signing_alg_values_supported", + "request_parameter_supported", + "request_uri_parameter_supported", + "require_pushed_authorization_requests", + "require_request_uri_registration", + "response_modes_supported", + "response_types_supported", + "revocation_endpoint", + "revocation_endpoint_auth_methods_supported", + "revocation_endpoint_auth_signing_alg_values_supported", + "scopes_supported", + "subject_types_supported", + "tls_client_certificate_bound_access_tokens", + "token_endpoint", + "token_endpoint_auth_methods_supported", + "token_endpoint_auth_signing_alg_values_supported", + "userinfo_encryption_alg_values_supported", + "userinfo_encryption_enc_values_supported", + "userinfo_endpoint", + "userinfo_signing_alg_values_supported", + ]: + assert key in res + + +@pytest.mark.asyncio +async def test_a_auth_url(env: KeycloakTestEnv, oid: KeycloakOpenID) -> None: + """ + Test the auth_url method. + + :param env: Environment fixture + :type env: KeycloakTestEnv + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + res = await oid.a_auth_url(redirect_uri="http://test.test/*") + assert ( + res == f"http://{env.keycloak_host}:{env.keycloak_port}/realms/{oid.realm_name}" + f"/protocol/openid-connect/auth?client_id={oid.client_id}&response_type=code" + "&redirect_uri=http://test.test/*&scope=email&state=&nonce=" + ) + + +@pytest.mark.asyncio +async def test_a_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test the token method. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + # Test with dummy totp + token = await oid.a_token(username=username, password=password, totp=123456) + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + # Test with extra param + token = await oid.a_token(username=username, password=password, extra_param="foo") + assert token == { + "access_token": mock.ANY, + "expires_in": mock.ANY, + "id_token": mock.ANY, + "not-before-policy": 0, + "refresh_expires_in": mock.ANY, + "refresh_token": mock.ANY, + "scope": mock.ANY, + "session_state": mock.ANY, + "token_type": "Bearer", + } + + +@pytest.mark.asyncio +async def test_a_exchange_token( + oid_with_credentials: tuple[KeycloakOpenID, str, str], + admin: KeycloakAdmin, +) -> None: + """ + Test the exchange token method. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Verify existing user + oid, username, password = oid_with_credentials + + # Allow impersonation + await admin.a_change_current_realm(oid.realm_name) + 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( + user_id=user_id, + client_id=client_id, + roles=[ + await admin.a_get_client_role(client_id=client_id, role_name="impersonation"), + ], + ) + + token = await oid.a_token(username=username, password=password) + assert await oid.a_userinfo(token=token["access_token"]) == { + "email": f"{username}@test.test", + "email_verified": True, + "family_name": "last", + "given_name": "first", + "name": "first last", + "preferred_username": username, + "sub": mock.ANY, + } + + # Exchange token with the new user + new_token = oid.exchange_token( + token=token["access_token"], + audience=oid.client_id, + subject=username, + ) + assert await oid.a_userinfo(token=new_token["access_token"]) == { + "email": f"{username}@test.test", + "email_verified": True, + "family_name": "last", + "given_name": "first", + "name": "first last", + "preferred_username": username, + "sub": mock.ANY, + } + assert token != new_token + + +@pytest.mark.asyncio +async def test_a_logout(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test logout. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + + token = await oid.a_token(username=username, password=password) + assert await oid.a_userinfo(token=token["access_token"]) != {} + assert await oid.a_logout(refresh_token=token["refresh_token"]) == {} + + with pytest.raises(KeycloakAuthenticationError): + await oid.a_userinfo(token=token["access_token"]) + + +@pytest.mark.asyncio +async def test_a_certs(oid: KeycloakOpenID) -> None: + """ + Test certificates. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + assert len((await oid.a_certs())["keys"]) == 2 + + +@pytest.mark.asyncio +async def test_a_public_key(oid: KeycloakOpenID) -> None: + """ + Test public key. + + :param oid: Keycloak OpenID client + :type oid: KeycloakOpenID + """ + assert await oid.a_public_key() is not None + + +@pytest.mark.asyncio +async def test_a_entitlement( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], + admin: KeycloakAdmin, +) -> None: + """ + Test entitlement. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + 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): + await oid.a_entitlement(token=token["access_token"], resource_server_id=resource_server_id) + + +@pytest.mark.asyncio +async def test_a_introspect(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test introspect. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + + assert (await oid.a_introspect(token=token["access_token"]))["active"] + assert await oid.a_introspect( + token=token["access_token"], + rpt="some", + token_type_hint="requesting_party_token", # noqa: S106 + ) == {"active": False} + + with pytest.raises(KeycloakRPTNotFound): + await oid.a_introspect( + token=token["access_token"], + token_type_hint="requesting_party_token", # noqa: S106 + ) + + +@pytest.mark.asyncio +async def test_a_decode_token(oid_with_credentials: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test decode token asynchronously. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + decoded_access_token = await oid.a_decode_token(token=token["access_token"]) + decoded_access_token_2 = await oid.a_decode_token(token=token["access_token"], validate=False) + decoded_refresh_token = await oid.a_decode_token(token=token["refresh_token"], validate=False) + + assert decoded_access_token == decoded_access_token_2 + assert decoded_access_token["preferred_username"] == username, decoded_access_token + assert decoded_refresh_token["typ"] == "Refresh", decoded_refresh_token + + +@pytest.mark.asyncio +async def test_a_decode_token_invalid_token( + oid_with_credentials: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test decode token asynchronously an invalid token. + + :param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials + :type oid_with_credentials: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials + token = await oid.a_token(username=username, password=password) + access_token = token["access_token"] + decoded_access_token = await oid.a_decode_token(token=access_token) + + key = await oid.a_public_key() + key = "-----BEGIN PUBLIC KEY-----\n" + key + "\n-----END PUBLIC KEY-----" + key = jwcrypto.jwk.JWK.from_pem(key.encode("utf-8")) + + invalid_access_token = access_token + "a" + with pytest.raises(jwcrypto.jws.InvalidJWSSignature): + decoded_invalid_access_token = await oid.a_decode_token( + token=invalid_access_token, + validate=True, + ) + + with pytest.raises(jwcrypto.jws.InvalidJWSSignature): + decoded_invalid_access_token = await oid.a_decode_token( + token=invalid_access_token, + validate=True, + key=key, + ) + + decoded_invalid_access_token = await oid.a_decode_token( + token=invalid_access_token, + validate=False, + ) + assert decoded_access_token == decoded_invalid_access_token + + decoded_invalid_access_token = await oid.a_decode_token( + token=invalid_access_token, + validate=False, + key=key, + ) + assert decoded_access_token == decoded_invalid_access_token + + +@pytest.mark.asyncio +async def test_a_load_authorization_config( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test load authorization config. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, _, _ = oid_with_credentials_authz + + await oid.a_load_authorization_config(path="tests/data/authz_settings.json") + assert "test-authz-rb-policy" in oid.authorization.policies + assert isinstance(oid.authorization.policies["test-authz-rb-policy"], Policy) + assert len(oid.authorization.policies["test-authz-rb-policy"].roles) == 1 + assert isinstance(oid.authorization.policies["test-authz-rb-policy"].roles[0], Role) + assert len(oid.authorization.policies["test-authz-rb-policy"].permissions) == 2 + assert isinstance( + oid.authorization.policies["test-authz-rb-policy"].permissions[0], + Permission, + ) + + +@pytest.mark.asyncio +async def test_a_has_uma_access( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], + admin: KeycloakAdmin, +) -> None: + """ + Test has UMA access. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + assert ( + str(await oid.a_has_uma_access(token=token["access_token"], permissions="")) + == "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" + ) + assert ( + str( + await oid.a_has_uma_access( + token=token["access_token"], + permissions="Default Resource", + ), + ) + == "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" + ) + + with pytest.raises(KeycloakPostError): + await oid.a_has_uma_access(token=token["access_token"], permissions="Does not exist") + + await oid.a_logout(refresh_token=token["refresh_token"]) + assert ( + str(await oid.a_has_uma_access(token=token["access_token"], permissions="")) + == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())" + ) + assert admin.connection.token is not None + assert ( + str( + await oid.a_has_uma_access( + token=admin.connection.token["access_token"], + permissions="Default Resource", + ), + ) + == "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=" + "{'Default Resource'})" + ) + + +@pytest.mark.asyncio +async def test_a_get_policies(oid_with_credentials_authz: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test get policies. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + with pytest.raises(KeycloakAuthorizationConfigError): + await oid.a_get_policies(token=token["access_token"]) + + await oid.a_load_authorization_config(path="tests/data/authz_settings.json") + assert await oid.a_get_policies(token=token["access_token"]) is None + + orig_client_id = oid.client_id + oid.client_id = "account" + assert await oid.a_get_policies(token=token["access_token"], method_token_info="decode") == [] # noqa: S106 + policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") + policy.add_role(role="account/view-profile") + oid.authorization.policies["test"] = policy + assert [ + str(x) + for x in ( + await oid.a_get_policies(token=token["access_token"], method_token_info="decode") or [] # noqa: S106 + ) + ] == ["Policy: test (role)"] + assert [ + repr(x) + for x in ( + await oid.a_get_policies(token=token["access_token"], method_token_info="decode") or [] # noqa: S106 + ) + ] == [""] + oid.client_id = orig_client_id + + await oid.a_logout(refresh_token=token["refresh_token"]) + with pytest.raises(KeycloakInvalidTokenError): + await oid.a_get_policies(token=token["access_token"]) + + +@pytest.mark.asyncio +async def test_a_get_permissions( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test get policies. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + with pytest.raises(KeycloakAuthorizationConfigError): + await oid.a_get_permissions(token=token["access_token"]) + + await oid.a_load_authorization_config(path="tests/data/authz_settings.json") + assert await oid.a_get_permissions(token=token["access_token"]) is None + + orig_client_id = oid.client_id + oid.client_id = "account" + assert ( + await oid.a_get_permissions(token=token["access_token"], method_token_info="decode") == [] # noqa: S106 + ) + policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") + policy.add_role(role="account/view-profile") + policy.add_permission( + permission=Permission( + name="test-perm", + type="resource", + logic="POSITIVE", + decision_strategy="UNANIMOUS", + ), + ) + oid.authorization.policies["test"] = policy + assert [ + str(x) + for x in ( + await oid.a_get_permissions( + token=token["access_token"], + method_token_info="decode", # noqa: S106 + ) + or [] + ) + ] == ["Permission: test-perm (resource)"] + assert [ + repr(x) + for x in ( + await oid.a_get_permissions( + token=token["access_token"], + method_token_info="decode", # noqa: S106 + ) + or [] + ) + ] == [""] + oid.client_id = orig_client_id + + await oid.a_logout(refresh_token=token["refresh_token"]) + with pytest.raises(KeycloakInvalidTokenError): + await oid.a_get_permissions(token=token["access_token"]) + + +@pytest.mark.asyncio +async def test_a_uma_permissions( + oid_with_credentials_authz: tuple[KeycloakOpenID, str, str], +) -> None: + """ + Test UMA permissions. + + :param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization + server with client credentials + :type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] + """ + oid, username, password = oid_with_credentials_authz + token = await oid.a_token(username=username, password=password) + + assert len(await oid.a_uma_permissions(token=token["access_token"])) == 1 + assert (await oid.a_uma_permissions(token=token["access_token"]))[0][ + "rsname" + ] == "Default Resource" + + +@pytest.mark.asyncio +async def test_a_device(oid_with_credentials_device: tuple[KeycloakOpenID, str, str]) -> None: + """ + Test device authorization flow. + + :param oid_with_credentials_device: Keycloak OpenID client with pre-configured user + credentials and device authorization flow enabled + :type oid_with_credentials_device: Tuple[KeycloakOpenID, str, str] + """ + oid, _, _ = oid_with_credentials_device + res = await oid.a_device() + assert res == { + "device_code": mock.ANY, + "user_code": mock.ANY, + "verification_uri": f"http://localhost:8081/realms/{oid.realm_name}/device", + "verification_uri_complete": f"http://localhost:8081/realms/{oid.realm_name}/" + f"device?user_code={res['user_code']}", + "expires_in": 600, + "interval": 5, + } + + +def test_counter_part() -> None: + """Test that each function has its async counter part.""" + openid_methods = [ + func for func in dir(KeycloakOpenID) if callable(getattr(KeycloakOpenID, func)) + ] + sync_methods = [ + method + for method in openid_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in openid_methods if iscoroutinefunction(getattr(KeycloakOpenID, method)) + ] + + for method in sync_methods: + async_method = f"a_{method}" + assert (async_method in openid_methods) is True + sync_sign = signature(getattr(KeycloakOpenID, method)) + async_sign = signature(getattr(KeycloakOpenID, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_keycloak_uma.py b/tests/test_keycloak_uma.py new file mode 100644 index 00000000..400587f7 --- /dev/null +++ b/tests/test_keycloak_uma.py @@ -0,0 +1,675 @@ +"""Test module for KeycloakUMA.""" + +import re +from inspect import iscoroutinefunction, signature + +import pytest + +from keycloak import KeycloakAdmin, KeycloakOpenIDConnection, KeycloakUMA +from keycloak.exceptions import ( + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, +) +from keycloak.uma_permissions import UMAPermission + + +def test_keycloak_uma_init(oid_connection_with_authz: KeycloakOpenIDConnection) -> None: + """ + Test KeycloakUMA's init method. + + :param oid_connection_with_authz: Keycloak OpenID connection manager with preconfigured authz + :type oid_connection_with_authz: KeycloakOpenIDConnection + """ + connection = oid_connection_with_authz + uma = KeycloakUMA(connection=connection) + + assert isinstance(uma.connection, KeycloakOpenIDConnection) + # should initially be empty + assert uma._well_known is None + assert uma.uma_well_known + # should be cached after first reference + assert uma._well_known is not None + + +def test_uma_well_known(uma: KeycloakUMA) -> None: + """ + Test the well_known method. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + res = uma.uma_well_known + assert res is not None + assert res != {} + for key in ["resource_registration_endpoint"]: + assert key in res + + +def test_uma_resource_sets(uma: KeycloakUMA) -> None: + """ + Test resource sets. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + # Check that only the default resource is present + resource_sets = uma.resource_set_list() + resource_set_list = list(resource_sets) + assert len(resource_set_list) == 1, resource_set_list + assert resource_set_list[0]["name"] == "Default Resource", resource_set_list[0]["name"] + + # Test query for resource sets + resource_set_list_ids = uma.resource_set_list_ids() + assert len(resource_set_list_ids) == 1 + + resource_set_list_ids2 = uma.resource_set_list_ids(name="Default") + assert resource_set_list_ids2 == resource_set_list_ids + + resource_set_list_ids2 = uma.resource_set_list_ids(name="Default Resource") + assert resource_set_list_ids2 == resource_set_list_ids + + resource_set_list_ids = uma.resource_set_list_ids(name="Default", exact_name=True) + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(first=1) + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(scope="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(owner="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(resource_type="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(name="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(uri="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = uma.resource_set_list_ids(maximum=0) + assert len(resource_set_list_ids) == 0 + + # Test create resource set + resource_to_create = { + "name": "mytest", + "scopes": ["test:read", "test:write"], + "type": "urn:test", + "uris": ["/some_resources/*"], + } + created_resource = uma.resource_set_create(resource_to_create) + assert created_resource + assert created_resource["_id"], created_resource + assert set(resource_to_create).issubset(set(created_resource)), created_resource + + # Test getting resource with wildcard + # Without matchingUri query option + resource_set_list_ids = uma.resource_set_list_ids(uri="/some_resources/resource") + assert len(resource_set_list_ids) == 0 + # With matchingUri query option + resource_set_list_ids = uma.resource_set_list_ids( + uri="/some_resources/resource", + matchingUri=True, + ) + assert len(resource_set_list_ids) == 1 + + # Test create the same resource set + with pytest.raises(KeycloakPostError) as err: + uma.resource_set_create(resource_to_create) + assert err.match( + re.escape( + '409: b\'{"error":"invalid_request","error_description":' + '"Resource with name [mytest] already exists."}\'', + ), + ) + + # Test get resource set + latest_resource = uma.resource_set_read(created_resource["_id"]) + assert latest_resource["name"] == created_resource["name"] + + # Test update resource set + latest_resource["name"] = "New Resource Name" + res = uma.resource_set_update(created_resource["_id"], latest_resource) + assert res == {}, res + updated_resource = uma.resource_set_read(created_resource["_id"]) + assert updated_resource["name"] == "New Resource Name" + + # Test update resource set fail + with pytest.raises(KeycloakPutError) as err: + uma.resource_set_update(resource_id=created_resource["_id"], payload={"wrong": "payload"}) + assert err.match("Unrecognized field") + + # Test delete resource set + res = uma.resource_set_delete(resource_id=created_resource["_id"]) + assert res == {}, res + with pytest.raises(KeycloakGetError) as err: + uma.resource_set_read(created_resource["_id"]) + err.match("404: b''") + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + uma.resource_set_delete(resource_id=created_resource["_id"]) + assert err.match("404: b''") + + +def test_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None: + """ + Test policies. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Create some required test data + resource_to_create = { + "name": "mytest", + "scopes": ["test:read", "test:write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + created_resource = uma.resource_set_create(resource_to_create) + group_id = admin.create_group({"name": "UMAPolicyGroup"}) + role_id = admin.create_realm_role(payload={"name": "roleUMAPolicy"}) + other_client_id = admin.create_client({"name": "UMAOtherClient"}) + client = admin.get_client(other_client_id) + + resource_id = created_resource["_id"] + + # Create a role policy + policy_to_create = { + "name": "TestPolicyRole", + "description": "Test resource policy description", + "scopes": ["test:read", "test:write"], + "roles": ["roleUMAPolicy"], + } + policy = uma.policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + # Create a client policy + policy_to_create = { + "name": "TestPolicyClient", + "description": "Test resource policy description", + "scopes": ["test:read"], + "clients": [client["clientId"]], + } + policy = uma.policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + policy_to_create = { + "name": "TestPolicyGroup", + "description": "Test resource policy description", + "scopes": ["test:read"], + "groups": ["/UMAPolicyGroup"], + } + policy = uma.policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + policies = uma.policy_query() + assert len(policies) == 3 + + policies = uma.policy_query(name="TestPolicyGroup") + assert len(policies) == 1 + + policy_id = policy["id"] + policy_delete_res = uma.policy_delete(policy_id) + assert policy_delete_res == {} + with pytest.raises(KeycloakDeleteError) as err: + uma.policy_delete(policy_id) + assert err.match( + '404: b\'{"error":"invalid_request","error_description":' + '"Policy with .* does not exist"}\'', + ) + + policies = uma.policy_query() + assert len(policies) == 2 + + policy = policies[0] + policy_update_res = uma.policy_update(policy_id=policy["id"], payload=policy) + assert policy_update_res == b"" + + policies = uma.policy_query() + assert len(policies) == 2 + + policies = uma.policy_query(name="Invalid") + assert len(policies) == 0 + policies = uma.policy_query(scope="Invalid") + assert len(policies) == 0 + policies = uma.policy_query(resource="Invalid") + assert len(policies) == 0 + policies = uma.policy_query(first=3) + assert len(policies) == 0 + policies = uma.policy_query(maximum=0) + assert len(policies) == 0 + + policies = uma.policy_query(name=policy["name"]) + assert len(policies) == 1 + policies = uma.policy_query(scope=policy["scopes"][0]) + assert len(policies) == 2 + policies = uma.policy_query(resource=resource_id) + assert len(policies) == 2 + + uma.resource_set_delete(resource_id) + admin.delete_client(other_client_id) + admin.delete_realm_role(role_id) + assert group_id is not None + admin.delete_group(group_id) + + +def test_uma_access(uma: KeycloakUMA) -> None: + """ + Test permission access checks. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + resource_to_create = { + "name": "mytest", + "scopes": ["read", "write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + resource = uma.resource_set_create(resource_to_create) + + policy_to_create = { + "name": "TestPolicy", + "description": "Test resource policy description", + "scopes": [resource_to_create["scopes"][0]], + "clients": [uma.connection.client_id], + } + uma.policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) + + token = uma.connection.token + permissions = [] + assert token is not None + assert uma.permissions_check(token["access_token"], permissions) + + permissions.append(UMAPermission(resource=resource_to_create["name"])) + assert uma.permissions_check(token["access_token"], permissions) + + permissions.append(UMAPermission(resource="not valid")) + assert not uma.permissions_check(token["access_token"], permissions) + uma.resource_set_delete(resource["_id"]) + + +def test_uma_permission_ticket(uma: KeycloakUMA) -> None: + """ + Test permission ticket generation. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + resource_to_create = { + "name": "mytest", + "scopes": ["read", "write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + resource = uma.resource_set_create(resource_to_create) + + policy_to_create = { + "name": "TestPolicy", + "description": "Test resource policy description", + "scopes": [resource_to_create["scopes"][0]], + "clients": [uma.connection.client_id], + } + uma.policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) + permissions = ( + UMAPermission(resource=resource_to_create["name"], scope=resource_to_create["scopes"][0]), + ) + response = uma.permission_ticket_create(permissions) + + rpt = uma.connection.keycloak_openid.token( + grant_type="urn:ietf:params:oauth:grant-type:uma-ticket", + ticket=response["ticket"], + ) + assert rpt + assert "access_token" in rpt + + permissions = (UMAPermission(resource="invalid"),) + with pytest.raises(KeycloakPostError): + uma.permission_ticket_create(permissions) + + uma.resource_set_delete(resource["_id"]) + + +# async function start + + +@pytest.mark.asyncio +async def test_a_uma_well_known(uma: KeycloakUMA) -> None: + """ + Test the well_known method. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + res = uma.uma_well_known + assert res is not None + assert res != {} + for key in ["resource_registration_endpoint"]: + assert key in res + + +@pytest.mark.asyncio +async def test_a_uma_resource_sets(uma: KeycloakUMA) -> None: + """ + Test resource sets. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + # Check that only the default resource is present + resource_sets = uma.resource_set_list() + resource_set_list = list(resource_sets) + assert len(resource_set_list) == 1, resource_set_list + assert resource_set_list[0]["name"] == "Default Resource", resource_set_list[0]["name"] + + # Test query for resource sets + resource_set_list_ids = await uma.a_resource_set_list_ids() + assert len(resource_set_list_ids) == 1 + + resource_set_list_ids2 = await uma.a_resource_set_list_ids(name="Default") + assert resource_set_list_ids2 == resource_set_list_ids + + resource_set_list_ids2 = await uma.a_resource_set_list_ids(name="Default Resource") + assert resource_set_list_ids2 == resource_set_list_ids + + resource_set_list_ids = await uma.a_resource_set_list_ids(name="Default", exact_name=True) + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(first=1) + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(scope="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(owner="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(resource_type="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(name="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(uri="Invalid") + assert len(resource_set_list_ids) == 0 + + resource_set_list_ids = await uma.a_resource_set_list_ids(maximum=0) + assert len(resource_set_list_ids) == 0 + + # Test create resource set + resource_to_create = { + "name": "mytest", + "scopes": ["test:read", "test:write"], + "type": "urn:test", + "uris": ["/some_resources/*"], + } + created_resource = await uma.a_resource_set_create(resource_to_create) + assert created_resource + assert created_resource["_id"], created_resource + assert set(resource_to_create).issubset(set(created_resource)), created_resource + + # Test getting resource with wildcard + # Without matchingUri query option + resource_set_list_ids = await uma.a_resource_set_list_ids(uri="/some_resources/resource") + assert len(resource_set_list_ids) == 0 + # With matchingUri query option + resource_set_list_ids = await uma.a_resource_set_list_ids( + uri="/some_resources/resource", + matchingUri=True, + ) + assert len(resource_set_list_ids) == 1 + + # Test create the same resource set + with pytest.raises(KeycloakPostError) as err: + await uma.a_resource_set_create(resource_to_create) + assert err.match( + re.escape( + '409: b\'{"error":"invalid_request","error_description":' + '"Resource with name [mytest] already exists."}\'', + ), + ) + + # Test get resource set + latest_resource = await uma.a_resource_set_read(created_resource["_id"]) + assert latest_resource["name"] == created_resource["name"] + + # Test update resource set + latest_resource["name"] = "New Resource Name" + res = await uma.a_resource_set_update(created_resource["_id"], latest_resource) + assert res == {}, res + updated_resource = await uma.a_resource_set_read(created_resource["_id"]) + assert updated_resource["name"] == "New Resource Name" + + # Test update resource set fail + with pytest.raises(KeycloakPutError) as err: + uma.resource_set_update(resource_id=created_resource["_id"], payload={"wrong": "payload"}) + assert err.match("Unrecognized field") + + # Test delete resource set + res = await uma.a_resource_set_delete(resource_id=created_resource["_id"]) + assert res == {}, res + with pytest.raises(KeycloakGetError) as err: + await uma.a_resource_set_read(created_resource["_id"]) + err.match("404: b''") + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + await uma.a_resource_set_delete(resource_id=created_resource["_id"]) + assert err.match("404: b''") + + +@pytest.mark.asyncio +async def test_a_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin) -> None: + """ + Test policies. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + :param admin: Keycloak Admin client + :type admin: KeycloakAdmin + """ + # Create some required test data + resource_to_create = { + "name": "mytest", + "scopes": ["test:read", "test:write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + created_resource = await uma.a_resource_set_create(resource_to_create) + group_id = admin.create_group({"name": "UMAPolicyGroup"}) + role_id = admin.create_realm_role(payload={"name": "roleUMAPolicy"}) + other_client_id = admin.create_client({"name": "UMAOtherClient"}) + client = admin.get_client(other_client_id) + + resource_id = created_resource["_id"] + + # Create a role policy + policy_to_create = { + "name": "TestPolicyRole", + "description": "Test resource policy description", + "scopes": ["test:read", "test:write"], + "roles": ["roleUMAPolicy"], + } + policy = await uma.a_policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + # Create a client policy + policy_to_create = { + "name": "TestPolicyClient", + "description": "Test resource policy description", + "scopes": ["test:read"], + "clients": [client["clientId"]], + } + policy = await uma.a_policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + policy_to_create = { + "name": "TestPolicyGroup", + "description": "Test resource policy description", + "scopes": ["test:read"], + "groups": ["/UMAPolicyGroup"], + } + policy = await uma.a_policy_resource_create(resource_id=resource_id, payload=policy_to_create) + assert policy + + policies = await uma.a_policy_query() + assert len(policies) == 3 + + policies = await uma.a_policy_query(name="TestPolicyGroup") + assert len(policies) == 1 + + policy_id = policy["id"] + policy_delete_res = await uma.a_policy_delete(policy_id) + assert policy_delete_res == {} + with pytest.raises(KeycloakDeleteError) as err: + await uma.a_policy_delete(policy_id) + assert err.match( + '404: b\'{"error":"invalid_request","error_description":' + '"Policy with .* does not exist"}\'', + ) + + policies = await uma.a_policy_query() + assert len(policies) == 2 + + policy = policies[0] + 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() + assert len(policies) == 2 + + policies = await uma.a_policy_query(name="Invalid") + assert len(policies) == 0 + policies = await uma.a_policy_query(scope="Invalid") + assert len(policies) == 0 + policies = await uma.a_policy_query(resource="Invalid") + assert len(policies) == 0 + policies = await uma.a_policy_query(first=3) + assert len(policies) == 0 + policies = await uma.a_policy_query(maximum=0) + assert len(policies) == 0 + + policies = await uma.a_policy_query(name=policy["name"]) + assert len(policies) == 1 + policies = await uma.a_policy_query(scope=policy["scopes"][0]) + assert len(policies) == 2 + policies = await uma.a_policy_query(resource=resource_id) + assert len(policies) == 2 + + await uma.a_resource_set_delete(resource_id) + await admin.a_delete_client(other_client_id) + await admin.a_delete_realm_role(role_id) + assert group_id is not None + await admin.a_delete_group(group_id) + + +@pytest.mark.asyncio +async def test_a_uma_access(uma: KeycloakUMA) -> None: + """ + Test permission access checks. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + resource_to_create = { + "name": "mytest", + "scopes": ["read", "write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + resource = await uma.a_resource_set_create(resource_to_create) + + policy_to_create = { + "name": "TestPolicy", + "description": "Test resource policy description", + "scopes": [resource_to_create["scopes"][0]], + "clients": [uma.connection.client_id], + } + await uma.a_policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) + + token = uma.connection.token + permissions = [] + assert token is not None + assert await uma.a_permissions_check(token["access_token"], permissions) + + permissions.append(UMAPermission(resource=resource_to_create["name"])) + assert await uma.a_permissions_check(token["access_token"], permissions) + + permissions.append(UMAPermission(resource="not valid")) + assert not await uma.a_permissions_check(token["access_token"], permissions) + uma.resource_set_delete(resource["_id"]) + + +@pytest.mark.asyncio +async def test_a_uma_permission_ticket(uma: KeycloakUMA) -> None: + """ + Test permission ticket generation. + + :param uma: Keycloak UMA client + :type uma: KeycloakUMA + """ + resource_to_create = { + "name": "mytest", + "scopes": ["read", "write"], + "type": "urn:test", + "ownerManagedAccess": True, + } + resource = await uma.a_resource_set_create(resource_to_create) + + policy_to_create = { + "name": "TestPolicy", + "description": "Test resource policy description", + "scopes": [resource_to_create["scopes"][0]], + "clients": [uma.connection.client_id], + } + await uma.a_policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) + permissions = ( + UMAPermission(resource=resource_to_create["name"], scope=resource_to_create["scopes"][0]), + ) + response = await uma.a_permission_ticket_create(permissions) + + rpt = await uma.connection.keycloak_openid.a_token( + grant_type="urn:ietf:params:oauth:grant-type:uma-ticket", + ticket=response["ticket"], + ) + assert rpt + assert "access_token" in rpt + + permissions = (UMAPermission(resource="invalid"),) + with pytest.raises(KeycloakPostError): + uma.permission_ticket_create(permissions) + + await uma.a_resource_set_delete(resource["_id"]) + + +def test_counter_part() -> None: + """Test that each function has its async counter part.""" + uma_methods = [func for func in dir(KeycloakUMA) if callable(getattr(KeycloakUMA, func))] + sync_methods = [ + method + for method in uma_methods + if not method.startswith("a_") and not method.startswith("_") + ] + async_methods = [ + method for method in uma_methods if iscoroutinefunction(getattr(KeycloakUMA, method)) + ] + + for method in sync_methods: + async_method = f"a_{method}" + assert (async_method in uma_methods) is True + sync_sign = signature(getattr(KeycloakUMA, method)) + async_sign = signature(getattr(KeycloakUMA, async_method)) + assert sync_sign.parameters == async_sign.parameters + + for async_method in async_methods: + if async_method[2:].startswith("_"): + continue + + assert async_method[2:] in sync_methods diff --git a/tests/test_license.py b/tests/test_license.py new file mode 100644 index 00000000..07cb883e --- /dev/null +++ b/tests/test_license.py @@ -0,0 +1,16 @@ +"""Tests for license.""" + +import os +import pathlib + + +def test_license_present() -> None: + """Test that the MIT license is present in the header of each module file.""" + for path, _, files in os.walk("src/keycloak"): + for _file in files: + if _file.endswith(".py"): + with pathlib.Path(pathlib.Path(path) / _file).open("r") as fp: + content = fp.read() + assert content.startswith( + "#\n# The MIT License (MIT)\n#\n#", + ) diff --git a/tests/test_pkce_flow.py b/tests/test_pkce_flow.py new file mode 100644 index 00000000..5fea9240 --- /dev/null +++ b/tests/test_pkce_flow.py @@ -0,0 +1,79 @@ +"""Tests for PKCE flow: code verifier and code challenge handling.""" + +import os +import re +import urllib.parse + +import requests +from packaging.version import Version + +from keycloak import KeycloakAdmin, KeycloakOpenID +from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier +from tests.conftest import KeycloakTestEnv + + +def test_pkce_auth_url_and_token(env: KeycloakTestEnv, admin: KeycloakAdmin) -> None: + """Test PKCE flow: auth_url includes code_challenge, token includes code_verifier.""" + if os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"] != "latest" and Version( + os.environ["KEYCLOAK_DOCKER_IMAGE_TAG"], + ) <= Version("22"): + return + + client_representation = { + "clientId": "pkce-test", + "enabled": True, + "publicClient": True, + "standardFlowEnabled": True, + "directAccessGrantsEnabled": False, + "serviceAccountsEnabled": False, + "implicitFlowEnabled": False, + "redirectUris": ["http://test.test/callback"], + "webOrigins": ["*"], + } + admin.create_client(client_representation) + + oid = KeycloakOpenID( + server_url=f"http://{env.keycloak_host}:{env.keycloak_port}", + realm_name="master", + client_id="pkce-test", + ) + code_verifier = generate_code_verifier() + code_challenge, code_challenge_method = generate_code_challenge(code_verifier) + + # Build PKCE auth URL + url = oid.auth_url( + redirect_uri="http://test.test/callback", + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + scope="openid%20email", + ) + assert f"code_challenge={code_challenge}" in url + assert f"code_challenge_method={code_challenge_method}" in url + + session = requests.Session() + resp = session.get(url, allow_redirects=False) + cookies = resp.cookies.get_dict() + assert resp.status_code == 200 + resp_url = re.findall(r"action=\"(.*)\" method", resp.text)[0] + resp = session.post( + resp_url, + data={"username": env.keycloak_admin, "password": env.keycloak_admin_password}, + allow_redirects=False, + cookies=cookies, + ) + assert resp.status_code == 302, resp.text + resp_code = urllib.parse.parse_qs(resp.headers["Location"])["code"][0] + + access_token = oid.token( + grant_type="authorization_code", + code=resp_code, + redirect_uri="http://test.test/callback", + code_verifier=code_verifier, + ) + info = oid.userinfo(access_token["access_token"]) + assert info["preferred_username"] == env.keycloak_admin + + # Cleanup + client_id = admin.get_client_id("pkce-test") + assert client_id is not None + admin.delete_client(client_id) diff --git a/tests/test_uma_permissions.py b/tests/test_uma_permissions.py new file mode 100644 index 00000000..33bb8666 --- /dev/null +++ b/tests/test_uma_permissions.py @@ -0,0 +1,178 @@ +# +# Copyright (C) 2017 Marcos Pereira +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Test uma permissions.""" + +import re + +import pytest + +from keycloak.exceptions import KeycloakPermissionFormatError +from keycloak.uma_permissions import ( + AuthStatus, + Resource, + Scope, + UMAPermission, + build_permission_param, +) + + +def test_uma_permission_obj() -> None: + """Test generic UMA permission.""" + p1 = UMAPermission(permission=Resource("Resource")) + assert p1.resource == "Resource" + assert p1.scope == "" + assert repr(p1) == "Resource" + assert str(p1) == "Resource" + + p2 = UMAPermission(permission=Scope("Scope")) + assert p2.resource == "" + assert p2.scope == "Scope" + assert repr(p2) == "#Scope" + assert str(p2) == "#Scope" + assert {p1, p1} != {p2, p2} + + +def test_resource_with_scope_obj() -> None: + """Test resource with scope.""" + r = Resource("Resource1") + s = Scope("Scope1") + assert r(s) == "Resource1#Scope1" + + +def test_scope_with_resource_obj() -> None: + """Test scope with resource.""" + r = Resource("Resource1") + s = Scope("Scope1") + assert s(r) == "Resource1#Scope1" + + +def test_resource_scope_str() -> None: + """Test resource scope as string.""" + r = Resource("Resource1") + s = "Scope1" + assert r(scope=s) == "Resource1#Scope1" + + +def test_scope_resource_str() -> None: + """Test scope resource as string.""" + r = "Resource1" + s = Scope("Scope1") + assert s(resource=r) == "Resource1#Scope1" + + +def test_build_permission_none() -> None: + """Test build permission param with None.""" + assert build_permission_param(None) == set() + + +def test_build_permission_empty_str() -> None: + """Test build permission param with an empty string.""" + assert build_permission_param("") == set() + + +def test_build_permission_empty_list() -> None: + """Test build permission param with an empty list.""" + assert build_permission_param([]) == set() + + +def test_build_permission_empty_tuple() -> None: + """Test build permission param with an empty tuple.""" + assert build_permission_param(()) == set() + + +def test_build_permission_empty_set() -> None: + """Test build permission param with an empty set.""" + assert build_permission_param(set()) == set() + + +def test_build_permission_empty_dict() -> None: + """Test build permission param with an empty dict.""" + assert build_permission_param({}) == set() + + +def test_build_permission_str() -> None: + """Test build permission param as string.""" + assert build_permission_param("resource1") == {"resource1"} + + +def test_build_permission_list_str() -> None: + """Test build permission param with list of strings.""" + assert build_permission_param(["res1#scope1", "res1#scope2"]) == {"res1#scope1", "res1#scope2"} + + +def test_build_permission_tuple_str() -> None: + """Test build permission param with tuple of strings.""" + assert build_permission_param(("res1#scope1", "res1#scope2")) == {"res1#scope1", "res1#scope2"} + + +def test_build_permission_set_str() -> None: + """Test build permission param with set of strings.""" + assert build_permission_param({"res1#scope1", "res1#scope2"}) == {"res1#scope1", "res1#scope2"} + + +def test_build_permission_tuple_dict_str_str() -> None: + """Test build permission param with dictionary.""" + assert build_permission_param({"res1": "scope1"}) == {"res1#scope1"} + + +def test_build_permission_tuple_dict_str_list_str() -> None: + """Test build permission param with dictionary of list.""" + assert build_permission_param({"res1": ["scope1", "scope2"]}) == {"res1#scope1", "res1#scope2"} + + +def test_build_permission_tuple_dict_str_list_str2() -> None: + """Test build permission param with mutliple-keyed dictionary.""" + assert build_permission_param( + {"res1": ["scope1", "scope2"], "res2": ["scope2", "scope3"]}, + ) == {"res1#scope1", "res1#scope2", "res2#scope2", "res2#scope3"} + + +def test_build_permission_uma() -> None: + """Test build permission param with UMA.""" + assert build_permission_param(Resource("res1")(Scope("scope1"))) == {"res1#scope1"} + + +def test_build_permission_uma_list() -> None: + """Test build permission param with list of UMAs.""" + assert build_permission_param( + [Resource("res1")(Scope("scope1")), Resource("res1")(Scope("scope2"))], + ) == {"res1#scope1", "res1#scope2"} + + +def test_build_permission_misbuilt_dict_str_list_list_str() -> None: + """Test bad build of permission param from dictionary.""" + with pytest.raises(KeycloakPermissionFormatError) as err: + build_permission_param({"res1": [["scope1", "scope2"]]}) + assert err.match(re.escape("misbuilt permission {'res1': [['scope1', 'scope2']]}")) + + +def test_build_permission_misbuilt_dict_non_iterable() -> None: + """Test bad build of permission param from non-iterable.""" + with pytest.raises(KeycloakPermissionFormatError) as err: + build_permission_param({"res1": 5}) + assert err.match(re.escape("misbuilt permission {'res1': 5}")) + + +def test_auth_status_bool() -> None: + """Test bool method of AuthStatus.""" + assert not bool(AuthStatus(is_logged_in=True, is_authorized=False, missing_permissions="")) + assert bool(AuthStatus(is_logged_in=True, is_authorized=True, missing_permissions="")) + + +def test_build_permission_without_scopes() -> None: + """Test build permission param with scopes.""" + assert build_permission_param(permissions={"Resource": None}) == {"Resource"} diff --git a/tests/test_urls_patterns.py b/tests/test_urls_patterns.py new file mode 100644 index 00000000..2155c78c --- /dev/null +++ b/tests/test_urls_patterns.py @@ -0,0 +1,37 @@ +"""Test URL patterns.""" + +import inspect + +from keycloak import urls_patterns + + +def test_correctness_of_patterns() -> None: + """Test that there are no duplicate url patterns.""" + # Test that the patterns are present + urls = [x for x in dir(urls_patterns) if not x.startswith("__")] + assert len(urls) >= 0 + + # Test that all patterns start with URL_ + for url in urls: + assert url.startswith("URL_"), f"The url pattern {url} does not begin with URL_" + + # Test that the patterns have unique names + seen_urls = [] + urls_from_src = [ + x.split("=")[0].strip() + for x in inspect.getsource(urls_patterns).splitlines() + if x.startswith("URL_") + ] + for url in urls_from_src: + assert url not in seen_urls, f"The url pattern {url} is present twice." + seen_urls.append(url) + + # Test that the pattern values are unique + seen_url_values = [] + for url in urls: + url_value = urls_patterns.__dict__[url] + assert url_value not in seen_url_values, f"The url {url} has a duplicate value {url_value}" + assert url_value == url_value.strip(), ( + f"The url {url} with value '{url_value}' has whitespace values" + ) + seen_url_values.append(url_value) diff --git a/tox.env b/tox.env new file mode 100644 index 00000000..5967d51a --- /dev/null +++ b/tox.env @@ -0,0 +1,5 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_HOST={env:KEYCLOAK_HOST:localhost} +KEYCLOAK_PORT=8081 +KEYCLOAK_DOCKER_IMAGE_TAG={env:KEYCLOAK_DOCKER_IMAGE_TAG:latest} diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..234a0f88 --- /dev/null +++ b/tox.ini @@ -0,0 +1,50 @@ +[tox] +isolated_build = true +skipsdist = true +envlist = check, apply-check, docs, tests, build, changelog + +[testenv] +allowlist_externals = poetry, ./test_keycloak_init.sh +commands_pre = + poetry sync + +[testenv:check] +commands = + ruff check src/keycloak tests docs + ruff format --check src/keycloak tests docs + codespell src tests docs + +[testenv:apply-check] +commands = + ruff check --fix src/keycloak tests docs + ruff format src/keycloak tests docs + +[testenv:docs] +commands = + sphinx-build -T -E -W -b html -d _build/doctrees -D language=en ./docs/source _build/html + +[testenv:tests] +setenv = file|tox.env +passenv = CONTAINER_HOST,KEYCLOAK_DOCKER_IMAGE_TAG +commands = + ./test_keycloak_init.sh "pytest -vv --cov=keycloak --cov-report term-missing {posargs}" + +[testenv:build] +commands = + poetry build --format sdist + poetry build --format wheel + +[testenv:changelog] +setenv = file|tox.env +passenv = CONTAINER_HOST +commands = + cz changelog + +[flake8] +max-line-length = 99 +docstring-convention = all +ignore = D203, D213, W503, E231 +docstring_style = sphinx + +[darglint] +enable = DAR104