diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e9fc962 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,119 @@ +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +__pycache__/ +**/__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +*.mo +*.pot +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +instance/ +.webassets-cache +.scrapy +docs/_build/ +.pybuilder/ +target/ +.ipynb_checkpoints +profile_default/ +ipython_config.py +.pdm.toml +__pypackages__/ +celerybeat-schedule +celerybeat.pid +*.sage.py +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.spyderproject +.spyproject +.ropeproject +/site +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +cython_debug/ +.idea +.vscode +.git +.github +.dockerignore +.gitignore +Docker diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..0976939 --- /dev/null +++ b/.envrc @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: the secureCodeBox authors +# +# SPDX-License-Identifier: Apache-2.0 + +layout python3 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e69de29 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d866e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 0000000..7778680 --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,51 @@ +name: Deploy documentation + +on: + push: + branches: + - main + +jobs: + build-docs: + runs-on: ubuntu-latest + env: + PIP_PROGRESS_BAR: "off" + PIP_DISABLE_PIP_VERSION_CHECK: "on" + container: + image: python:3.12-alpine + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install MkDocs + run: pip install mkdocs + + - name: Build + run: mkdocs build + + - name: Create Pages Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: kcwarden/site + + deploy: + runs-on: ubuntu-latest + + # Add a dependency to the build job + needs: build-docs + + # Grant GITHUB_TOKEN the permissions required to make a Pages deployment + permissions: + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source + + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to Github Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/.github/workflows/pytest-ci.yaml b/.github/workflows/pytest-ci.yaml new file mode 100644 index 0000000..b2057f7 --- /dev/null +++ b/.github/workflows/pytest-ci.yaml @@ -0,0 +1,40 @@ +name: Run pytest + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + tests: + runs-on: ubuntu-latest + env: + PIP_PROGRESS_BAR: "off" + PIP_DISABLE_PIP_VERSION_CHECK: "on" + POETRY_NO_INTERACTION: 1 + POETRY_VIRTUALENVS_CREATE: false + strategy: + matrix: + python-version: ['3.11', '3.12'] # Define Python versions here + + container: + image: python:${{ matrix.python-version }}-alpine # Use Python Docker images + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Poetry + run: | + pip install poetry + + - name: Install Dependencies + run: | + poetry install --with dev + + - name: Run Pytest + run: | + poetry run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb0b945 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version +.direnv + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +*.iml + +# VSCode +.vscode + +# Ignore folders generated by Bundler +.bundle/ +vendor/ + +# Legacy / testing content +semgrep-rules +_nocommit +out diff --git a/.pyre_configuration b/.pyre_configuration new file mode 100644 index 0000000..5697e48 --- /dev/null +++ b/.pyre_configuration @@ -0,0 +1,14 @@ +{ + "site_package_search_strategy": "pep561", + "source_directories": [ + "." + ], + "search_path": [ + { + "site-package": "keycloak" + }, + { + "site-package": "testcontainers" + } + ] +} diff --git a/Docker/Dockerfile b/Docker/Dockerfile new file mode 100644 index 0000000..9121137 --- /dev/null +++ b/Docker/Dockerfile @@ -0,0 +1,52 @@ +# Build image +FROM docker.io/library/python:3-alpine as builder + +# Switch to non-root (a group with gid=uid is automatically created) +RUN adduser -D -u 65532 nonroot +USER 65532 + +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_CACHE_DIR="/home/nonroot/.cache/poetry" \ + PIP_CACHE_DIR="/home/nonroot/.cache/pip" \ + PIP_PROGRESS_BAR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PATH="/home/nonroot/.local/bin:${PATH}" + +# Install poetry with cache +# hadolint ignore=DL3042 +RUN --mount=type=cache,uid=65532,gid=65532,target=$PIP_CACHE_DIR pip install --user poetry==1.8.2 + +WORKDIR /app + +# Copy application +COPY pyproject.toml poetry.lock ./ +COPY kcwarden/ ./kcwarden +RUN touch README.md +RUN --mount=type=cache,uid=65532,gid=65532,target=$POETRY_CACHE_DIR poetry install --without dev --no-root +# Build wheel +RUN poetry build + + +# Actual image +FROM docker.io/library/python:3-alpine + +# Update packages and switch to non-root +RUN apk upgrade -U && adduser -D --u 65532 nonroot +USER 65532 + +WORKDIR /app + +ENV PIP_CACHE_DIR="/home/nonroot/.cache/pip" \ + PIP_PROGRESS_BAR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PATH="/home/nonroot/.local/bin:${PATH}" + +COPY --from=builder /app/dist/kcwarden*.whl . + +# Install kcwarden from wheel as user-global package +# hadolint ignore=DL3042 +RUN --mount=type=cache,uid=65532,gid=65532,target=$PIP_CACHE_DIR pip install --user kcwarden*.whl && rm /app/kcwarden*.whl + +ENTRYPOINT ["kcwarden"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1e8798 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# kcwarden - Keycloak Configuration Auditor + +kcwarden checks your Keycloak configuration for common misconfigurations and security vulnerabilities. + +## Installation + +TBD + +## Development + +### Docker Image + +To build a Docker image with a bundled kcwarden, you can use: + +```shell +docker build -f Docker/Dockerfile -t kcwarden:0.0.1 . +``` + +or + +```shell +buildah build -f Docker/Dockerfile -t kcwarden:0.0.1 . +``` + +It uses a multi-stage build to first build the application as Python wheel and afterwards install this wheel in a second +image. + +### Tests + +The unit tests can be run with `poetry run pytest`. + +The integration tests that actually start Keycloak containers using Docker can be executed +with `poetry run pytest --integration`. +The Keycloak versions for which the tests are executed can be found in [`conftest.py`](./tests/integration/conftest.py). +It can be overridden by setting the environment variable `INTEGRATION_TEST_KEYCLOAK_VERSIONS` to a space-separated list +of Keycloak container image tags (see [quay.io](https://quay.io/repository/keycloak/keycloak?tab=tags)). + +### Build the Docs + +The documentation is created using [MkDocs](https://www.mkdocs.org/) and lives in the [`docs`](./docs) directory. +The dependencies for _MkDocs_ can be installed using this command: `poetry install --with docs`. +Afterward, the documentation can be built using `poetry run mkdocs build`. +The static output is then located in the `site` directory. +A development server that serves the documentation, watches for changes and automatically re-creates the site can be +spun up using `poetry run mkdocs serve`. + +## Usage + +Documentation will follow. \ No newline at end of file diff --git a/docs/auditors/clients.md b/docs/auditors/clients.md new file mode 100644 index 0000000..45c8901 --- /dev/null +++ b/docs/auditors/clients.md @@ -0,0 +1,239 @@ +--- +title: Clients +--- + +# Client Misconfigurations + +These auditors consider misconfigurations in OIDC Clients configured in Keycloak. +These OIDC clients are used by other applications to interact with Keycloak, authenticate users, obtain and exchange +access tokens, and perform other tasks. + +!!! important + + As kcwarden can only audit the configuration of Keycloak, it cannot make any statements about the security of the + *applications* that use these OIDC clients, and whether their use of the clients is secure. + +## PublicClientsMustEnforcePKCE + +This auditor checks if public clients (clients that cannot securely store credentials) are enforcing the use of Proof +Key for Code Exchange (PKCE). PKCE adds an additional layer of security, especially important for public clients, to +mitigate authorization code interception attacks. Its use is strongly recommended, as it protects against several types +of attacks. It should be implemented by the client, and enforced on the Keycloak server. The only allowed value is " +S256", as this is the only secure mode of PKCE. + +## ConfidentialClientShouldEnforcePKCE + +While PKCE is primarily recommended for public clients, this auditor verifies if confidential clients (clients that can +securely store credentials) also enforce PKCE. Enforcing PKCE for confidential clients provides an extra security +measure, safeguarding against certain attack vectors. + +## ClientShouldDisableImplicitGrantFlow + +This auditor identifies OIDC clients within Keycloak that have the implicit grant flow enabled. The implicit grant flow +is discouraged because it exposes the access token in the URL. This exposure can lead to vulnerabilities, such as access +token leakage or replay attacks. Instead, the "Authorization Code" flow (referred to as "Standard Flow" in Keycloak) is +recommended. The implicit flow should be disabled to prevent these security risks and ensure the confidentiality and +integrity of the access token. + +## PublicClientShouldDisableDirectAccessGrants + +This auditor focuses on identifying OIDC clients, specifically public clients within Keycloak, that have the direct +access grant flow enabled. The direct access grant flow, known in OAuth as the resource owner password credentials +grant, poses significant security risks by requiring clients to handle user credentials directly. This not only +increases the attack surface for credential exposure but also often conflicts with advanced authentication mechanisms +such as two-factor authentication (2FA) methods, including WebAuthN or SMS tokens. Given these considerations, the +direct access grant flow should be explicitly disabled for all clients, especially for public clients. Public clients +are particularly vulnerable as they can be used by anyone, not just the client's rightful users, making the misuse of +direct access grants a significant concern. + +!!! info + + While some systems may use this flow for obtaining tokens for technical users, it's recommended to use + Keycloak's _service accounts_ feature as a more secure and intended alternative for such use cases. + +## ConfidentialClientShouldDisableDirectAccessGrants + +This auditor targets confidential OIDC clients within Keycloak that have enabled the direct access grant flow. The +direct access grant flow, also known in OAuth 2.0 as the resource owner password credentials grant, poses significant +security risks. It necessitates clients to directly handle user credentials, substantially increasing the vulnerability +of these credentials to exposure outside of Keycloak's managed authentication environment. Moreover, this flow often +cannot be integrated with two-factor authentication (2FA) methods, such as WebAuthN or SMS tokens, potentially +undermining modern security practices. + +While the use of direct access grants might seem less perilous for confidential clients, due to the requirement of a +client secret for interaction, the fundamental security concerns remain. The exposure of user credentials outside of a +controlled environment and the potential bypass of advanced authentication mechanisms advise against the use of this +flow for any client type, public or confidential. + +!!! info + + While some systems may use this flow for obtaining tokens for technical users, it's recommended to use Keycloak's ' + service accounts' feature as a more secure and intended alternative for such use cases. + +## ClientAuthenticationViaMTLSOrJWTRecommended + +This auditor evaluates whether confidential OIDC clients within Keycloak are utilizing mutual TLS (mTLS) or signed JWTs +for client authentication, as opposed to the default 'shared client secret' method. Confidential clients are those that +can securely hold credentials, making them responsible for authenticating to Keycloak to access its features. While +using a shared client secret is common, it's recommended to opt for more secure authentication methods such as mTLS or +signed JWTs. These methods provide enhanced security by ensuring that client credentials are not exposed and are +authenticated in a manner that is both secure and verifiable. This recommendation aligns with best practices for +securing OAuth clients and protecting resource access. For more detailed guidance on implementing these recommended +authentication methods, refer to the Keycloak documentation. If switching to these methods is impossible in your setup, +you can silence this auditor in the configuration. + +## ClientMustNotUseUnencryptedNonlocalRedirectUri + +This auditor checks for OIDC clients that transmit authorization responses over unencrypted connections, a practice +strongly discouraged due to the sensitivity of the data involved, such as the OAuth Response Code. To safeguard this +data, `redirect_uri`s must be configured to use HTTPS URIs exclusively, or, in the case of native applications, a +localhost address. This measure is crucial for protecting authorization responses from potential exposure and +interception. The auditor evaluates OIDC clients with active flows utilizing `redirect_uri`s, specifically those with +standard or implicit flows enabled, ensuring they adhere to these security standards. It focuses on identifying and +reporting clients that use `http` in their redirect URIs for non-local addresses, as these present a significant +security risk. Localhost addresses (`localhost`, `127.0.0.1`, `::1`) using `http` are considered exceptions due to their +nature. + +## ClientUsesCustomRedirectUriScheme + +This auditor identifies OIDC clients that utilize custom protocol schemes in their `redirect_uri` configurations, +diverging from the standard `http://` or `https://`. Authorization responses, which include sensitive data such as the +OAuth Response Code, must be securely managed to prevent unauthorized exposure. The employment of custom protocols, +especially when integrating with mobile apps on smartphones (like `myapp://login`), introduces a potential security +risk. + +## ClientHasUndefinedBaseDomainAndSchema + +This auditor checks if OIDC clients have undefined or insufficiently specified redirect URI schemes, which cannot be +effectively audited. Redirect URIs are critical for ensuring the security of OAuth response codes and must be protected +from exposure. Ideally, the `redirect_uri` should be an HTTPS URI or, for native applications, a localhost address. This +auditor identifies clients where the redirect URI, in combination with the client's root URL, does not adequately define +the scheme used. This often indicates that a fully qualified domain name, including the scheme ( +e.g., 'https://example.com/login'), is not defined for either the client root URL or the redirect URIs. To address this +issue, clients should specify clear redirect URIs with proper schemes to enhance security. + +## ClientShouldNotUseWildcardRedirectURI + +This auditor focuses on identifying OIDC clients within Keycloak that utilize wildcard characters in their +`redirect_uri` configurations. Authorization responses, which include sensitive data such as the OAuth Response Code, +necessitate secure handling to prevent unauthorized disclosure. The use of wildcards in redirect URIs introduces +security risks by potentially allowing responses to be redirected to unintended or malicious URLs, thus compromising the +confidentiality and integrity of the exchanged data. + +The recommendation is to avoid wildcards in `redirect_uri` settings whenever possible to ensure that authorization +responses are directed to explicitly trusted and predefined locations. If the use of a wildcard is absolutely necessary +for a client's operation, it should be employed with the greatest possible specificity to limit the scope of acceptable +redirect destinations (e.g., `https://example.com/login/token/*` instead of `https://example.com/*`), thereby reducing +the attack surface for data leakage or redirection attacks. + +Clients found to employ wildcards in their redirect URIs are flagged for review. Administrators are encouraged to refine +these configurations, removing or narrowing the use of wildcards, to enhance the security of OAuth flows and protect +sensitive information inherent to the authorization process. Once a client has been reviewed, further warnings for it +can be silenced using the tool configuration. + +## ClientHasErroneouslyConfiguredWildcardURI + +This auditor identifies clients with dangerously configured redirect URIs that potentially allow redirects to arbitrary +domains, posing a significant security risk. In OAuth, redirect URIs are crucial for directing the user-agent back to +the application with the authorization code. Keycloak mandates specifying allowed redirect URIs to prevent unauthorized +redirects. However, a configuration error like specifying a wildcard in the domain part of the redirect URI (e.g., +`https://example.com*`) instead of after a path delimiter (e.g., `https://example.com/*`) could let an attacker specify +a malicious domain that still matches the configured pattern (e.g., `https://example.com.attacker.com/`). + +## ClientWithServiceAccountAndOtherFlowEnabled + +This auditor examines confidential OIDC clients that have service accounts enabled alongside other authorization flows, +such as standard, implicit, or direct access grants. Typically, confidential clients with service accounts are utilized +solely for server-to-server interactions via the service account. In these scenarios, enabling additional authorization +flows may not be necessary and could potentially increase the client's attack surface. + +The recommendation is to disable any extraneous flows for clients primarily used for service account purposes. This +audit generates informational findings to highlight clients where service accounts and additional flows are +simultaneously active, prompting a review to ensure that this configuration aligns with the intended use of the client. + +If a client is intentionally using both service accounts and other authorization flows for valid use cases, the finding +can be disregarded. However, we would still recommend splitting the functionality into two clients whenever possible, to +avoid the chance of misconfigurations leading to overprivileged tokens. + +## UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous + +This auditor identifies a critical configuration issue where Keycloak clients use custom user attributes without +enabling the User Profiles feature. Keycloak permits the addition of custom attributes to user accounts beyond the +default ones like name, email, and phone number. However, by default, users can edit their own attributes, which poses a +risk if these attributes contain sensitive information used in external systems, such as customer numbers linking +Keycloak accounts to other databases. + +The use of custom user attributes without restrictions is hazardous because it allows users to alter information that +external systems rely on for important operations or access control decisions. This practice must be avoided to ensure +the integrity of the data shared across integrated systems. + +To mitigate this risk, Keycloak introduced the User Profiles feature, allowing administrators to define policies that +restrict user ability to edit specific attributes. This auditor flags clients that utilize custom user attributes +without activating the User Profiles feature, signaling a potential security vulnerability. It encourages the use of the +User Profiles feature to securely manage user attributes and prevent unauthorized modifications, as detailed in +Keycloak's documentation. + +!!! info + + The user profiles feature was an experimental feature for many versions of Keycloak that was disabled by default. It was + enabled by default for newly created realms starting with Keycloak version 24. + +## ClientWithDefaultOfflineAccessScope + +TODO Check if this is correct, as offline tokens also require refresh tokens to be enabled in the client. => Check +implementation and description, make them correct and consistent. + +This auditor warns against clients that include the `offline_access` scope in their default client scopes within +Keycloak. The `offline_access` scope grants the use of offline tokens, which are an extended and more potent form of +refresh tokens. Offline tokens maintain user login sessions for extended periods, often lasting several months, and are +typically utilized by native applications (e.g., mobile apps) or for server-to-server connections that require access to +a user's account in the user's absence. + +While offline tokens are beneficial for certain use cases, their inclusion as a default scope for clients that do not +require such extended access poses a significant security risk. If an offline token is compromised, it could allow an +attacker to maintain unauthorized access to a user's account for an extended period, potentially bypassing regular +session expiration mechanisms. + +Clients should be carefully reviewed to ensure that the use of offline tokens is genuinely necessary for their +operation. If not required, it is advisable to remove the `offline_access` scope from the default client scopes, disable +refresh tokens for the client, or adjust the configuration to mitigate the potential security risks associated with +long-lived access tokens. This auditor aims to highlight clients with potentially unnecessary default offline access to +prompt a security review and adjustment of their configuration. + +## ClientWithOptionalOfflineAccessScope + +TODO Check if this is correct, as offline tokens also require refresh tokens to be enabled in the client. => Check +implementation and description, make them correct and consistent. + +This auditor alerts on Keycloak clients that have the `offline_access` scope set as an optional client scope. The +`offline_access` scope grants applications the ability to use offline tokens, which are enhanced versions of refresh +tokens with significantly longer lifespans. These tokens are especially useful for applications requiring prolonged +access to a user's account without active user participation, such as mobile applications or server-to-server +communications. + +However, the inclusion of the `offline_access` scope, even as an optional one, raises security concerns for clients that +do not necessitate such extended access capabilities. The potential exposure of offline tokens poses a risk of long-term +unauthorized access to user accounts if these tokens are compromised. + +Clients leveraging the `offline_access` scope should undergo a thorough review to ascertain the necessity of this +capability for their functionality. If the use of offline tokens is not imperative, it's recommended to either remove +this scope from the list of optional scopes, disable refresh tokens to prevent the issuance of offline tokens, or adjust +the client's configuration to ensure that the use of offline tokens aligns with the security requirements and +operational needs. This warning aims to prompt a reevaluation of the need for offline access, advocating for tighter +control and minimization of potential security vulnerabilities associated with long-lived token usage. + +## ClientWithFullScopeAllowed + +This auditor identifies Keycloak clients configured with the 'full scope allowed' setting enabled. In Keycloak, scopes +dictate the breadth of information and roles appended to an access token. Adhering to the principle of least privilege +is crucial in access token configuration, ensuring tokens are granted only the permissions necessary for their intended +tasks. + +When 'full scope allowed' is activated for a client, Keycloak bypasses the scoped limitations and indiscriminately +includes all user roles in the token, effectively treating it as if all possible scopes were granted. This configuration +can lead to the issuance of access tokens with excessive privileges, escalating the risk of unauthorized actions if such +tokens were to be compromised. + +The finding prompts a review of client configurations, encouraging administrators to specifically tailor access token +scopes to match the minimal requirements of each client. Adjusting the scope settings to disable 'full scope allowed' +mitigates the risk associated with overly permissive tokens, aligning with best practices for secure token management. diff --git a/docs/auditors/idp.md b/docs/auditors/idp.md new file mode 100644 index 0000000..4366d93 --- /dev/null +++ b/docs/auditors/idp.md @@ -0,0 +1,63 @@ +--- +title: IDP +--- + +# IDP Misconfigurations +Keycloak can be configured to delegate user authentication to an upstream Identity Provider (IDP) like Google, Azure AD, or an LDAP server. The IDP auditors check for problems related to how this integration is configured. + +!!! info + + These auditors are currently fairly bare-bones, as we haven't yet had time to read up on what specific problems may lurk in the different possible setups. If you have expertise in this area, please reach out or contribute your own auditors. + +## OIDCIdentityProviderWithoutPKCE + +This auditor warns about OIDC Identity Providers configured within a realm that do not have the Proof Key for Code +Exchange (PKCE) enabled. PKCE is a security enhancement for the authorization code flow in OAuth 2.0 and OpenID +Connect (OIDC) protocols, designed to mitigate several attack vectors, including interception and unauthorized use of +authorization codes. + +The recommendation is for all OIDC Identity Providers, particularly those utilizing the "oidc" or "keycloak-oidc" +provider types, to enable PKCE and set it to use the "S256" method. This configuration is crucial for protecting against +attacks on the OIDC protocol by ensuring that the code challenge and verifier mechanism is securely implemented. + +Identity Providers failing to enable PKCE, leaving it unset (which defaults to disabled), or incorrectly using the " +plain" method instead of "S256" are flagged by this auditor. Such configurations expose the authentication process to +potential vulnerabilities, emphasizing the need for immediate corrective actions to uphold security best practices in +authentication flows. + +## IdentityProviderWithOneTimeSync + +This auditor highlights external identity providers (IDPs) configured within Keycloak that are set to only synchronize +user information from the upstream IDP at the time of the user's first login, without accepting updates on subsequent +logins. Keycloak's default behavior imports user details (such as name and email address) from the external IDP during +the user's initial login, but it does not automatically update these details based on subsequent changes in the upstream +IDP. + +This setup might be by design, intending to prevent overwriting local modifications to user attributes within Keycloak. +However, if keeping user information in sync with the upstream IDP is required, the auditor recommends considering the +synchronization mode 'Force'. The 'Force' mode ensures that updates made to a user's information in the upstream IDP are +imported into Keycloak at every login, potentially overwriting any local changes. + +Entities configured without the 'Force' sync mode are identified by this auditor to encourage a review of the intended +behavior regarding user data synchronization. If the current setup aligns with the organizational requirements, the +finding can be ignored. Otherwise, updating the sync mode to 'Force' may be advisable to ensure consistent and +up-to-date user information across systems. + +## IdentityProviderWithMappersWithoutForceSyncMode + +This auditor targets Keycloak configurations where external identity providers are set up with Identity Provider Mappers +but are not configured to update user information from the upstream IDP beyond the initial login. Keycloak's default +behavior for Identity Provider Mappers is to import data (e.g., group assignments or roles) from the upstream IDP's +access token only once, during the user's first login, without reflecting any subsequent changes in the upstream IDP. + +This configuration could lead to security issues or inconsistencies in user permissions if the upstream IDP modifies +user roles, groups, or other attributes that affect access control within Keycloak-managed services. If the use of +mappers to assign static groups or roles without future updates is intentional, this finding may be disregarded. + +However, if dynamic synchronization of user attributes and roles with the upstream IDP is required, it's advised to +adjust the sync mode to 'Force'. This setting can be applied globally to the IDP, affecting all user data, including +name and email, or specifically to relevant mappers, allowing for selective updates based on upstream changes. + +This finding carries a higher severity compared to the general recommendation for enabling 'Force' sync mode due to the +explicit use of Identity Provider Mappers, indicating a reliance on upstream IDP data for crucial access control +decisions. \ No newline at end of file diff --git a/docs/auditors/index.md b/docs/auditors/index.md new file mode 100644 index 0000000..c5ad99b --- /dev/null +++ b/docs/auditors/index.md @@ -0,0 +1,52 @@ +--- +title: Introduction +--- + +# Introduction + +kcwarden comes with a number of pre-built detection rules that can detect common Keycloak misconfigurations. +We call these _auditors_, and each auditor checks for one specific problem. +There are auditors for OAuth clients, scopes, upstream Identity Provider (IDP) configurations, and realm settings. +The auditors are based on a combination of +the [OAuth 2.0 Security Best Current Practices RFC Draft (Version 24)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-24), +and additional, Keycloak-specific checks. + +## Running Auditors + +All auditors are run by default when running kcwarden. However, you can also limit the set of auditors using a CLI flag, and control which severity of findings you want to have reported. See [usage](../usage.md) for more details. + +## Silencing Findings + +If you run kcwarden for the first time on a Keycloak configuration, chances are that you will receive a large number of +findings. +kcwarden tends to err on the side of reporting too much instead of too little, so there are likely to be some findings +that you don't want to act on because you have good reasons to configure the system in this way, even though it may not +be 100% compliant with official recommendations. +In these cases, you can ignore specific findings or entire auditors to prevent kcwarden from reporting them again. + +### Ignoring a Specific Finding + +To ignore a specific finding, you can [create a config file](../usage.md#generate-config-template) and add the specific entity that was flagged to the allowlist. For example, if you have a client `mobile_app` that is used by a native mobile application that requires the use of offline access tokens, you can silence the warning about the use of these tokens for this specific client with the following configuration entry: + +```yaml +auditors: +- auditor: ClientWithOptionalOfflineAccessScope + allowed: + - mobile_app # Allow offline access for mobile app client +``` + +### Ignoring Multiple Findings + +In some cases, you may want to ignore a finding for a large set of clients, or maybe even for all clients. In this case, you can use the built-in regular expression support of the allowlist feature: + +```yaml +auditors: +- auditor: ClientWithOptionalOfflineAccessScope + allowed: + - app_.* # Allow access for app_ios, app_android, app_firetv, ... +- auditor: ClientAuthenticationViaMTLSOrJWTRecommended + allowed: + # Due to the use of legacy software, we need to allow client-secret + # auth for the forseeable future. Allowlist all clients. + - .* +``` \ No newline at end of file diff --git a/docs/auditors/realm.md b/docs/auditors/realm.md new file mode 100644 index 0000000..d10e9b8 --- /dev/null +++ b/docs/auditors/realm.md @@ -0,0 +1,83 @@ +--- +title: Realm +--- + +# Realm Misconfigurations +These auditors check the realm-wide settings, like token lifetimes and global security features. + +## RefreshTokensShouldBeRevokedAfterUse + +This auditor warns about the configuration within Keycloak realms where refresh tokens are not invalidated after each +use. Refresh tokens are critical for maintaining active user sessions by allowing clients to request new access tokens +once the current ones expire. While this mechanism supports seamless user experiences, especially in long-lived +sessions, it introduces security risks if refresh tokens are compromised. An exposed refresh token could potentially +allow attackers to gain prolonged unauthorized access to user accounts. + +To mitigate such risks, it is strongly recommended that refresh tokens be configured to rotate upon each use, thereby +invalidating the old token and issuing a new one for subsequent requests. This practice ensures that even if a refresh +token were to be leaked, it would be quickly rendered useless once used by the legitimate client. + +However, it's important to note that enabling refresh token rotation may lead to complications under certain +circumstances, such as when a client issues multiple refresh tokens to the same user. This can result in unexpected +behavior and potential session disruptions. Administrators are advised to review relevant Keycloak issues and +documentation, such as the one mentioned in the auditor's reference, to understand the implications fully and configure +their realms in a manner that balances usability and security effectively. + +Realms identified with refresh token revocation disabled are highlighted by this auditor to encourage a review of their +token management policies, aiming to enhance security without significantly impacting user experience. + +## RefreshTokenReuseCountShouldBeZero + +This auditor raises a warning about Keycloak realms configured to allow refresh tokens to be used more than once before +being revoked. Refresh tokens play a vital role in OAuth 2.0 by enabling clients to obtain new access tokens, thus +facilitating long-lived sessions without requiring the user to re-authenticate frequently. While this feature enhances +user experience, improperly managed refresh tokens can pose significant security risks, particularly if they are leaked +or exposed to malicious actors. + +The recommended security practice is to rotate refresh tokens after each use, immediately invalidating the previous +token upon issuing a new one. This approach minimizes the window of opportunity for unauthorized use of a leaked token. +However, in configurations where the refresh token maximum reuse count is set to allow multiple uses, the effectiveness +of token rotation as a security measure is diminished. + +Administrators should consider setting the refresh token maximum reuse count to zero, enforcing token rotation and +revocation after a single use. While mindful of potential challenges such as disruptions in user sessions, especially in +scenarios where multiple refresh tokens might be issued to the same user by the same client, it's crucial to balance +usability with security. + +Realms found to permit refresh token reuse, contrary to best practices for secure token management, are flagged for +review. Administrators are encouraged to reassess their token revocation settings in light of security recommendations +and the potential implications for application behavior, aiming to enhance the overall security posture of their +Keycloak deployments. + +## RealmSelfRegistrationEnabled + +This auditor flags realms within Keycloak where self-registration is enabled, allowing anyone to create an account. +While self-registration can be a convenient feature for public applications or services aiming to simplify the user +onboarding process, it might not be appropriate for all contexts. Enabling self-registration can expose the system to +risks such as unauthorized access, fake account creation, and potential abuse. + +The decision to allow self-registration should be carefully considered, taking into account the nature of the +application, the expected user base, and the potential security implications. In scenarios where strict control over +user access is required, or where user verification is critical, it may be advisable to disable self-registration and +opt for a more controlled account creation process. + +Realms identified with self-registration enabled are brought to attention for review. Administrators should evaluate +whether this setting aligns with their security policies and operational requirements, adjusting the configuration as +necessary to safeguard against unintended or unauthorized access. + +## RealmEmailVerificationDisabled + +This auditor brings attention to Keycloak realms where email verification is disabled. Email verification is a crucial +feature that ensures the authenticity of the email addresses provided by users during registration. It typically +involves sending a verification link or code to the user's email address, which the user must acknowledge to complete +their registration process. This double opt-in mechanism helps in confirming that the email address is valid and +accessible by the user, adding a layer of trustworthiness to user accounts. + +Disabling email verification can lead to several issues, including the inability to communicate reliably with users, +increased risk of fraudulent account creation, and potential challenges in implementing effective password recovery +mechanisms. It may also compromise the integrity of user data, especially in applications where the email address is a +critical component of the user's identity. + +Realms detected with email verification turned off are highlighted for administrators to reassess this configuration +choice. Depending on the application's requirements and the level of trust needed in user-provided email addresses, +enabling email verification may be advisable to enhance security and ensure the credibility of user accounts. diff --git a/docs/auditors/scope.md b/docs/auditors/scope.md new file mode 100644 index 0000000..9422753 --- /dev/null +++ b/docs/auditors/scope.md @@ -0,0 +1,32 @@ +--- +title: Scope +--- + +# Scope Misconfigurations +These auditors check the configuration of the OIDC scopes configured in Keycloak. + +!!! info + + These auditors are currently fairly bare-bones, as we haven't yet had time to read up on what specific problems may lurk in the different possible setups. If you have expertise in this area, please reach out or contribute your own auditors. + +## UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous + +This auditor focuses on the usage of non-default user attributes in client scopes within Keycloak, particularly when the +server does not have the User Profiles feature enabled. Keycloak permits the assignment of custom attributes to user +profiles beyond the standard attributes (e.g., name, email, and phone number). However, without proper restrictions, +users might modify their own attributes via the user console, potentially affecting the reliability of these attributes +in external systems. For instance, a customer number stored as a custom attribute could be altered, disrupting the +linkage between a Keycloak account and a customer database. + +The danger arises when these custom attributes are employed in client scopes without enabling Keycloak's experimental +User Profiles feature. This feature allows administrators to define policies controlling attribute editability, thus +preventing users from altering sensitive information. + +This auditor raises a flag when client scopes utilize custom user attributes, and the realm lacks the User Profiles +feature activation. It suggests a review of the utilization of these attributes within scopes, advocating for the +activation of User Profiles and the establishment of attribute editing policies to safeguard sensitive information. + +The finding is particularly severe because the lack of restriction could lead to security vulnerabilities, where +critical information stored in user attributes could be tampered with by the users themselves or exploited by attackers. +Implementing the User Profiles feature and adjusting scope configurations accordingly is recommended to ensure data +integrity and security. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a16443f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,13 @@ +--- +title: About +--- + +# kcwarden—A Keycloak Config Auditor + +kcwarden is an Open Source [Keycloak](https://www.keycloak.org/) configuration auditor tool, developed by [iteratec](https://iteratec.com). +It aims to automatically detect issues with your Keycloak realms by parsing a realm export and looking for common +misconfigurations. +Its focus is on security and correctness issues. + +[Installation](installation.md){: .btn .btn-primary role=button } +[Usage](usage.md){: .btn .btn-primary role=button } \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..660079a --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,28 @@ +--- +title: Installation +--- + +# How to install + +There are two ways to install _kcwarden_: + +## Python + +You can install _kcwarden_ from PyPI: + +```shell +pip install kcwarden +``` + +You might want to use [pipx](https://github.com/pypa/pipx) to automatically encapsulate the dependencies of _kcwarden_ +in a virtual environment. + +## Docker + +Alternatively, _kcwarden_ is provided as a Docker image and can be executed in this way: + +```shell +docker run --rm ghcr.io/iteratec/kcwarden:latest +``` + +Using this way, you need to replace the `kcwarden` call with the Docker command above. \ No newline at end of file diff --git a/docs/monitors/client_monitor.md b/docs/monitors/client_monitor.md new file mode 100644 index 0000000..eff1721 --- /dev/null +++ b/docs/monitors/client_monitor.md @@ -0,0 +1,71 @@ +--- +title: Clients +--- + +# Client Monitors + +You can use client monitors to keep an eye on the configuration of specific OIDC clients. Please be sure that you have read our [general introduction to Monitors](index.md) to understand the context of this feature. + + +## ClientWithSensitiveRole + +Many applications rely on the roles mapped into an access token to grant or reject access to specific functions. +This makes it important to ensure that roles are only added to the tokens you expect. +For a role to be added to an access token, several things have to be true: + +- The authenticating user or service account must have the role assigned (directly, through a composite role, or through group membership) +- The client must have a scope assigned that includes the relevant role, OR the client must have "full scope allowed" set +- The client must have a way of mapping roles into the access token (through a role mapper, assigned directly to the client or assigned indirectly through scopes) + +If all of these conditions are true, a role will be added to the access token. +Checking this manually is time-consuming and error-prone, so this monitor encapsulates this logic. +If you have several clients that are authenticating users, and only one of them should be able to map a specific role into the access token, use this monitor to control which clients can map this role. +For example, if you are interested in the realm role `org_admin`, the configuration could look like this: + +```yaml +monitors: +- monitor: ClientWithSensitiveRole + config: + - role: "org_admin" + severity: Critical + role-client: realm # it's a realm-wide role, not a client-specific role + allowed: + - admin_backend + note: org_admin controls access to org management. Only the client admin_backend should have access to it. +``` + +The field `role` and `allowed` fields support the Python RegEx syntax, so you can match all roles beginning with `org_` by writing `^org_.*` as the role name you want to monitor, and similarly use wildcards in your allowlists. +If you want to match a realm role (i.e., a role defined on the level of the Keycloak realm), put "realm" as the role-client. +If you are using a client role (i.e., a role defined on the level of an individual client, like the built-in realm-management roles), put the name of the client that defines the role in the field. +The role-client field does not support regular expressions and is case-sensitive. + +!!! info + + If you want to create comprehensive monitoring for a single role, we recommend combining this monitor with the [GroupWithSensitiveRole](group_monitor.md#groupwithsensitiverole) and [ServiceAccountWithSensitiveRole](service_account_monitor.md#serviceaccountwithsensitiverole) monitors to achieve more comprehensive coverage. + + +## ClientWithSensitiveScope + +Some applications differentiate which features a user can use based on the scopes included in their access token. +Thus, for sensitive scopes, it is important that you keep an eye on which clients can generate tokens that include these scopes. +In general, to generate an access token containing a specific scope, one of the following conditions must be met: + +- The client contains the scope as a default scope, or +- The client contains the scope as an optional scope, and the request to generate the access token explicitly requests this scope + +!!! info + + Additionally, in order for the scope name to appear in the token, the scope must have the option "include in token scope" enabled - however, this auditor does not enforce this setting, as you may want to monitor scope assignments for other reasons than their inclusion into the access token (e.g., because they are used to include additional mappers or other features), and enforcing this limitation would lead to false negatives. + +In order to monitor a specific scope, you can add the following to your config file: + +```yaml +monitors: +- monitor: ClientWithSensitiveScope + config: + - scope: interesting_scope + allowed: + - allowed_client + note: This scope should only be assigned to allowed_client + severity: Medium +``` diff --git a/docs/monitors/group_monitor.md b/docs/monitors/group_monitor.md new file mode 100644 index 0000000..76be015 --- /dev/null +++ b/docs/monitors/group_monitor.md @@ -0,0 +1,32 @@ +--- +title: Group +--- + +# Group Monitor +You can use grouo monitors to detect if groups have incorrect configurations. +Please be sure that you have read our [general introduction to Monitors](index.md) to understand the context of this feature. + +## GroupWithSensitiveRole +This monitor allows you to check if a group has unexpected role assignments. +For example, if you have a specific role that should only be assigned to one group, you can monitor this restriction using a configuration like the following: + +```yaml +monitors: +- monitor: GroupWithSensitiveRole + config: + - role: "org_admin" + severity: Critical + role-client: realm + allowed: + - /OrgAdm + note: org_admin controls access to org management. Only the group /OrgAdm should have it. +``` + +This monitor will consider both roles assigned directly to a group, and roles inherited from parent groups. +It will also detect if a composite role containing the monitored role is assigned to the group. +The `role` and `allowed` fields support regular expressions, the `role-client` field does not. +Set the `role-client` field to `realm` when monitoring realm roles, or to the name of the client if it is a client role. + +!!! info + + If you want to create comprehensive monitoring for a single role, we recommend combining this monitor with the [ClientWithSensitiveRole](client_monitor.md#clientwithsensitiverole) and [ServiceAccountWithSensitiveRole](service_account_monitor.md#serviceaccountwithsensitiverole) monitors to achieve more comprehensive coverage. \ No newline at end of file diff --git a/docs/monitors/index.md b/docs/monitors/index.md new file mode 100644 index 0000000..654b995 --- /dev/null +++ b/docs/monitors/index.md @@ -0,0 +1,66 @@ +--- +title: Introduction +--- + +# Security Guardrails + +kcwarden can automatically detect many common misconfigurations using its Auditors feature. However, because it doesn't have any knowledge about your specific context, it cannot detect misconfigurations specific to your setup, like incorrectly assigned roles or scopes. In practice, these can be as bad as (or even worse than) more generic misconfigurations, because it can expose application permissions to unintended users. Additionally, such errors can creep in during operation, meaning that checking for them once isn't enough - ideally, you want to continuously monitor for these problems and be notified as soon as they crop up. + +## Introducing Monitors +To cover this use case, kcwarden contains a feature called _Monitors_, which allows you to configure custom checks using your knowledge of your specific setup. For example, let's say that you have a role `org_admin`, which allows holders to configure organization-wide settings in your application. Clearly, this shouldn't be assigned to just anyone. Since all roles in your Keycloak setup are assigned to groups (instead of directly to users), let's create a monitor that checks which groups the role is assigned to, by adding the following to the configuration file: + +```yaml +monitors: +- monitor: GroupWithSensitiveRole + config: + - role: "org_admin" + severity: Critical + role-client: realm # it's a realm-wide role, not a client-specific role + allowed: + - /OrgAdm + note: org_admin controls access to org management. Only the group /OrgAdm should have it. +``` + +Running kcwarden with this configuration file will flag any groups that have this role assigned and aren't in the allowlist. You can then periodically run it on the current version of the realm configuration to ensure that there hasn't been any dangerous config drift. + +!!! info + + We recommend testing monitors with an empty allowlist first to ensure that it returns results. Once you are satisfied that the detecting is working well, add your allowlist entries and test again. + +kcwarden supports several different monitors. Check the documentation of the individual monitors on the following pages to see what you can monitor with them. Or contribute your own, if your use case is not yet supported. + +## Configuring Monitors +Generally, you can configure monitors as part of the kcwarden config file - generate a template using `kcwarden generate-config-template > config.yaml`, make your changes, and pass in the resulting file using `kcwarden audit -c config.yaml realm-dump.json`. +Monitors are configured under the top-level key `monitors` in the YAML file. +Each monitor has its own entry in the config list, and you can put as many individual monitor configuration into the config list as you want: + +```yaml +monitors: +- monitor: GroupWithSensitiveRole + config: + - role: "org_admin" + severity: Critical + role-client: realm + allowed: + - /OrgAdm + note: org_admin controls access to org management. Only the group /OrgAdm should have it. + - role: "site_admin" + severity: Critical + role-client: realm + allowed: + - /Staff + note: site_admin controls access to overall admin functionality. Only the group /Staff should have it. +``` + +Part of the configuration options for each monitor are specific to that monitor, but two are generic: `severity` and `note`. + +### Severity +The severity describes how serious a violation of the security guardrail would be. +You can set it to one of the following: `Info`, `Low`, `Medium`, `High`, `Critical`. +This can be used to prioritize your remediation work. +You can also filter the results using the `--min-severity` switch. + +### Note +The note is a human-readable description of the semantics of the configuration. +It can contain arbitrary text, and will be returned as part of the output of the auditor when it has a finding. +Use it to remind yourself why you set up this rule, and what the consequences of violating it are. diff --git a/docs/monitors/protocol_mapper_monitor.md b/docs/monitors/protocol_mapper_monitor.md new file mode 100644 index 0000000..6c6f713 --- /dev/null +++ b/docs/monitors/protocol_mapper_monitor.md @@ -0,0 +1,82 @@ +--- +title: Mappers +--- + +# Protocol Mapper Monitors +You can use protocol mapper monitors to keep an eye on the configuration of specific protocol mappers. Please be sure that you have read our [general introduction to Monitors](index.md) to understand the context of this feature. + +## ProtocolMapperWithConfig + +This monitor checks the configuration of OIDC protocol mappers. +But first, two important disclaimers: + +!!! warning + + This monitor will only check protocol mappers that are assigned to a client (either directly or through a scope). If you have a mapper that is not assigned to a client (e.g., because it is assigned to a scope which is not assigned to any clients), this monitor will currently not find it. If you need to be able to detect these, please open an issue and describe your use case, ideally providing a configuration file that we can use to reproduce and test. + + +!!! warning + + This monitor tests client protocol mappers, not identity provider mappers (which would be assigned to upstream IDP configurations). At the moment, we have not implemented a monitor for the latter. If there is interest in this, we can build it - please open an issue (or contribute it yourself ;). + +With that out of the way: This is one of the more situation-specific monitors kcwarden supports. +In some environments, protocol mappers are used to add specific information to the access tokens generated by the clients. +Using this monitor, you can enforce certain rules about these configurations. +To do this, first, you need to check what the configuration you are interested in actually looks like in the JSON config you download from Keycloak. +You will find them under the `protocolMappers` key of the client. +One of them may look like this: + +```json +{ + "id": "78890c6c-5dfb-4c1c-a469-4f21d170f702", + "name": "user-id-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "user_id", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "user_id", + "jsonType.label": "String" + } +} +``` + +This is a mapper that takes the "user_id" field from the user attributes and writes it to the access token in the `user_id` key (specified by the `claim.name` key of the configuration). +For the sake of demonstration, let's say you want to ensure that only a user attribute mapper can write to that key, and no other mapper type can do so. +The resulting monitor would look like this: + +```yaml +monitors: +- monitor: ProtocolMapperWithConfig + config: + - protocol-mapper-type: "^(?!oidc-usermodel-attribute-mapper).*" + matched-config: # Define which part of the config dict to look for + claim.name: user_id + allowed: [] + severity: Critical + note: The user_id claim should only be writable from a user attribute mapper +``` + +To further ensure the correctness of that field in the access tokens, you could also write a second detection that ensures that usermodel-attribute-mappers can only write to the field from the user_id user attribute, and cannot use any other attributes. +Add the following right below the last line of the previous configuration: + +```yaml + - protocol-mapper-type: "oidc-usermodel-attribute-mapper" + matched-config: + claim.name: user_id + user.attribute: (?!user_id).* + allowed: [] + severity: Critical + note: The user_id claim should only be writable from the user_id field of the user attributes +``` + +As seen in the two examples, both the `protocol-mapper-type` and any value below `matched-config` support regular expressions, so you can use negative lookahead to check for violations of your rules. +You can perform matching on arbitrary keys in the `config` part of the JSON object. +If a key specified in `matched-config` is not found, the entire protocol mapper is treated as not matching - however, since the config keys should be consistent across different instances of the same protocol mapper type, this should rarely be an issue in practice. + +!!! info + + Right now, you cannot match based on the `name`, `protocol` and `consentRequired` fields of the config. If you have a use case for this, please open an issue on the repository and we'll add this functionality. diff --git a/docs/monitors/service_account_monitor.md b/docs/monitors/service_account_monitor.md new file mode 100644 index 0000000..0b4d3b6 --- /dev/null +++ b/docs/monitors/service_account_monitor.md @@ -0,0 +1,5 @@ +--- +title: Service Accounts +--- + +# Service Account Monitor diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..ad54418 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,60 @@ +--- +title: Usage +--- + +# Usage + +## Getting a Config Dump + +You need a realm export as input to the latter commands. +It can be acquired using the Keycloak administration interface or using the `download` command: + +```shell +kcwarden download --realm $REALM --user $USER --output $KEYCLOAK_CONFIG_FILE $KEYCLOAK_BASE_URL +``` + +Additionally, you might specify a separate realm for login, e.g., the `master` realm, using the `--auth-realm` +parameter. + +## Running the Audit + +To execute the actual audit, you can use the `audit` command: + +```shell +kcwarden audit $KEYCLOAK_CONFIG_FILE +``` + +There are several optional parameters to customize the execution: + +| Parameter | Description | +|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--format` | The output format of the findings. Can be plain text (`txt`), `csv` or `json`. | +| `--output` | The path to the output file. If not provided, the output will be printed on stdout. | +| `--min-severity` | The minimum severity of findings that should be reported. Can be one of INFO, WARNING, ERROR, CRITICAL. | +| `--auditors` | Specify the exact auditors to run, separated by space (others will be ignored). | +| `--config` | Provide a config file with auditor-specific exclusions and parameters. Generate a template using [generate-config-template](#generate-config-template). | +| `--ignore-disabled-clients` | When set, will not audit disabled OIDC clients. | + +## Generating a _kcwarden_ Configuration {: #generate-config-template} + +The [auditors](./auditors/index.md) and [monitors](./monitors/index.md) can be configured in a YAML configuration file. +The stub for this file can be generated using the `generate-config-template` command: + +```shell +kcwarden generate-config-template --output $CONFIG_FILE +``` + +If `--output` is not specified, it is printed to stdout. + +## Review Permissions + +!!! info + + This feature is not part of the main scope of _kcwarden_ and thus only partly maintained. + +There is an additional command `review` that outputs roles and its usages on services accounts and groups as matrix for +human analysis. + +```shell +kcwarden review $KEYCLOAK_CONFIG_FILE +``` diff --git a/kcwarden/__init__.py b/kcwarden/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/api/__init__.py b/kcwarden/api/__init__.py new file mode 100644 index 0000000..e7d694d --- /dev/null +++ b/kcwarden/api/__init__.py @@ -0,0 +1,4 @@ +from .monitor import Monitor +from .auditor import Auditor + +__all__ = ["Monitor", "Auditor"] diff --git a/kcwarden/api/auditor.py b/kcwarden/api/auditor.py new file mode 100644 index 0000000..dcf6abe --- /dev/null +++ b/kcwarden/api/auditor.py @@ -0,0 +1,99 @@ +from abc import ABC, abstractmethod +from typing import Generator + +from kcwarden.custom_types import config_keys +from kcwarden.custom_types.database import Database +from kcwarden.custom_types.keycloak_object import Dataclass, Client +from kcwarden.custom_types.result import Severity, Result +from kcwarden.database import helper + + +class Auditor(ABC): + DEFAULT_SEVERITY: Severity + SHORT_DESCRIPTION: str + LONG_DESCRIPTION: str + REFERENCE: str + HAS_CUSTOM_CONFIG: bool = False + _DB: Database + _CONFIG: dict[str, str | list | dict | bool] + + def __init__(self, db: Database, config: dict[str, str | list | dict | bool]): + self._DB = db + self._CONFIG = config + + @abstractmethod + def audit(self) -> Generator[Result, None, None]: + raise NotImplementedError() + + def generate_finding( + self, + dataclass_obj: Dataclass, + additional_details: dict | None = None, + override_short_description: str | None = None, + override_long_description: str | None = None, + override_reference: str | None = None, + override_severity: Severity | None = None, + ) -> Result: + return Result( + severity=override_severity if override_severity is not None else self.DEFAULT_SEVERITY, + offending_object=dataclass_obj, + short_description=self.SHORT_DESCRIPTION + if override_short_description is None + else override_short_description, + long_description=self.LONG_DESCRIPTION if override_long_description is None else override_long_description, + reference=self.REFERENCE if override_reference is None else override_reference, + reporting_auditor=self.get_classname(), + additional_details=additional_details or {}, + ) + + ### Configuration Management + # Generic config getter + def get_config(self, key: str, default: str | bool | list | dict | None = None) -> str | list | dict | bool | None: + return self._CONFIG.get(key, default) + + # Custom, Auditor-specific config + @classmethod + def has_custom_config(cls) -> bool: + return cls.HAS_CUSTOM_CONFIG + + @classmethod + def get_custom_config_template(cls) -> list[dict] | None: + raise NotImplementedError( + "Calling get_custom_config_template on an Auditor, which is only supported for Monitors." + ) + + def get_custom_config(self) -> list[dict]: + custom_config_dict = self._CONFIG.get(config_keys.MONITOR_CONFIG, {}) + assert isinstance(custom_config_dict, dict) + return custom_config_dict.get(self.get_classname(), []) + + ### Ignore List configuration + # Specific ignore list from the config + def _get_ignore_list(self) -> list[str]: + ignore_dict = self._CONFIG[config_keys.AUDITOR_CONFIG] + assert isinstance(ignore_dict, dict) + return ignore_dict.get(self.get_classname(), []) + + # More generic ignores (also calls specific ignore list from config) + def is_not_ignored(self, keycloak_object: Dataclass) -> bool: + # Check if the provided object should be considered, based on the audit configuration. + # If the object is in the explicit ignore list for the auditor, it should always be ignored. + if helper.matches_list_of_regexes(keycloak_object.get_name(), self._get_ignore_list()): + return False + + # Checks for clients: + if isinstance(keycloak_object, Client): + # If it is enabled, it should always be considered + if keycloak_object.is_enabled(): + return True + # Otherwise, it should be considered if "ignore disabled clients" is not set + return not self.get_config(config_keys.IGNORE_DISABLED_CLIENTS) + # Anything that doesn't have specific ignore rules associated with it is always considered. + return True + + @classmethod + def get_classname(cls) -> str: + return cls.__name__ + + def __str__(self) -> str: + return self.get_classname() diff --git a/kcwarden/api/monitor.py b/kcwarden/api/monitor.py new file mode 100644 index 0000000..e989526 --- /dev/null +++ b/kcwarden/api/monitor.py @@ -0,0 +1,56 @@ +from abc import ABC + +from .auditor import Auditor +from kcwarden.custom_types.keycloak_object import Dataclass +from kcwarden.custom_types.result import Severity, Result, get_severity_by_name + + +class Monitor(Auditor, ABC): + HAS_CUSTOM_CONFIG: bool = True + COMMON_CUSTOM_CONFIG_TEMPLATE: dict = { + "allowed": ["allowed-entity-name", "allowed-entity-regex.*"], + "note": "A note on why a match is interesting. Will be part of the output on matches.", + "severity": "Placeholder, will be replaced in get_custom_config_template", + } + + # The CUSTOM_CONFIG_TEMPLATE defines additional fields for the configuration that should be merged into the common + # custom config format. + CUSTOM_CONFIG_TEMPLATE: dict | None = None + + @classmethod + def get_custom_config_template(cls) -> list[dict] | None: + if cls.CUSTOM_CONFIG_TEMPLATE is None: + raise NotImplementedError( + "Monitor %s does not set CUSTOM_CONFIG_TEMPLATE. Must be set." % cls.get_classname() + ) + + config = cls.COMMON_CUSTOM_CONFIG_TEMPLATE | cls.CUSTOM_CONFIG_TEMPLATE + # Set default severity override + config["severity"] = cls.DEFAULT_SEVERITY.name + return [config] + + def generate_finding_with_severity_from_config( + self, + dataclass_obj: Dataclass, + matched_config: dict, + additional_details: dict | None = None, + override_short_description: str | None = None, + override_long_description: str | None = None, + override_reference: str | None = None, + override_severity: Severity | None = None, + ) -> Result: + """ + Generate a finding that considers the severity from the matched config. + """ + severity = override_severity + if severity is None: + severity = get_severity_by_name(matched_config.get("severity")) if "severity" in matched_config else None + + return self.generate_finding( + dataclass_obj, + additional_details, + override_short_description, + override_long_description, + override_reference, + severity, + ) diff --git a/kcwarden/auditors/__init__.py b/kcwarden/auditors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/auditors/client/__init__.py b/kcwarden/auditors/client/__init__.py new file mode 100644 index 0000000..307116c --- /dev/null +++ b/kcwarden/auditors/client/__init__.py @@ -0,0 +1,49 @@ +from kcwarden.auditors.client.client_authentication_via_mtls_or_jwt_recommended import ( + ClientAuthenticationViaMTLSOrJWTRecommended, +) +from kcwarden.auditors.client.client_has_erroneously_configured_wildcard_uri import ( + ClientHasErroneouslyConfiguredWildcardURI, +) +from kcwarden.auditors.client.client_has_undefined_base_domain_and_schema import ClientHasUndefinedBaseDomainAndSchema +from kcwarden.auditors.client.client_must_not_use_unencrypted_nonlocal_redirect_uri import ( + ClientMustNotUseUnencryptedNonlocalRedirectUri, +) +from kcwarden.auditors.client.client_should_not_use_wildcard_redirect_uri import ClientShouldNotUseWildcardRedirectURI +from kcwarden.auditors.client.client_uses_custom_redirect_uri_scheme import ClientUsesCustomRedirectUriScheme +from kcwarden.auditors.client.client_with_default_offline_access_scope import ClientWithDefaultOfflineAccessScope +from kcwarden.auditors.client.client_with_full_scope_allowed import ClientWithFullScopeAllowed +from kcwarden.auditors.client.client_with_optional_offline_access_scope import ClientWithOptionalOfflineAccessScope +from kcwarden.auditors.client.client_with_service_account_and_other_flow_enabled import ( + ClientWithServiceAccountAndOtherFlowEnabled, +) +from kcwarden.auditors.client.client_should_disable_implicit_grant_flow import ClientShouldDisableImplicitGrantFlow +from kcwarden.auditors.client.confidential_client_should_disable_direct_access_grants import ( + ConfidentialClientShouldDisableDirectAccessGrants, +) +from kcwarden.auditors.client.confidential_client_should_enforce_pkce import ConfidentialClientShouldEnforcePKCE +from kcwarden.auditors.client.public_client_should_disable_direct_access_grants import ( + PublicClientShouldDisableDirectAccessGrants, +) +from kcwarden.auditors.client.using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous import ( + UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous, +) +from kcwarden.auditors.client.public_clients_must_enforce_pkce import PublicClientsMustEnforcePKCE + +AUDITORS = [ + PublicClientsMustEnforcePKCE, + ConfidentialClientShouldEnforcePKCE, + ClientShouldDisableImplicitGrantFlow, + PublicClientShouldDisableDirectAccessGrants, + ConfidentialClientShouldDisableDirectAccessGrants, + ClientAuthenticationViaMTLSOrJWTRecommended, + ClientMustNotUseUnencryptedNonlocalRedirectUri, + ClientUsesCustomRedirectUriScheme, + ClientHasUndefinedBaseDomainAndSchema, + ClientShouldNotUseWildcardRedirectURI, + ClientHasErroneouslyConfiguredWildcardURI, + ClientWithServiceAccountAndOtherFlowEnabled, + UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous, + ClientWithDefaultOfflineAccessScope, + ClientWithOptionalOfflineAccessScope, + ClientWithFullScopeAllowed, +] diff --git a/kcwarden/auditors/client/client_authentication_via_mtls_or_jwt_recommended.py b/kcwarden/auditors/client/client_authentication_via_mtls_or_jwt_recommended.py new file mode 100644 index 0000000..b3e79b7 --- /dev/null +++ b/kcwarden/auditors/client/client_authentication_via_mtls_or_jwt_recommended.py @@ -0,0 +1,27 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientAuthenticationViaMTLSOrJWTRecommended(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Client Authentication via mTLS or Signed JWT is Recommended" + LONG_DESCRIPTION = "Confidential Clients need to authenticate to Keycloak to use its features. By default, is uses a shared client secret. It is RECOMMENDED to use mTLS or signed JWTs instead, if possible. For details, see the Keycloak documentation: https://www.keycloak.org/docs/latest/server_admin/#_client-credentials" + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.5" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - Confidential Clients + return self.is_not_ignored(client) and client.is_oidc_client() and not client.is_public() + + def client_does_not_use_mtls_or_jwt_auth(self, client) -> bool: + # If the clientAuthenticatorType is client-secret, basic client secret authentication is used. + # TODO Check what the correct values for mTLS or signed JWT are, and update this check + return client.get_client_authenticator_type() == "client-secret" + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_does_not_use_mtls_or_jwt_auth(client): + # All clients matching these criteria should be reported + yield self.generate_finding(client) diff --git a/kcwarden/auditors/client/client_has_erroneously_configured_wildcard_uri.py b/kcwarden/auditors/client/client_has_erroneously_configured_wildcard_uri.py new file mode 100644 index 0000000..ef70a50 --- /dev/null +++ b/kcwarden/auditors/client/client_has_erroneously_configured_wildcard_uri.py @@ -0,0 +1,50 @@ +import urllib.parse + +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientHasErroneouslyConfiguredWildcardURI(Auditor): + DEFAULT_SEVERITY = Severity.Critical + SHORT_DESCRIPTION = "Erroneously configured Redirect URI allows arbitrary domains for redirects" + LONG_DESCRIPTION = "Authorization responses contain sensitive data, like the OAuth Response Code, which should not be exposed. Keycloak requires specifying an allowed set of redirect URIs. In this case, a redirect URI was specified that is almost certainly incorrect, as the domain name contains a wildcard in the domain name part (i.e., https://example.com*). This allows arbitrary domains to be specified as a redirect URI as long as they begin with the specified part of the redirect URI, e.g. example.com.attacker.tk. The wildcard should almost certainly be placed behind a slash to make it part of the Path (e.g., https://example.com/*)." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - At least one flow that uses the redirect_uri active + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (client.has_standard_flow_enabled() or client.has_implicit_flow_enabled()) + ) + + def redirect_uri_has_wildcard_in_domain(self, redirect) -> bool: + parsed_redirect_uri = urllib.parse.urlparse(redirect) + # The redirect URI has the form https://domain.tld* + if parsed_redirect_uri.scheme in ["https", "http"] and parsed_redirect_uri.netloc.endswith("*"): + return True + # If the protocol is missing, the domain is recognized as part of the path by urllib. + # Workaround for these cases: + # - URI scheme has to be empty + # - netloc has to be empty (i.e., no domain was recognized) + # - path does not contain a slash (i.e., we are not in the "real" path, but the path only contains the incorrectly specified Domain) + # - path ends with wildcard (to trigger the vulnerability) + # To be honest, I am not sure what Keycloak would do with data that is specified like this, and if it would even work. + # However, I will flag it, just in case Keycloak is a bit too robust in dealing with these things. + return ( + parsed_redirect_uri.scheme == "" + and parsed_redirect_uri.netloc == "" + and "/" not in parsed_redirect_uri.path + and parsed_redirect_uri.path.endswith("*") + ) + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + # These clients should use either a localhost or an HTTPS URI + redirect_uris = client.get_resolved_redirect_uris() + for redirect in redirect_uris: + if self.redirect_uri_has_wildcard_in_domain(redirect): + yield self.generate_finding(client, additional_details={"redirect_uri": redirect}) diff --git a/kcwarden/auditors/client/client_has_undefined_base_domain_and_schema.py b/kcwarden/auditors/client/client_has_undefined_base_domain_and_schema.py new file mode 100644 index 0000000..da85e92 --- /dev/null +++ b/kcwarden/auditors/client/client_has_undefined_base_domain_and_schema.py @@ -0,0 +1,35 @@ +import urllib.parse + +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientHasUndefinedBaseDomainAndSchema(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Client redirect URL scheme undefined, cannot be audited" + LONG_DESCRIPTION = "Authorization responses contain sensitive data, like the OAuth Response Code, which should not be exposed. Therefore, the redirect_uri MUST be set to a HTTPS URI or (for native apps) a localhost address. For this client, this rule could not be validated, as the redirect URI combined with the root URL is insufficient to determine the used scheme. In most cases, this means that no clear redirect URI is defined. To remediate, define a fully qualified domain name including scheme (e.g. 'https://example.com/login') for either the client root URL or the redirect URI(s)." + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.6" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - At least one flow that uses the redirect_uri active + # TODO Are there more flows that use redirect_uri? + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (client.has_standard_flow_enabled() or client.has_implicit_flow_enabled()) + ) + + def redirect_uri_has_empty_scheme(self, redirect) -> bool: + parsed_redirect_uri = urllib.parse.urlparse(redirect) + return parsed_redirect_uri.scheme == "" + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + redirect_uris = client.get_resolved_redirect_uris() + for redirect in redirect_uris: + if self.redirect_uri_has_empty_scheme(redirect): + # The redirect URI is insufficiently specified to determine the URI scheme. + yield self.generate_finding(client, additional_details={"redirect_uri": redirect}) diff --git a/kcwarden/auditors/client/client_must_not_use_unencrypted_nonlocal_redirect_uri.py b/kcwarden/auditors/client/client_must_not_use_unencrypted_nonlocal_redirect_uri.py new file mode 100644 index 0000000..78f87c0 --- /dev/null +++ b/kcwarden/auditors/client/client_must_not_use_unencrypted_nonlocal_redirect_uri.py @@ -0,0 +1,59 @@ +import urllib.parse + +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientMustNotUseUnencryptedNonlocalRedirectUri(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Authorization Responses MUST NOT be transmitted via unencrypted connections" + LONG_DESCRIPTION = "Authorization responses contain sensitive data, like the OAuth Response Code, which should not be exposed. Therefore, the redirect_uri MUST be set to a HTTPS URI or (for native apps) a localhost address." + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.6" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - At least one flow that uses the redirect_uri active + # TODO Are there more flows that use redirect_uri? + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (client.has_standard_flow_enabled() or client.has_implicit_flow_enabled()) + ) + + def assert_non_default_client_has_redirect_uris(self, client, redirect_uris) -> None: + # TODO Refactor this as a sanity check in the client parser - this is the wrong location for that. + if not client.is_default_keycloak_client(): + assert len(redirect_uris) > 0, ( + "Assumption violated: no redirect URIs specified for client %s, even though I would expect there to be some. Please file a bug with a copy of the clients' JSON." + % client + ) + + def redirect_uri_is_http_and_non_local(self, redirect) -> bool: + # Parse the redirect URI as an URL + parsed_redirect_uri = urllib.parse.urlparse(redirect) + # We only consider those URLs that are explicitly recognized as http. + # There are several cases where this will not happen, for example if URLs + # are defined relative to the base URL of keycloak, which cannot be determined + # based on the config dumps. + # In these cases, we do not match them in this rule. Instead, we have a separate + # Auditor that emits informational findings for these cases. + # Unencrypted connections to a localhost address are permitted. + # All others should be reported + return parsed_redirect_uri.scheme == "http" and parsed_redirect_uri.netloc not in [ + "localhost", + "127.0.0.1", + "::1", + ] + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + redirect_uris = client.get_resolved_redirect_uris() + # Ensure that the client is sane + self.assert_non_default_client_has_redirect_uris(client, redirect_uris) + + # Run checks for every redirect URI + for redirect in redirect_uris: + if self.redirect_uri_is_http_and_non_local(redirect): + yield self.generate_finding(client, additional_details={"redirect_uri": redirect}) diff --git a/kcwarden/auditors/client/client_should_disable_implicit_grant_flow.py b/kcwarden/auditors/client/client_should_disable_implicit_grant_flow.py new file mode 100644 index 0000000..3da7f7d --- /dev/null +++ b/kcwarden/auditors/client/client_should_disable_implicit_grant_flow.py @@ -0,0 +1,24 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientShouldDisableImplicitGrantFlow(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "The 'implicit grant' flow SHOULD NOT be used" + LONG_DESCRIPTION = "The implicit grant flow exposes the access token in the URL, which can lead to access token leakage or replay vulnerabilities. The 'Authorization Code' flow (called 'Standard Flow' in Keycloak) should be used, and the implicit flow disabled in Keycloak." + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.1.2" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + return self.is_not_ignored(client) and client.is_oidc_client() + + def client_uses_implicit_grant_flow(self, client) -> bool: + # All clients that have implicit flow enabled are considered suspect + return client.has_implicit_flow_enabled() + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_uses_implicit_grant_flow(client): + yield self.generate_finding(client) diff --git a/kcwarden/auditors/client/client_should_not_use_wildcard_redirect_uri.py b/kcwarden/auditors/client/client_should_not_use_wildcard_redirect_uri.py new file mode 100644 index 0000000..fa15aab --- /dev/null +++ b/kcwarden/auditors/client/client_should_not_use_wildcard_redirect_uri.py @@ -0,0 +1,34 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientShouldNotUseWildcardRedirectURI(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Clients should not use wildcard redirect URIs" + LONG_DESCRIPTION = "Authorization responses contain sensitive data, like the OAuth Response Code, which should not be exposed. Therefore, the redirect_uri should not be set with a wildcard, if possible. If a wildcard is required, it should still be as specific as possible." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - At least one flow that uses the redirect_uri active + # TODO Are there more flows that use redirect_uri? + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (client.has_standard_flow_enabled() or client.has_implicit_flow_enabled()) + ) + + def redirect_uri_is_wildcard_uri(self, redirect) -> bool: + # The only place Keycloak allows wildcards in a redirect URI is at the very end. + # So the first approximation can be "is the last character a *?" + return redirect[-1:] == "*" + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + # These clients should use either a localhost or an HTTPS URI + redirect_uris = client.get_resolved_redirect_uris() + for redirect in redirect_uris: + if self.redirect_uri_is_wildcard_uri(redirect): + yield self.generate_finding(client, additional_details={"redirect_uri": redirect}) diff --git a/kcwarden/auditors/client/client_uses_custom_redirect_uri_scheme.py b/kcwarden/auditors/client/client_uses_custom_redirect_uri_scheme.py new file mode 100644 index 0000000..19b5fc3 --- /dev/null +++ b/kcwarden/auditors/client/client_uses_custom_redirect_uri_scheme.py @@ -0,0 +1,50 @@ +import urllib.parse + +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientUsesCustomRedirectUriScheme(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Client redirect URL scheme uses custom protocol" + LONG_DESCRIPTION = "Authorization responses contain sensitive data, like the OAuth Response Code, which should not be exposed. This client uses a custom protocol (i.e., not http:// or https://), which should be closely inspected. Note that the use of custom protocols can pose a security risk when used to connect to a mobile app on a smartphone. See the online documentation for more information." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - At least one flow that uses the redirect_uri active + # TODO Are there more flows that use redirect_uri? + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (client.has_standard_flow_enabled() or client.has_implicit_flow_enabled()) + ) + + def assert_non_default_client_has_redirect_uris(self, client, redirect_uris) -> None: + # TODO Refactor this as a sanity check in the client parser - this is the wrong location for that. + if not client.is_default_keycloak_client(): + assert len(redirect_uris) > 0, ( + "Assumption violated: no redirect URIs specified for client %s, even though I would expect there to be some. Please file a bug with a copy of the clients' JSON." + % client + ) + + def redirect_uri_uses_custom_protocol(self, redirect) -> bool: + # Parse as an URL to get access to the scheme + parsed_redirect_uri = urllib.parse.urlparse(redirect) + # http connections are covered by ClientMustNotUseUnencryptedNonlocalRedirectUri. + # https connections are permitted. + # Empty scheme would indicate that a relative address is provided and we can't make any statements + # All others are suspect and should be reported. + return parsed_redirect_uri.scheme not in ["http", "https", ""] + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + redirect_uris = client.get_resolved_redirect_uris() + # Ensure client is sane + self.assert_non_default_client_has_redirect_uris(client, redirect_uris) + + for redirect in redirect_uris: + if self.redirect_uri_uses_custom_protocol(redirect): + yield self.generate_finding(client, additional_details={"redirect_uri": redirect}) diff --git a/kcwarden/auditors/client/client_with_default_offline_access_scope.py b/kcwarden/auditors/client/client_with_default_offline_access_scope.py new file mode 100644 index 0000000..a63cc58 --- /dev/null +++ b/kcwarden/auditors/client/client_with_default_offline_access_scope.py @@ -0,0 +1,42 @@ +# TODO Create versions of the profile auditors for the default attributes + + +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientWithDefaultOfflineAccessScope(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Client has offline_access scope by default" + LONG_DESCRIPTION = "The 'offline_access' scope of Keycloak enables the use of offline tokens, which are a more powerful and long-lives version of refresh tokens. Having an offline token allows a user to keep a login session for a long time (depending on the server configuration - often half a year or longer). They are generally used for native applications (e.g., mobile apps) or server-to-server connections that need to be able to access a users' account while the user is not present. Other clients should not use this feature, as it is unnecessary, and because leaking an offline token to an attacker can allow them to gain long-term access to a users' account. Please check if this client really requires the use of offline tokens, and remove the scope, disable refresh tokens for this client, or add the client to the list of allowed clients in the kcwarden configuration to silence this warning." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + return self.is_not_ignored(client) + + def client_can_generate_offline_tokens(self, client) -> bool: + # Check if the "offline_access" scope is in the default scopes + # But only report if a flow is activated that can actually give out offline tokens + # and if refresh tokens are active on that client + return ( + "offline_access" in client.get_default_client_scopes() + and client.allows_user_authentication() + and client.get_attributes().get("use.refresh.tokens", "false") == "true" + ) + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_can_generate_offline_tokens(client): + yield self.generate_finding( + client, + additional_details={ + "default_scopes": client.get_default_client_scopes(), + "optional_scopes": client.get_optional_client_scopes(), + "client_public": client.is_public(), + "standard_flow_enabled": client.has_standard_flow_enabled(), + "implicit_flow_enabled": client.has_implicit_flow_enabled(), + "direct_access_grant_enabled": client.has_direct_access_grants_enabled(), + "device_flow_enabled": client.has_device_authorization_grant_flow_enabled(), + }, + ) diff --git a/kcwarden/auditors/client/client_with_full_scope_allowed.py b/kcwarden/auditors/client/client_with_full_scope_allowed.py new file mode 100644 index 0000000..105f208 --- /dev/null +++ b/kcwarden/auditors/client/client_with_full_scope_allowed.py @@ -0,0 +1,28 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientWithFullScopeAllowed(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Client has 'full scope allowed' set" + LONG_DESCRIPTION = "Keycloak scopes control what information and roles are added to an access token. Generally, access tokens should be 'least privilege', meaning that they only contain the roles and information that are actually required to achieve the task. If the 'Full scope allowed' option is set on a client, it ignores the configured scopes, and simply adds all roles that the user has to the token, as if all scopes were selected. This leads to overprivileged tokens." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + return self.is_not_ignored(client) and client.allows_user_authentication() + + def client_has_full_scope_allowed(self, client) -> bool: + return client.has_full_scope_allowed() + + def audit(self): + for client in self._DB.get_all_clients(): + # Report clients with full scope allowed + if self.should_consider_client(client): + if self.client_has_full_scope_allowed(client): + yield self.generate_finding( + client, + additional_details={ + "default_scopes": client.get_default_client_scopes(), + "optional_scopes": client.get_optional_client_scopes(), + }, + ) diff --git a/kcwarden/auditors/client/client_with_optional_offline_access_scope.py b/kcwarden/auditors/client/client_with_optional_offline_access_scope.py new file mode 100644 index 0000000..aa10948 --- /dev/null +++ b/kcwarden/auditors/client/client_with_optional_offline_access_scope.py @@ -0,0 +1,39 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientWithOptionalOfflineAccessScope(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Client has offline_access scope as optional scope" + LONG_DESCRIPTION = "The 'offline_access' scope of Keycloak enables the use of offline tokens, which are a more powerful and long-lives version of refresh tokens. Having an offline token allows a user to keep a login session for a long time (depending on the server configuration - often half a year or longer). They are generally used for native applications (e.g., mobile apps) or server-to-server connections that need to be able to access a users' account while the user is not present. Other clients should not use this feature, as it is unnecessary, and because leaking an offline token to an attacker can allow them to gain long-term access to a users' account. Please check if this client really requires the use of offline tokens, and remove the scope, disable refresh tokens for this client, or add the client to the list of allowed clients in the kcwarden configuration to silence this warning." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + return self.is_not_ignored(client) + + def client_can_generate_offline_tokens(self, client) -> bool: + # Check if the "offline_access" scope is in the optional scopes + # But only report if a flow is activated that can actually give out offline tokens + # and if refresh tokens are active on that client + return ( + "offline_access" in client.get_optional_client_scopes() + and client.allows_user_authentication() + and client.get_attributes().get("use.refresh.tokens", "false") == "true" + ) + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_can_generate_offline_tokens(client): + yield self.generate_finding( + client, + additional_details={ + "default_scopes": client.get_default_client_scopes(), + "optional_scopes": client.get_optional_client_scopes(), + "client_public": client.is_public(), + "standard_flow_enabled": client.has_standard_flow_enabled(), + "implicit_flow_enabled": client.has_implicit_flow_enabled(), + "direct_access_grant_enabled": client.has_direct_access_grants_enabled(), + "device_flow_enabled": client.has_device_authorization_grant_flow_enabled(), + }, + ) diff --git a/kcwarden/auditors/client/client_with_service_account_and_other_flow_enabled.py b/kcwarden/auditors/client/client_with_service_account_and_other_flow_enabled.py new file mode 100644 index 0000000..110bcc2 --- /dev/null +++ b/kcwarden/auditors/client/client_with_service_account_and_other_flow_enabled.py @@ -0,0 +1,42 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ClientWithServiceAccountAndOtherFlowEnabled(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Confidential Client with Service Accounts and other flow enabled" + LONG_DESCRIPTION = "Often, confidential clients that have service accounts associated with them are exclusively used for their service account. In these cases, any additional methods (standard flow, implicit flow, ...) can be disabled as a matter of general hygene. If you are using both features of the client, feel free to ignore this finding." + REFERENCE = "" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - Confidential + # - Has service account + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (not client.is_public()) + and client.has_service_account_enabled() + ) + + def client_has_non_service_account_flow_enabled(self, client): + # If this client has any other flows enabled, emit an informational finding + # TODO Are there any other flows that could be enabled? + return client.allows_user_authentication() + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_has_non_service_account_flow_enabled(client): + yield self.generate_finding( + client, + additional_details={ + "client_public": client.is_public(), + "service_account_enabled": client.has_service_account_enabled(), + "standard_flow_enabled": client.has_standard_flow_enabled(), + "implicit_flow_enabled": client.has_implicit_flow_enabled(), + "direct_access_grant_enabled": client.has_direct_access_grants_enabled(), + "device_flow_enabled": client.has_device_authorization_grant_flow_enabled(), + }, + ) diff --git a/kcwarden/auditors/client/confidential_client_should_disable_direct_access_grants.py b/kcwarden/auditors/client/confidential_client_should_disable_direct_access_grants.py new file mode 100644 index 0000000..e0aec58 --- /dev/null +++ b/kcwarden/auditors/client/confidential_client_should_disable_direct_access_grants.py @@ -0,0 +1,25 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ConfidentialClientShouldDisableDirectAccessGrants(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "The 'direct access grant' flow MUST NOT be used" + LONG_DESCRIPTION = "The resource owner password credentials grant (called 'direct access grant' in Keycloak) requires the client to submit username and password of the authenticating user. This greatly increases the attack surface for the credentials, as they are exposed outside of Keycloak. Additionally, it is generally incompatible with two-factor-authentication methods like WebAuthN or SMS tokens. This flow MUST NOT be used, and should be disabled on all clients. While it is less dangerous in the case of confidential clients like this one, as using the client requires knowledge of the client secret, the same recommendation still applies. (Some systems use the direct access grant flow to obtain tokens for technical users. In this case, please note that using technical users is strongly discouraged in favor of the 'service accounts' feature of Keycloak.)" + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.4" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC clients + # - Are confidential clients + return self.is_not_ignored(client) and client.is_oidc_client() and not client.is_public() + + def client_uses_direct_access_grants(self, client) -> bool: + # All clients with direct access grants should be reported + return client.has_direct_access_grants_enabled() + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_uses_direct_access_grants(client): + yield self.generate_finding(client) diff --git a/kcwarden/auditors/client/confidential_client_should_enforce_pkce.py b/kcwarden/auditors/client/confidential_client_should_enforce_pkce.py new file mode 100644 index 0000000..6b3dda3 --- /dev/null +++ b/kcwarden/auditors/client/confidential_client_should_enforce_pkce.py @@ -0,0 +1,31 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class ConfidentialClientShouldEnforcePKCE(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Confidential Clients should use and enforce PKCE" + LONG_DESCRIPTION = "It is RECOMMENDED that Confidential Clients using the Authorization Code Grant flow (called 'standard flow' in Keycloak) use PKCE when using the Authorization Code Flow. Otherwise, they may be vulnerable to authorization code injection, Cross-Site Request Forgery (CSRF), or other attacks. PKCE should also be enforced in the Keycloak client settings by setting the PKCE Code Challenge Method to 'S256'. Other methods are less secure. Alternatively, the client MAY use the nonce parameter and respective claim, as described in section 4.5.3.2 of the linked reference." + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.1.1" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - Confidential + # - Have the standard flow enabled + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and (not client.is_public()) + and client.has_standard_flow_enabled() + ) + + def client_does_not_enforce_pkce(self, client) -> bool: + # These clients should use PKCE and pin to S256 as the algorithm + return client.get_attributes().get("pkce.code.challenge.method", None) != "S256" + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_does_not_enforce_pkce(client): + yield self.generate_finding(client) diff --git a/kcwarden/auditors/client/public_client_should_disable_direct_access_grants.py b/kcwarden/auditors/client/public_client_should_disable_direct_access_grants.py new file mode 100644 index 0000000..c76dce4 --- /dev/null +++ b/kcwarden/auditors/client/public_client_should_disable_direct_access_grants.py @@ -0,0 +1,25 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class PublicClientShouldDisableDirectAccessGrants(Auditor): + DEFAULT_SEVERITY = Severity.High + SHORT_DESCRIPTION = "The 'direct access grant' flow MUST NOT be used, particularly not by public clients" + LONG_DESCRIPTION = "The resource owner password credentials grant (called 'direct access grant' in Keycloak) requires the client to submit username and password of the authenticating user. This greatly increases the attack surface for the credentials, as they are exposed outside of Keycloak. Additionally, it is generally incompatible with two-factor-authentication methods like WebAuthN or SMS tokens. This flow MUST NOT be used, and should be disabled on all clients. This is especially important on public clients like this one, as they allow anyone to authenticate using Direct Access Grants, not just the rightful user of the client, as in the case of confidential clients. (Some systems use the direct access grant flow to obtain tokens for technical users. In this case, please note that using technical users is strongly discouraged in favor of the 'service accounts' feature of Keycloak.)" + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.4" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC clients + # - Are public clients + return self.is_not_ignored(client) and client.is_oidc_client() and client.is_public() + + def client_uses_direct_access_grants(self, client) -> bool: + # All clients with direct access grants should be reported + return client.has_direct_access_grants_enabled() + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_uses_direct_access_grants(client): + yield self.generate_finding(client) diff --git a/kcwarden/auditors/client/public_clients_must_enforce_pkce.py b/kcwarden/auditors/client/public_clients_must_enforce_pkce.py new file mode 100644 index 0000000..cf31178 --- /dev/null +++ b/kcwarden/auditors/client/public_clients_must_enforce_pkce.py @@ -0,0 +1,31 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class PublicClientsMustEnforcePKCE(Auditor): + DEFAULT_SEVERITY = Severity.High + SHORT_DESCRIPTION = "Public Clients MUST use and enforce PKCE" + LONG_DESCRIPTION = "Public Clients using the Authorization Code Grant flow (called 'standard flow' in Keycloak) MUST use PKCE when using the Authorization Code Flow. Otherwise, they may be vulnerable to authorization code injection, Cross-Site Request Forgery (CSRF), or other attacks. PKCE must also be enforced in the Keycloak client settings by setting the PKCE Code Challenge Method to 'S256'. Other methods are less secure." + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.1.1" + + def should_consider_client(self, client) -> bool: + # We are interested in clients that are: + # - OIDC Clients + # - Public + # - Have the standard flow enabled + return ( + self.is_not_ignored(client) + and client.is_oidc_client() + and client.is_public() + and client.has_standard_flow_enabled() + ) + + def client_does_not_enforce_pkce(self, client) -> bool: + # Clients should use PKCE and pin to S256 as the algorithm + return client.get_attributes().get("pkce.code.challenge.method", None) != "S256" + + def audit(self): + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + if self.client_does_not_enforce_pkce(client): + yield self.generate_finding(client) diff --git a/kcwarden/auditors/client/using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous.py b/kcwarden/auditors/client/using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous.py new file mode 100644 index 0000000..dc2bea5 --- /dev/null +++ b/kcwarden/auditors/client/using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous.py @@ -0,0 +1,45 @@ +from kcwarden.auditors.scope.using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous import ( + UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous, +) +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +# TODO Create versions of the profile auditors for the default attributes + + +class UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous(Auditor): + DEFAULT_SEVERITY = Severity.High + SHORT_DESCRIPTION = "Client uses user attributes, but server does not have User Profiles feature enabled" + LONG_DESCRIPTION = "Keycloak allows assigning attributes to users. In addition to the default attributes (like name, email, phone number, etc.) you can also add custom attributes. By default, any user is allowed to edit their own attributes when signing up or accessing the default user console. This means that you MUST NOT store sensitive information in the attributes that you rely on in other systems (e.g., a customer number that is used to link their Keycloak account to a customer database). You can prevent the user from editing their own attributes using the experimental User Profiles feature of Keycloak and defining a policy that controls who is allowed to edit specific attributes. See the linked documentation for details." + REFERENCE = "https://www.keycloak.org/docs/latest/server_admin/#user-profile" + + def should_consider_client(self, client) -> bool: + # If the client's realm has activated the user profiles feature, the client + # is not affected no matter which mappers it has. + return self.is_not_ignored(client) and not client.get_realm().has_declarative_user_profiles_enabled() + + def mapper_references_non_default_user_attribute(self, mapper) -> bool: + # The mapper type must be oidc-usermodel-attribute-mapper + # The referenced user attribute must be a non-default user attribute (we will have a separate auditor for default user attributes) + return ( + mapper.get_protocol_mapper() == "oidc-usermodel-attribute-mapper" + and mapper.get_config()["user.attribute"] + not in UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous.DEFAULT_ATTRIBUTES + ) + + def audit(self): + # First, we need to determine if there are any clients that are actually using + # user attributes. As far as I know, the only way to use them which can be + # detected from the Keycloak configuration is using a mapper in a client or scope. + # Scopes are checked in UsingNonDefaultUserAttributesInScopesWithoutUserAccountsFeatureIsDangerous + # (It may be possible to also gain access to the attributes using other APIs, + # but these cannot be identified from looking at the Keycloak configuration alone). + for client in self._DB.get_all_clients(): + if self.should_consider_client(client): + # Check the mappers of the client + for mapper in client.get_protocol_mappers(): + if self.mapper_references_non_default_user_attribute(mapper): + yield self.generate_finding( + client, additional_details={"used-attribute": mapper.get_config()["user.attribute"]} + ) diff --git a/kcwarden/auditors/client_auditor.py b/kcwarden/auditors/client_auditor.py new file mode 100644 index 0000000..9b2a4b5 --- /dev/null +++ b/kcwarden/auditors/client_auditor.py @@ -0,0 +1,52 @@ +from kcwarden.auditors.client.client_authentication_via_mtls_or_jwt_recommended import ( + ClientAuthenticationViaMTLSOrJWTRecommended, +) +from kcwarden.auditors.client.client_has_erroneously_configured_wildcard_uri import ( + ClientHasErroneouslyConfiguredWildcardURI, +) +from kcwarden.auditors.client.client_has_undefined_base_domain_and_schema import ClientHasUndefinedBaseDomainAndSchema +from kcwarden.auditors.client.client_must_not_use_unencrypted_nonlocal_redirect_uri import ( + ClientMustNotUseUnencryptedNonlocalRedirectUri, +) +from kcwarden.auditors.client.client_should_not_use_wildcard_redirect_uri import ClientShouldNotUseWildcardRedirectURI +from kcwarden.auditors.client.client_uses_custom_redirect_uri_scheme import ClientUsesCustomRedirectUriScheme +from kcwarden.auditors.client.client_with_default_offline_access_scope import ClientWithDefaultOfflineAccessScope +from kcwarden.auditors.client.client_with_full_scope_allowed import ClientWithFullScopeAllowed +from kcwarden.auditors.client.client_with_optional_offline_access_scope import ClientWithOptionalOfflineAccessScope +from kcwarden.auditors.client.client_with_service_account_and_other_flow_enabled import ( + ClientWithServiceAccountAndOtherFlowEnabled, +) +from kcwarden.auditors.client.client_should_disable_implicit_grant_flow import ClientShouldDisableImplicitGrantFlow +from kcwarden.auditors.client.confidential_client_should_disable_direct_access_grants import ( + ConfidentialClientShouldDisableDirectAccessGrants, +) +from kcwarden.auditors.client.confidential_client_should_enforce_pkce import ConfidentialClientShouldEnforcePKCE +from kcwarden.auditors.client.public_client_should_disable_direct_access_grants import ( + PublicClientShouldDisableDirectAccessGrants, +) +from kcwarden.auditors.client.using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous import ( + UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous, +) +from kcwarden.auditors.client.public_clients_must_enforce_pkce import PublicClientsMustEnforcePKCE + +# TODO Refactor this bit out of here to get rid of this file. +# Idea: Rely on the auto-import logic that will be the basis for the plugin infrastructure? + +AUDITORS = [ + PublicClientsMustEnforcePKCE, + ConfidentialClientShouldEnforcePKCE, + ClientShouldDisableImplicitGrantFlow, + PublicClientShouldDisableDirectAccessGrants, + ConfidentialClientShouldDisableDirectAccessGrants, + ClientAuthenticationViaMTLSOrJWTRecommended, + ClientMustNotUseUnencryptedNonlocalRedirectUri, + ClientUsesCustomRedirectUriScheme, + ClientHasUndefinedBaseDomainAndSchema, + ClientShouldNotUseWildcardRedirectURI, + ClientHasErroneouslyConfiguredWildcardURI, + ClientWithServiceAccountAndOtherFlowEnabled, + UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous, + ClientWithDefaultOfflineAccessScope, + ClientWithOptionalOfflineAccessScope, + ClientWithFullScopeAllowed, +] diff --git a/kcwarden/auditors/idp/__init__.py b/kcwarden/auditors/idp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/auditors/idp/identity_provider_with_mappers_without_force_sync_mode.py b/kcwarden/auditors/idp/identity_provider_with_mappers_without_force_sync_mode.py new file mode 100644 index 0000000..8d75a05 --- /dev/null +++ b/kcwarden/auditors/idp/identity_provider_with_mappers_without_force_sync_mode.py @@ -0,0 +1,27 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class IdentityProviderWithMappersWithoutForceSyncMode(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Identity Provider uses upstream data, but does not update it" + LONG_DESCRIPTION = "Keycloak allows you to configure external identity providers. You can also set up one or more Identity Provider Mappers, which pull information from the access token of the IDP and import it into Keycloak. By default, this import only happens on the first login, and any future updates in the upstream IDP are ignored. If you use mappers to assign groups or other access rights, this means that the rights will not be updated if the upstream IDP changes them. This may be intended by you (in which case you can silence this finding), but it may also be a security bug. To accept updates from upstream, you can set the sync mode to 'Force', either for the entire IDP (in which case it will also overwrite user information like name and email on each login), or for the relevant mappers. This finding has a higher severity than the IdentityProviderWithOneTimeSync finding, as the configured IDP uses at least one Identity Provider Mapper." + REFERENCE = "" + + def should_consider_idp(self, idp) -> bool: + return self.is_not_ignored(idp) + + def idp_uses_sync_mode_force(self, idp) -> bool: + return idp.get_sync_mode() == "FORCE" + + def idp_uses_information_from_access_token(self, idp) -> bool: + return idp.get_identity_provider_mappers() != [] + + def audit(self): + for idp in self._DB.get_all_identity_providers(): + # We are looking for IDPs that do not use the "Force" sync mode + if self.idp_uses_sync_mode_force(idp): + continue + # Among these, we are looking for ones that are pulling information from the token + if self.idp_uses_information_from_access_token(idp): + yield self.generate_finding(idp) diff --git a/kcwarden/auditors/idp/identity_provider_with_one_time_sync.py b/kcwarden/auditors/idp/identity_provider_with_one_time_sync.py new file mode 100644 index 0000000..ccf8552 --- /dev/null +++ b/kcwarden/auditors/idp/identity_provider_with_one_time_sync.py @@ -0,0 +1,21 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class IdentityProviderWithOneTimeSync(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Identity Provider does not accept updates from upstream IDP" + LONG_DESCRIPTION = "Keycloak allows you to configure external identity providers. By default, on the first login, information about the user is pulled from the upstream IDP and imported into Keycloak. Subsequent updates of the user in the upstream IDP (e.g., Name, Email address, ...) are then ignored. If this behavior is intended in your setup, silence this finding. If not, you may want to look into the sync mode 'Force', which will import updates from the upstream IDP on every login (overwriting any changes you may have performed locally)." + REFERENCE = "" + + def should_consider_idp(self, idp) -> bool: + return self.is_not_ignored(idp) + + def idp_does_not_use_force_sync_mode(self, idp) -> bool: + return idp.get_sync_mode() != "FORCE" + + def audit(self): + for idp in self._DB.get_all_identity_providers(): + # We are looking for IDPs that do not use the "Force" sync mode + if self.idp_does_not_use_force_sync_mode(idp): + yield self.generate_finding(idp) diff --git a/kcwarden/auditors/idp/oidc_identity_provider_without_pkce.py b/kcwarden/auditors/idp/oidc_identity_provider_without_pkce.py new file mode 100644 index 0000000..9348993 --- /dev/null +++ b/kcwarden/auditors/idp/oidc_identity_provider_without_pkce.py @@ -0,0 +1,38 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class OIDCIdentityProviderWithoutPKCE(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "OIDC Identity Provider configured without PKCE" + LONG_DESCRIPTION = "The realm has configured an OIDC Identity Provider, but does not enable PKCE for it. PKCE prevents different kinds of attacks on the OIDC protocol, and it is RECOMMENDED to enable it." + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.1.1" + + def should_consider_idp(self, idp) -> bool: + # TODO Support the ignore list from the config here + # We are interested in identity providers that are: + # - using either the "oidc" or the "keycloak-oidc" provider (the others don't allow configuring the setting) + return idp.get_provider_id() in ["oidc", "keycloak-oidc"] + + def idp_does_not_enforce_pkce(self, cfg) -> bool: + # TODO Refactor with .get once unit tests exist + # Flag IDPs that: + # - Either do not explicitly state the PKCE status, or have it set to false + # - Alternatively, they use PKCE, but use it in 'plain' mode (the default, for some reason) + return "pkceEnabled" not in cfg or cfg["pkceEnabled"] != "true" or cfg["pkceMethod"] != "S256" + + def audit(self): + for idp in self._DB.get_all_identity_providers(): + # - Either do not explicitly state the PKCE status, or have it set to false + # - Alternatively, they use PKCE, but use it in 'plain' mode (the default, for some reason) + if not self.should_consider_idp(idp): + continue + cfg = idp.get_config() + if self.idp_does_not_enforce_pkce(cfg): + yield self.generate_finding( + idp, + additional_details={ + "pkceEnabled": cfg.get("pkceEnabled", "[unset, defaults to false]"), + "pkceMethod": cfg.get("pkceMethod", "[unset]"), + }, + ) diff --git a/kcwarden/auditors/idp_auditor.py b/kcwarden/auditors/idp_auditor.py new file mode 100644 index 0000000..8833a42 --- /dev/null +++ b/kcwarden/auditors/idp_auditor.py @@ -0,0 +1,15 @@ +from kcwarden.auditors.idp.identity_provider_with_mappers_without_force_sync_mode import ( + IdentityProviderWithMappersWithoutForceSyncMode, +) +from kcwarden.auditors.idp.identity_provider_with_one_time_sync import IdentityProviderWithOneTimeSync +from kcwarden.auditors.idp.oidc_identity_provider_without_pkce import OIDCIdentityProviderWithoutPKCE + +# TODO Refactor this bit out of here to get rid of this file. +# Idea: Rely on the auto-import logic that will be the basis for the plugin infrastructure? + + +AUDITORS = [ + OIDCIdentityProviderWithoutPKCE, + IdentityProviderWithOneTimeSync, + IdentityProviderWithMappersWithoutForceSyncMode, +] diff --git a/kcwarden/auditors/realm/__init__.py b/kcwarden/auditors/realm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/auditors/realm/realm_email_verification_disabled.py b/kcwarden/auditors/realm/realm_email_verification_disabled.py new file mode 100644 index 0000000..eb88e61 --- /dev/null +++ b/kcwarden/auditors/realm/realm_email_verification_disabled.py @@ -0,0 +1,21 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class RealmEmailVerificationDisabled(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Email verification disabled" + LONG_DESCRIPTION = "The realm does not have email verification enabled, meaning that email addresses of users haven't been verified using a double opt-in mechanism. Depending on the source of the addresses, they may not be trustworthy." + REFERENCE = "" + + def should_consider_realm(self, realm) -> bool: + return self.is_not_ignored(realm) + + def realm_has_email_verification_disabled(self, realm) -> bool: + return not realm.is_verify_email_enabled() + + def audit(self): + for realm in self._DB.get_all_realms(): + if self.should_consider_realm(realm): + if self.realm_has_email_verification_disabled(realm): + yield self.generate_finding(realm) diff --git a/kcwarden/auditors/realm/realm_self_registration_enabled.py b/kcwarden/auditors/realm/realm_self_registration_enabled.py new file mode 100644 index 0000000..17c5a63 --- /dev/null +++ b/kcwarden/auditors/realm/realm_self_registration_enabled.py @@ -0,0 +1,21 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class RealmSelfRegistrationEnabled(Auditor): + DEFAULT_SEVERITY = Severity.Info + SHORT_DESCRIPTION = "Self-Registration enabled" + LONG_DESCRIPTION = "The realm supports self-registration, which means that anyone can register an account. In some cases, this may not be desired, hence kcwarden is flagging this behavior." + REFERENCE = "" + + def should_consider_realm(self, realm) -> bool: + return self.is_not_ignored(realm) + + def realm_has_self_registration_enabled(self, realm) -> bool: + return realm.is_self_registration_enabled() + + def audit(self): + for realm in self._DB.get_all_realms(): + if self.should_consider_realm(realm): + if self.realm_has_self_registration_enabled(realm): + yield self.generate_finding(realm) diff --git a/kcwarden/auditors/realm/refresh_token_reuse_count_should_be_zero.py b/kcwarden/auditors/realm/refresh_token_reuse_count_should_be_zero.py new file mode 100644 index 0000000..eaf1bf7 --- /dev/null +++ b/kcwarden/auditors/realm/refresh_token_reuse_count_should_be_zero.py @@ -0,0 +1,22 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class RefreshTokenReuseCountShouldBeZero(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Refresh tokens MUST be invalidated after use" + LONG_DESCRIPTION = "Refresh tokens allow a client to obtain a new access token. However, if they get leaked, it may allow an attacker to obtain a long-lived session. Thus, they MUST be rotated after use. In this case, the realm is configured to revoke refresh tokens after a set number of uses, but allows the token to be used more than once. This weakens the security of the setting. (Be advised that at the time of writing, revoking refresh tokens may have undesired results when more than one refresh token can be issued by the same client to the same user, for example in some methods of keeping keys in the frontend. Please consult the following Keycloak issue for more details: https://github.com/keycloak/keycloak/issues/14122)" + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.2.2" + + def should_consider_realm(self, realm) -> bool: + return self.is_not_ignored(realm) + + def realm_has_refresh_token_reuse_enabled(self, realm) -> bool: + return realm.has_refresh_token_revocation_enabled() and realm.get_refresh_token_maximum_reuse_count() > 0 + + def audit(self): + for realm in self._DB.get_all_realms(): + # Find realms that have refresh token revocation enabled, but allow a token to be reused more than once + if self.should_consider_realm(realm): + if self.realm_has_refresh_token_reuse_enabled(realm): + yield self.generate_finding(realm) diff --git a/kcwarden/auditors/realm/refresh_tokens_should_be_revoked_after_use.py b/kcwarden/auditors/realm/refresh_tokens_should_be_revoked_after_use.py new file mode 100644 index 0000000..6342df4 --- /dev/null +++ b/kcwarden/auditors/realm/refresh_tokens_should_be_revoked_after_use.py @@ -0,0 +1,22 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity + + +class RefreshTokensShouldBeRevokedAfterUse(Auditor): + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Refresh tokens MUST be invalidated after use" + LONG_DESCRIPTION = "Refresh tokens allow a client to obtain a new access token. However, if they get leaked, it may allow an attacker to obtain a long-lived session. Thus, they MUST be rotated after use. (Be advised that at the time of writing, revoking refresh tokens may have undesired results when more than one refresh token can be issued by the same client to the same user, for example in some methods of keeping keys in the frontend. Please consult the following Keycloak issue for more details: https://github.com/keycloak/keycloak/issues/14122)" + REFERENCE = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-23#section-2.2.2" + + def should_consider_realm(self, realm) -> bool: + return self.is_not_ignored(realm) + + def realm_has_refresh_token_revocation_disabled(self, realm) -> bool: + return not realm.has_refresh_token_revocation_enabled() + + def audit(self): + for realm in self._DB.get_all_realms(): + # Find realms that have refresh token revocation disabled + if self.should_consider_realm(realm): + if self.realm_has_refresh_token_revocation_disabled(realm): + yield self.generate_finding(realm) diff --git a/kcwarden/auditors/realm_auditor.py b/kcwarden/auditors/realm_auditor.py new file mode 100644 index 0000000..ff15b8f --- /dev/null +++ b/kcwarden/auditors/realm_auditor.py @@ -0,0 +1,14 @@ +from kcwarden.auditors.realm.realm_email_verification_disabled import RealmEmailVerificationDisabled +from kcwarden.auditors.realm.realm_self_registration_enabled import RealmSelfRegistrationEnabled +from kcwarden.auditors.realm.refresh_token_reuse_count_should_be_zero import RefreshTokenReuseCountShouldBeZero +from kcwarden.auditors.realm.refresh_tokens_should_be_revoked_after_use import RefreshTokensShouldBeRevokedAfterUse + +# TODO Refactor this bit out of here to get rid of this file. +# Idea: Rely on the auto-import logic that will be the basis for the plugin infrastructure? + +AUDITORS = [ + RefreshTokensShouldBeRevokedAfterUse, + RefreshTokenReuseCountShouldBeZero, + RealmSelfRegistrationEnabled, + RealmEmailVerificationDisabled, +] diff --git a/kcwarden/auditors/scope/__init__.py b/kcwarden/auditors/scope/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/auditors/scope/using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous.py b/kcwarden/auditors/scope/using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous.py new file mode 100644 index 0000000..b08e6fb --- /dev/null +++ b/kcwarden/auditors/scope/using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous.py @@ -0,0 +1,68 @@ +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Severity +from kcwarden.database import helper + +# TODO Create versions of the profile auditors for the default attributes + + +class UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous(Auditor): + DEFAULT_SEVERITY = Severity.High + SHORT_DESCRIPTION = "Scope uses user attributes, but server does not have User Profiles feature enabled" + LONG_DESCRIPTION = "Keycloak allows assigning attributes to users. In addition to the default attributes (like name, email, phone number, etc.) you can also add custom attributes. By default, any user is allowed to edit their own attributes when signing up or accessing the default user console. This means that you MUST NOT store sensitive information in the attributes that you rely on in other systems (e.g., a customer number that is used to link their Keycloak account to a customer database). You can prevent the user from editing their own attributes using the experimental User Profiles feature of Keycloak and defining a policy that controls who is allowed to edit specific attributes. See the linked documentation for details." + REFERENCE = "https://www.keycloak.org/docs/latest/server_admin/#user-profile" + DEFAULT_ATTRIBUTES = [ + "firstName", + "nickname", + "zoneinfo", + "lastName", + "username", + "middleName", + "picture", + "birthdate", + "locale", + "website", + "gender", + "updatedAt", + "profile", + "phoneNumber", + "phoneNumberVerified", + "mobile_number", + "email", + "emailVerified", + ] + + def should_consider_scope(self, scope) -> bool: + return self.is_not_ignored(scope) + + def realm_has_user_profiles_enabled(self, realm) -> bool: + return realm.has_declarative_user_profiles_enabled() + + def mapper_references_non_default_user_attribute(self, mapper) -> bool: + return ( + mapper.get_protocol_mapper() == "oidc-usermodel-attribute-mapper" + and mapper.get_config()["user.attribute"] not in self.DEFAULT_ATTRIBUTES + ) + + def audit(self): + # First, we need to determine if there are any scopes that are actually using + # user attributes. As far as I know, the only way to use them which can be + # detected from the Keycloak configuration is using a mapper in a client or scope. + # Clients are checked in a separate auditor (below). + # (It may be possible to also gain access to the attributes using other APIs, + # but these cannot be identified from looking at the Keycloak configuration alone). + for scope in self._DB.get_all_scopes(): + # If the scopes' realm has activated the user profiles feature, the scope + # is not affected no matter which mappers it has. + if self.should_consider_scope(scope) and not self.realm_has_user_profiles_enabled(scope.get_realm()): + # Check the mappers of the scope + for mapper in scope.get_protocol_mappers(): + if self.mapper_references_non_default_user_attribute(mapper): + yield self.generate_finding( + scope, + additional_details={ + "used-attribute": mapper.get_config()["user.attribute"], + "clients-using-scope": [ + client.get_name() for client in helper.get_clients_with_scope(self._DB, scope) + ], + }, + ) diff --git a/kcwarden/auditors/scope_auditor.py b/kcwarden/auditors/scope_auditor.py new file mode 100644 index 0000000..e0d81f2 --- /dev/null +++ b/kcwarden/auditors/scope_auditor.py @@ -0,0 +1,10 @@ +from kcwarden.auditors.scope.using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous import ( + UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous, +) + + +# TODO Refactor this bit out of here to get rid of this file. +# Idea: Rely on the auto-import logic that will be the basis for the plugin infrastructure? + + +AUDITORS = [UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous] diff --git a/kcwarden/cli.py b/kcwarden/cli.py new file mode 100644 index 0000000..9c63d17 --- /dev/null +++ b/kcwarden/cli.py @@ -0,0 +1,167 @@ +import argparse +import sys + +from kcwarden.subcommands import download, audit, configuration, review +from kcwarden.utils.arguments import is_dir + + +def add_plugin_directory_argument(parser: argparse.ArgumentParser): + parser.add_argument( + "--plugin-dir", + "-p", + help="The path to a directory with additional auditors.", + required=False, + nargs="*", + type=is_dir, + ) + + +def get_parsers() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="Keycloak Auditor", + description="Audit all the things!", + ) + + subparsers = parser.add_subparsers(required=True) + + # + # The different subcommands: + # The first positional argument determines which operation should be executed. + # This allows a single entrypoint, e.g., for the Docker image. + # + + # The actual audit execution + add_audit_parser(subparsers) + + # Outputting default config + add_config_generator_parser(subparsers) + + # Downloading the Keycloak configuration + add_download_parser(subparsers) + + # Prepare data for human review + add_review_parser(subparsers) + + return parser + + +def add_audit_parser(subparsers): + parser_audit = subparsers.add_parser("audit", aliases=["a"], help="Audit a Keycloak configuration") + parser_audit.set_defaults(func=audit.audit) + parser_audit.add_argument("input_file", help="Specify the file that contains the Keycloak config dump") + parser_audit.add_argument( + "-c", + "--config", + help="Provide a config file with auditor-specific exclusions and parameters. " + "Generate a template using generate-config-template", + ) + parser_audit.add_argument( + "--format", + "-f", + help="The format of the output", + choices=["txt", "csv", "json"], + default="txt", + ) + parser_audit.add_argument( + "-o", + "--output", + help="File to which the results should be written. Defaults to stdout", + ) + parser_audit.add_argument( + "-s", + "--min-severity", + help="The minimum severity of findings that should be reported. Can be one of INFO, WARNING, ERROR, CRITICAL.", + type=str, + ) + add_plugin_directory_argument(parser_audit) + parser_audit.add_argument( + "--auditors", + help="Specify the exact auditors to run, separated by space (others will be ignored)", + type=str, + nargs="*", + ) + parser_audit.add_argument( + "--ignore-disabled-clients", + help="When set, will not audit disabled OIDC clients", + action="store_true", + ) + + +def add_config_generator_parser(subparsers): + parser_config_generator = subparsers.add_parser( + "generate-config-template", aliases=["gct"], help="Generate a config file template" + ) + parser_config_generator.set_defaults(func=configuration.generate_config) + add_plugin_directory_argument(parser_config_generator) + parser_config_generator.add_argument( + "-o", + "--output", + help="File to which the config should be written. Defaults to stdout", + ) + + +def add_download_parser(subparsers): + parser_download = subparsers.add_parser( + "download", + aliases=["d"], + help="Download the Keycloak realm configuration.\n\n" + "The password will be requested interactively or read from the KEYCLOAK_PASSWORD env variable.", + ) + parser_download.set_defaults(func=download.download_config) + parser_download.add_argument( + "base_url", help="The base URL of the Keycloak install, including /auth if appropriate" + ) + parser_download.add_argument( + "-r", + "--realm", + help="The realm to download", + required=True, + ) + parser_download.add_argument( + "-a", + "--auth-realm", + help="The realm used for authentication (default: master)", + default="master", + required=False, + ) + parser_download.add_argument( + "-u", + "--user", + help="The user used for authentication", + required=True, + ) + parser_download.add_argument( + "-t", + "--totp", + help="Indicates that a TOTP code is required for authentication", + action="store_true", + ) + parser_download.add_argument( + "-o", + "--output", + help="Specifies the file to which the export should be written. If not set, export will be written to STDOUT.", + ) + + +def add_review_parser(subparsers): + parser_review = subparsers.add_parser( + "review", aliases=["r"], help="Prepare a matrix of Keycloak permissions for human review." + ) + parser_review.set_defaults(func=review.prepare_review) + parser_review.add_argument("input_file", help="Specify the file that contains the Keycloak config dump") + parser_review.add_argument( + "-o", + "--output", + help="File to which the results should be written. Defaults to stdout. Will be in CSV format.", + ) + + +def main(args: list[str] | None = None) -> int | None: + # Parse CLI args + args_ns = get_parsers().parse_args(args) + # Execute the subcommand + return args_ns.func(args_ns) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/kcwarden/configuration/__init__.py b/kcwarden/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/configuration/auditors.py b/kcwarden/configuration/auditors.py new file mode 100644 index 0000000..77c21d3 --- /dev/null +++ b/kcwarden/configuration/auditors.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import Type + +from kcwarden.auditors import client_auditor, realm_auditor, idp_auditor, scope_auditor +from kcwarden.api import Auditor +from kcwarden.monitors import client_monitor, group_monitor, service_account_monitor, protocol_mapper_monitor +from kcwarden.utils import plugins + + +def collect_auditors( + requested_auditors: list[str] | None = None, additional_auditors_dirs: list[Path] | None = None +) -> list[Type[Auditor]]: + auditors = [] + # TODO Add new auditor modules here + auditors.extend(client_auditor.AUDITORS) + auditors.extend(realm_auditor.AUDITORS) + auditors.extend(idp_auditor.AUDITORS) + auditors.extend(scope_auditor.AUDITORS) + auditors.extend(client_monitor.AUDITORS) + auditors.extend(group_monitor.AUDITORS) + auditors.extend(service_account_monitor.AUDITORS) + auditors.extend(protocol_mapper_monitor.AUDITORS) + + if additional_auditors_dirs is not None: + for directory in additional_auditors_dirs: + auditors.extend(plugins.get_auditors(directory)) + + if requested_auditors is not None: + auditors = [auditor for auditor in auditors if auditor.get_classname() in requested_auditors] + return auditors diff --git a/kcwarden/configuration/template.py b/kcwarden/configuration/template.py new file mode 100644 index 0000000..0714151 --- /dev/null +++ b/kcwarden/configuration/template.py @@ -0,0 +1,17 @@ +from typing import Type + +from kcwarden.custom_types import config_keys +from kcwarden.api import Auditor + + +def generate_config_template(auditors: list[Type[Auditor]]) -> dict[str, list[dict]]: + config = {config_keys.AUDITOR_CONFIG: [], config_keys.MONITOR_CONFIG: []} + + for auditor in auditors: + if auditor.has_custom_config(): + config[config_keys.MONITOR_CONFIG].append( + {"monitor": auditor.get_classname(), "config": auditor.get_custom_config_template()} + ) + else: + config[config_keys.AUDITOR_CONFIG].append({"auditor": auditor.get_classname(), "allowed": []}) + return config diff --git a/kcwarden/custom_types/__init__.py b/kcwarden/custom_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/custom_types/config_keys.py b/kcwarden/custom_types/config_keys.py new file mode 100644 index 0000000..53a4902 --- /dev/null +++ b/kcwarden/custom_types/config_keys.py @@ -0,0 +1,6 @@ +# Strings that are used in the config map +# No idea if this has a future, but it works for now :) + +IGNORE_DISABLED_CLIENTS = "ignore_disabled_clients" +AUDITOR_CONFIG = "auditors" +MONITOR_CONFIG = "monitors" diff --git a/kcwarden/custom_types/database.py b/kcwarden/custom_types/database.py new file mode 100644 index 0000000..27412f5 --- /dev/null +++ b/kcwarden/custom_types/database.py @@ -0,0 +1,113 @@ +from abc import ABC, abstractmethod + +from kcwarden.custom_types.keycloak_object import ( + Client, + ClientScope, + ServiceAccount, + Realm, + Group, + RealmRole, + ClientRole, + IdentityProvider, +) + + +class Database(ABC): + ### "Adders" + @abstractmethod + def add_realm(self, realm: Realm) -> None: + raise NotImplementedError() + + @abstractmethod + def add_client(self, client: Client) -> None: + raise NotImplementedError() + + @abstractmethod + def add_scope(self, scope: ClientScope) -> None: + raise NotImplementedError() + + @abstractmethod + def add_service_account(self, saccount: ServiceAccount) -> None: + raise NotImplementedError() + + @abstractmethod + def add_group(self, group: Group) -> None: + raise NotImplementedError() + + @abstractmethod + def add_realm_role(self, role: RealmRole) -> None: + raise NotImplementedError() + + @abstractmethod + def add_client_role(self, role: ClientRole) -> None: + raise NotImplementedError() + + @abstractmethod + def add_identity_provider(self, idp: IdentityProvider) -> None: + raise NotImplementedError() + + ### Full list getters + @abstractmethod + def get_all_realms(self) -> list[Realm]: + raise NotImplementedError() + + @abstractmethod + def get_all_clients(self) -> list[Client]: + raise NotImplementedError() + + @abstractmethod + def get_all_scopes(self) -> list[ClientScope]: + raise NotImplementedError() + + @abstractmethod + def get_all_service_accounts(self) -> list[ServiceAccount]: + raise NotImplementedError() + + @abstractmethod + def get_all_groups(self) -> list[Group]: + raise NotImplementedError() + + @abstractmethod + def get_all_realm_roles(self) -> list[RealmRole]: + raise NotImplementedError() + + @abstractmethod + def get_all_client_roles(self) -> dict[str, dict[str, ClientRole]]: + raise NotImplementedError() + + @abstractmethod + def get_all_identity_providers(self) -> list[IdentityProvider]: + raise NotImplementedError() + + ### Specific getters + @abstractmethod + def get_realm(self, realm_name: str) -> Realm: + raise NotImplementedError() + + @abstractmethod + def get_client(self, client_id: str) -> Client: + raise NotImplementedError() + + @abstractmethod + def get_scope(self, scope: str) -> ClientScope: + raise NotImplementedError() + + @abstractmethod + def get_service_account(self, saccount: str) -> ServiceAccount: + raise NotImplementedError() + + @abstractmethod + def get_group(self, group: str) -> Group: + raise NotImplementedError() + + @abstractmethod + def get_realm_role(self, role: str) -> RealmRole: + raise NotImplementedError() + + @abstractmethod + def get_client_role(self, role: str, client: str) -> ClientRole: + raise NotImplementedError() + + @abstractmethod + def get_identity_provider(self, alias: str) -> IdentityProvider: + raise NotImplementedError() diff --git a/kcwarden/custom_types/keycloak_object.py b/kcwarden/custom_types/keycloak_object.py new file mode 100644 index 0000000..72805ce --- /dev/null +++ b/kcwarden/custom_types/keycloak_object.py @@ -0,0 +1,897 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from urllib.parse import urlparse + + +class Dataclass(ABC): + CLASSNAME = "REPLACE_ME" + + @abstractmethod + def get_name(self) -> str: + raise NotImplementedError() + + @abstractmethod + def get_realm(self) -> "Realm": + raise NotImplementedError() + + def get_type(self) -> str: + return self.CLASSNAME + + def __str__(self) -> str: + return f"<{self.get_type()}: {self.get_name()} in Realm {self.get_realm().get_name()}>" + + +class Realm(Dataclass): + """ + Aktuell enthält _d einmal den kompletten Dump, da es kein definiertes Feld + in dem dump gibt, das die Realm-Eigenschaften kapselt. Daher wird hier kein + Beispiel-Datensatz hinterlegt. + """ + + CLASSNAME = "Realm" + _d = {} + + def __init__(self, raw_json: dict) -> None: + # Create a deepcopy, just in case + self._d = deepcopy(raw_json) + + def get_name(self) -> str: + return self._d["realm"] + + def get_realm(self) -> "Realm": + return self + + def is_self_registration_enabled(self) -> bool: + return self._d["registrationAllowed"] + + def is_verify_email_enabled(self) -> bool: + return self._d["verifyEmail"] + + # Token Handling and Validity + def has_refresh_token_revocation_enabled(self) -> bool: + return self._d["revokeRefreshToken"] + + def get_refresh_token_maximum_reuse_count(self) -> int: + return self._d["refreshTokenMaxReuse"] + + # Optional / Experimental Features + def has_declarative_user_profiles_enabled(self) -> bool: + return self._d["attributes"].get("userProfileEnabled", "false") == "true" + + +class RealmRole(Dataclass): + """ + Example Payload + + { + "id": "ff7eefd3-03df-4226-a7de-9e7495120bb0", + "name": "sensitive_composite_role", + "description": "", + "composite": true, + "composites": { + "realm": [ + "normal_role", + "sensitive-role" + ] + }, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "eb8fdce9-75b2-41a5-a91a-3a2a7689d3f7", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + """ + + CLASSNAME = "RealmRole" + REALM: Realm + _d = {} + + def __init__(self, raw_json: dict, realm: Realm): + self._d = raw_json + self.REALM = realm + + def get_name(self) -> str: + return self._d["name"] + + def get_realm(self) -> Realm: + return self.REALM + + def is_client_role(self) -> bool: + assert self._d["clientRole"] is False, "Client role has been parsed as realm role, wtf?!" + return self._d["clientRole"] + + def is_composite_role(self) -> bool: + return self._d["composite"] + + def get_composite_roles(self) -> dict[str, list[str | dict[str, list[str]]]]: + return self._d.get("composites", {}) + + +class ClientRole(Dataclass): + """ + Example Payload (information about the client are not contained here, + but have to be provided separately) + + { + "id": "0c8d7745-7391-458c-95b7-d8a70c42a6fc", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "fac44c0b-ed3c-487e-8d0d-25a0a249d320", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + + """ + + CLASSNAME = "ClientRole" + REALM: Realm + _d = {} + _client: str + + def __init__(self, raw_json: dict, realm: Realm, client: str): + self._d = raw_json + self.REALM = realm + self._client = client + + def get_name(self) -> str: + return self._d["name"] + + def get_realm(self) -> Realm: + return self.REALM + + def is_client_role(self) -> bool: + assert self._d["clientRole"] is True, "Realm role has been parsed as client role, wtf?!" + return self._d["clientRole"] + + def is_composite_role(self) -> bool: + return self._d["composite"] + + def get_composite_roles(self) -> dict[str, list[str]]: + return self._d.get("composites", {}) + + def get_client_name(self) -> str: + return self._client + + def __str__(self) -> str: + return ( + f"<{self.get_type()}: {self.get_client_name()}[{self.get_name()}] in Realm {self.get_realm().get_name()}>" + ) + + +class ProtocolMapper(Dataclass): + """ + Example Payload + + { + "id": "78890c6c-5dfb-4c1c-a469-4f21d170f702", + "name": "user-id-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "user-id", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "user-id", + "jsonType.label": "String" + } + } + + """ + + CLASSNAME = "ProtocolMapper" + REALM: Realm + _d = {} + + def __init__(self, raw_json: dict, realm: Realm): + self._d = raw_json + self.REALM = realm + + def get_name(self) -> str: + return self._d["name"] + + def get_realm(self) -> Realm: + return self.REALM + + def get_protocol(self) -> str: + return self._d["protocol"] + + def get_protocol_mapper(self) -> str: + return self._d["protocolMapper"] + + def get_config(self) -> dict[str, str]: + return self._d["config"] + + +class ClientScope(Dataclass): + """ + Some examples: + + { + "id": "b1261941-93bd-4c7c-819f-326b99c8f7f1", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "2df747f5-3357-4d84-b648-a6772b386973", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "926f9c0c-69a0-45f1-8e75-42a7987a85c7", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "0399894e-ff43-42b3-896b-b3df2a3be079", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "6b797a34-a333-4e24-845a-b05ad3d3d926", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "d5b44d1d-323d-4ee0-962f-b8d6f18b92a7", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + + Client Scope Mapping: + + { + "client-with-client-roles": [ + { + "clientScope": "client-scope-with-client-role", + "roles": [ + "sensitive-client-role" + ] + } + ], + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + """ + + CLASSNAME = "ClientScope" + REALM: Realm + _d = {} + + def __init__(self, client_scope: dict, scope_mapping: list, client_scope_mapping: dict, realm: Realm): + client_scope["roles"] = {"realm": [], "client": {}} + + scope_name = client_scope["name"] + for scope_map in scope_mapping: + if scope_map.get("clientScope", None) == scope_name: + client_scope["roles"]["realm"] = scope_map["roles"] + break + + for role_client in client_scope_mapping: + for mapping_scope in client_scope_mapping[role_client]: + if mapping_scope.get("clientScope", None) == scope_name: + client_scope["roles"]["client"][role_client] = mapping_scope["roles"] + + self._d = client_scope + self.REALM = realm + + def get_name(self) -> str: + return self._d["name"] + + def get_realm(self) -> Realm: + return self.REALM + + def get_realm_roles(self) -> list[str]: + return self._d["roles"]["realm"] + + def get_client_roles(self) -> dict[str, list[str]]: + return self._d["roles"]["client"] + + def get_protocol_mappers(self) -> list[ProtocolMapper]: + protocol_mappers = self._d.get("protocolMappers", []) + return [ProtocolMapper(data, self.REALM) for data in protocol_mappers] + + +class Client(Dataclass): + """ + Example Payload: + + { + "id": "277f1aef-2ca2-4992-92b3-7823941db631", + "clientId": "client-with-recursive-sensitive-composite-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "scope-with-recursive-sensitive-composite-role", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + """ + + CLASSNAME = "Client" + REALM: Realm + _d = {} + + def __init__(self, client: dict, scope_mappings: list, client_scope_mappings: dict, realm: Realm): + client["directly_assigned_roles"] = {"realm": [], "client": {}} + + client_name = client["clientId"] + for scope_map in scope_mappings: + if scope_map.get("client", None) == client_name: + client["directly_assigned_roles"]["realm"] = scope_map["roles"] + break + + for role_client in client_scope_mappings: + for mapping_scope in client_scope_mappings[role_client]: + if mapping_scope.get("client", None) == client_name: + client["directly_assigned_roles"]["client"][role_client] = mapping_scope["roles"] + + self._d = client + self.REALM = realm + + def get_client_id(self) -> str: + return self._d["clientId"] + + def get_name(self) -> str: + return self.get_client_id() + + def get_realm(self) -> Realm: + return self.REALM + + # Basic Properties + def is_public(self) -> bool: + return self._d["publicClient"] + + def is_enabled(self) -> bool: + return self._d["enabled"] + + # Scopes + def get_default_client_scopes(self) -> list[str]: + return self._d["defaultClientScopes"] + + def get_optional_client_scopes(self) -> list[str]: + return self._d["optionalClientScopes"] + + def has_full_scope_allowed(self) -> bool: + return self._d["fullScopeAllowed"] + + # Directly assigned roles + def get_directly_assigned_realm_roles(self) -> list[str]: + return self._d["directly_assigned_roles"]["realm"] + + def get_directly_assigned_client_roles(self) -> dict[str, list[str]]: + return self._d["directly_assigned_roles"]["client"] + + # Specific Flows + def has_standard_flow_enabled(self) -> bool: + return self._d["standardFlowEnabled"] + + def has_implicit_flow_enabled(self) -> bool: + return self._d["implicitFlowEnabled"] + + def has_device_authorization_grant_flow_enabled(self) -> bool: + # For some reason, this flow is encoded as part of the "attributes" dict, where it maps + # to the string "true" or "false", and this config is not always present. Thus, this + # check has to look like this. + if "oauth2.device.authorization.grant.enabled" in self._d["attributes"]: + return self._d["attributes"]["oauth2.device.authorization.grant.enabled"] == "true" + # If the config is not present in the attributes, the flow is always disabled + return False + + def has_direct_access_grants_enabled(self) -> bool: + return self._d["directAccessGrantsEnabled"] + + def has_service_account_enabled(self) -> bool: + return self._d["serviceAccountsEnabled"] + + def get_service_account_name(self) -> str | None: + if not self.has_service_account_enabled(): + return None + return "service-account-" + self.get_client_id().lower() + + # More Specific Properties + def get_protocol(self) -> str: + # Every client should have the "protocol" field set, but the "master-realm" + # client in the "master" realm for some reason does not include this field. + # This code works around that by returning openid-connect in that case. + if self.get_name() == "master-realm" and self.get_realm().get_name() == "master": + return "openid-connect" + return self._d["protocol"] + + def is_oidc_client(self) -> bool: + return self.get_protocol() == "openid-connect" + + def get_attributes(self) -> dict[str, str]: + return self._d["attributes"] + + def get_protocol_mappers(self) -> list[ProtocolMapper]: + protocol_mappers = self._d.get("protocolMappers", []) + return [ProtocolMapper(data, self.REALM) for data in protocol_mappers] + + def get_client_authenticator_type(self) -> str | None: + if self.is_public(): + return None + return self._d["clientAuthenticatorType"] + + def get_root_url(self) -> str | None: + return self._d.get("rootUrl", None) + + def get_redirect_uris(self) -> list[str]: + return self._d["redirectUris"] + + def get_resolved_redirect_uris(self) -> list[str]: + redirect_uris = self.get_redirect_uris() + if len(redirect_uris) == 0: + return redirect_uris + root_url = self.get_root_url() + if root_url is None: + root_url = "" + rv = [] + for uri in redirect_uris: + # If the uri is a valid full domain, it is used as-is by keycloak. + # We approximate this by saying that it has to have something that + # urlllib.parse.urlparse recognizes as a hostname. This will usually + # mean that it is defined as http[s]://domain.tld/path. + if urlparse(uri).hostname != "": + rv.append(uri) + else: + # If it is not a valid full domain, it is resolved relative to the root + # URI of the client. We append the two together. + rv.append(root_url + uri) + # Note that this still does not guarantee that the result is a valid URL. + # If the root URL is unset, the value is relative to the keycloak URL (?). + # However, this keycloak URL apparently cannot be pulled from the config + # dump, so there is no way of resolving the actual redirect URIs, as + # used by Keycloak. + return rv + + def is_default_keycloak_client(self) -> bool: + return self.get_name() in [ + "account", + "account-console", + "admin-cli", + "broker", + "realm-management", + "security-admin-console", + "master-realm", + ] + + def allows_user_authentication(self) -> bool: + return ( + self.has_device_authorization_grant_flow_enabled() + or self.has_direct_access_grants_enabled() + or self.has_standard_flow_enabled() + or self.has_implicit_flow_enabled() + ) + + +class Group(Dataclass): + """ + Example Data: + + { + "id": "71f4ec07-96c5-4a43-bd2d-3da010cede4a", + "name": "group-with-sensitive-child-group", + "path": "/group-with-sensitive-child-group", + "attributes": {}, + "realmRoles": [ + "sensitive_composite_role" + ], + "clientRoles": {}, + "subGroups": [ + { + "id": "0f130934-933d-4ac1-8033-2646c4dd6bde", + "name": "sensitive-child-group", + "path": "/group-with-sensitive-child-group/sensitive-child-group", + "attributes": {}, + "realmRoles": [ + "sensitive-role" + ], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "9f936e3d-8457-4b68-8ce5-f03799e8394e", + "name": "composite-sensitive-child-group", + "path": "/group-with-sensitive-child-group/composite-sensitive-child-group", + "attributes": {}, + "realmRoles": [ + "sensitive_composite_role" + ], + "clientRoles": {}, + "subGroups": [] + } + ] + }, + + Note that the list of roles is not necessarily complete here: child groups also implicitly inherit + the roles and attributes of their parent group(s). So, to get a complete list of attributes or roles, + the whole inheritance tree needs to be traversed. + """ + + CLASSNAME = "Group" + REALM: Realm + PARENT: "Group | None" = None + _d = {} + + def __init__(self, raw_json: dict, realm: Realm, parent_group: "Group | None" = None): + self._d = raw_json + self.REALM = realm + self.PARENT = parent_group + + def get_name(self) -> str: + return self._d["name"] + + def get_realm(self) -> Realm: + return self.REALM + + def get_path(self) -> str: + return self._d["path"] + + def get_parent(self) -> "Group | None": + return self.PARENT + + def get_attributes(self) -> dict[str, str]: + return self._d["attributes"] + + def get_realm_roles(self) -> list: + return self._d["realmRoles"] + + def get_client_roles(self) -> dict[str, list[str]]: + return self._d["clientRoles"] + + def get_effective_realm_roles(self) -> list[str]: + # If no parent exists, the effective realm roles are the roles of this group only. + if self.PARENT is None: + return self.get_realm_roles() + + # Otherwise, incorporate the parent's roles + my_realm_roles = set(self._d["realmRoles"]) + my_realm_roles.update(self.PARENT.get_effective_realm_roles()) + return list(my_realm_roles) + + def get_effective_client_roles(self) -> dict[str, list[str]]: + if self.PARENT is None: + return deepcopy(self._d["clientRoles"]) + parent_client_roles = self.PARENT.get_effective_client_roles() + my_client_roles = self.get_client_roles() + for client in my_client_roles.keys(): + if client in parent_client_roles: + parent_client_roles[client] += my_client_roles[client] + else: + parent_client_roles[client] = my_client_roles[client] + return parent_client_roles + + def has_subgroups(self) -> bool: + return self._d["subGroups"] != [] + + def get_subgroups(self) -> "list[Group]": + return [Group(subgroup, self.REALM, self) for subgroup in self._d["subGroups"]] + + +class ServiceAccount(Dataclass): + """ + Example Payload: + + { + "id": "8cbc5918-40bf-4be4-b7bd-44500abf8a15", + "createdTimestamp": 1695986518227, + "username": "service-account-service-account-client-with-client-role", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "service-account-client-with-client-role", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test" + ], + "clientRoles": { + "client-with-client-roles": [ + "sensitive-client-role" + ] + }, + "notBefore": 0, + "groups": [ + "/group-with-sensitive-child-group/composite-sensitive-child-group" + ] + } + + """ + + CLASSNAME = "ServiceAccount" + REALM: Realm + _d = {} + + def __init__(self, raw_json: dict, realm: Realm): + self._d = raw_json + self.REALM = realm + + def get_username(self) -> str: + return self._d["username"] + + def get_name(self) -> str: + return self.get_username() + + def get_realm(self) -> Realm: + return self.REALM + + def get_client_id(self) -> str: + return self._d["serviceAccountClientId"] + + def get_realm_roles(self) -> list[str]: + return self._d.get("realmRoles", []) + + def has_client_roles(self) -> bool: + return "clientRoles" in self._d + + def get_client_roles(self) -> dict[str, list]: + if self.has_client_roles(): + return self._d["clientRoles"] + return {} + + def get_groups(self) -> list[str]: + return self._d["groups"] + + +class IdentityProviderMapper(Dataclass): + """ + Example Payloads: + + { + "id": "61fbd7df-5b57-4913-8745-6c8f197175fa", + "name": "Demo Mapper", + "identityProviderAlias": "openid-connect-provider", + "identityProviderMapper": "oidc-advanced-group-idp-mapper", + "config": { + "syncMode": "INHERIT", + "claims": "[{\"key\":\"groups\",\"value\":\"test-group\"}]", + "are.claim.values.regex": "false", + "group": "/benign-group" + } + }, + { + "id": "c13c8a30-5640-4bd8-ac60-f93eb675c9fe", + "name": "hardcoded-attr", + "identityProviderAlias": "openid-connect-provider", + "identityProviderMapper": "hardcoded-user-session-attribute-idp-mapper", + "config": { + "syncMode": "INHERIT", + "attribute.value": "test-attribute-value", + "are.claim.values.regex": "false", + "attribute": "test-attribute-name" + } + } + + """ + + CLASSNAME = "IdentityProviderMapper" + REALM: Realm + _d = {} + + def __init__(self, raw_json: dict, realm: Realm): + self._d = raw_json + self.REALM = realm + + def get_name(self) -> str: + return self._d["name"] + + def get_realm(self) -> Realm: + return self.REALM + + def get_identity_provider_alias(self) -> str: + return self._d["identityProviderAlias"] + + def get_identity_provider_mapper_type(self) -> str: + return self._d["identityProviderMapper"] + + def get_config(self) -> dict: + return self._d["config"] + + +class IdentityProvider(Dataclass): + """ + Example Payload for OIDC client: + + { + "alias": "openid-connect-provider", + "displayName": "", + "internalId": "ebb360e7-61ff-4d5e-a9a0-e06ea3c11a7c", + "providerId": "oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "userInfoUrl": "https://other.keycloak.com/auth/realms/users/protocol/openid-connect/userinfo", + "validateSignature": "true", + "tokenUrl": "https://other.keycloak.com/auth/realms/users/protocol/openid-connect/token", + "clientId": "yolo", + "jwksUrl": "https://other.keycloak.com/auth/realms/users/protocol/openid-connect/certs", + "issuer": "https://other.keycloak.com/auth/realms/users", + "useJwksUrl": "true", + "pkceEnabled": "false", + "authorizationUrl": "https://other.keycloak.com/auth/realms/users/protocol/openid-connect/auth", + "clientAuthMethod": "client_secret_post", + "logoutUrl": "https://other.keycloak.com/auth/realms/users/protocol/openid-connect/logout", + "clientSecret": "**********" + } + } + + Example Payload for Microsoft broker: + + { + "alias": "microsoft", + "internalId": "06b17b97-f3a7-492b-9aa2-447cf8354224", + "providerId": "microsoft", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "clientSecret": "**********", + "clientId": "client-id" + } + } + """ + + CLASSNAME = "IdentityProvider" + REALM: Realm + _d = {} + + def __init__(self, raw_json: dict, realm: Realm, identity_provider_mappers: list[dict]): + self._d = raw_json + self.REALM = realm + # Import all IdentityProviderMappers from the realm that reference this Identity Provider + self._d["idpMappings"] = [ + IdentityProviderMapper(idpmap, realm) + for idpmap in identity_provider_mappers + if idpmap["identityProviderAlias"] == raw_json["alias"] + ] + + def get_alias(self) -> str: + return self._d["alias"] + + def get_name(self) -> str: + return self.get_alias() + + def get_realm(self) -> Realm: + return self.REALM + + def get_provider_id(self) -> str: + return self._d["providerId"] + + def is_enabled(self) -> bool: + return self._d["enabled"] + + def get_config(self) -> dict: + return self._d["config"] + + def get_identity_provider_mappers(self) -> list[IdentityProviderMapper]: + return self._d["idpMappings"] + + def get_sync_mode(self) -> str | None: + return self._d["config"].get("syncMode", "LEGACY") diff --git a/kcwarden/custom_types/result.py b/kcwarden/custom_types/result.py new file mode 100644 index 0000000..994d62b --- /dev/null +++ b/kcwarden/custom_types/result.py @@ -0,0 +1,106 @@ +from enum import IntEnum +import json +import hashlib + +from kcwarden.custom_types import result_headers +from kcwarden.custom_types.keycloak_object import Dataclass + + +class Severity(IntEnum): + """ + The severity of an audit result. + Based on the CVSS severities. + """ + + Info = 0 # Matches _None_ in CVSS + Low = 2 + Medium = 5 + High = 7 + Critical = 9 + + +class Result: + def __init__( + self, + severity: Severity, + offending_object: Dataclass, + short_description: str, + long_description: str, + reference: str, + reporting_auditor: str, + additional_details: dict | None = None, + ): + self._severity = severity + self._offending_object = offending_object + self._reporting_auditor = reporting_auditor + self._short_description = short_description + self._long_description = long_description + self._reference = reference + self._additional_details = additional_details or dict() + + @property + def severity(self) -> Severity: + return self._severity + + def __lt__(self, other: "Result"): + return self.severity < other.severity + + def __gt__(self, other: "Result"): + return self.severity > other.severity + + def __le__(self, other: "Result"): + return self.severity <= other.severity + + def __ge__(self, other: "Result"): + return self.severity >= other.severity + + def get_reporting_auditor(self) -> str: + return self._reporting_auditor + + def get_fingerprint(self) -> str: + # This function generates a unique-but-constant fingerprint that covers + # the salient fields of the result. This should stay identical over multiple + # runs, assuming the result stays identical. We guarantee this by creating + # a string representing the relevant parts of the finding and hashing it. + fp_dict = { + "realm": str(self._offending_object.get_realm().get_name()), + "entityname": str(self._offending_object.get_name()), + "entitytype": str(self._offending_object.get_type()), + "auditor": str(self._reporting_auditor), + "details": self._additional_details, + } + + return hashlib.sha256(json.dumps(fp_dict, sort_keys=True).encode("utf-8")).hexdigest() + + def to_dict(self) -> dict: + return { + result_headers.FINGERPRINT: self.get_fingerprint(), + result_headers.SEVERITY_NAME: self.severity.name, + result_headers.REALM_NAME: self._offending_object.get_realm().get_name(), + result_headers.ENTITY_NAME: self._offending_object.get_name(), + result_headers.ENTITY_TYPE_NAME: self._offending_object.get_type(), + result_headers.REPORTING_AUDITOR_NAME: self._reporting_auditor, + result_headers.SHORT_DESCRIPTION_NAME: self._short_description, + result_headers.LONG_DESCRIPTION_NAME: self._long_description, + result_headers.REFERENCE_NAME: self._reference, + result_headers.ADDITIONAL_DETAILS_NAME: self._additional_details, + } + + def __str__(self) -> str: + return f"""{self._offending_object} {self.severity.name}: {self._short_description} + +{self._long_description} + +Additional Details: +{json.dumps(self._additional_details, indent=4)} + +Reported by: {self._reporting_auditor}""" + + +def get_severity_by_name(severity_name: str) -> Severity: + try: + return Severity[severity_name.lower().capitalize()] + except KeyError: + raise ValueError( + f"Provided severity was \"{severity_name}\" but must be one of {', '.join(s.name for s in Severity)}." + ) diff --git a/kcwarden/custom_types/result_headers.py b/kcwarden/custom_types/result_headers.py new file mode 100644 index 0000000..4486f7a --- /dev/null +++ b/kcwarden/custom_types/result_headers.py @@ -0,0 +1,23 @@ +# What should the headers for the individual sections be? +FINGERPRINT = "fingerprint" +SEVERITY_NAME = "severity" +REALM_NAME = "realm" +ENTITY_NAME = "entity" +ENTITY_TYPE_NAME = "entity_type" +REPORTING_AUDITOR_NAME = "reporting_auditor" +SHORT_DESCRIPTION_NAME = "short_description" +LONG_DESCRIPTION_NAME = "long_description" +REFERENCE_NAME = "reference" +ADDITIONAL_DETAILS_NAME = "additional_details" +ALL_HEADERS = [ + FINGERPRINT, + SEVERITY_NAME, + REALM_NAME, + ENTITY_NAME, + ENTITY_TYPE_NAME, + REPORTING_AUDITOR_NAME, + SHORT_DESCRIPTION_NAME, + LONG_DESCRIPTION_NAME, + REFERENCE_NAME, + ADDITIONAL_DETAILS_NAME, +] diff --git a/kcwarden/database/__init__.py b/kcwarden/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/database/helper.py b/kcwarden/database/helper.py new file mode 100644 index 0000000..07bde02 --- /dev/null +++ b/kcwarden/database/helper.py @@ -0,0 +1,218 @@ +import re +import sys + +from kcwarden.custom_types.database import Database +from kcwarden.custom_types.keycloak_object import RealmRole, ClientRole, ClientScope, Client, Group, ServiceAccount + + +### Internal helper functions +def _role_contains_role(contained_role: RealmRole | ClientRole, container_role: RealmRole | ClientRole) -> bool: + if contained_role.is_client_role(): + return contained_role.get_name() in container_role.get_composite_roles().get("client", {}).get( + contained_role.get_client_name(), [] + ) # type: ignore - can only be a ClientRole here + return contained_role.get_name() in container_role.get_composite_roles().get("realm", []) + + +def _scope_contains_role(role: RealmRole | ClientRole, scope: ClientScope) -> bool: + if role.is_client_role(): + return role.get_name() in scope.get_client_roles().get(role.get_client_name(), []) # type: ignore - can only be a ClientRole here + return role.get_name() in scope.get_realm_roles() + + +def _client_contains_directly_assigned_role(role: RealmRole | ClientRole, client: Client) -> bool: + if role.is_client_role(): + return role.get_name() in client.get_directly_assigned_client_roles().get(role.get_client_name(), []) # type: ignore - can only be a ClientRole here + return role.get_name() in client.get_directly_assigned_realm_roles() + + +def _group_contains_role(role: RealmRole | ClientRole, group: Group) -> bool: + if role.is_client_role(): + effective_client_roles = group.get_effective_client_roles() + return ( + role.get_client_name() in effective_client_roles + and role.get_name() in effective_client_roles[role.get_client_name()] + ) # type: ignore - can only be a ClientRole here + return role.get_name() in group.get_effective_realm_roles() + + +def _service_account_has_role(role: RealmRole | ClientRole, account: ServiceAccount) -> bool: + if role.is_client_role(): + client_roles = account.get_client_roles() + return role.get_client_name() in client_roles and role.get_name() in client_roles[role.get_client_name()] # type: ignore - can only be a ClientRole here + return role.get_name() in account.get_realm_roles() + + +def _merge_role_dict(existing, new): + existing["realm"] = list(set(existing["realm"]) | set(new["realm"])) + for client in new["client"]: + if client not in existing["client"]: + existing["client"][client] = [] + existing["client"][client] = list(set(existing["client"][client]) | set(new["client"][client])) + return existing + + +def matches_as_string_or_regex(input_string: str, string_or_regex: str) -> bool: + if input_string == string_or_regex: + return True + try: + # We aren't sure if the string should be interpreted as a regular expression. + # Regex matching will interpret the (non)-regex "some-string" to match + # "some-string-that-is-not-the-same". This is unexpected if I don't explicitly + # ask for regular expression matching, and means that we may false-positive match + # some strings. + # The workaround is to check if the string contains one of a number of common + # regular expression control characters. If so, we will activate regex matching. + # This has a small chance of false negatives if the user provides a string that + # does not contain any of the control characters listed below. However, in practice, + # I would expect this to be extremely rare. + # It may also introduce false positive matching if someone has these characters + # as part of the normal (non-regex) name for a resource. However, once again, + # I would expect this to be a rare occurrence (I am not even sure if these characters + # are allowed by Keycloak). + if any(re_control in string_or_regex for re_control in ["?", "!", "+", "*"]): + return re.match(re.compile(string_or_regex), input_string) is not None + return False + except re.error as e: + print( + f"Interpreting input '{string_or_regex}' as regular expression resulted in an error." + f" Treated as not matching. Error: {e}", + file=sys.stderr, + ) + return False + + +def matches_list_of_regexes(input_string: str, re_list: list[str]) -> bool: + """Checks if the provided string matches at least one entry in the provided + list of strings or patterns. First considers exact string matches, then interprets + the list entries as regular expressions and sees if the provided input string + matches the regex. If the re_list entry isn't a valid regular expression, it is + interpreted as not matching in the regular expression matching step (but can still + match on an exact-string basis). + """ + return any([matches_as_string_or_regex(input_string, pattern_entry) for pattern_entry in re_list]) + + +def regex_matches_list_entry(pattern_string: str, string_list: list[str]) -> bool: + """Checks if the provided pattern string matches at least one entry in the + provided list. First considers exact string matches, then interprets the + pattern string as a regular expression and sees if it matches at least one + entry of the list. If the pattern_string isn't a valid regular expression, it is + interpreted as not matching in the regular expression matching step (but can + still match on an exact-string basis) + """ + return any([matches_as_string_or_regex(list_entry, pattern_string) for list_entry in string_list]) + + +def retrieve_roles_from_db_with_regex( + db: Database, role_client: str, role_name: str +) -> list[RealmRole] | list[ClientRole]: + # TODO rewrite with regex support + if role_client is None or role_client.lower() == "realm": + return [role for role in db.get_all_realm_roles() if matches_as_string_or_regex(role.get_name(), role_name)] + return [ + role + for role in db.get_all_client_roles()[role_client].values() + if matches_as_string_or_regex(role.get_name(), role_name) + ] + + +def get_roles_containing_role(db: Database, role: ClientRole | RealmRole) -> list[RealmRole | ClientRole]: + """Get roles containing a specified role + + In Keycloak, roles can contain other roles. In some situations, you may want to know + which other roles contain a specific role, through however many layers of recursion + are necessary to enumerate them. This function encapsulates this logic. + + The output list will always contain the role itself. + """ + matching_roles = [role] + for realm_role in db.get_all_realm_roles(): + if realm_role.is_composite_role(): + if _role_contains_role(role, realm_role): + matching_roles += get_roles_containing_role(db, realm_role) + + all_client_roles = db.get_all_client_roles() + for client in all_client_roles: + for client_role in all_client_roles[client].values(): + if client_role.is_composite_role(): + if _role_contains_role(role, client_role): + matching_roles += get_roles_containing_role(db, client_role) + + return matching_roles + + +def get_scopes_containing_role(db: Database, role: ClientRole | RealmRole) -> list[ClientScope]: + """Get all scopes containing a specific role + + In Keycloak, scopes can contain one or more roles. This helper finds all scopes that + contain a specific client- or realm role. + """ + return [scope for scope in db.get_all_scopes() if _scope_contains_role(role, scope)] + + +def get_clients_with_scope(db: Database, scope: ClientScope) -> list[Client]: + matching_clients = [] + for client in db.get_all_clients(): + if ( + scope.get_name() in client.get_default_client_scopes() + or scope.get_name() in client.get_optional_client_scopes() + ): + matching_clients.append(client) + + return matching_clients + + +def get_clients_with_directly_assigned_role(db: Database, role: RealmRole | ClientRole) -> list[Client]: + return [client for client in db.get_all_clients() if _client_contains_directly_assigned_role(role, client)] + + +def get_groups_containing_role(db: Database, role: RealmRole | ClientRole) -> list[Group]: + return [group for group in db.get_all_groups() if _group_contains_role(role, group)] + + +def get_service_accounts_with_role(db: Database, role: RealmRole | ClientRole) -> list[ServiceAccount]: + return [account for account in db.get_all_service_accounts() if _service_account_has_role(role, account)] + + +def get_service_accounts_in_group(db: Database, group: Group) -> list[ServiceAccount]: + return [account for account in db.get_all_service_accounts() if group.get_path() in account.get_groups()] + + +def get_effective_roles(db: Database, role: RealmRole | ClientRole) -> dict: + rv = {"realm": [], "client": {}} + if role.is_client_role(): + rv["client"][role.get_client_name()] = [role.get_name()] # type: ignore + else: + rv["realm"].append(role.get_name()) + + if role.is_composite_role(): + for client_or_realm in role.get_composite_roles().keys(): + if client_or_realm == "realm": + # We are dealing with the "realm roles" block of the composite roles + for realm_role_name in role.get_composite_roles()[client_or_realm]: + realm_role = db.get_realm_role(realm_role_name) + rv = _merge_role_dict(rv, get_effective_roles(db, realm_role)) + else: + assert client_or_realm == "client" + client_composite_roles = role.get_composite_roles()["client"] + assert isinstance(client_composite_roles, dict) + for client, role_names in client_composite_roles.items(): + for client_role_name in role_names: + client_role = db.get_client_role(client_role_name, client) + rv = _merge_role_dict(rv, get_effective_roles(db, client_role)) + return rv + + +def get_effective_roles_for_service_account(db: Database, saccount: ServiceAccount) -> dict: + roles = {"realm": [], "client": {}} + + for role_name in saccount.get_realm_roles(): + role = db.get_realm_role(role_name) + roles = _merge_role_dict(roles, get_effective_roles(db, role)) + + for client in saccount.get_client_roles(): + for role_name in saccount.get_client_roles()[client]: + role = db.get_client_role(role_name, client) + roles = _merge_role_dict(roles, get_effective_roles(db, role)) + return roles diff --git a/kcwarden/database/importer.py b/kcwarden/database/importer.py new file mode 100644 index 0000000..7e5bc43 --- /dev/null +++ b/kcwarden/database/importer.py @@ -0,0 +1,73 @@ +import json + +from kcwarden.custom_types.database import Database +from kcwarden.custom_types.keycloak_object import ( + Realm, + Client, + ClientScope, + ServiceAccount, + Group, + RealmRole, + ClientRole, + IdentityProvider, +) + + +def add_realm(realm: dict, db: Database) -> Realm: + r = Realm(realm) + db.add_realm(r) + return r + + +def add_group(group: dict, realm: Realm, db: Database) -> None: + def recursive_add_to_database(g: Group) -> None: + db.add_group(g) + + # Recursively add subgroups + for subgroup in g.get_subgroups(): + recursive_add_to_database(subgroup) + + recursive_add_to_database(Group(group, realm)) + + +def load_realm_dump(filename: str, db: Database) -> None: + with open(filename, "r") as fo: + data = json.load(fo) + + ### Start loading the data into our own structure + # Realm + realm = add_realm(data, db) + + # Load scope and client scope mappings + scope_mappings = data["scopeMappings"] + client_scope_mappings = data["clientScopeMappings"] + + # Client + for client in data["clients"]: + db.add_client(Client(client, scope_mappings, client_scope_mappings, realm)) + + # Scope + for scope in data.get("clientScopes", []): + db.add_scope(ClientScope(scope, scope_mappings, client_scope_mappings, realm)) + + # Service Accounts + for saccount in data.get("users", []): + db.add_service_account(ServiceAccount(saccount, realm)) + + # Realm Roles + for role in data["roles"]["realm"]: + db.add_realm_role(RealmRole(role, realm)) + + # Client Roles + for client in data["roles"]["client"]: + for role in data["roles"]["client"][client]: + db.add_client_role(ClientRole(role, realm, client)) + + # Groups (including subgroups through recursive calls) + for group in data.get("groups", []): + add_group(group, realm, db) + + # Identity Providers + idp_mappers = data["identityProviderMappers"] + for idp in data.get("identityProviders", []): + db.add_identity_provider(IdentityProvider(idp, realm, idp_mappers)) diff --git a/kcwarden/database/in_memory_db.py b/kcwarden/database/in_memory_db.py new file mode 100644 index 0000000..f1b90b0 --- /dev/null +++ b/kcwarden/database/in_memory_db.py @@ -0,0 +1,99 @@ +from kcwarden.custom_types.database import Database +from kcwarden.custom_types.keycloak_object import ( + Client, + ClientScope, + IdentityProvider, + ServiceAccount, + Group, + Realm, + ClientRole, + RealmRole, +) + + +class InMemoryDatabase(Database): + CLIENTS = {} + SCOPES = {} + SERVICE_ACCOUNTS = {} + GROUPS = {} + REALMS = {} + REALM_ROLES = {} + CLIENT_ROLES = {} + IDENTITY_PROVIDERS = {} + + ### "Adders" + def add_realm(self, realm: Realm): + self.REALMS[realm.get_name()] = realm + + def add_client(self, client: Client): + self.CLIENTS[client.get_client_id()] = client + + def add_scope(self, scope: ClientScope): + self.SCOPES[scope.get_name()] = scope + + def add_service_account(self, saccount: ServiceAccount): + self.SERVICE_ACCOUNTS[saccount.get_username()] = saccount + + def add_group(self, group: Group): + self.GROUPS[group.get_name()] = group + + def add_realm_role(self, role: RealmRole): + self.REALM_ROLES[role.get_name()] = role + + def add_client_role(self, role: ClientRole): + if role.get_client_name() not in self.CLIENT_ROLES: + self.CLIENT_ROLES[role.get_client_name()] = {} + self.CLIENT_ROLES[role.get_client_name()][role.get_name()] = role + + def add_identity_provider(self, idp: IdentityProvider): + self.IDENTITY_PROVIDERS[idp.get_alias()] = idp + + ### Full list getters + def get_all_realms(self): + return self.REALMS.values() + + def get_all_clients(self): + return self.CLIENTS.values() + + def get_all_scopes(self): + return self.SCOPES.values() + + def get_all_service_accounts(self): + return self.SERVICE_ACCOUNTS.values() + + def get_all_groups(self): + return self.GROUPS.values() + + def get_all_realm_roles(self): + return self.REALM_ROLES.values() + + def get_all_client_roles(self): + return self.CLIENT_ROLES + + def get_all_identity_providers(self): + return self.IDENTITY_PROVIDERS.values() + + ### Specific getters + def get_realm(self, realm_name): + return self.REALMS[realm_name] + + def get_client(self, client_id): + return self.CLIENTS[client_id] + + def get_scope(self, scope): + return self.SCOPES[scope] + + def get_service_account(self, saccount): + return self.SERVICE_ACCOUNTS[saccount] + + def get_group(self, group): + return self.GROUPS[group] + + def get_realm_role(self, role): + return self.REALM_ROLES[role] + + def get_client_role(self, role, client): + return self.CLIENT_ROLES[client][role] + + def get_identity_provider(self, alias): + return self.IDENTITY_PROVIDERS[alias] diff --git a/kcwarden/monitors/__init__.py b/kcwarden/monitors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/monitors/client_monitor.py b/kcwarden/monitors/client_monitor.py new file mode 100644 index 0000000..3b1936d --- /dev/null +++ b/kcwarden/monitors/client_monitor.py @@ -0,0 +1,230 @@ +from kcwarden.custom_types.keycloak_object import ClientScope, Client, ProtocolMapper +from kcwarden.custom_types.result import Severity +from kcwarden.api import Monitor +from kcwarden.database import helper + + +class ClientWithSensitiveScope(Monitor): + """Checks for the use of sensitive scopes. + + In some situations, specific scopes should only be available for specific clients. + This Auditor checks which OIDC clients have a specific scope in their default or + optional scopes. You can define which clients you would expect to have access to + this scope in the config file. All other clients that have the scope are reported. + + If no scopes are defined in the config file, this auditor will not run. + """ + + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Unexpected client uses monitored sensitive scope" + LONG_DESCRIPTION = "In the configuration, you have defined this scope to be sensitive, and defined a set of expected clients that are allowed to use it. An unexpected client has been detected that has been assigned this scope as either optional or default scope. If this is expected, please add it to the allowlist in the configuration file." + REFERENCE = "" + HAS_CUSTOM_CONFIG = True + CUSTOM_CONFIG_TEMPLATE = { + "scope": "scope name or regular expression", + } + + def audit(self): + custom_config = self.get_custom_config() + for monitor_definition in custom_config: + # Load config + monitored_scope: str = monitor_definition["scope"] + allowed_clients: list[str] = monitor_definition["allowed"] + # Skip default config entry, in case it was still present + if monitored_scope == self.CUSTOM_CONFIG_TEMPLATE["scope"]: # type: ignore - confused linter + continue + for client in self._DB.get_all_clients(): + # if self.is_not_ignored(client) and (monitored_scope in client.get_default_client_scopes() or monitored_scope in client.get_optional_client_scopes()): + if self.is_not_ignored(client) and ( + helper.regex_matches_list_entry(monitored_scope, client.get_default_client_scopes()) + or helper.regex_matches_list_entry(monitored_scope, client.get_optional_client_scopes()) + ): + if not helper.matches_list_of_regexes(client.get_name(), allowed_clients): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details={ + "monitored_scope": monitored_scope, + "default_scopes": client.get_default_client_scopes(), + "optional_scopes": client.get_optional_client_scopes(), + }, + ) + + +class ClientWithSensitiveRole(Monitor): + """Checks for the use of sensitive roles. + + In some situations, specific roles should only be available for specific clients. + This Auditor checks which OIDC clients have a specific role in their default or + optional scopes, also considering composite groups. You can define which clients + you would expect to have access to this role in the config file. All other clients + that have the role in one of their scopes are reported. In addition, all clients + that have "full scopes allowed" and the "roles" scope in their settings will also be + reported. + + If no roles are defined in the config file, this auditor will not run. + """ + + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Unexpected client uses monitored sensitive role" + LONG_DESCRIPTION = "In the configuration, you have defined this role to be sensitive, and defined a set of expected clients that are allowed to use it. An unexpected client has been detected that has been assigned this role as part of an optional or default scope. If this is expected, please add it to the allowlist in the configuration file." + REFERENCE = "" + HAS_CUSTOM_CONFIG = True + CUSTOM_CONFIG_TEMPLATE = { + "role": "Role name or regular expression", + "role-client": "Client name (set to 'realm' for realm roles). No regular expression support", + "ignore_full_scope_allowed": True, + } + + def _protocol_mapper_is_role_mapper(self, mapper: ProtocolMapper) -> bool: + return mapper.get_protocol_mapper() in ["oidc-usermodel-client-role-mapper", "oidc-usermodel-realm-role-mapper"] + + def _get_role_mappers_for_client(self, client: Client) -> list[ProtocolMapper]: + matched_mappers = [] + for mapper in client.get_protocol_mappers(): + if self._protocol_mapper_is_role_mapper(mapper): + matched_mappers.append(mapper) + return matched_mappers + + def _get_role_mapping_default_scopes_for_client(self, client: Client) -> list[str]: + scopes = [] + for scope in client.get_default_client_scopes(): + for mapper in self._DB.get_scope(scope).get_protocol_mappers(): + if self._protocol_mapper_is_role_mapper(mapper): + scopes.append(scope) + return scopes + + def _get_role_mapping_optional_scopes_for_client(self, client: Client) -> list[str]: + scopes = [] + for scope in client.get_optional_client_scopes(): + for mapper in self._DB.get_scope(scope).get_protocol_mappers(): + if self._protocol_mapper_is_role_mapper(mapper): + scopes.append(scope) + return scopes + + def _client_has_some_way_of_mapping_roles(self, client: Client) -> bool: + return ( + self._get_role_mappers_for_client(client) != [] + or self._get_role_mapping_default_scopes_for_client(client) != [] + or self._get_role_mapping_optional_scopes_for_client(client) != [] + ) + + def audit(self): + custom_config = self.get_custom_config() + for monitor_definition in custom_config: + # Load config + monitored_role: str = monitor_definition["role"] + role_client: str = monitor_definition["role-client"] + allowed_clients: list[str] = monitor_definition["allowed"] + ignore_full_scope: bool = monitor_definition["ignore_full_scope_allowed"] + + if monitored_role == self.CUSTOM_CONFIG_TEMPLATE["role"]: # type: ignore - Confused linter + continue + + for role in helper.retrieve_roles_from_db_with_regex(self._DB, role_client, monitored_role): + # Generate the final list of relevant roles. This includes all composite roles + # that contain the relevant role. + final_roles = helper.get_roles_containing_role(self._DB, role) + + # First, we check if the role has been directly assigned to any client. + for considered_role in final_roles: + for client in helper.get_clients_with_directly_assigned_role(self._DB, considered_role): + # Having the right role directly assigned is not enough, the client also needs to have + # a role mapper that writes these roles to a token => also check for that + if ( + self.is_not_ignored(client) + and not helper.matches_list_of_regexes(client.get_name(), allowed_clients) + and self._client_has_some_way_of_mapping_roles(client) + ): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_by": "RoleAssignmentToClient", + "client_roles": client.get_directly_assigned_client_roles(), + "realm_roles": client.get_directly_assigned_realm_roles(), + }, + ) + + # Next, we need to find all scopes that contain the relevant roles. + scopes: list[ClientScope] = [] + for considered_role in final_roles: + scopes += helper.get_scopes_containing_role(self._DB, considered_role) + + # Finally, we need to find all clients that contain the relevant scopes + for scope in scopes: + for client in helper.get_clients_with_scope(self._DB, scope): + # Having the right scope assigned is not enough, the client also needs to have + # a role mapper that writes these roles to a token => also check for that + if ( + self.is_not_ignored(client) + and not helper.matches_list_of_regexes(client.get_name(), allowed_clients) + and self._client_has_some_way_of_mapping_roles(client) + ): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_by": "clientScope", + "matched_scope": scope.get_name(), + "default_scopes": client.get_default_client_scopes(), + "optional_scopes": client.get_optional_client_scopes(), + }, + ) + + if ignore_full_scope: + continue + + # Finally, regardless of any specifics from scopes, clients that have "full scope allowed" + # and a scope that contains mappers for client/realm roles (normally the "roles" scope, but + # may also be a different one) will also always map all roles, including sensitive ones. + # Relevant mapper types are oidc-usermodel-client-role-mapper and oidc-usermodel-realm-role-mapper. + # In this case, we do not use _client_has_some_way_of_mapping_roles, as we want to be able to + # trace where exactly the method for mapping roles is coming from, so we can report it. + for client in self._DB.get_all_clients(): + if ( + self.is_not_ignored(client) + and not helper.matches_list_of_regexes(client.get_name(), allowed_clients) + and client.is_oidc_client() + and client.has_full_scope_allowed() + ): + # Check directly assigned protocol mappers for role mappers + for mapper in self._get_role_mappers_for_client(client): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_by": "full_scope_allowed_and_directly_assigned_role_mapper", + "matched_role_mapper_name": mapper.get_name(), + "matched_role_mapper_type": mapper.get_protocol_mapper(), + }, + ) + + # Check default scopes + for scope in self._get_role_mapping_default_scopes_for_client(client): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_by": "full_scope_allowed_and_default_scope_with_role_mapper", + "matched_scope": scope, + }, + ) + # Same for optional scopes + for scope in self._get_role_mapping_optional_scopes_for_client(client): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_by": "full_scope_allowed_and_optional_scope_with_role_mapper", + "matched_scope": scope, + }, + ) + + +AUDITORS = [ClientWithSensitiveRole, ClientWithSensitiveScope] diff --git a/kcwarden/monitors/group_monitor.py b/kcwarden/monitors/group_monitor.py new file mode 100644 index 0000000..cc8d0a3 --- /dev/null +++ b/kcwarden/monitors/group_monitor.py @@ -0,0 +1,61 @@ +from kcwarden.custom_types.result import Severity +from kcwarden.api import Monitor +from kcwarden.database import helper + + +class GroupWithSensitiveRole(Monitor): + """Checks for the use of sensitive roles with specific groups. + + In some situations, specific roles should only be available for specific groups. + This Auditor checks which groups have a specific role assigned to them. This includes + roles inherited from their parent groups. Groups that are not explicitly permitted + are reported. + """ + + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Unexpected group has sensitive role assigned" + LONG_DESCRIPTION = "In the configuration, you have defined this role to be sensitive, and defined a set of expected groups that are allowed to use it. An unexpected group has been detected that has been assigned this role as part of an optional or default scope. If this is expected, please add it to the allowlist in the configuration file." + REFERENCE = "" + HAS_CUSTOM_CONFIG = True + CUSTOM_CONFIG_TEMPLATE = { + "role": "Role name or regular expression", + "role-client": "Client name (set to 'realm' for realm roles). No regular expression support", + "allowed": ["/group-path", "/group/subgroup", "/group-name-(regex|support)"], + # Overwrite the "allowed" key in the common template to show the correct format + } + + def audit(self): + custom_config = self.get_custom_config() + for monitor_definition in custom_config: + # Load config + monitored_role: str = monitor_definition["role"] + role_client: str = monitor_definition["role-client"] + allowed_groups: list[str] = monitor_definition["allowed"] + + # Skip default config entry, in case it was still present + if monitored_role == self.CUSTOM_CONFIG_TEMPLATE["role"]: # type: ignore - confused linter + continue + + for role in helper.retrieve_roles_from_db_with_regex(self._DB, role_client, monitored_role): + # Find other roles that contain this role: + for contained_role in helper.get_roles_containing_role(self._DB, role): + # Find groups that have this role assigned + for group in helper.get_groups_containing_role(self._DB, contained_role): + if not helper.matches_list_of_regexes(group.get_path(), allowed_groups) and self.is_not_ignored( + group + ): + yield self.generate_finding_with_severity_from_config( + group, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_role": str(contained_role), + "directly_assigned_realm_roles": group.get_realm_roles(), + "directly_assigned_client_roles": group.get_client_roles(), + "effective_realm_roles": group.get_effective_realm_roles(), + "effective_client_roles": group.get_effective_client_roles(), + }, + ) + + +AUDITORS = [GroupWithSensitiveRole] diff --git a/kcwarden/monitors/protocol_mapper_monitor.py b/kcwarden/monitors/protocol_mapper_monitor.py new file mode 100644 index 0000000..6209b95 --- /dev/null +++ b/kcwarden/monitors/protocol_mapper_monitor.py @@ -0,0 +1,124 @@ +from kcwarden.custom_types.keycloak_object import Client, ProtocolMapper +from kcwarden.custom_types.result import Severity +from kcwarden.api import Monitor +from kcwarden.database import helper + + +class ProtocolMapperWithConfig(Monitor): + """Checks for the use of a specific Protocol Mapper, optionally with specific parameters + + Protocol Mappers allow incorporating information into the access token and performing other + tasks. They are assigned to clients. In some situations, you may wish to monitor the use of + specific mappers, e.g. those that accept input from HTTP header and write the result into + specific fields of the access token. + + Protocol Mappers can be assigned to both clients and scopes. However, mappers assigned to a + scope that isn't used by any client aren't interesting. So, whenever a scope is identified + that uses a matching protocol mapper, it is only reported if it is used by a client that + is not in the allowlist. + """ + + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Unexpected use of Protocol Mapper detected" + LONG_DESCRIPTION = "In the configuration, you have defined a specific type of Protocol Mapper to be sensitive. An unexpected use of this protocol mapper has been detected. If this is expected, please add it to the allowlist in the configuration file." + REFERENCE = "" + HAS_CUSTOM_CONFIG = True + CUSTOM_CONFIG_TEMPLATE = { + "protocol-mapper-type": "mapper name or regular expression", + "matched-config": { + "config-key (no regular expression)": "Matched config value (string or regular expression)", + "hint": "you can also leave this dictionary empty to match all mappers of the defined type", + }, + } + + def _protocol_mapper_matches_config( + self, mapper: ProtocolMapper, target_mapper_type: str, target_mapper_config: dict[str, str] + ) -> bool: + # If the mapper type does not match, the whole thing isn't a match + if not helper.matches_as_string_or_regex(mapper.get_protocol_mapper(), target_mapper_type): + return False + + # Next, we need to check if the provided configuration matches. + mapper_config = mapper.get_config() + for cfg_key, cfg_value in target_mapper_config.items(): + # If the target config key is not defined for the mapper, it does not match + if cfg_key not in mapper_config: + return False + # If it is defined, the actual value must match the provided value from the config. + if not helper.matches_as_string_or_regex(mapper_config[cfg_key], cfg_value): + return False + # If we haven't returned False so far, all checks were successful and we can return True + return True + + def _generate_additional_details( + self, client: Client, mapper: ProtocolMapper, matched_by: str, matched_scope: str | None = None + ) -> dict: + additional_details = { + "matched_by": matched_by, + "mapper": str(mapper), + "mapper_config": mapper.get_config(), + "client_default_scopes": client.get_default_client_scopes(), + "client_optional_scopes": client.get_optional_client_scopes(), + "client_has_service_account": client.has_service_account_enabled() and not client.is_public(), + } + if matched_scope is not None: + additional_details["matched_scope"] = matched_scope + if additional_details["client_has_service_account"]: + saccount = self._DB.get_service_account(client.get_service_account_name()) # type: ignore + additional_details["client_service_account_realm_roles"] = saccount.get_realm_roles() + additional_details["client_service_account_client_roles"] = saccount.get_client_roles() + additional_details["client_service_account_resolved_composite_roles"] = ( + helper.get_effective_roles_for_service_account(self._DB, saccount) + ) + return additional_details + + def audit(self): + custom_config = self.get_custom_config() + for monitor_definition in custom_config: + # Load config + monitored_mapper_type: str = monitor_definition["protocol-mapper-type"] + matched_config: dict[str, str] = monitor_definition["matched-config"] + allowed_clients: list[str] = monitor_definition["allowed"] + + # Skip default config entry, in case it was still present + if monitored_mapper_type == self.CUSTOM_CONFIG_TEMPLATE["protocol-mapper-type"]: # type: ignore - Confused linter + continue + + for client in self._DB.get_all_clients(): + if helper.matches_list_of_regexes(client.get_name(), allowed_clients): + continue + # First, find all directly defined ProtocolMappers + for mapper in client.get_protocol_mappers(): + if self._protocol_mapper_matches_config(mapper, monitored_mapper_type, matched_config): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details=self._generate_additional_details( + client, mapper, "client_defined_mapper" + ), + ) + + # Now, search all default and optional scopes + for scope_name in client.get_default_client_scopes(): + for mapper in self._DB.get_scope(scope_name).get_protocol_mappers(): + if self._protocol_mapper_matches_config(mapper, monitored_mapper_type, matched_config): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details=self._generate_additional_details( + client, mapper, "default_scope_defined_mapper", scope_name + ), + ) + for scope_name in client.get_optional_client_scopes(): + for mapper in self._DB.get_scope(scope_name).get_protocol_mappers(): + if self._protocol_mapper_matches_config(mapper, monitored_mapper_type, matched_config): + yield self.generate_finding_with_severity_from_config( + client, + monitor_definition, + additional_details=self._generate_additional_details( + client, mapper, "optional_scope_defined_mapper", scope_name + ), + ) + + +AUDITORS = [ProtocolMapperWithConfig] diff --git a/kcwarden/monitors/service_account_monitor.py b/kcwarden/monitors/service_account_monitor.py new file mode 100644 index 0000000..db2a764 --- /dev/null +++ b/kcwarden/monitors/service_account_monitor.py @@ -0,0 +1,131 @@ +from kcwarden.custom_types.result import Severity +from kcwarden.api import Monitor +from kcwarden.database import helper + + +class ServiceAccountWithSensitiveRole(Monitor): + """Checks for the use of sensitive roles with Service Accounts. + + In some situations, specific roles should only be available for specific users. + This Auditor checks which Service Accounts have a specific role assigned to them, + also considering composite groups. You can define which service accounts you would + expect to have access to this role in the config file. All other service accounts + with that role are reported. + + If no roles are defined in the config file, this auditor will not run. + """ + + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Unexpected service account uses monitored sensitive role" + LONG_DESCRIPTION = "In the configuration, you have defined this role to be sensitive, and defined a set of expected service accounts that are allowed to use it. An unexpected service account has been detected that has been assigned this role as part of an optional or default scope. If this is expected, please add it to the allowlist in the configuration file." + REFERENCE = "" + HAS_CUSTOM_CONFIG = True + CUSTOM_CONFIG_TEMPLATE = { + "role": "role name or regular expression", + "role-client": "Client name (set to 'realm' for realm roles). No regular expression support", + } + + def audit(self): + custom_config = self.get_custom_config() + for monitor_definition in custom_config: + # Load config + monitored_role: str = monitor_definition["role"] + role_client: str = monitor_definition["role-client"] + allowed_service_accounts: list[str] = monitor_definition["allowed"] + + # Skip default config entry, in case it was still present + if monitored_role == self.CUSTOM_CONFIG_TEMPLATE["role"]: # type: ignore - confused linter + continue + + for role in helper.retrieve_roles_from_db_with_regex(self._DB, role_client, monitored_role): + # Generate the final list of relevant roles. This includes all composite roles + # that contain the relevant role. + final_roles = helper.get_roles_containing_role(self._DB, role) + + # Get all groups that contain at least one of these roles + groups = set([]) + for considered_role in final_roles: + groups.update(helper.get_groups_containing_role(self._DB, considered_role)) + + # Next, we need to find all service accounts that have at least one of these roles + for considered_role in final_roles: + for serviceaccount in helper.get_service_accounts_with_role(self._DB, considered_role): + if not helper.matches_list_of_regexes(serviceaccount.get_name(), allowed_service_accounts): + yield self.generate_finding_with_severity_from_config( + serviceaccount, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_role": str(considered_role), + "matched_by": "role", + "service_account_realm_roles": serviceaccount.get_realm_roles(), + "service_account_client_roles": serviceaccount.get_client_roles(), + }, + ) + + # And all service accounts that are in at least one of these groups + for considered_group in groups: + for serviceaccount in helper.get_service_accounts_in_group(self._DB, considered_group): + if not helper.matches_list_of_regexes(serviceaccount.get_name(), allowed_service_accounts): + yield self.generate_finding_with_severity_from_config( + serviceaccount, + monitor_definition, + additional_details={ + "monitored_role": str(role), + "matched_by": "group", + "service_account_groups": serviceaccount.get_groups(), + }, + ) + + +class ServiceAccountWithGroup(Monitor): + """Checks for service accounts assigned to specific groups. + + You may have a situation where you expect all service accounts to be assigned + to specific groups (e.g., "/TecUser"), or no service accounts to be + assigned to a group (e.g., "no service accounts should be assigned to /Customer"). + This monitor allows you to check for any violations of these rules. + + If no groups are defined in the config file, this auditor will not run. + """ + + DEFAULT_SEVERITY = Severity.Medium + SHORT_DESCRIPTION = "Service Account in unexpected group" + LONG_DESCRIPTION = "In the configuration, you have defined rules for which groups service accounts are allowed to be assigned to. This service account violates these rules. If this is a mistake, add an exclusion in the configuration." + REFERENCE = "" + HAS_CUSTOM_CONFIG = True + CUSTOM_CONFIG_TEMPLATE = {"group": "/group path or regular expression", "allow_no_group": True} + + def audit(self): + custom_config = self.get_custom_config() + for monitor_definition in custom_config: + # Load config + monitored_group: str = monitor_definition["group"] + allowed_service_accounts: list[str] = monitor_definition["allowed"] + allow_no_group: bool = monitor_definition["allow_no_group"] + + # Skip default config entry, in case it was still present + if monitored_group == self.CUSTOM_CONFIG_TEMPLATE["group"]: # type: ignore - confused linter + continue + + for saccount in self._DB.get_all_service_accounts(): + assigned_groups = saccount.get_groups() + + if not allow_no_group and assigned_groups == []: + yield self.generate_finding_with_severity_from_config( + saccount, + monitor_definition, + additional_details={"monitored_group": monitored_group, "assigned_groups": assigned_groups}, + ) + continue + + if helper.regex_matches_list_entry(monitored_group, assigned_groups): + if not helper.matches_list_of_regexes(saccount.get_username(), allowed_service_accounts): + yield self.generate_finding_with_severity_from_config( + saccount, + monitor_definition, + additional_details={"monitored_group": monitored_group, "assigned_groups": assigned_groups}, + ) + + +AUDITORS = [ServiceAccountWithGroup, ServiceAccountWithSensitiveRole] diff --git a/kcwarden/subcommands/__init__.py b/kcwarden/subcommands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/subcommands/audit.py b/kcwarden/subcommands/audit.py new file mode 100644 index 0000000..754df0e --- /dev/null +++ b/kcwarden/subcommands/audit.py @@ -0,0 +1,115 @@ +import argparse +import contextlib +import csv +import json +import sys +from typing import Type + +import yaml + +from kcwarden.configuration.auditors import collect_auditors +from kcwarden.configuration.template import generate_config_template +from kcwarden.custom_types import config_keys, result_headers +from kcwarden.api import Auditor +from kcwarden.custom_types.database import Database +from kcwarden.custom_types.result import Result, get_severity_by_name, Severity +from kcwarden.database.in_memory_db import InMemoryDatabase +from kcwarden.database.importer import load_realm_dump + +DATABASE: Database = InMemoryDatabase() + + +def load_config_from_file(filename: str) -> dict[str, dict]: + with open(filename, "r") as cfg_file: + return yaml.safe_load(cfg_file) + + +def generate_config(args: argparse.Namespace, auditors: list[Type[Auditor]]) -> dict[str, str | list | dict | bool]: + def _convert_config_template(config: dict, template: dict): + for auditor_config in template[config_keys.AUDITOR_CONFIG]: + assert isinstance(auditor_config, dict) + auditor_name = auditor_config["auditor"] + allowlist = auditor_config["allowed"] + config[config_keys.AUDITOR_CONFIG][auditor_name] = allowlist + for monitor_config in template[config_keys.MONITOR_CONFIG]: + assert isinstance(monitor_config, dict) + monitor_name = monitor_config["monitor"] + monitor_cfg = monitor_config["config"] + config[config_keys.MONITOR_CONFIG][monitor_name] = monitor_cfg + return config + + # We are first generating an empty dictionary and then updating with the config template + # because otherwise, the type checking of python will throw a fit about the types not + # matching. + cfg_dict = {config_keys.AUDITOR_CONFIG: {}, config_keys.MONITOR_CONFIG: {}} + # We now need to load the config template and convert it into an internal representation + # which is easier to work with for the rest of the system. + cfg_template = generate_config_template(auditors) + cfg_dict = _convert_config_template(cfg_dict, cfg_template) + # Load data from the config file, if provided + if args.config: + cfg_file = load_config_from_file(args.config) + # TODO At the moment, this will just blindly overwrite the values in the template. + # In particular, it will not emit a warning if no Auditor of the specified name exists. + # This is because the list of auditors in this part of the code may be smaller than + # the list of auditors that actually exist, since we may only be using some auditors + # (based on filtering using the --auditors parameter). + # Emitting warnings if we have a more general config file while filtering down to specific + # auditors would be unexpected and undesired, so I am leaving it like this for the moment + # and will come back to it when I have more time. + cfg_dict = _convert_config_template(cfg_dict, cfg_file) + # Update remaining configuration from the CLI parameters + cfg_dict[config_keys.IGNORE_DISABLED_CLIENTS] = args.ignore_disabled_clients + return cfg_dict + + +def execute_auditors(auditors: list[Type[Auditor]], config: dict[str, str | list | dict | bool]) -> list[Result]: + # Execute Auditor Modules + findings = [] + for auditor in auditors: + findings += [result for result in auditor(DATABASE, config).audit()] + return findings + + +def output_findings(findings: list[Result], arguments: argparse.Namespace) -> None: + # Long-term, this should support filtering by severity, etc. + if arguments.min_severity is not None: + min_sev = get_severity_by_name(arguments.min_severity) + else: + min_sev = Severity.Info + + output_file = arguments.output + + filtered_findings = [finding for finding in findings if finding.severity >= min_sev] + + output_format = arguments.format + + # If output_file is None, we want to fall back to stdout. + # stdout should not be closed thus we use `nullcontext`. + with open(output_file, "w") if output_file else contextlib.nullcontext(sys.stdout) as fo: + if output_format == "json": + json.dump([finding.to_dict() for finding in filtered_findings], fo, indent=4) + elif output_format == "csv": + writer = csv.DictWriter(fo, fieldnames=result_headers.ALL_HEADERS, dialect="excel") + writer.writeheader() + for finding in filtered_findings: + writer.writerow(finding.to_dict()) + else: + for finding in filtered_findings: + fo.write(str(finding) + "\n") + fo.write("\n---\n") + + +def audit(args: argparse.Namespace): + # Split auditors, if available + selected_auditors: list[str] | None = args.auditors + # Collect all configured Auditors + auditors = collect_auditors(selected_auditors, args.plugin_dir) + # Generate config + config = generate_config(args, auditors) + # Load JSON data into database + load_realm_dump(args.input_file, DATABASE) + # Execute all auditor modules + findings = execute_auditors(auditors, config) + # Output the results + output_findings(findings, args) diff --git a/kcwarden/subcommands/configuration.py b/kcwarden/subcommands/configuration.py new file mode 100644 index 0000000..5b835ab --- /dev/null +++ b/kcwarden/subcommands/configuration.py @@ -0,0 +1,29 @@ +import argparse +import contextlib +import sys + +import yaml + +from kcwarden.configuration.auditors import collect_auditors +from kcwarden.configuration.template import generate_config_template + + +def output_config(config: dict, file) -> None: + # Create a custom yaml SafeDumper that deactivates aliases, as this makes the + # resulting config file less readable. + class NoAliasDumper(yaml.SafeDumper): + def ignore_aliases(self, data): + return True + + yaml.dump(config, Dumper=NoAliasDumper, sort_keys=False, stream=file) + + +def generate_config(args: argparse.Namespace): + output_file = args.output + + auditors = collect_auditors(additional_auditors_dirs=args.plugin_dir) + + # If output_file is None, we want to fall back to stdout. + # stdout should not be closed thus we use `nullcontext`. + with open(output_file, "w") if output_file else contextlib.nullcontext(sys.stdout) as fo: + output_config(config=generate_config_template(auditors), file=fo) diff --git a/kcwarden/subcommands/download.py b/kcwarden/subcommands/download.py new file mode 100644 index 0000000..5123186 --- /dev/null +++ b/kcwarden/subcommands/download.py @@ -0,0 +1,81 @@ +import contextlib + +import requests +from requests.auth import HTTPBasicAuth +import argparse +from getpass import getpass +import os +import json +import sys + +# Hardcoded Keycloak URLs +KC_TOKEN_AUTH = "{}/realms/{}/protocol/openid-connect/token" +KC_CLIENT_LIST = "{}/admin/realms/{}/clients/" +KC_GROUP_LIST = "{}/admin/realms/{}/groups/" +KC_GROUP_DETAILS = "{}/admin/realms/{}/groups/{}" +KC_ROLE_LIST = "{}/admin/realms/{}/roles" +KC_ROLE_COMPOSITES = "{}/admin/realms/{}/roles/{}/composites" +KC_CLIENTSCOPE_LIST = "{}/admin/realms/{}/client-scopes/" +KC_CLIENTSCOPE_DETAILS = "{}/admin/realms/{}/client-scopes/{}/scope-mappings/realm" +KC_CLIENTSCOPE_COMPOSITE = "{}/admin/realms/{}/client-scopes/{}/scope-mappings/realm/composite" +KC_EXPORT_URL = "{}/admin/realms/{}/partial-export?exportClients=true&exportGroupsAndRoles=true" + + +### Network helper functions +def authorized_get(url, token): + return requests.get(url=url, headers={"Authorization": "Bearer {}".format(token)}).json() + + +### Authentication-related functions +def get_password(user): + if "KEYCLOAK_PASSWORD" in os.environ: + return os.environ["KEYCLOAK_PASSWORD"] + return getpass("Please enter the password for user {}: ".format(user)) + + +def get_totp(): + return input("Please enter the TOTP code: ") + + +def get_session(base_url, user, totp_required, auth_realm): + password = get_password(user) + + auth_data = {"username": user, "password": password, "grant_type": "password"} + + if totp_required: + auth_data["totp"] = get_totp() + + token_url = KC_TOKEN_AUTH.format(base_url, auth_realm) + + req = requests.post(token_url, auth=HTTPBasicAuth("admin-cli", "pass"), data=auth_data) + try: + req.json() + except requests.RequestException: + assert False, "Could not parse JSON. Response was: {}".format(req.content) + assert "access_token" in req.json(), "Did not receive an access token in response. Response was: {}".format( + req.json() + ) + return req.json()["access_token"] + + +### Main Loop +def download_config(args: argparse.Namespace): + # Remove trailing slash on BASE URL, as Keycloak despises them + base_url = args.base_url.removesuffix("/") + + realm = args.realm + output_file = args.output + + session_token = get_session(base_url, args.user, args.totp, args.auth_realm) + + export = requests.post( + KC_EXPORT_URL.format(base_url, realm), headers={"Authorization": f"Bearer {session_token}"} + ).json() + + # TODO Mache ich eigentlich schon was mit Gruppen? + # export = resolve_composite_roles_for_users(export) + + # If output_file is None, we want to fall back to stdout. + # stdout should not be closed thus we use `nullcontext`. + with open(output_file, "w") if output_file else contextlib.nullcontext(sys.stdout) as fo: + json.dump(export, fo, indent=4) diff --git a/kcwarden/subcommands/review.py b/kcwarden/subcommands/review.py new file mode 100644 index 0000000..6a6daaa --- /dev/null +++ b/kcwarden/subcommands/review.py @@ -0,0 +1,91 @@ +import argparse +import contextlib +import csv +import sys + +from kcwarden.custom_types import config_keys +from kcwarden.custom_types.database import Database +from kcwarden.custom_types.keycloak_object import RealmRole, ClientRole +from kcwarden.database.in_memory_db import InMemoryDatabase +from kcwarden.database.importer import load_realm_dump +from kcwarden.monitors.service_account_monitor import ServiceAccountWithSensitiveRole + +DATABASE: Database = InMemoryDatabase() + + +def _configure_monitor_for_role(role: RealmRole | ClientRole) -> dict: + return { + config_keys.MONITOR_CONFIG: { + ServiceAccountWithSensitiveRole.get_classname(): [ + { + "role": role.get_name(), + "role-client": role.get_client_name() if role.is_client_role() else "realm", # type: ignore + "allowed": [], + "severity": "INFO", + } + ] + } + } + + +def _combine_role_identifier(role: RealmRole | ClientRole) -> str: + prefix = role.get_client_name() if role.is_client_role() else "realm" # type: ignore + return prefix + "." + role.get_name() + + +def get_service_account_list() -> list[str]: + return [sa.get_username() for sa in DATABASE.get_all_service_accounts()] + + +def map_service_account_to_roles(service_accounts: list[str]) -> list[dict]: + # Prepare a list for the results + results = [] + # Now, go through every realm role and see which service account has access to it + for role in DATABASE.get_all_realm_roles(): + # Prepare a dict to hold the results for this role + role_res = {x: "" for x in service_accounts} + role_res["role"] = _combine_role_identifier(role) + # Instantiate a config for the relevant monitor to find the realm roles + monitor_cfg = _configure_monitor_for_role(role) + mon = ServiceAccountWithSensitiveRole(DATABASE, monitor_cfg) + for result in mon.audit(): + role_res[result._offending_object.get_name()] = result._additional_details["matched_by"] + results.append(role_res) + + # Next, do the same thing for client roles + client_roles = DATABASE.get_all_client_roles() + for client in client_roles.keys(): + for role in client_roles[client].values(): + # Prepare a dict to hold the results for this role + role_res = {x: "" for x in service_accounts} + role_res["role"] = _combine_role_identifier(role) + # Instantiate a config for the relevant monitor to find the realm roles + monitor_cfg = _configure_monitor_for_role(role) + mon = ServiceAccountWithSensitiveRole(DATABASE, monitor_cfg) + for result in mon.audit(): + role_res[result._offending_object.get_name()] = result._additional_details["matched_by"] + results.append(role_res) + + return results + + +def output_findings(findings: list[dict], service_accounts: list[str], output_file: str) -> None: + field_names = ["role"] + field_names += service_accounts + # If output_file is None, we want to fall back to stdout. + # stdout should not be closed thus we use `nullcontext`. + with open(output_file, "w") if output_file else contextlib.nullcontext(sys.stdout) as fo: + writer = csv.DictWriter(fo, fieldnames=field_names, dialect="excel") + writer.writeheader() + for finding in findings: + writer.writerow(finding) + + +def prepare_review(args: argparse.Namespace): + load_realm_dump(args.input_file, DATABASE) + # Load list of service accounts + service_accounts = get_service_account_list() + # Find mapping of service accounts and roles + findings = map_service_account_to_roles(service_accounts) + # Output the results + output_findings(findings, service_accounts, args.output) diff --git a/kcwarden/utils/__init__.py b/kcwarden/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kcwarden/utils/arguments.py b/kcwarden/utils/arguments.py new file mode 100644 index 0000000..23ad046 --- /dev/null +++ b/kcwarden/utils/arguments.py @@ -0,0 +1,16 @@ +import argparse +from pathlib import Path + + +def is_dir(val: str) -> Path: + """ + Converts a string to a Path object. + + :param val: The parameter's value to check. + :return: The value as Path. + :raises argparse.ArgumentTypeError: If the value is not a directory. + """ + path = Path(val) + if not path.is_dir(): + raise argparse.ArgumentTypeError(f"{val} is not a directory") + return path diff --git a/kcwarden/utils/plugins.py b/kcwarden/utils/plugins.py new file mode 100644 index 0000000..82470d0 --- /dev/null +++ b/kcwarden/utils/plugins.py @@ -0,0 +1,32 @@ +import importlib +import inspect +import logging +import sys +from pathlib import Path +from typing import Type + +from kcwarden.api import Auditor + +logger = logging.getLogger(__name__) + + +def get_auditors(directory: Path) -> list[Type[Auditor]]: + # Append the parent directory to the path thus we can import modules from there + sys.path.append(str(directory.parent)) + + auditors: list[Type[Auditor]] = [] + # List all Python files in the specified directory + auditor_files = [f for f in directory.iterdir() if f.suffix == ".py" and f.name != "__init__.py"] + + # Iterate through the files and dynamically import the modules + for file in auditor_files: + module_name = file.stem # Remove the .py extension + module = importlib.import_module(f"{directory.name}.{module_name}") + + # Inspect the module to find classes that inherit from Auditor + for name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Auditor) and obj.__module__ == module.__name__: + logger.debug(f"Found class {name} in {file} that inherits from {Auditor}") + auditors.append(obj) + + return auditors diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c70aa2c --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,35 @@ +site_name: kcwarden +site_description: An Open Source Keycloak Configuration Auditor, developed by iteratec + +theme: + name: readthedocs + highlightjs: true + hljs_languages: + - yaml + - python + +markdown_extensions: + admonition: {} + attr_list: {} + +site_url: https://iteratec.github.io/kcwarden/ + +repo_url: https://github.com/iteratec/kcwarden +edit_uri: edit/main/docs/ + +nav: + - index.md + - installation.md + - usage.md + - Auditors: + - auditors/index.md + - auditors/clients.md + - auditors/scope.md + - auditors/realm.md + - auditors/idp.md + - Monitors: + - monitors/index.md + - monitors/client_monitor.md + - monitors/group_monitor.md + - monitors/service_account_monitor.md + - monitors/protocol_mapper_monitor.md \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..8517705 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1378 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "async-property" +version = "0.2.2" +description = "Python decorator for async properties." +optional = false +python-versions = "*" +files = [ + {file = "async_property-0.2.2-py2.py3-none-any.whl", hash = "sha256:8924d792b5843994537f8ed411165700b27b2bd966cefc4daeefc1253442a9d7"}, + {file = "async_property-0.2.2.tar.gz", hash = "sha256:17d9bd6ca67e27915a75d92549df64b5c7174e9dc806b30a3934dc4ff0506380"}, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[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" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "43.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "dataclasses-json" +version = "0.5.7" +description = "Easily serialize dataclasses to and from JSON" +optional = false +python-versions = ">=3.6" +files = [ + {file = "dataclasses-json-0.5.7.tar.gz", hash = "sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90"}, + {file = "dataclasses_json-0.5.7-py3-none-any.whl", hash = "sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd"}, +] + +[package.dependencies] +marshmallow = ">=3.3.0,<4.0.0" +marshmallow-enum = ">=1.5.1,<2.0.0" +typing-inspect = ">=0.4.0" + +[package.extras] +dev = ["flake8", "hypothesis", "ipython", "mypy (>=0.710)", "portray", "pytest (>=6.2.3)", "simplejson", "types-dataclasses"] + +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = false +python-versions = "*" +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 = "docker" +version = "7.1.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, + {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, +] + +[package.dependencies] +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.8" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, + {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "intervaltree" +version = "3.1.0" +description = "Editable interval tree data structure for Python 2 and 3" +optional = false +python-versions = "*" +files = [ + {file = "intervaltree-3.1.0.tar.gz", hash = "sha256:902b1b88936918f9b2a19e0e5eb7ccb430ae45cde4f39ea4b36932920d33952d"}, +] + +[package.dependencies] +sortedcontainers = ">=2.0,<3.0" + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[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" +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 = "libcst" +version = "1.4.0" +description = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.12 programs." +optional = false +python-versions = ">=3.9" +files = [ + {file = "libcst-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:279b54568ea1f25add50ea4ba3d76d4f5835500c82f24d54daae4c5095b986aa"}, + {file = "libcst-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3401dae41fe24565387a65baee3887e31a44e3e58066b0250bc3f3ccf85b1b5a"}, + {file = "libcst-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1989fa12d3cd79118ebd29ebe2a6976d23d509b1a4226bc3d66fcb7cb50bd5d"}, + {file = "libcst-1.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addc6d585141a7677591868886f6bda0577529401a59d210aa8112114340e129"}, + {file = "libcst-1.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17d71001cb25e94cfe8c3d997095741a8c4aa7a6d234c0f972bc42818c88dfaf"}, + {file = "libcst-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:2d47de16d105e7dd5f4e01a428d9f4dc1e71efd74f79766daf54528ce37f23c3"}, + {file = "libcst-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e6227562fc5c9c1efd15dfe90b0971ae254461b8b6b23c1b617139b6003de1c1"}, + {file = "libcst-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3399e6c95df89921511b44d8c5bf6a75bcbc2d51f1f6429763609ba005c10f6b"}, + {file = "libcst-1.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48601e3e590e2d6a7ab8c019cf3937c70511a78d778ab3333764531253acdb33"}, + {file = "libcst-1.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f42797309bb725f0f000510d5463175ccd7155395f09b5e7723971b0007a976d"}, + {file = "libcst-1.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb4e42ea107a37bff7f9fdbee9532d39f9ea77b89caa5c5112b37057b12e0838"}, + {file = "libcst-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:9d0cc3c5a2a51fa7e1d579a828c0a2e46b2170024fd8b1a0691c8a52f3abb2d9"}, + {file = "libcst-1.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7ece51d935bc9bf60b528473d2e5cc67cbb88e2f8146297e40ee2c7d80be6f13"}, + {file = "libcst-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81653dea1cdfa4c6520a7c5ffb95fa4d220cbd242e446c7a06d42d8636bfcbba"}, + {file = "libcst-1.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6abce0e66bba2babfadc20530fd3688f672d565674336595b4623cd800b91ef"}, + {file = "libcst-1.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5da9d7dc83801aba3b8d911f82dc1a375db0d508318bad79d9fb245374afe068"}, + {file = "libcst-1.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c54aa66c86d8ece9c93156a2cf5ca512b0dce40142fe9e072c86af2bf892411"}, + {file = "libcst-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:62e2682ee1567b6a89c91853865372bf34f178bfd237853d84df2b87b446e654"}, + {file = "libcst-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b8ecdba8934632b4dadacb666cd3816627a6ead831b806336972ccc4ba7ca0e9"}, + {file = "libcst-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8e54c777b8d27339b70f304d16fc8bc8674ef1bd34ed05ea874bf4921eb5a313"}, + {file = "libcst-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:061d6855ef30efe38b8a292b7e5d57c8e820e71fc9ec9846678b60a934b53bbb"}, + {file = "libcst-1.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb0abf627ee14903d05d0ad9b2c6865f1b21eb4081e2c7bea1033f85db2b8bae"}, + {file = "libcst-1.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d024f44059a853b4b852cfc04fec33e346659d851371e46fc8e7c19de24d3da9"}, + {file = "libcst-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3c6a8faab9da48c5b371557d0999b4ca51f4f2cbd37ee8c2c4df0ac01c781465"}, + {file = "libcst-1.4.0.tar.gz", hash = "sha256:449e0b16604f054fa7f27c3ffe86ea7ef6c409836fe68fe4e752a1894175db00"}, +] + +[package.dependencies] +pyyaml = ">=5.2" + +[package.extras] +dev = ["Sphinx (>=5.1.1)", "black (==23.12.1)", "build (>=0.10.0)", "coverage (>=4.5.4)", "fixit (==2.1.0)", "flake8 (==7.0.0)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jinja2 (==3.1.4)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<1.6)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "pyre-check (==0.9.18)", "setuptools-rust (>=1.5.2)", "setuptools-scm (>=6.0.1)", "slotscheck (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==2.6.0)", "usort (==1.0.8.post1)"] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "marshmallow" +version = "3.22.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.8" +files = [ + {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, + {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] +docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "marshmallow-enum" +version = "1.5.1" +description = "Enum field for Marshmallow" +optional = false +python-versions = "*" +files = [ + {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"}, + {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"}, +] + +[package.dependencies] +marshmallow = ">=2.0.0" + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psutil" +version = "6.0.0" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyre-check" +version = "0.9.22" +description = "A performant type checker for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyre-check-0.9.22.tar.gz", hash = "sha256:e082f926dff71661959535c3936fca5ad40a44858b5fd3e99009a616a1b57083"}, + {file = "pyre_check-0.9.22-py3-none-macosx_10_11_x86_64.whl", hash = "sha256:4bbd61dad5669dfef00e875bf8a573866595ecbd8240f595339a9781e8a1e22e"}, + {file = "pyre_check-0.9.22-py3-none-manylinux1_x86_64.whl", hash = "sha256:d331e2687e194fa22505e0724b1536e61bf06fddc5416d7b83d542d2270c91ce"}, +] + +[package.dependencies] +click = ">=8.0" +dataclasses-json = "0.5.7" +intervaltree = "*" +libcst = "*" +psutil = "*" +pyre-extensions = ">=0.0.29" +tabulate = "*" +testslide = ">=2.7.0" +typing-extensions = "*" +typing-inspect = "*" + +[[package]] +name = "pyre-extensions" +version = "0.0.30" +description = "Type system extensions for use with the pyre type checker" +optional = false +python-versions = "*" +files = [ + {file = "pyre-extensions-0.0.30.tar.gz", hash = "sha256:ba7923c486e089afb37a10623a8f4ae82d73cff42426d711c48af070e5bc31b2"}, + {file = "pyre_extensions-0.0.30-py3-none-any.whl", hash = "sha256:32b37ede4eed0ea879fdd6d84e0c7811e129f19b76614f1be3a6b47f9a4b1fa0"}, +] + +[package.dependencies] +typing-extensions = "*" +typing-inspect = "*" + +[[package]] +name = "pytest" +version = "8.3.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[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" +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 = "python-keycloak" +version = "4.3.0" +description = "python-keycloak is a Python package providing access to the Keycloak API." +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "python_keycloak-4.3.0-py3-none-any.whl", hash = "sha256:6dc1e89c38346a90bb4386850381d84fedb489a7dea19d561aaad5882ceeed72"}, + {file = "python_keycloak-4.3.0.tar.gz", hash = "sha256:c187ceee7d79f0d76d9c2ed26e3d10ce35e42a407896bf5ec15b8fb24fc0c9ce"}, +] + +[package.dependencies] +async-property = ">=0.2.2" +deprecation = ">=2.1.0" +httpx = ">=0.23.2" +jwcrypto = ">=1.5.4" +requests = ">=2.20.0" +requests-toolbelt = ">=0.6.0" + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[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.*" +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 = "ruff" +version = "0.6.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, + {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, + {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, + {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, + {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, + {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, + {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "testcontainers" +version = "4.8.1" +description = "Python library for throwaway instances of anything that can run in a Docker container" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "testcontainers-4.8.1-py3-none-any.whl", hash = "sha256:d8ae43e8fe34060fcd5c3f494e0b7652b7774beabe94568a2283d0881e94d489"}, + {file = "testcontainers-4.8.1.tar.gz", hash = "sha256:5ded4820b7227ad526857eb3caaafcabce1bbac05d22ad194849b136ffae3cb0"}, +] + +[package.dependencies] +docker = "*" +python-keycloak = {version = "*", optional = true, markers = "extra == \"keycloak\""} +typing-extensions = "*" +urllib3 = "*" +wrapt = "*" + +[package.extras] +arangodb = ["python-arango (>=7.8,<8.0)"] +aws = ["boto3", "httpx"] +azurite = ["azure-storage-blob (>=12.19,<13.0)"] +chroma = ["chromadb-client"] +clickhouse = ["clickhouse-driver"] +cosmosdb = ["azure-cosmos"] +db2 = ["ibm_db_sa", "sqlalchemy"] +generic = ["httpx", "redis"] +google = ["google-cloud-datastore (>=2)", "google-cloud-pubsub (>=2)"] +influxdb = ["influxdb", "influxdb-client"] +k3s = ["kubernetes", "pyyaml"] +keycloak = ["python-keycloak"] +localstack = ["boto3"] +mailpit = ["cryptography"] +minio = ["minio"] +mongodb = ["pymongo"] +mssql = ["pymssql", "sqlalchemy"] +mysql = ["pymysql[rsa]", "sqlalchemy"] +nats = ["nats-py"] +neo4j = ["neo4j"] +opensearch = ["opensearch-py"] +oracle = ["oracledb", "sqlalchemy"] +oracle-free = ["oracledb", "sqlalchemy"] +qdrant = ["qdrant-client"] +rabbitmq = ["pika"] +redis = ["redis"] +registry = ["bcrypt"] +scylla = ["cassandra-driver (==3.29.1)"] +selenium = ["selenium"] +sftp = ["cryptography"] +test-module-import = ["httpx"] +trino = ["trino"] +weaviate = ["weaviate-client (>=4.5.4,<5.0.0)"] + +[[package]] +name = "testslide" +version = "2.7.1" +description = "A test framework for Python that makes mocking and iterating over code with tests a breeze" +optional = false +python-versions = "*" +files = [ + {file = "TestSlide-2.7.1.tar.gz", hash = "sha256:d25890d5c383f673fac44a5f9e2561b7118d04f29f2c2b3d4f549e6db94cb34d"}, +] + +[package.dependencies] +psutil = ">=5.6.7" +Pygments = ">=2.2.0" +typeguard = "<3.0" + +[package.extras] +build = ["black", "coverage", "coveralls", "flake8", "ipython", "isort (>=5.1,<6.0)", "mypy (==0.991)", "sphinx", "sphinx-autobuild", "sphinx-kr-theme", "twine"] + +[[package]] +name = "typeguard" +version = "2.13.3" +description = "Run-time type checker for Python" +optional = false +python-versions = ">=3.5.3" +files = [ + {file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"}, + {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"}, +] + +[package.extras] +doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["mypy", "pytest", "typing-extensions"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +description = "Runtime inspection utilities for typing module." +optional = false +python-versions = "*" +files = [ + {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, + {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, +] + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "watchdog" +version = "5.0.2" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +files = [ + {file = "watchdog-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d961f4123bb3c447d9fcdcb67e1530c366f10ab3a0c7d1c0c9943050936d4877"}, + {file = "watchdog-5.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72990192cb63872c47d5e5fefe230a401b87fd59d257ee577d61c9e5564c62e5"}, + {file = "watchdog-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bec703ad90b35a848e05e1b40bf0050da7ca28ead7ac4be724ae5ac2653a1a0"}, + {file = "watchdog-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae7a1879918f6544201d33666909b040a46421054a50e0f773e0d870ed7438d"}, + {file = "watchdog-5.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4a440f725f3b99133de610bfec93d570b13826f89616377715b9cd60424db6e"}, + {file = "watchdog-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8b2918c19e0d48f5f20df458c84692e2a054f02d9df25e6c3c930063eca64c1"}, + {file = "watchdog-5.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:aa9cd6e24126d4afb3752a3e70fce39f92d0e1a58a236ddf6ee823ff7dba28ee"}, + {file = "watchdog-5.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f627c5bf5759fdd90195b0c0431f99cff4867d212a67b384442c51136a098ed7"}, + {file = "watchdog-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7594a6d32cda2b49df3fd9abf9b37c8d2f3eab5df45c24056b4a671ac661619"}, + {file = "watchdog-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba32efcccfe2c58f4d01115440d1672b4eb26cdd6fc5b5818f1fb41f7c3e1889"}, + {file = "watchdog-5.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:963f7c4c91e3f51c998eeff1b3fb24a52a8a34da4f956e470f4b068bb47b78ee"}, + {file = "watchdog-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8c47150aa12f775e22efff1eee9f0f6beee542a7aa1a985c271b1997d340184f"}, + {file = "watchdog-5.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:14dd4ed023d79d1f670aa659f449bcd2733c33a35c8ffd88689d9d243885198b"}, + {file = "watchdog-5.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b84bff0391ad4abe25c2740c7aec0e3de316fdf7764007f41e248422a7760a7f"}, + {file = "watchdog-5.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e8d5ff39f0a9968952cce548e8e08f849141a4fcc1290b1c17c032ba697b9d7"}, + {file = "watchdog-5.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fb223456db6e5f7bd9bbd5cd969f05aae82ae21acc00643b60d81c770abd402b"}, + {file = "watchdog-5.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9814adb768c23727a27792c77812cf4e2fd9853cd280eafa2bcfa62a99e8bd6e"}, + {file = "watchdog-5.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:901ee48c23f70193d1a7bc2d9ee297df66081dd5f46f0ca011be4f70dec80dab"}, + {file = "watchdog-5.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:638bcca3d5b1885c6ec47be67bf712b00a9ab3d4b22ec0881f4889ad870bc7e8"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5597c051587f8757798216f2485e85eac583c3b343e9aa09127a3a6f82c65ee8"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:53ed1bf71fcb8475dd0ef4912ab139c294c87b903724b6f4a8bd98e026862e6d"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:29e4a2607bd407d9552c502d38b45a05ec26a8e40cc7e94db9bb48f861fa5abc"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:b6dc8f1d770a8280997e4beae7b9a75a33b268c59e033e72c8a10990097e5fde"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:d2ab34adc9bf1489452965cdb16a924e97d4452fcf88a50b21859068b50b5c3b"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7d1aa7e4bb0f0c65a1a91ba37c10e19dabf7eaaa282c5787e51371f090748f4b"}, + {file = "watchdog-5.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:726eef8f8c634ac6584f86c9c53353a010d9f311f6c15a034f3800a7a891d941"}, + {file = "watchdog-5.0.2-py3-none-win32.whl", hash = "sha256:bda40c57115684d0216556671875e008279dea2dc00fcd3dde126ac8e0d7a2fb"}, + {file = "watchdog-5.0.2-py3-none-win_amd64.whl", hash = "sha256:d010be060c996db725fbce7e3ef14687cdcc76f4ca0e4339a68cc4532c382a73"}, + {file = "watchdog-5.0.2-py3-none-win_ia64.whl", hash = "sha256:3960136b2b619510569b90f0cd96408591d6c251a75c97690f4553ca88889769"}, + {file = "watchdog-5.0.2.tar.gz", hash = "sha256:dcebf7e475001d2cdeb020be630dc5b687e9acdd60d16fea6bb4508e7b94cf76"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "a5c4e8497e2caa8679d874acff60fbd03852c4acf952d44d3507f1e3aa6d3852" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa443df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[tool.poetry] +name = "kcwarden" +version = "0.0.1" +description = "Keycloak auditor" +authors = ["Max Maass ", "Tim Walter "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +requests = "^2.32.3" +pyyaml = "^6.0.2" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.2" +testcontainers = { extras = ["keycloak"], version = "^4.8.0" } +python-keycloak = "^4.3.0" + +[tool.poetry.group.lint] +optional = true + +[tool.poetry.group.lint.dependencies] +pyre-check = "^0.9.22" +ruff = "^0.6.3" + +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +mkdocs = "^1.6.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +kcwarden = "kcwarden.cli:main" + +[tool.pytest.ini_options] +testpaths = [ + "tests" +] +addopts = "-ra -q" + +[tool.ruff] +src = ["kcwarden", "tests"] +line-length = 120 +indent-width = 4 + +[tool.ruff.lint] +select = ["E", "F", "C90", "N", "RUF", "PL"] +ignore = [ + "E501", # line length + "PLR2004", # Magic value used in comparison + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` + "PLR0913", # Too many arguments in function definition + "PLR0912", # Too many branches + "C901", # Too complex +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "lf" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auditors/__init__.py b/tests/auditors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auditors/client/__init__.py b/tests/auditors/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auditors/client/test_client_authentication_via_mtls_or_jwt_recommended.py b/tests/auditors/client/test_client_authentication_via_mtls_or_jwt_recommended.py new file mode 100644 index 0000000..fd8aafb --- /dev/null +++ b/tests/auditors/client/test_client_authentication_via_mtls_or_jwt_recommended.py @@ -0,0 +1,59 @@ +from unittest.mock import Mock + +import pytest + +from kcwarden.auditors.client.client_authentication_via_mtls_or_jwt_recommended import ( + ClientAuthenticationViaMTLSOrJWTRecommended, +) + + +class TestClientAuthenticationViaMTLSOrJWTRecommended: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientAuthenticationViaMTLSOrJWTRecommended(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,is_public,expected", + [ + (True, False, True), # OIDC, confidential client + (False, False, False), # Non-OIDC client should be excluded + (True, True, False), # Public client should be excluded + ], + ) + def test_should_consider_client(self, mock_client, auditor, is_oidc, is_public, expected): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.is_public.return_value = is_public + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "client_authenticator_type, should_alert", + [ + ("client-secret", True), # Using client-secret, should alert + ("jwt", False), # Using JWT, should not alert + ("mtls", False), # Using mTLS, should not alert + ], + ) + def test_client_does_not_use_mtls_or_jwt_auth(self, mock_client, auditor, client_authenticator_type, should_alert): + mock_client.get_client_authenticator_type.return_value = client_authenticator_type + assert auditor.client_does_not_use_mtls_or_jwt_auth(mock_client) == should_alert + + def test_audit_function_no_findings(self, confidential_client, auditor): + confidential_client.get_client_authenticator_type.return_value = "jwt" + auditor._DB.get_all_clients.return_value = [confidential_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, confidential_client, auditor): + confidential_client.get_client_authenticator_type.return_value = "client-secret" + auditor._DB.get_all_clients.return_value = [confidential_client] + results = list(auditor.audit()) + assert len(results) == 1 + confidential_client.get_client_authenticator_type.assert_called_with() + + def test_audit_function_multiple_clients(self, confidential_client, auditor): + confidential_client.get_client_authenticator_type.side_effect = ["client-secret", "jwt", "mtls"] + auditor._DB.get_all_clients.return_value = [confidential_client, confidential_client, confidential_client] + results = list(auditor.audit()) + assert len(results) == 1 # Expect findings from one client diff --git a/tests/auditors/client/test_client_has_erroneously_configured_wildcard_uri.py b/tests/auditors/client/test_client_has_erroneously_configured_wildcard_uri.py new file mode 100644 index 0000000..3e51944 --- /dev/null +++ b/tests/auditors/client/test_client_has_erroneously_configured_wildcard_uri.py @@ -0,0 +1,73 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_has_erroneously_configured_wildcard_uri import ( + ClientHasErroneouslyConfiguredWildcardURI, +) + + +class TestClientHasErroneouslyConfiguredWildcardURI: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientHasErroneouslyConfiguredWildcardURI(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,has_standard_flow,has_implicit_flow,expected", + [ + (True, True, False, True), # OIDC with standard flow + (True, False, True, True), # OIDC with implicit flow + (False, True, True, False), # Non-OIDC should be excluded + (True, False, False, False), # OIDC but no relevant flows + ], + ) + def test_should_consider_client( + self, mock_client, auditor, is_oidc, has_standard_flow, has_implicit_flow, expected + ): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.has_standard_flow_enabled.return_value = has_standard_flow + mock_client.has_implicit_flow_enabled.return_value = has_implicit_flow + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "redirect_uri, should_alert", + [ + ("https://example.com*", True), # Wildcard in domain part + ("https://example.com/*", False), # Wildcard correctly in path + ("https://example.com/subpath*", False), # Wildcard correctly in path + ("https://example.com/subpath/login?*", False), # Wildcard in GET Parameters + ("http://example.com*", True), # Wildcard in domain part with http + ("", False), # Empty URI + ("https://example.com", False), # No wildcard + ("https://*", True), # Edge case: entire domain as wildcard + ("*", True), # Edge case: Only wildcard without protocol + ], + ) + def test_redirect_uri_has_wildcard_in_domain(self, auditor, redirect_uri, should_alert): + assert auditor.redirect_uri_has_wildcard_in_domain(redirect_uri) == should_alert + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.get_resolved_redirect_uris.return_value = ["https://example.com/path", "https://example.com/other"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.get_resolved_redirect_uris.return_value = ["https://example.com*", "https://valid.com/path"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert finding.to_dict()["additional_details"]["redirect_uri"] == "https://example.com*" + + def test_audit_function_multiple_clients(self, mock_client, auditor): + # Setting up various redirect URI configurations + mock_client.get_resolved_redirect_uris.side_effect = [ + ["https://example.com*", "https://valid.com/path"], + ["https://secure.com/path"], + ["https://anotherbad.com*"], + ] + auditor._DB.get_all_clients.return_value = [mock_client, mock_client, mock_client] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from two clients diff --git a/tests/auditors/client/test_client_has_undefined_base_domain_and_schema.py b/tests/auditors/client/test_client_has_undefined_base_domain_and_schema.py new file mode 100644 index 0000000..5be430b --- /dev/null +++ b/tests/auditors/client/test_client_has_undefined_base_domain_and_schema.py @@ -0,0 +1,67 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_has_undefined_base_domain_and_schema import ClientHasUndefinedBaseDomainAndSchema + + +class TestClientHasUndefinedBaseDomainAndSchema: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientHasUndefinedBaseDomainAndSchema(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,has_standard_flow,has_implicit_flow,expected", + [ + (True, True, False, True), # OIDC with standard flow + (True, False, True, True), # OIDC with implicit flow + (False, True, True, False), # Non-OIDC should be excluded + (True, False, False, False), # OIDC but no relevant flows + ], + ) + def test_should_consider_client( + self, mock_client, auditor, is_oidc, has_standard_flow, has_implicit_flow, expected + ): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.has_standard_flow_enabled.return_value = has_standard_flow + mock_client.has_implicit_flow_enabled.return_value = has_implicit_flow + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "redirect_uri, should_alert", + [ + ("https://example.com/path", False), # Properly defined scheme + ("http://example.com/path", False), # HTTP scheme, not secure but defined + ("//example.com/path", True), # Scheme-relative URL (undefined scheme) + ("example.com/login", True), # No scheme defined + ], + ) + def test_redirect_uri_has_empty_scheme(self, auditor, redirect_uri, should_alert): + assert auditor.redirect_uri_has_empty_scheme(redirect_uri) == should_alert + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.get_resolved_redirect_uris.return_value = ["https://example.com/path", "http://localhost/login"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.get_resolved_redirect_uris.return_value = ["//example.com/login", "example.com/login"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 2 # Expect two finding for one client with all redirects problematic + finding = results[0] + assert "redirect_uri" in finding.to_dict()["additional_details"] and finding.to_dict()["additional_details"][ + "redirect_uri" + ] in ["//example.com/login", "example.com/login"] + + def test_audit_function_multiple_clients(self, mock_client, auditor): + # Setting up various redirect URI configurations + mock_client.get_resolved_redirect_uris.side_effect = [ + ["https://example.com", "http://localhost"], + ["//example.com/login", "example.com/login"], + ] + auditor._DB.get_all_clients.return_value = [mock_client, mock_client] + results = list(auditor.audit()) + assert len(results) == 2 # Expect two findings from one client diff --git a/tests/auditors/client/test_client_must_not_use_unencrypted_nonlocal_redirect_uri.py b/tests/auditors/client/test_client_must_not_use_unencrypted_nonlocal_redirect_uri.py new file mode 100644 index 0000000..52cfddb --- /dev/null +++ b/tests/auditors/client/test_client_must_not_use_unencrypted_nonlocal_redirect_uri.py @@ -0,0 +1,77 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_must_not_use_unencrypted_nonlocal_redirect_uri import ( + ClientMustNotUseUnencryptedNonlocalRedirectUri, +) + + +class TestClientMustNotUseUnencryptedNonlocalRedirectUri: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientMustNotUseUnencryptedNonlocalRedirectUri(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,has_standard_flow,has_implicit_flow,expected", + [ + (True, True, False, True), # OIDC with standard flow + (True, False, True, True), # OIDC with implicit flow + (False, True, True, False), # Non-OIDC should be excluded + (True, False, False, False), # OIDC but no relevant flows + ], + ) + def test_should_consider_client( + self, mock_client, auditor, is_oidc, has_standard_flow, has_implicit_flow, expected + ): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.has_standard_flow_enabled.return_value = has_standard_flow + mock_client.has_implicit_flow_enabled.return_value = has_implicit_flow + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "redirect_uri, should_alert", + [ + ("http://example.com/path", True), # HTTP non-local should alert + ("https://example.com/path", False), # HTTPS should not alert + ("http://localhost/callback", False), # HTTP local should not alert + ("http://127.0.0.1/callback", False), # HTTP local IP should not alert + ("http://::1/callback", False), # HTTP local IPv6 should not alert + ("example.com", False), # Incorrect URI, no proper validation here + ], + ) + def test_redirect_uri_is_http_and_non_local(self, auditor, redirect_uri, should_alert): + assert auditor.redirect_uri_is_http_and_non_local(redirect_uri) == should_alert + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.is_default_keycloak_client.return_value = False + mock_client.get_resolved_redirect_uris.return_value = [ + "https://example.com/callback", + "http://localhost/callback", + ] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.is_default_keycloak_client.return_value = False + mock_client.get_resolved_redirect_uris.return_value = ["http://example.com/callback"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert ( + "redirect_uri" in finding.to_dict()["additional_details"] + and finding.to_dict()["additional_details"]["redirect_uri"] == "http://example.com/callback" + ) + + def test_audit_function_multiple_clients(self, mock_client, auditor): + mock_client.is_default_keycloak_client.return_value = False + mock_client.get_resolved_redirect_uris.side_effect = [ + ["https://secure.com/path"], + ["http://example.com/callback", "https://example.com/secure"], + ] + auditor._DB.get_all_clients.return_value = [mock_client, mock_client] + results = list(auditor.audit()) + assert len(results) == 1 # Expect findings from one client diff --git a/tests/auditors/client/test_client_should_not_use_wildcard_redirect_uri.py b/tests/auditors/client/test_client_should_not_use_wildcard_redirect_uri.py new file mode 100644 index 0000000..b6e8c3d --- /dev/null +++ b/tests/auditors/client/test_client_should_not_use_wildcard_redirect_uri.py @@ -0,0 +1,77 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_should_not_use_wildcard_redirect_uri import ClientShouldNotUseWildcardRedirectURI + + +class TestClientShouldNotUseWildcardRedirectURI: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientShouldNotUseWildcardRedirectURI(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,has_standard_flow,has_implicit_flow,expected", + [ + (True, True, False, True), # OIDC with standard flow + (True, False, True, True), # OIDC with implicit flow + (False, True, True, False), # Non-OIDC should be excluded + (True, False, False, False), # OIDC but no relevant flows + ], + ) + def test_should_consider_client( + self, mock_client, auditor, is_oidc, has_standard_flow, has_implicit_flow, expected + ): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.has_standard_flow_enabled.return_value = has_standard_flow + mock_client.has_implicit_flow_enabled.return_value = has_implicit_flow + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "redirect_uri, should_alert", + [ + ("https://example.com/callback*", True), # Wildcard at the end + ("https://example.com/callback", False), # No wildcard + ("https://example.com/call*back", False), # Asterisk not at the end + ("http://localhost/callback/*", True), # Localhost with wildcard + ("https://example.com/*", True), # Wildcard directly after domain + ("https://example.com/auth?*", True), # Wildcard in GET parameters + ("http://::1/auth?*", True), # Wildcard in GET parameters + ], + ) + def test_redirect_uri_is_wildcard_uri(self, auditor, redirect_uri, should_alert): + assert auditor.redirect_uri_is_wildcard_uri(redirect_uri) == should_alert + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.get_resolved_redirect_uris.return_value = [ + "https://example.com/callback", + "https://another.com/path", + ] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.get_resolved_redirect_uris.return_value = [ + "https://example.com/callback*", + "https://valid.com/path", + ] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert ( + "redirect_uri" in finding.to_dict()["additional_details"] + and finding.to_dict()["additional_details"]["redirect_uri"] == "https://example.com/callback*" + ) + + def test_audit_function_multiple_clients(self, mock_client, auditor): + # Setting up various redirect URI configurations + mock_client.get_resolved_redirect_uris.side_effect = [ + ["https://secure.com/path", "https://example.com/callback"], + ["https://bad.com/callback*", "https://also.bad.com/endswith*"], + ] + auditor._DB.get_all_clients.return_value = [mock_client, mock_client] + results = list(auditor.audit()) + assert len(results) == 2 # Expect two findings from the second client diff --git a/tests/auditors/client/test_client_uses_custom_redirect_uri_scheme.py b/tests/auditors/client/test_client_uses_custom_redirect_uri_scheme.py new file mode 100644 index 0000000..4b085fe --- /dev/null +++ b/tests/auditors/client/test_client_uses_custom_redirect_uri_scheme.py @@ -0,0 +1,75 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_uses_custom_redirect_uri_scheme import ClientUsesCustomRedirectUriScheme + + +class TestClientUsesCustomRedirectUriScheme: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientUsesCustomRedirectUriScheme(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,has_standard_flow,has_implicit_flow,expected", + [ + (True, True, False, True), # OIDC with standard flow + (True, False, True, True), # OIDC with implicit flow + (False, True, True, False), # Non-OIDC should be excluded + (True, False, False, False), # OIDC but no relevant flows + ], + ) + def test_should_consider_client( + self, mock_client, auditor, is_oidc, has_standard_flow, has_implicit_flow, expected + ): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.has_standard_flow_enabled.return_value = has_standard_flow + mock_client.has_implicit_flow_enabled.return_value = has_implicit_flow + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "redirect_uri, should_alert", + [ + ("myapp://example.com/callback", True), # Custom scheme + ("https://example.com/callback", False), # Standard HTTPS + ("http://localhost/callback", False), # Standard HTTP, but localhost + ("ftp://example.com/data", True), # FTP scheme + ("mailto:someone@example.com", True), # Mailto scheme + ("/relative/path/callback", False), # Relative path + ], + ) + def test_redirect_uri_uses_custom_protocol(self, auditor, redirect_uri, should_alert): + assert auditor.redirect_uri_uses_custom_protocol(redirect_uri) == should_alert + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.is_default_keycloak_client.return_value = False + mock_client.get_resolved_redirect_uris.return_value = [ + "https://example.com/callback", + "http://localhost/callback", + ] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.is_default_keycloak_client.return_value = False + mock_client.get_resolved_redirect_uris.return_value = ["myapp://example.com/callback"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert ( + "redirect_uri" in finding.to_dict()["additional_details"] + and finding.to_dict()["additional_details"]["redirect_uri"] == "myapp://example.com/callback" + ) + + def test_audit_function_multiple_clients(self, mock_client, auditor): + mock_client.is_default_keycloak_client.return_value = False + mock_client.get_resolved_redirect_uris.side_effect = [ + ["https://secure.com/path"], + ["myapp://example.com/callback", "https://example.com/secure"], + ] + auditor._DB.get_all_clients.return_value = [mock_client, mock_client] + results = list(auditor.audit()) + assert len(results) == 1 # Expect one finding from one client diff --git a/tests/auditors/client/test_client_with_default_offline_access_scope.py b/tests/auditors/client/test_client_with_default_offline_access_scope.py new file mode 100644 index 0000000..5ef31e7 --- /dev/null +++ b/tests/auditors/client/test_client_with_default_offline_access_scope.py @@ -0,0 +1,105 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_with_default_offline_access_scope import ClientWithDefaultOfflineAccessScope + + +class TestClientWithDefaultOfflineAccessScope: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientWithDefaultOfflineAccessScope(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_client(self, mock_client, auditor): + assert auditor.should_consider_client(mock_client) is True # Always consider unless ignored + + @pytest.mark.parametrize( + "default_scopes,flow_status,use_refresh_tokens,expected", + [ + (["offline_access"], {"device": True, "direct": False, "standard": True, "implicit": False}, "true", True), + (["offline_access"], {"device": False, "direct": True, "standard": False, "implicit": False}, "true", True), + ([], {"device": True, "direct": True, "standard": True, "implicit": True}, "true", False), + ( + ["offline_access"], + {"device": False, "direct": False, "standard": False, "implicit": False}, + "true", + False, + ), + (["offline_access"], {"device": True, "direct": True, "standard": True, "implicit": True}, "false", False), + ], + ) + def test_client_can_generate_offline_tokens( + self, mock_client, auditor, default_scopes, flow_status, use_refresh_tokens, expected + ): + mock_client.get_default_client_scopes.return_value = default_scopes + mock_client.has_device_authorization_grant_flow_enabled.return_value = flow_status["device"] + mock_client.has_direct_access_grants_enabled.return_value = flow_status["direct"] + mock_client.has_standard_flow_enabled.return_value = flow_status["standard"] + mock_client.has_implicit_flow_enabled.return_value = flow_status["implicit"] + mock_client.allows_user_authentication.return_value = ( + flow_status["device"] or flow_status["direct"] or flow_status["standard"] or flow_status["implicit"] + ) + mock_client.get_attributes.return_value = {"use.refresh.tokens": use_refresh_tokens} + assert auditor.client_can_generate_offline_tokens(mock_client) == expected + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.get_default_client_scopes.return_value = [] + mock_client.get_attributes.return_value = {"use.refresh.tokens": "false"} + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.get_default_client_scopes.return_value = ["offline_access"] + mock_client.has_device_authorization_grant_flow_enabled.return_value = True + mock_client.has_direct_access_grants_enabled.return_value = True + mock_client.has_standard_flow_enabled.return_value = True + mock_client.has_implicit_flow_enabled.return_value = False + mock_client.is_public.return_value = False + mock_client.get_attributes.return_value = {"use.refresh.tokens": "true"} + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert finding.to_dict()["additional_details"]["default_scopes"] == ["offline_access"] + assert finding.to_dict()["additional_details"]["client_public"] == mock_client.is_public() + assert finding.to_dict()["additional_details"]["standard_flow_enabled"] is True + + def test_audit_function_multiple_clients(self, auditor): + # Create separate mock clients with distinct settings + # We need to do this because the mocked functions are called more than once. + # This means that the `side_effect` method used in other test functions fails. + client1 = Mock() + client1.get_default_client_scopes.return_value = ["offline_access"] + client1.has_device_authorization_grant_flow_enabled.return_value = True + client1.has_direct_access_grants_enabled.return_value = True + client1.has_standard_flow_enabled.return_value = True + client1.has_implicit_flow_enabled.return_value = False + client1.allows_user_authentication.return_value = True + client1.get_attributes.return_value = {"use.refresh.tokens": "true"} + client1.is_public.return_value = False + + client2 = Mock() + client2.get_default_client_scopes.return_value = [] + client2.has_device_authorization_grant_flow_enabled.return_value = False + client2.has_direct_access_grants_enabled.return_value = False + client2.has_standard_flow_enabled.return_value = True + client2.has_implicit_flow_enabled.return_value = False + client2.get_attributes.return_value = {"use.refresh.tokens": "false"} + client2.is_public.return_value = True + client2.allows_user_authentication.return_value = True + + client3 = Mock() + client3.get_default_client_scopes.return_value = ["offline_access"] + client3.has_device_authorization_grant_flow_enabled.return_value = True + client3.has_direct_access_grants_enabled.return_value = False + client3.has_standard_flow_enabled.return_value = False + client3.has_implicit_flow_enabled.return_value = True + client3.get_attributes.return_value = {"use.refresh.tokens": "true"} + client3.is_public.return_value = False + client3.allows_user_authentication.return_value = True + + auditor._DB.get_all_clients.return_value = [client1, client2, client3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from two clients (client1 and client3) diff --git a/tests/auditors/client/test_client_with_full_scope_allowed.py b/tests/auditors/client/test_client_with_full_scope_allowed.py new file mode 100644 index 0000000..9496a41 --- /dev/null +++ b/tests/auditors/client/test_client_with_full_scope_allowed.py @@ -0,0 +1,74 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_with_full_scope_allowed import ClientWithFullScopeAllowed + + +class TestClientWithFullScopeAllowed: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientWithFullScopeAllowed(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "allows_user_auth, expected", + [ + (True, True), # User auth allowed + (False, False), # User auth not allowed + ], + ) + def test_should_consider_client(self, mock_client, auditor, allows_user_auth, expected): + mock_client.allows_user_authentication.return_value = allows_user_auth + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "full_scope_allowed, expected", + [ + (True, True), # Full scope allowed + (False, False), # Full scope not allowed + ], + ) + def test_client_has_full_scope_allowed(self, mock_client, auditor, full_scope_allowed, expected): + mock_client.has_full_scope_allowed.return_value = full_scope_allowed + assert auditor.client_has_full_scope_allowed(mock_client) == expected + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.has_full_scope_allowed.return_value = False + mock_client.get_default_client_scopes.return_value = ["email", "profile"] + mock_client.get_optional_client_scopes.return_value = ["address"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.has_full_scope_allowed.return_value = True + mock_client.get_default_client_scopes.return_value = ["email", "profile"] + mock_client.get_optional_client_scopes.return_value = ["address"] + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert finding.to_dict()["additional_details"]["default_scopes"] == ["email", "profile"] + assert finding.to_dict()["additional_details"]["optional_scopes"] == ["address"] + + def test_audit_function_multiple_clients(self, auditor): + # Create separate mock clients with distinct settings + client1 = Mock() + client1.has_full_scope_allowed.return_value = True + client1.get_default_client_scopes.return_value = ["email"] + client1.get_optional_client_scopes.return_value = ["profile"] + + client2 = Mock() + client2.has_full_scope_allowed.return_value = False + client2.get_default_client_scopes.return_value = ["email", "profile"] + client2.get_optional_client_scopes.return_value = ["address"] + + client3 = Mock() + client3.has_full_scope_allowed.return_value = True + client3.get_default_client_scopes.return_value = ["offline_access"] + client3.get_optional_client_scopes.return_value = [] + + auditor._DB.get_all_clients.return_value = [client1, client2, client3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from client1 and client3 diff --git a/tests/auditors/client/test_client_with_optional_offline_access_scope.py b/tests/auditors/client/test_client_with_optional_offline_access_scope.py new file mode 100644 index 0000000..a62514b --- /dev/null +++ b/tests/auditors/client/test_client_with_optional_offline_access_scope.py @@ -0,0 +1,100 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_with_optional_offline_access_scope import ClientWithOptionalOfflineAccessScope + + +class TestClientWithOptionalOfflineAccessScope: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientWithOptionalOfflineAccessScope(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_client(self, mock_client, auditor): + assert auditor.should_consider_client(mock_client) is True # Always consider unless ignored + + @pytest.mark.parametrize( + "optional_scopes, flow_status, use_refresh_tokens, expected", + [ + (["offline_access"], {"device": True, "direct": True, "standard": True, "implicit": False}, "true", True), + (["offline_access"], {"device": False, "direct": True, "standard": False, "implicit": False}, "true", True), + ([], {"device": True, "direct": True, "standard": True, "implicit": True}, "true", False), + ( + ["offline_access"], + {"device": False, "direct": False, "standard": False, "implicit": False}, + "true", + False, + ), + (["offline_access"], {"device": True, "direct": True, "standard": True, "implicit": True}, "false", False), + ], + ) + def test_client_can_generate_offline_tokens( + self, mock_client, auditor, optional_scopes, flow_status, use_refresh_tokens, expected + ): + mock_client.get_optional_client_scopes.return_value = optional_scopes + mock_client.has_device_authorization_grant_flow_enabled.return_value = flow_status["device"] + mock_client.has_direct_access_grants_enabled.return_value = flow_status["direct"] + mock_client.has_standard_flow_enabled.return_value = flow_status["standard"] + mock_client.has_implicit_flow_enabled.return_value = flow_status["implicit"] + mock_client.allows_user_authentication.return_value = ( + flow_status["device"] or flow_status["direct"] or flow_status["standard"] or flow_status["implicit"] + ) + mock_client.get_attributes.return_value = {"use.refresh.tokens": use_refresh_tokens} + assert auditor.client_can_generate_offline_tokens(mock_client) == expected + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.get_optional_client_scopes.return_value = [] + mock_client.get_attributes.return_value = {"use.refresh.tokens": "false"} + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.get_optional_client_scopes.return_value = ["offline_access"] + mock_client.has_device_authorization_grant_flow_enabled.return_value = True + mock_client.has_direct_access_grants_enabled.return_value = True + mock_client.has_standard_flow_enabled.return_value = True + mock_client.has_implicit_flow_enabled.return_value = False + mock_client.get_attributes.return_value = {"use.refresh.tokens": "true"} + mock_client.is_public.return_value = False + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert finding.to_dict()["additional_details"]["optional_scopes"] == ["offline_access"] + assert finding.to_dict()["additional_details"]["client_public"] == mock_client.is_public() + assert finding.to_dict()["additional_details"]["standard_flow_enabled"] is True + + def test_audit_function_multiple_clients(self, auditor): + # Create separate mock clients with distinct settings + client1 = Mock() + client1.get_optional_client_scopes.return_value = ["offline_access"] + client1.has_device_authorization_grant_flow_enabled.return_value = True + client1.has_direct_access_grants_enabled.return_value = True + client1.has_standard_flow_enabled.return_value = True + client1.has_implicit_flow_enabled.return_value = False + client1.get_attributes.return_value = {"use.refresh.tokens": "true"} + client1.is_public.return_value = False + + client2 = Mock() + client2.get_optional_client_scopes.return_value = [] + client2.has_device_authorization_grant_flow_enabled.return_value = False + client2.has_direct_access_grants_enabled.return_value = False + client2.has_standard_flow_enabled.return_value = True + client2.has_implicit_flow_enabled.return_value = False + client2.get_attributes.return_value = {"use.refresh.tokens": "false"} + client2.is_public.return_value = True + + client3 = Mock() + client3.get_optional_client_scopes.return_value = ["offline_access"] + client3.has_device_authorization_grant_flow_enabled.return_value = True + client3.has_direct_access_grants_enabled.return_value = False + client3.has_standard_flow_enabled.return_value = False + client3.has_implicit_flow_enabled.return_value = True + client3.get_attributes.return_value = {"use.refresh.tokens": "true"} + client3.is_public.return_value = False + + auditor._DB.get_all_clients.return_value = [client1, client2, client3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from client1 and client3 diff --git a/tests/auditors/client/test_client_with_service_account_and_other_flow_enabled.py b/tests/auditors/client/test_client_with_service_account_and_other_flow_enabled.py new file mode 100644 index 0000000..74ff8a6 --- /dev/null +++ b/tests/auditors/client/test_client_with_service_account_and_other_flow_enabled.py @@ -0,0 +1,116 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.client_with_service_account_and_other_flow_enabled import ( + ClientWithServiceAccountAndOtherFlowEnabled, +) + + +class TestClientWithServiceAccountAndOtherFlowEnabled: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientWithServiceAccountAndOtherFlowEnabled(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,is_public,service_account,expected", + [ + (True, False, True, True), # OIDC, confidential, service account + (True, True, True, False), # OIDC, public, service account + (False, False, True, False), # Not OIDC, confidential, service account + (True, False, False, False), # OIDC, confidential, no service account + ], + ) + def test_should_consider_client(self, mock_client, auditor, is_oidc, is_public, service_account, expected): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.is_public.return_value = is_public + mock_client.has_service_account_enabled.return_value = service_account + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "flows_enabled, expected", + [ + ({"direct": True, "implicit": True, "standard": True, "device": False}, True), + ({"direct": False, "implicit": False, "standard": False, "device": False}, False), + ({"direct": True, "implicit": False, "standard": False, "device": False}, True), + ({"direct": False, "implicit": True, "standard": False, "device": False}, True), + ({"direct": True, "implicit": True, "standard": True, "device": True}, True), + ({"direct": False, "implicit": False, "standard": False, "device": True}, True), + ({"direct": True, "implicit": False, "standard": False, "device": True}, True), + ({"direct": False, "implicit": True, "standard": False, "device": True}, True), + ], + ) + def test_client_has_non_service_account_flow_enabled(self, mock_client, auditor, flows_enabled, expected): + mock_client.has_direct_access_grants_enabled.return_value = flows_enabled["direct"] + mock_client.has_implicit_flow_enabled.return_value = flows_enabled["implicit"] + mock_client.has_standard_flow_enabled.return_value = flows_enabled["standard"] + mock_client.has_device_authorization_grant_flow_enabled.return_value = flows_enabled["device"] + mock_client.allows_user_authentication.return_value = ( + flows_enabled["device"] or flows_enabled["direct"] or flows_enabled["standard"] or flows_enabled["implicit"] + ) + assert auditor.client_has_non_service_account_flow_enabled(mock_client) == expected + + def test_audit_function_no_findings(self, mock_client, auditor): + mock_client.is_oidc_client.return_value = True + mock_client.is_public.return_value = False + mock_client.has_service_account_enabled.return_value = True + mock_client.has_direct_access_grants_enabled.return_value = False + mock_client.has_implicit_flow_enabled.return_value = False + mock_client.has_standard_flow_enabled.return_value = False + mock_client.has_device_authorization_grant_flow_enabled.return_value = False + mock_client.allows_user_authentication.return_value = False + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + mock_client.is_oidc_client.return_value = True + mock_client.is_public.return_value = False + mock_client.has_service_account_enabled.return_value = True + mock_client.has_direct_access_grants_enabled.return_value = True + mock_client.has_implicit_flow_enabled.return_value = True + mock_client.has_standard_flow_enabled.return_value = True + mock_client.has_device_authorization_grant_flow_enabled.return_value = True + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0] + assert finding.to_dict()["additional_details"]["service_account_enabled"] is True + assert finding.to_dict()["additional_details"]["standard_flow_enabled"] is True + + def test_audit_function_multiple_clients(self, auditor): + # Create separate mock clients with distinct settings + client1 = Mock() + client1.is_oidc_client.return_value = True + client1.is_public.return_value = False + client1.has_service_account_enabled.return_value = True + client1.has_direct_access_grants_enabled.return_value = True + client1.has_implicit_flow_enabled.return_value = True + client1.has_device_authorization_grant_flow_enabled.return_value = True + client1.has_standard_flow_enabled.return_value = True + client1.allows_user_authentication.return_value = True + + client2 = Mock() + client2.is_oidc_client.return_value = True + client2.is_public.return_value = False + client2.has_service_account_enabled.return_value = True + client2.has_direct_access_grants_enabled.return_value = False + client2.has_implicit_flow_enabled.return_value = False + client2.has_device_authorization_grant_flow_enabled.return_value = False + client2.has_standard_flow_enabled.return_value = False + client1.allows_user_authentication.return_value = False + + client3 = Mock() + client3.is_oidc_client.return_value = True + client3.is_public.return_value = False + client3.has_service_account_enabled.return_value = True + client3.has_direct_access_grants_enabled.return_value = True + client3.has_implicit_flow_enabled.return_value = False + client3.has_standard_flow_enabled.return_value = True + client3.has_device_authorization_grant_flow_enabled.return_value = False + client3.allows_user_authentication.return_value = True + + auditor._DB.get_all_clients.return_value = [client1, client2, client3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from client1 and client3 diff --git a/tests/auditors/client/test_clients_should_disable_implicit_grant_flow.py b/tests/auditors/client/test_clients_should_disable_implicit_grant_flow.py new file mode 100644 index 0000000..31dc577 --- /dev/null +++ b/tests/auditors/client/test_clients_should_disable_implicit_grant_flow.py @@ -0,0 +1,63 @@ +from kcwarden.auditors.client.client_should_disable_implicit_grant_flow import ClientShouldDisableImplicitGrantFlow + + +import pytest + + +from unittest.mock import Mock + + +class TestClientsShouldDisableImplicitGrantFlow: + # Fixture inside the class + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ClientShouldDisableImplicitGrantFlow(database, default_config) + auditor_instance._DB = Mock() # Ensure that database interactions are mocked + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,expected", + [ + (True, True), # OIDC client should be considered + (False, False), # Non-OIDC client should not be considered + ], + ) + def test_consider_client_based_on_oidc_status(self, mock_client, auditor, is_oidc, expected): + mock_client.is_oidc_client.return_value = is_oidc + assert ( + auditor.should_consider_client(mock_client) == expected + ), "Client consideration logic failed based on OIDC status" + + @pytest.mark.parametrize( + "has_implicit_flow,expected", + [ + (True, True), # Client with implicit grant flow should be detected + (False, False), # Client without implicit grant flow should not be detected + ], + ) + def test_detect_implicit_grant_flow(self, mock_client, auditor, has_implicit_flow, expected): + mock_client.has_implicit_flow_enabled.return_value = has_implicit_flow + assert ( + auditor.client_uses_implicit_grant_flow(mock_client) == expected + ), "Implicit grant flow detection logic failed" + + @pytest.mark.parametrize( + "enable_implicit_flow,expected_count", + [ + (True, 1), # Client using implicit flow should result in a finding + (False, 0), # Client not using implicit flow should result in no findings + ], + ) + def test_audit_function_with_single_client(self, mock_client, auditor, enable_implicit_flow, expected_count): + mock_client.is_oidc_client.return_value = True + mock_client.has_implicit_flow_enabled.return_value = enable_implicit_flow + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == expected_count, "Audit findings count mismatch for single client" + + def test_audit_function_with_multiple_clients(self, mock_client, auditor): + mock_client.is_oidc_client.return_value = True + mock_client.has_implicit_flow_enabled.side_effect = [True, False, True] # Varying setups among three clients + auditor._DB.get_all_clients.return_value = [mock_client, mock_client, mock_client] + results = list(auditor.audit()) + assert len(results) == 2, "Audit did not yield correct number of findings for multiple clients" diff --git a/tests/auditors/client/test_confidential_client_should_disable_direct_access_grants.py b/tests/auditors/client/test_confidential_client_should_disable_direct_access_grants.py new file mode 100644 index 0000000..cd25b36 --- /dev/null +++ b/tests/auditors/client/test_confidential_client_should_disable_direct_access_grants.py @@ -0,0 +1,72 @@ +from kcwarden.auditors.client.confidential_client_should_disable_direct_access_grants import ( + ConfidentialClientShouldDisableDirectAccessGrants, +) + + +import pytest + + +from unittest.mock import Mock + + +class TestConfidentialClientShouldDisableDirectAccessGrants: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ConfidentialClientShouldDisableDirectAccessGrants(database, default_config) + auditor_instance._DB = Mock() # Mocking the database interactions + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,is_public,expected", + [ + (True, False, True), # Confidential OIDC client should be considered + (True, True, False), # Public clients should not be considered + (False, False, False), # Non-OIDC confidential clients should not be considered + ], + ) + def test_should_consider_client(self, mock_client, auditor, is_oidc, is_public, expected): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.is_public.return_value = is_public + assert ( + auditor.should_consider_client(mock_client) == expected + ), "Client consideration logic failed based on OIDC status and confidentiality" + + @pytest.mark.parametrize( + "has_direct_access_grants,expected", + [ + (True, True), # Clients with direct access grants should be detected + (False, False), # Clients without direct access grants should not be detected + ], + ) + def test_client_uses_direct_access_grants(self, confidential_client, auditor, has_direct_access_grants, expected): + confidential_client.has_direct_access_grants_enabled.return_value = has_direct_access_grants + assert ( + auditor.client_uses_direct_access_grants(confidential_client) == expected + ), "Direct access grants detection logic failed" + + @pytest.mark.parametrize( + "enable_direct_access_grants,expected_count", + [ + (True, 1), # Client using direct access grants should result in a finding + (False, 0), # Client not using direct access grants should result in no findings + ], + ) + def test_audit_function_with_single_client( + self, confidential_client, auditor, enable_direct_access_grants, expected_count + ): + confidential_client.is_oidc_client.return_value = True + confidential_client.has_direct_access_grants_enabled.return_value = enable_direct_access_grants + auditor._DB.get_all_clients.return_value = [confidential_client] + results = list(auditor.audit()) + assert len(results) == expected_count, "Audit findings count mismatch for single client" + + def test_audit_function_with_multiple_clients(self, confidential_client, auditor): + confidential_client.is_oidc_client.return_value = True + confidential_client.has_direct_access_grants_enabled.side_effect = [ + True, + False, + True, + ] # Varying setups among three clients + auditor._DB.get_all_clients.return_value = [confidential_client, confidential_client, confidential_client] + results = list(auditor.audit()) + assert len(results) == 2, "Audit did not yield correct number of findings for multiple clients" diff --git a/tests/auditors/client/test_confidential_client_should_enforce_pkce.py b/tests/auditors/client/test_confidential_client_should_enforce_pkce.py new file mode 100644 index 0000000..3eafcb0 --- /dev/null +++ b/tests/auditors/client/test_confidential_client_should_enforce_pkce.py @@ -0,0 +1,67 @@ +from kcwarden.auditors.client.confidential_client_should_enforce_pkce import ConfidentialClientShouldEnforcePKCE + + +import pytest + + +from unittest.mock import Mock + + +class TestConfidentialClientShouldEnforcePKCE: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = ConfidentialClientShouldEnforcePKCE(database, default_config) + # Mock the database calls properly + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,is_public,has_flow,expected", + [ + (True, False, True, True), # Standard case for inclusion + (False, False, True, False), # Non-OIDC client should be excluded + (True, True, True, False), # Public client should be excluded + (True, False, False, False), # Client without standard flow should be excluded + ], + ) + def test_should_consider_client(self, confidential_client, auditor, is_oidc, is_public, has_flow, expected): + confidential_client.is_oidc_client.return_value = is_oidc + confidential_client.is_public.return_value = is_public + confidential_client.has_standard_flow_enabled.return_value = has_flow + assert auditor.should_consider_client(confidential_client) == expected + + @pytest.mark.parametrize( + "pkce_method,should_detect", + [ + ("S256", False), # PKCE enforced correctly + (None, True), # PKCE not enforced + ("plain", True), # Incorrect PKCE method enforced + ], + ) + def test_client_does_not_enforce_pkce(self, confidential_client, auditor, pkce_method, should_detect): + confidential_client.get_attributes.return_value = {"pkce.code.challenge.method": pkce_method} + assert auditor.client_does_not_enforce_pkce(confidential_client) == should_detect + + def test_audit_function_no_findings(self, confidential_client, auditor): + confidential_client.get_attributes.return_value = {"pkce.code.challenge.method": "S256"} + auditor._DB.get_all_clients.return_value = [confidential_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, confidential_client, auditor): + confidential_client.get_attributes.return_value = {} + auditor._DB.get_all_clients.return_value = [confidential_client] + results = list(auditor.audit()) + assert len(results) == 1 + confidential_client.get_attributes.assert_called_with() + + def test_audit_function_multiple_clients(self, confidential_client, auditor): + # Setting up different PKCE configurations + confidential_client.get_attributes.side_effect = [ + {"pkce.code.challenge.method": "S256"}, + {}, + {"pkce.code.challenge.method": "None"}, + ] + auditor._DB.get_all_clients.return_value = [confidential_client, confidential_client, confidential_client] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from two clients diff --git a/tests/auditors/client/test_public_client_must_enforce_pkce.py b/tests/auditors/client/test_public_client_must_enforce_pkce.py new file mode 100644 index 0000000..94c916a --- /dev/null +++ b/tests/auditors/client/test_public_client_must_enforce_pkce.py @@ -0,0 +1,67 @@ +from kcwarden.auditors.client.public_clients_must_enforce_pkce import PublicClientsMustEnforcePKCE + + +import pytest + + +from unittest.mock import Mock + + +class TestPublicClientMustEnforcePKCE: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = PublicClientsMustEnforcePKCE(database, default_config) + # Mock the database calls properly + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,is_public,has_flow,expected", + [ + (True, True, True, True), # Standard case for inclusion + (False, True, True, False), # Non-OIDC client should be excluded + (True, False, True, False), # Confidential client should be excluded + (True, True, False, False), # Client without standard flow should be excluded + ], + ) + def test_should_consider_client(self, mock_client, auditor, is_oidc, is_public, has_flow, expected): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.is_public.return_value = is_public + mock_client.has_standard_flow_enabled.return_value = has_flow + assert auditor.should_consider_client(mock_client) == expected + + @pytest.mark.parametrize( + "pkce_method,should_detect", + [ + ("S256", False), # PKCE enforced correctly + (None, True), # PKCE not enforced + ("plain", True), # Incorrect PKCE method enforced + ], + ) + def test_client_does_not_enforce_pkce(self, public_client, auditor, pkce_method, should_detect): + public_client.get_attributes.return_value = {"pkce.code.challenge.method": pkce_method} + assert auditor.client_does_not_enforce_pkce(public_client) == should_detect + + def test_audit_function_no_findings(self, public_client, auditor): + public_client.get_attributes.return_value = {"pkce.code.challenge.method": "S256"} + auditor._DB.get_all_clients.return_value = [public_client] + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, public_client, auditor): + public_client.get_attributes.return_value = {} + auditor._DB.get_all_clients.return_value = [public_client] + results = list(auditor.audit()) + assert len(results) == 1 + public_client.get_attributes.assert_called_with() + + def test_audit_function_multiple_clients(self, public_client, auditor): + # Setting up different PKCE configurations + public_client.get_attributes.side_effect = [ + {"pkce.code.challenge.method": "S256"}, + {}, + {"pkce.code.challenge.method": "None"}, + ] + auditor._DB.get_all_clients.return_value = [public_client, public_client, public_client] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from two clients diff --git a/tests/auditors/client/test_public_client_should_disable_direct_access_grants.py b/tests/auditors/client/test_public_client_should_disable_direct_access_grants.py new file mode 100644 index 0000000..2796ec3 --- /dev/null +++ b/tests/auditors/client/test_public_client_should_disable_direct_access_grants.py @@ -0,0 +1,72 @@ +from kcwarden.auditors.client.public_client_should_disable_direct_access_grants import ( + PublicClientShouldDisableDirectAccessGrants, +) + + +import pytest + + +from unittest.mock import Mock + + +class TestPublicClientShouldDisableDirectAccessGrants: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = PublicClientShouldDisableDirectAccessGrants(database, default_config) + auditor_instance._DB = Mock() # Mocking the database interactions + return auditor_instance + + @pytest.mark.parametrize( + "is_oidc,is_public,expected", + [ + (True, True, True), # Public OIDC client should be considered + (False, True, False), # Non-OIDC public client should not be considered + (True, False, False), # OIDC but confidential client should not be considered + ], + ) + def test_client_consideration_logic(self, mock_client, auditor, is_oidc, is_public, expected): + mock_client.is_oidc_client.return_value = is_oidc + mock_client.is_public.return_value = is_public + assert ( + auditor.should_consider_client(mock_client) == expected + ), "Client consideration logic failed based on type and OIDC status" + + @pytest.mark.parametrize( + "has_direct_access_grants,expected", + [ + (True, True), # Client with direct access grants should be detected + (False, False), # Client without direct access grants should not be detected + ], + ) + def test_detect_direct_access_grants(self, public_client, auditor, has_direct_access_grants, expected): + public_client.has_direct_access_grants_enabled.return_value = has_direct_access_grants + assert ( + auditor.client_uses_direct_access_grants(public_client) == expected + ), "Direct access grants detection logic failed" + + @pytest.mark.parametrize( + "enable_direct_access_grants,expected_count", + [ + (True, 1), # Client using direct access grants should result in a finding + (False, 0), # Client not using direct access grants should result in no findings + ], + ) + def test_audit_function_with_single_client( + self, public_client, auditor, enable_direct_access_grants, expected_count + ): + public_client.is_oidc_client.return_value = True + public_client.has_direct_access_grants_enabled.return_value = enable_direct_access_grants + auditor._DB.get_all_clients.return_value = [public_client] + results = list(auditor.audit()) + assert len(results) == expected_count, "Audit findings count mismatch for single client" + + def test_audit_function_with_multiple_clients(self, public_client, auditor): + public_client.is_oidc_client.return_value = True + public_client.has_direct_access_grants_enabled.side_effect = [ + True, + False, + True, + ] # Varying setups among three clients + auditor._DB.get_all_clients.return_value = [public_client, public_client, public_client] + results = list(auditor.audit()) + assert len(results) == 2, "Audit did not yield correct number of findings for multiple clients" diff --git a/tests/auditors/client/test_using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous.py b/tests/auditors/client/test_using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous.py new file mode 100644 index 0000000..4f5b1eb --- /dev/null +++ b/tests/auditors/client/test_using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous.py @@ -0,0 +1,124 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.client.using_nondefault_user_attributes_in_clients_without_user_profiles_feature_is_dangerous import ( + UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous, +) + +DEFAULT_ATTRIBUTES = [ + "firstName", + "nickname", + "zoneinfo", + "lastName", + "username", + "middleName", + "picture", + "birthdate", + "locale", + "website", + "gender", + "updatedAt", + "profile", + "phoneNumber", + "phoneNumberVerified", + "mobile_number", + "email", + "emailVerified", +] + + +class TestUsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = UsingNonDefaultUserAttributesInClientsWithoutUserProfilesFeatureIsDangerous( + database, default_config + ) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_client(self, mock_client, auditor): + # Set up mock realm response + realm = Mock() + realm.has_declarative_user_profiles_enabled.return_value = False + mock_client.get_realm.return_value = realm + + assert ( + auditor.should_consider_client(mock_client) is True + ) # Consider client if user profiles feature is not enabled + + @pytest.mark.parametrize( + "mapper_config, expected", + [ + ({"protocol_mapper": "oidc-usermodel-attribute-mapper", "user.attribute": "custom_attribute"}, True), + ( + {"protocol_mapper": "oidc-usermodel-attribute-mapper", "user.attribute": "email"}, + False, + ), # 'email' is a default attribute + ( + {"protocol_mapper": "other-mapper-type", "user.attribute": "custom_attribute"}, + False, + ), # Not the right type of mapper + ], + ) + def test_mapper_references_non_default_user_attribute(self, auditor, mapper_config, expected): + mapper = Mock() + mapper.get_protocol_mapper.return_value = mapper_config["protocol_mapper"] + mapper.get_config.return_value = {"user.attribute": mapper_config["user.attribute"]} + result = auditor.mapper_references_non_default_user_attribute(mapper) + assert result == expected + + def test_audit_function_no_findings(self, mock_client, auditor): + # Setup client and mappers + mock_client.get_realm.return_value.has_declarative_user_profiles_enabled.return_value = True + mapper = Mock() + mapper.get_protocol_mapper.return_value = "oidc-usermodel-attribute-mapper" + mapper.get_config.return_value = {"user.attribute": "email"} # 'email' is a default attribute + mock_client.get_protocol_mappers.return_value = [mapper] + auditor._DB.get_all_clients.return_value = [mock_client] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, mock_client, auditor): + # Setup client and mappers + realm = Mock() + realm.has_declarative_user_profiles_enabled.return_value = False + mock_client.get_realm.return_value = realm + mock_client.is_oidc_client.return_value = True + mock_client.is_public.return_value = False + mock_client.has_service_account_enabled.return_value = True + + mapper = Mock() + mapper.get_protocol_mapper.return_value = "oidc-usermodel-attribute-mapper" + mapper.get_config.return_value = {"user.attribute": "custom_attribute"} + mock_client.get_protocol_mappers.return_value = [mapper] + + auditor._DB.get_all_clients.return_value = [mock_client] + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0].to_dict() + assert finding["additional_details"]["used-attribute"] == "custom_attribute" + + def test_audit_function_multiple_clients(self, auditor): + # Create separate mock clients with distinct settings + client1 = Mock() + realm1 = Mock() + realm1.has_declarative_user_profiles_enabled.return_value = False + client1.get_realm.return_value = realm1 + mapper1 = Mock() + mapper1.get_protocol_mapper.return_value = "oidc-usermodel-attribute-mapper" + mapper1.get_config.return_value = {"user.attribute": "custom_attribute"} + client1.get_protocol_mappers.return_value = [mapper1] + + client2 = Mock() + realm2 = Mock() + realm2.has_declarative_user_profiles_enabled.return_value = True + client2.get_realm.return_value = realm2 + mapper2 = Mock() + mapper2.get_protocol_mapper.return_value = "oidc-usermodel-attribute-mapper" + mapper2.get_config.return_value = {"user.attribute": "name"} + client2.get_protocol_mappers.return_value = [mapper2] + + auditor._DB.get_all_clients.return_value = [client1, client2] + results = list(auditor.audit()) + assert len(results) == 1 # Expect findings from client1 only diff --git a/tests/auditors/idp/__init__.py b/tests/auditors/idp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auditors/idp/test_identity_provider_with_mappers_without_force_sync_mode.py b/tests/auditors/idp/test_identity_provider_with_mappers_without_force_sync_mode.py new file mode 100644 index 0000000..fb663f1 --- /dev/null +++ b/tests/auditors/idp/test_identity_provider_with_mappers_without_force_sync_mode.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.idp.identity_provider_with_mappers_without_force_sync_mode import ( + IdentityProviderWithMappersWithoutForceSyncMode, +) + + +class TestIdentityProviderWithMappersWithoutForceSyncMode: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = IdentityProviderWithMappersWithoutForceSyncMode(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_idp(self, mock_idp, auditor): + assert auditor.should_consider_idp(mock_idp) is True # Always consider unless ignored + + @pytest.mark.parametrize( + "sync_mode, expected", + [ + ("FORCE", True), # Force sync mode + ("INHERIT", False), # Inherit sync mode + ("LEGACY", False), # Legacy sync mode + ], + ) + def test_idp_uses_sync_mode_force(self, auditor, sync_mode, expected): + mock_idp = Mock() + mock_idp.get_sync_mode.return_value = sync_mode + assert auditor.idp_uses_sync_mode_force(mock_idp) == expected + + @pytest.mark.parametrize( + "mappers, expected", + [ + ([{"name": "mapper1"}, {"name": "mapper2"}], True), # IDP has mappers + ([], False), # IDP does not have mappers + ], + ) + def test_idp_uses_information_from_access_token(self, auditor, mappers, expected): + mock_idp = Mock() + mock_idp.get_identity_provider_mappers.return_value = mappers + assert auditor.idp_uses_information_from_access_token(mock_idp) == expected + + def test_audit_function_no_findings(self, auditor, mock_idp): + # Setup IDP with force sync mode and mappers + mock_idp.get_sync_mode.return_value = "FORCE" + mock_idp.get_identity_provider_mappers.return_value = [{"name": "mapper1"}] + auditor._DB.get_all_identity_providers.return_value = [mock_idp] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_idp): + # Setup IDP without force sync mode and with mappers + mock_idp.get_sync_mode.return_value = "INHERIT" + mock_idp.get_identity_provider_mappers.return_value = [{"name": "mapper1"}] + auditor._DB.get_all_identity_providers.return_value = [mock_idp] + + results = list(auditor.audit()) + assert len(results) == 1 + + def test_audit_function_multiple_idps(self, auditor): + # Create separate mock IDPs with distinct settings + idp1 = Mock() + idp1.get_sync_mode.return_value = "INHERIT" + idp1.get_identity_provider_mappers.return_value = [{"name": "mapper1"}] + + idp2 = Mock() + idp2.get_sync_mode.return_value = "FORCE" + idp2.get_identity_provider_mappers.return_value = [{"name": "mapper2"}] + + idp3 = Mock() + idp3.get_sync_mode.return_value = "LEGACY" + idp3.get_identity_provider_mappers.return_value = [] + + auditor._DB.get_all_identity_providers.return_value = [idp1, idp2, idp3] + results = list(auditor.audit()) + assert len(results) == 1 # Expect findings from idp1 only diff --git a/tests/auditors/idp/test_identity_provider_with_one_time_sync.py b/tests/auditors/idp/test_identity_provider_with_one_time_sync.py new file mode 100644 index 0000000..d6f6a0a --- /dev/null +++ b/tests/auditors/idp/test_identity_provider_with_one_time_sync.py @@ -0,0 +1,61 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.idp.identity_provider_with_one_time_sync import IdentityProviderWithOneTimeSync + + +class TestIdentityProviderWithOneTimeSync: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = IdentityProviderWithOneTimeSync(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_idp(self, mock_idp, auditor): + # Assuming the is_not_ignored function is simply a placeholder for actual implementation + # Here it would typically check against a configuration setting or similar + assert auditor.should_consider_idp(mock_idp) is True # Always consider unless specifically ignored + + @pytest.mark.parametrize( + "sync_mode, expected", + [ + ("FORCE", False), # IDP uses Force sync mode, should not generate a finding + ("INHERIT", True), # IDP uses Inherit sync mode, should generate a finding + ("", True), # IDP uses no specified sync mode, should generate a finding + ], + ) + def test_idp_does_not_use_force_sync_mode(self, auditor, sync_mode, expected): + mock_idp = Mock() + mock_idp.get_sync_mode.return_value = sync_mode + assert auditor.idp_does_not_use_force_sync_mode(mock_idp) == expected + + def test_audit_function_no_findings(self, auditor, mock_idp): + # Setup IDP with force sync mode + mock_idp.get_sync_mode.return_value = "FORCE" + auditor._DB.get_all_identity_providers.return_value = [mock_idp] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_idp): + # Setup IDP without force sync mode + mock_idp.get_sync_mode.return_value = "INHERIT" + auditor._DB.get_all_identity_providers.return_value = [mock_idp] + + results = list(auditor.audit()) + assert len(results) == 1 + + def test_audit_function_multiple_idps(self, auditor): + # Create separate mock IDPs with distinct settings + idp1 = Mock() + idp1.get_sync_mode.return_value = "INHERIT" + + idp2 = Mock() + idp2.get_sync_mode.return_value = "FORCE" + + idp3 = Mock() + idp3.get_sync_mode.return_value = "" + + auditor._DB.get_all_identity_providers.return_value = [idp1, idp2, idp3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from idp1 and idp3, but not from idp2 diff --git a/tests/auditors/idp/test_oidc_identity_provider_without_pkce.py b/tests/auditors/idp/test_oidc_identity_provider_without_pkce.py new file mode 100644 index 0000000..f6d0bbb --- /dev/null +++ b/tests/auditors/idp/test_oidc_identity_provider_without_pkce.py @@ -0,0 +1,77 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.idp.oidc_identity_provider_without_pkce import OIDCIdentityProviderWithoutPKCE + + +class TestOIDCIdentityProviderWithoutPKCE: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = OIDCIdentityProviderWithoutPKCE(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + @pytest.mark.parametrize( + "provider_id, expected", + [ + ("oidc", True), # OIDC provider should be considered + ("keycloak-oidc", True), # Keycloak OIDC provider should be considered + ("saml", False), # SAML provider should not be considered + ], + ) + def test_should_consider_idp(self, auditor, provider_id, expected): + mock_idp = Mock() + mock_idp.get_provider_id.return_value = provider_id + assert auditor.should_consider_idp(mock_idp) == expected + + @pytest.mark.parametrize( + "config, expected", + [ + ({"pkceEnabled": "true", "pkceMethod": "S256"}, False), # Correctly configured PKCE + ({"pkceEnabled": "false", "pkceMethod": "S256"}, True), # PKCE disabled + ({"pkceEnabled": "true", "pkceMethod": "plain"}, True), # PKCE enabled, but with plain method + ({}, True), # No PKCE settings + ({"pkceMethod": "S256"}, True), # PKCE method set, but not enabled + ], + ) + def test_idp_does_not_enforce_pkce(self, auditor, config, expected): + assert auditor.idp_does_not_enforce_pkce(config) == expected + + def test_audit_function_no_findings(self, auditor, mock_idp): + # Setup IDP with correct PKCE configuration + mock_idp.get_provider_id.return_value = "oidc" + mock_idp.get_config.return_value = {"pkceEnabled": "true", "pkceMethod": "S256"} + auditor._DB.get_all_identity_providers.return_value = [mock_idp] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_idp): + # Setup IDP without correct PKCE configuration + mock_idp.get_provider_id.return_value = "oidc" + mock_idp.get_config.return_value = {"pkceEnabled": "false"} + auditor._DB.get_all_identity_providers.return_value = [mock_idp] + + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0].to_dict() + assert finding["additional_details"]["pkceEnabled"] == "false" + assert finding["additional_details"]["pkceMethod"] == "[unset]" + + def test_audit_function_multiple_idps(self, auditor): + # Create separate mock IDPs with distinct settings + idp1 = Mock() + idp1.get_provider_id.return_value = "oidc" + idp1.get_config.return_value = {"pkceEnabled": "true", "pkceMethod": "S256"} + + idp2 = Mock() + idp2.get_provider_id.return_value = "oidc" + idp2.get_config.return_value = {"pkceEnabled": "false"} + + idp3 = Mock() + idp3.get_provider_id.return_value = "keycloak-oidc" + idp3.get_config.return_value = {"pkceMethod": "plain"} + + auditor._DB.get_all_identity_providers.return_value = [idp1, idp2, idp3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from idp2 and idp3 diff --git a/tests/auditors/realm/__init__.py b/tests/auditors/realm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auditors/realm/test_realm_email_verification_disabled.py b/tests/auditors/realm/test_realm_email_verification_disabled.py new file mode 100644 index 0000000..7bf4ef2 --- /dev/null +++ b/tests/auditors/realm/test_realm_email_verification_disabled.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.realm.realm_email_verification_disabled import RealmEmailVerificationDisabled + + +class TestRealmEmailVerificationDisabled: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = RealmEmailVerificationDisabled(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_realm(self, mock_realm, auditor): + assert auditor.should_consider_realm(mock_realm) is True # Always consider unless specifically ignored + + @pytest.mark.parametrize( + "is_verify_email_enabled, expected", + [ + (True, False), # Email verification enabled + (False, True), # Email verification disabled + ], + ) + def test_realm_has_email_verification_disabled(self, auditor, is_verify_email_enabled, expected, mock_realm): + mock_realm.is_verify_email_enabled.return_value = is_verify_email_enabled + assert auditor.realm_has_email_verification_disabled(mock_realm) == expected + + def test_audit_function_no_findings(self, auditor, mock_realm): + # Setup realm with email verification enabled + mock_realm.is_verify_email_enabled.return_value = True + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_realm): + # Setup realm with email verification disabled + mock_realm.is_verify_email_enabled.return_value = False + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 1 + + def test_audit_function_multiple_realms(self, auditor): + # Create separate mock realms with distinct settings + realm1 = Mock() + realm1.is_verify_email_enabled.return_value = False + + realm2 = Mock() + realm2.is_verify_email_enabled.return_value = True + + realm3 = Mock() + realm3.is_verify_email_enabled.return_value = False + + auditor._DB.get_all_realms.return_value = [realm1, realm2, realm3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from realm1 and realm3, but not from realm2 diff --git a/tests/auditors/realm/test_realm_self_registration_enabled.py b/tests/auditors/realm/test_realm_self_registration_enabled.py new file mode 100644 index 0000000..aee6f52 --- /dev/null +++ b/tests/auditors/realm/test_realm_self_registration_enabled.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.realm.realm_self_registration_enabled import RealmSelfRegistrationEnabled + + +class TestRealmSelfRegistrationEnabled: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = RealmSelfRegistrationEnabled(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_realm(self, mock_realm, auditor): + assert auditor.should_consider_realm(mock_realm) is True # Always consider unless specifically ignored + + @pytest.mark.parametrize( + "self_registration_enabled, expected", + [ + (True, True), # Self-registration enabled + (False, False), # Self-registration disabled + ], + ) + def test_realm_has_self_registration_enabled(self, auditor, self_registration_enabled, expected, mock_realm): + mock_realm.is_self_registration_enabled.return_value = self_registration_enabled + assert auditor.realm_has_self_registration_enabled(mock_realm) == expected + + def test_audit_function_no_findings(self, auditor, mock_realm): + # Setup realm with self-registration disabled + mock_realm.is_self_registration_enabled.return_value = False + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_realm): + # Setup realm with self-registration enabled + mock_realm.is_self_registration_enabled.return_value = True + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 1 + + def test_audit_function_multiple_realms(self, auditor): + # Create separate mock realms with distinct settings + realm1 = Mock() + realm1.is_self_registration_enabled.return_value = True + + realm2 = Mock() + realm2.is_self_registration_enabled.return_value = False + + realm3 = Mock() + realm3.is_self_registration_enabled.return_value = True + + auditor._DB.get_all_realms.return_value = [realm1, realm2, realm3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from realm1 and realm3, but not from realm2 diff --git a/tests/auditors/realm/test_refresh_token_reuse_count_should_be_zero.py b/tests/auditors/realm/test_refresh_token_reuse_count_should_be_zero.py new file mode 100644 index 0000000..dddc69a --- /dev/null +++ b/tests/auditors/realm/test_refresh_token_reuse_count_should_be_zero.py @@ -0,0 +1,66 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.realm.refresh_token_reuse_count_should_be_zero import RefreshTokenReuseCountShouldBeZero + + +class TestRefreshTokenReuseCountShouldBeZero: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = RefreshTokenReuseCountShouldBeZero(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_realm(self, mock_realm, auditor): + assert auditor.should_consider_realm(mock_realm) is True # Always consider unless specifically ignored + + @pytest.mark.parametrize( + "revocation_enabled, max_reuse_count, expected", + [ + (True, 1, True), # Revocation enabled, but reuse allowed + (True, 0, False), # Revocation enabled, no reuse allowed + (False, 1, False), # Revocation not enabled, reuse irrelevant + ], + ) + def test_realm_has_refresh_token_reuse_enabled( + self, mock_realm, auditor, revocation_enabled, max_reuse_count, expected + ): + mock_realm.has_refresh_token_revocation_enabled.return_value = revocation_enabled + mock_realm.get_refresh_token_maximum_reuse_count.return_value = max_reuse_count + assert auditor.realm_has_refresh_token_reuse_enabled(mock_realm) == expected + + def test_audit_function_no_findings(self, auditor, mock_realm): + # Setup realm with correct refresh token settings + mock_realm.has_refresh_token_revocation_enabled.return_value = True + mock_realm.get_refresh_token_maximum_reuse_count.return_value = 0 + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_realm): + # Setup realm with incorrect refresh token settings + mock_realm.has_refresh_token_revocation_enabled.return_value = True + mock_realm.get_refresh_token_maximum_reuse_count.return_value = 1 + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 1 + + def test_audit_function_multiple_realms(self, auditor): + # Create separate mock realms with distinct settings + realm1 = Mock() + realm1.has_refresh_token_revocation_enabled.return_value = True + realm1.get_refresh_token_maximum_reuse_count.return_value = 1 + + realm2 = Mock() + realm2.has_refresh_token_revocation_enabled.return_value = True + realm2.get_refresh_token_maximum_reuse_count.return_value = 0 + + realm3 = Mock() + realm3.has_refresh_token_revocation_enabled.return_value = True + realm3.get_refresh_token_maximum_reuse_count.return_value = 2 + + auditor._DB.get_all_realms.return_value = [realm1, realm2, realm3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from realm1 and realm3 diff --git a/tests/auditors/realm/test_refresh_tokens_should_be_revoked_after_use.py b/tests/auditors/realm/test_refresh_tokens_should_be_revoked_after_use.py new file mode 100644 index 0000000..bc9739a --- /dev/null +++ b/tests/auditors/realm/test_refresh_tokens_should_be_revoked_after_use.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.realm.refresh_tokens_should_be_revoked_after_use import RefreshTokensShouldBeRevokedAfterUse + + +class TestRefreshTokensShouldBeRevokedAfterUse: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = RefreshTokensShouldBeRevokedAfterUse(database, default_config) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_realm(self, mock_realm, auditor): + assert auditor.should_consider_realm(mock_realm) is True # Always consider unless specifically ignored + + @pytest.mark.parametrize( + "revocation_enabled, expected", + [ + (True, False), # Revocation enabled, should not produce a finding + (False, True), # Revocation disabled, should produce a finding + ], + ) + def test_realm_has_refresh_token_revocation_disabled(self, mock_realm, auditor, revocation_enabled, expected): + mock_realm.has_refresh_token_revocation_enabled.return_value = revocation_enabled + assert auditor.realm_has_refresh_token_revocation_disabled(mock_realm) == expected + + def test_audit_function_no_findings(self, auditor, mock_realm): + # Setup realm with refresh token revocation enabled + mock_realm.has_refresh_token_revocation_enabled.return_value = True + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_realm): + # Setup realm with refresh token revocation disabled + mock_realm.has_refresh_token_revocation_enabled.return_value = False + auditor._DB.get_all_realms.return_value = [mock_realm] + + results = list(auditor.audit()) + assert len(results) == 1 + + def test_audit_function_multiple_realms(self, auditor): + # Create separate mock realms with distinct settings + realm1 = Mock() + realm1.has_refresh_token_revocation_enabled.return_value = False + + realm2 = Mock() + realm2.has_refresh_token_revocation_enabled.return_value = True + + realm3 = Mock() + realm3.has_refresh_token_revocation_enabled.return_value = False + + auditor._DB.get_all_realms.return_value = [realm1, realm2, realm3] + results = list(auditor.audit()) + assert len(results) == 2 # Expect findings from realm1 and realm3 diff --git a/tests/auditors/scope/__init__.py b/tests/auditors/scope/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auditors/scope/test_using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous.py b/tests/auditors/scope/test_using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous.py new file mode 100644 index 0000000..51cc9af --- /dev/null +++ b/tests/auditors/scope/test_using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous.py @@ -0,0 +1,145 @@ +import pytest +from unittest.mock import Mock + +from kcwarden.auditors.scope.using_nondefault_user_attributes_in_scopes_without_user_profiles_feature_is_dangerous import ( + UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous, +) + + +class TestUsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous: + @pytest.fixture + def auditor(self, database, default_config): + auditor_instance = UsingNonDefaultUserAttributesInScopesWithoutUserProfilesFeatureIsDangerous( + database, default_config + ) + auditor_instance._DB = Mock() + return auditor_instance + + def test_should_consider_scope(self, mock_scope, auditor): + assert auditor.should_consider_scope(mock_scope) is True # Always consider unless specifically ignored + + @pytest.mark.parametrize( + "user_profiles_enabled, expected", + [ + (True, True), # User profiles feature enabled, not considered dangerous + (False, False), # User profiles feature disabled, considered dangerous + ], + ) + def test_realm_has_user_profiles_enabled(self, mock_scope, mock_realm, auditor, user_profiles_enabled, expected): + mock_realm.has_declarative_user_profiles_enabled.return_value = user_profiles_enabled + mock_scope.get_realm.return_value = mock_realm + assert auditor.realm_has_user_profiles_enabled(mock_realm) == expected + + @pytest.mark.parametrize( + "mapper_config, expected", + [ + ( + {"protocol_mapper": "oidc-usermodel-attribute-mapper", "user.attribute": "custom_attribute"}, + True, + ), # Non-default attribute + ( + {"protocol_mapper": "oidc-usermodel-attribute-mapper", "user.attribute": "firstName"}, + False, + ), # Default attribute + ({"protocol_mapper": "other-mapper", "user.attribute": "custom_attribute"}, False), # Wrong mapper type + ], + ) + def test_mapper_references_non_default_user_attribute(self, auditor, mapper_config, expected): + mapper = Mock() + mapper.get_protocol_mapper.return_value = mapper_config["protocol_mapper"] + mapper.get_config.return_value = {"user.attribute": mapper_config["user.attribute"]} + assert auditor.mapper_references_non_default_user_attribute(mapper) == expected + + def test_audit_function_no_findings(self, auditor, mock_scope): + # Setup scope in a realm with user profiles enabled + realm = Mock() + realm.has_declarative_user_profiles_enabled.return_value = True + mock_scope.get_realm.return_value = realm + mock_scope.get_protocol_mappers.return_value = [] + auditor._DB.get_all_scopes.return_value = [mock_scope] + + results = list(auditor.audit()) + assert len(results) == 0 + + def test_audit_function_with_findings(self, auditor, mock_scope): + # Setup scope in a realm without user profiles enabled + realm = Mock() + realm.has_declarative_user_profiles_enabled.return_value = False + mock_scope.get_realm.return_value = realm + mapper = Mock() + mapper.get_protocol_mapper.return_value = "oidc-usermodel-attribute-mapper" + mapper.get_config.return_value = {"user.attribute": "custom_attribute"} + mock_scope.get_protocol_mappers.return_value = [mapper] + mock_scope.get_name.return_value = "mock-scope" + auditor._DB.get_all_scopes.return_value = [mock_scope] + + # Prepare clients to test the output details + client_1 = Mock() + client_1.get_name.return_value = "optional_scope_client" + client_1.get_default_client_scopes.return_value = ["mock-scope", "other-scope"] + client_1.get_optional_client_scopes.return_value = [] + + client_2 = Mock() + client_2.get_name.return_value = "default_scope_client" + client_2.get_default_client_scopes.return_value = ["other-scope"] + client_2.get_optional_client_scopes.return_value = ["mock-scope"] + + client_3 = Mock() + client_3.get_name.return_value = "no_scope_client" + client_3.get_default_client_scopes.return_value = ["other-scope"] + client_3.get_optional_client_scopes.return_value = ["another-scope"] + + # Prepare database + auditor._DB.get_all_clients.return_value = [client_1, client_2, client_3] + + results = list(auditor.audit()) + assert len(results) == 1 + finding = results[0].to_dict() + assert finding["additional_details"]["used-attribute"] == "custom_attribute" + assert finding["additional_details"]["clients-using-scope"] == [client_1.get_name(), client_2.get_name()] + + def test_audit_function_multiple_scopes(self, auditor): + # Create separate mock scopes with distinct settings in different realms + scope1, scope2, scope3 = Mock(), Mock(), Mock() + realm1, realm2 = Mock(), Mock() + realm1.has_declarative_user_profiles_enabled.return_value = False + realm2.has_declarative_user_profiles_enabled.return_value = True + scope1.get_realm.return_value = realm1 + scope2.get_realm.return_value = realm1 + scope3.get_realm.return_value = realm2 + scope1.get_name.return_value = "scope1" + scope2.get_name.return_value = "scope2" + scope3.get_name.return_value = "scope3" + mapper1 = Mock() + mapper1.get_protocol_mapper.return_value = "oidc-usermodel-attribute-mapper" + mapper1.get_config.return_value = {"user.attribute": "custom_attribute"} + scope1.get_protocol_mappers.return_value = [mapper1] + scope2.get_protocol_mappers.return_value = [] + scope3.get_protocol_mappers.return_value = [mapper1] + + auditor._DB.get_all_scopes.return_value = [scope1, scope2, scope3] + + # Prepare clients to test the output details + client_1 = Mock() + client_1.get_name.return_value = "optional_scope_client" + client_1.get_default_client_scopes.return_value = ["scope1", "other-scope"] + client_1.get_optional_client_scopes.return_value = [] + + client_2 = Mock() + client_2.get_name.return_value = "default_scope_client" + client_2.get_default_client_scopes.return_value = ["other-scope"] + client_2.get_optional_client_scopes.return_value = ["scope1"] + + client_3 = Mock() + client_3.get_name.return_value = "no_scope_client" + client_3.get_default_client_scopes.return_value = ["other-scope"] + client_3.get_optional_client_scopes.return_value = ["another-scope"] + + # Prepare database + auditor._DB.get_all_clients.return_value = [client_1, client_2, client_3] + + results = list(auditor.audit()) + assert len(results) == 1 # Expect findings from scope1 only + finding = results[0].to_dict() + assert finding["additional_details"]["used-attribute"] == "custom_attribute" + assert finding["additional_details"]["clients-using-scope"] == [client_1.get_name(), client_2.get_name()] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..490d484 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,173 @@ +from unittest.mock import Mock + +import pytest + +from kcwarden.database.in_memory_db import InMemoryDatabase +from kcwarden.custom_types.config_keys import AUDITOR_CONFIG + +# Adapted from +# https://docs.pytest.org/en/latest/example/simple.html#control-skipping-of-tests-according-to-command-line-option +OPTION_INTEGRATION = "--integration" +MARKER_INTEGRATION_TEST = "integration" + + +def pytest_addoption(parser): + parser.addoption( + OPTION_INTEGRATION, + action="store_true", + default=False, + help="Run integration tests that leverage a Docker container for Keycloak", + ) + + +def pytest_configure(config): + config.addinivalue_line("markers", f"{MARKER_INTEGRATION_TEST}: mark test as integration test") + + +def pytest_collection_modifyitems(config, items): + if len(items) == 1: + return # ignore options if a single test is executed + + int_test_enabled = config.getoption(OPTION_INTEGRATION) + + skip_regular = pytest.mark.skip(reason="only integration tests are executed") + skip_int_test = pytest.mark.skip(reason=f"need {OPTION_INTEGRATION} option to run") + + # If the whole execution contains only integration tests, execute them + if all(MARKER_INTEGRATION_TEST in item.keywords for item in items): + return + + for item in items: + is_int_test = MARKER_INTEGRATION_TEST in item.keywords + if int_test_enabled and not is_int_test: + item.add_marker(skip_regular) + elif is_int_test and not int_test_enabled: + item.add_marker(skip_int_test) + + +# Fixtures + + +# Configuration +@pytest.fixture +def default_config(): + return {AUDITOR_CONFIG: {"PublicClientMustEnforcePKCE": []}} + + +# Input for generating Dataclass instances +@pytest.fixture +def realm_role_json(): + return { + "id": "eb8fdce9-75b2-41a5-a91a-3a2a7689d3f7", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": False, + "clientRole": False, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {}, + } + + +@pytest.fixture +def composite_realm_role_json(realm_role_json): + realm_role_json["composite"] = True + realm_role_json["composites"] = {} # To be filled by testing code + return realm_role_json + + +@pytest.fixture +def client_role_json(): + return { + "id": "fac44c0b-ed3c-487e-8d0d-25a0a249d320", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": False, + "clientRole": True, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {}, + } + + +@pytest.fixture +def composite_client_role_json(client_role_json): + client_role_json["composite"] = True + client_role_json["composites"] = {} # To be filled by testing code + return client_role_json + + +@pytest.fixture +def realm_json(): + return {} + + +# Data storage +@pytest.fixture +def database(): + return InMemoryDatabase() + + +# Mocked data objects +@pytest.fixture +def mock_idp(): + idp = Mock() + return idp + + +@pytest.fixture +def mock_realm(): + realm = Mock() + realm.get_name.return_value = "mock-realm" + return realm + + +@pytest.fixture +def mock_scope(): + scope = Mock() + return scope + + +@pytest.fixture +def mock_client(mock_realm): + client = Mock() + client.get_name.return_value = "mock-test-client" + client.is_enabled.return_value = True + client.get_realm.return_value = mock_realm + client.get_default_client_scopes.return_value = [] + client.get_optional_client_scopes.return_value = [] + client.is_oidc_client.return_value = True + return client + + +@pytest.fixture +def public_client(mock_client): + mock_client.is_public.return_value = True + return mock_client + + +@pytest.fixture +def confidential_client(mock_client): + mock_client.is_public.return_value = False + return mock_client + + +@pytest.fixture +def mock_role(): + role = Mock() + role.is_client_role.return_value = False + role.is_composite_role.return_value = False + role.get_composite_roles.return_value = {} + role.get_client_name.return_value = "realm" + return role + + +@pytest.fixture +def mock_client_role(mock_role): + mock_role.is_client_role.return_value = True + mock_role.get_client_name.return_value = "mock_client" + return mock_role + + +@pytest.fixture +def mock_composite_role(mock_role): + mock_role.is_composite_role.return_value = True + return mock_role diff --git a/tests/database/__init__.py b/tests/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/database/test_helper.py b/tests/database/test_helper.py new file mode 100644 index 0000000..e6b2340 --- /dev/null +++ b/tests/database/test_helper.py @@ -0,0 +1,52 @@ +from kcwarden.database.helper import _role_contains_role +from kcwarden.custom_types.keycloak_object import Realm, RealmRole, ClientRole +import pytest + + +class TestRoleContainsRole: + def wrap_role(self, wrapper_role_json, wrapped_role_json, wrapped_role_client=None): + assert wrapper_role_json["composite"], "Must pass a composite role JSON as parameter" + if wrapped_role_client: + wrapper_role_json["composites"] = {"client": {wrapped_role_client: [wrapped_role_json["name"]]}} + else: + wrapper_role_json["composites"] = {"realm": [wrapped_role_json["name"]]} + return wrapper_role_json, wrapped_role_json + + @pytest.fixture + def wrapped_client_role(self, composite_realm_role_json, client_role_json, realm_json): + client_name = "client-role-client" + realm = Realm(realm_json) + wrapper, wrapped = self.wrap_role(composite_realm_role_json, client_role_json, client_name) + print(wrapper, wrapped) + return RealmRole(wrapper, realm), ClientRole(wrapped, realm, client_name) + + @pytest.fixture + def wrapped_realm_role(self, composite_realm_role_json, realm_role_json, realm_json): + realm = Realm(realm_json) + wrapper, wrapped = self.wrap_role(composite_realm_role_json, realm_role_json) + return RealmRole(wrapper, realm), RealmRole(wrapped, realm) + + def test_role_contains_role_client_role_in_comp(self, wrapped_client_role): + wrapper_role, wrapped_role = wrapped_client_role + assert _role_contains_role(wrapped_role, wrapper_role) + + def test_role_contains_role_realm_role_in_comp(self, wrapped_realm_role): + wrapper_role, wrapped_role = wrapped_realm_role + assert _role_contains_role(wrapped_role, wrapper_role) + + def test_role_contains_role_container_role_is_no_composite_role( + self, realm_role_json, client_role_json, realm_json + ): + realm = Realm(realm_json) + role1 = RealmRole(realm_role_json, realm) + role2 = ClientRole(client_role_json, realm, "client") + assert not _role_contains_role(role1, role2) + + def test_role_contains_role_container_role_is_composite_but_does_not_contain_role( + self, client_role_json, composite_realm_role_json, realm_json + ): + realm = Realm(realm_json) + role1 = ClientRole(client_role_json, realm, "client") + role2 = RealmRole(composite_realm_role_json, realm) + assert role2.is_composite_role() + assert not _role_contains_role(role1, role2) diff --git a/tests/fixtures/default-realm.json b/tests/fixtures/default-realm.json new file mode 100644 index 0000000..300202f --- /dev/null +++ b/tests/fixtures/default-realm.json @@ -0,0 +1,2159 @@ +{ + "id": "9a7d2e5f-e003-4704-b946-98506074591e", + "realm": "default-realm", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "6705d87d-8914-44ca-915e-7719642b0100", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "9a7d2e5f-e003-4704-b946-98506074591e", + "attributes": {} + }, + { + "id": "a0d08caa-b54e-4ac5-862a-73064609b768", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "9a7d2e5f-e003-4704-b946-98506074591e", + "attributes": {} + }, + { + "id": "1b0ff1ef-9eb9-4222-bcf9-2c9ab28932b5", + "name": "default-roles-default-realm", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "9a7d2e5f-e003-4704-b946-98506074591e", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "c1f26ca6-85c1-428e-9920-aeadd92d9acb", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "2db24b21-564d-4cd7-ba99-af159951e723", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "be623230-69ed-4ba3-9fa1-416f2732aaf0", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "b0f8a130-e4a8-4c0e-9010-a1c7b5c0313d", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "432f3175-cd43-479d-abb5-a3ef1d900d23", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "4f86cccf-d785-4e27-a0ae-f2f223436ff5", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "dc044b99-89cd-4c1c-9b8e-0da0fe49e1d1", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "7c1d2397-27a4-42be-8cf8-0c458a37d358", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "2542e9d4-3e02-4a1a-b66e-712291d7d888", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "67e3798a-5620-4298-8a95-775f581319f3", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "072f3e0a-7407-4556-9e35-ac5d23e2df48", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "c95c0115-a596-4935-8f20-cb47c6dc62e2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "73ff58b2-6b59-461c-859c-56d8221aff3a", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-events", + "manage-clients", + "manage-authorization", + "query-clients", + "view-events", + "create-client", + "query-realms", + "impersonation", + "query-users", + "view-authorization", + "manage-users", + "view-users", + "query-groups", + "manage-identity-providers", + "view-realm", + "view-clients", + "manage-realm", + "view-identity-providers" + ] + } + }, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "cfa52113-1872-41cb-b06a-a2d371e3b352", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "6a407c87-43e1-40df-a17c-f3b348144982", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "5c22e8c2-46ad-42df-a848-6e55fd76c4a0", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "0a6f07bf-e4d3-4e81-9cf2-2e39b46f4dfa", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "2d8928bd-7f9a-4b31-b40e-5a4a3c3a8016", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + }, + { + "id": "38ea6d53-81a0-4a9a-a172-215fb0c9d927", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "6446a610-5098-43a6-a10a-d5e14013ea14", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "f0debeca-52e9-40e3-a3fc-698abd123924", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "0e695979-45f1-4f2e-9dbc-9774abd0ba5e", + "attributes": {} + } + ], + "account": [ + { + "id": "deb89a38-0c56-4f32-a8f9-cd232c14d962", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "ec397254-616f-42ee-be35-ee6d26eb4acb", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "71775911-79af-491d-80bb-b9facec4dc3a", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "ff42ed2a-dbb2-497a-8c62-7ee8529112a1", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "75dfd034-a5d3-4098-9ea2-ca792c7fb7b7", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "aa7ef12b-2cff-4977-b1fe-d7fabfcd1689", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "c30a5246-f8d8-41f0-9316-08d119c79150", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + }, + { + "id": "8dfd91e0-3d03-4887-9b5e-1af78fe026bf", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "1b0ff1ef-9eb9-4222-bcf9-2c9ab28932b5", + "name": "default-roles-default-realm", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "9a7d2e5f-e003-4704-b946-98506074591e" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppMicrosoftAuthenticatorName", + "totpAppGoogleName" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "70a4f6db-cc0f-4a8d-bcff-c173fa28374d", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/default-realm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/default-realm/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "7d034dc9-e56c-48d8-b565-ba1e56a5372d", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/default-realm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/default-realm/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "e963ab5b-3b2e-4763-ba02-2f5ea1166a42", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "098ee97d-683b-4745-ad34-7e95223baf9e", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0e695979-45f1-4f2e-9dbc-9774abd0ba5e", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "6446a610-5098-43a6-a10a-d5e14013ea14", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "31eda631-c6eb-43d1-b036-f8399bc51e03", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/default-realm/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/default-realm/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "1ccfa798-674c-4c4e-84a6-d695bf13232f", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "def02db1-400b-4bca-a559-47de8a2a8d95", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "2f50855d-53ad-4343-9f91-c5f7101c42a2", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "ffd53428-f709-498d-834a-4adf0c67a6c2", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "65cc72e9-4adc-4c6c-a97c-2300ba86b18e", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "52ecd406-c0f7-4257-9563-77000a902691", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "cdeab1c6-1471-4eb3-acdd-0d9b849d1a34", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "15eecde1-6788-468f-aa98-c16dfeef2775", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "12cabd69-e433-4303-8bc3-a3b25f6832f9", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a904fafb-c4a4-4381-8405-ea10631194fc", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "0fcc03e4-c7bf-4792-9f27-80a932c7d7b0", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "9189c4f5-f1a1-4f27-9801-000ecee15e6c", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "49b066f6-e254-4e05-9a10-2ff05f9a838a", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "be252f4a-bf65-41c4-a80c-1d3b646b0222", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "614e1c94-792b-46c4-ad9e-e40c95864e70", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "06dbc9a6-9b4b-4abf-86bf-b0fd1f451254", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "ac64effb-b232-4841-96b9-4f7007775744", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "8b4e5803-1557-44b7-9a04-9184e81dbb74", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0b0e8248-9dbe-4699-b693-48c858f9e93a", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "b7b18b00-7767-484b-891c-2fba3012b939", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "1eb96484-71b6-4632-a6ac-610bd003f416", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "8d49a377-bcbe-4867-9717-e49cd2ce23cf", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "55166c05-ae39-48d4-bff8-cbeab324e829", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "eb370f01-62d6-4b1f-ad6f-9f5c0f66f361", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "b64b9426-469f-4308-a353-f85a4a10bff8", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "2ce178c8-baf4-429f-9c7a-c6656d5c1be5", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "a088e04c-7e7f-4829-aff7-24500797a4b7", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "c9e66ebc-453b-4916-b993-00b76f2b5878", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "6b3120c9-0139-4500-b66f-cc32627cfaac", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "da7c4576-5159-4367-9ba1-fc42aa2a870c", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "d4bc061f-9566-4f6f-9325-741390727f08", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "3d147e78-f8f4-46bf-a571-617df568dcb9", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "57ac8748-f542-40ec-818d-4668c7674e30", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "6657f58b-07ea-4cf5-83c9-488a487c9a22", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "856b2359-6809-44e2-978f-d7cf732add07", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "90d96390-fb9f-465f-87f2-2ad4057e35be", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "85784d4e-4a0f-4cbf-a20b-68a349d5ac5b", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "675c0a31-ebe4-41b5-897c-4aaef3e344d0", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "8982a278-4497-4e74-9d63-3cd23f9dae36", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "1b04b4a1-3892-40b6-91cd-09c78c1a5ac7", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "6942f11d-315f-4ddd-a520-e2bddfb991c2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "54485111-2e5d-40e1-9909-ba28780c5e72", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "3fb41a40-ae7c-4ffc-8ab7-a7a3474636ef", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "c08b6c9d-27f9-423b-9f19-80df06c080f2", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "57b476d9-a168-4a48-a2f0-0557c6e0c715", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "3161b00b-5f69-43ef-aefc-237a0ff33f20", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "c0ef4647-ac44-4856-9f15-a5f6462a7c3c", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "4cfabd44-76fa-45d7-9e83-33c931b0d4c9", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "861302e6-30eb-4779-80bf-2023cfa6a875", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "68c6b6e8-54a9-41ec-aa36-7cd6dd4b4c80", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "7236251d-73a0-4098-beaf-addf59ec1cba", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "59770992-b303-4f19-a487-fd262753cc9e", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3b69a44b-75fb-4dd5-8031-609cebc685c0", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d48b6d84-f419-4602-8dd7-c55d0231a604", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "f153b4f7-98c6-41e5-8fb0-6258bff8458f", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "5f9bbdbd-5734-4504-a3c1-7ec9d9014ca2", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "c1d5def9-67e8-4861-97e6-2877a5ebc517", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "7d9ad278-576d-46e0-83b5-8eb7ba86ca47", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "a5a27546-a5e9-45f3-be43-cbdbfb08d458", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "3b9d5a1f-62b0-45de-a116-605fd90c2ba7", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "79f000b4-a7f7-4c11-abb1-c01e9145ec77", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "58e5b911-e704-402f-904b-01c7f1383529", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "5226f086-3bfa-4207-8511-2e10f529cdc6", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "59987bec-e47b-4ab7-a09c-a85f2608be23", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "5eef09bb-ae74-4a7f-b957-aa39553b3ef8", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "f61afa32-b52a-41b6-bc2e-001ae6bda566", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Authentication Options", + "userSetupAllowed": false + } + ] + }, + { + "id": "09b9d5bf-84b2-408c-bef0-6bd2ca9947bb", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "52670ffa-6839-4424-908b-d52f84366e42", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "527802c6-8229-4e31-b1ac-f7eb240dcfb6", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "fe218e20-97ed-4b63-965d-c78652ccb639", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "17929f7a-aee5-4294-bdfa-abd9de01cac1", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "ad0af34f-6764-433a-8b05-548394bf7bf6", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "21.1.1", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } + } \ No newline at end of file diff --git a/tests/fixtures/test-realm-with-client.json b/tests/fixtures/test-realm-with-client.json new file mode 100644 index 0000000..0f63016 --- /dev/null +++ b/tests/fixtures/test-realm-with-client.json @@ -0,0 +1,4050 @@ +{ + "id": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "realm": "lint-test", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "ff7eefd3-03df-4226-a7de-9e7495120bb0", + "name": "sensitive_composite_role", + "description": "", + "composite": true, + "composites": { + "realm": [ + "normal_role", + "sensitive-role" + ] + }, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "42a4a742-e0eb-4065-856b-e4e5a534d5a3", + "name": "sensitive-default-role", + "description": "", + "composite": false, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "ded86198-3cc5-4134-a4dc-5654f86cde25", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "eb8fdce9-75b2-41a5-a91a-3a2a7689d3f7", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "ad58b3aa-5b79-47f0-a0c5-9a1b00159cca", + "name": "default-roles-lint-test", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "sensitive-default-role", + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "be8e2169-938a-477a-a2d6-a9e01ee74639", + "name": "normal_role", + "description": "An okay role :)", + "composite": false, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "43d639df-7f58-4b4f-9c1b-981668e7ac63", + "name": "recursive_sensitive_composite_role", + "description": "A composite role containing another composite role containing a sensitive role. Phew", + "composite": true, + "composites": { + "realm": [ + "sensitive_composite_role" + ] + }, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + }, + { + "id": "27312e64-d5ae-4de2-b6ce-efc7bb0c9367", + "name": "sensitive-role", + "description": "A sensitive role for testing", + "composite": false, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7", + "attributes": {} + } + ], + "client": { + "security-admin-console": [], + "client-with-direct-access-grants-enabled": [], + "client-with-sensitive-role": [], + "account-console": [], + "client-with-service-account-with-sensitive-role": [], + "client-with-userattribute-mapper": [], + "client-with-service-account-with-sensitive-composite-role": [], + "admin-cli": [], + "client-with-scope-containing-client-role-with-sensitive-realm-role": [], + "client-with-explicitly-defined-roles-in-client-scope": [], + "client-with-service-account-in-recursive-sensitive-group": [], + "client-with-device-flow-active": [], + "realm-management": [ + { + "id": "0c8d7745-7391-458c-95b7-d8a70c42a6fc", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "fac44c0b-ed3c-487e-8d0d-25a0a249d320", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "4c8dbf9d-55a9-4461-99e9-b45b2a9ba055", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "67332345-b024-4d67-a082-edc5aa7f1ba1", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "d2315f96-f3c4-4b82-917a-6786abd2695e", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "0dabe6df-8a38-4b2b-9290-7ee2b28570cb", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "01f8cd31-31ad-40f1-ac9a-92282dff0294", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "04fab1b5-8ba5-410c-89f3-c61627de1865", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "e94b8469-4168-4fb9-a6ea-d71ea952e5b9", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "27f61f9c-164b-4dd9-a16b-aa85f6ea07b0", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "ab7ac7df-6bc8-4d77-b6da-3d1a18f59bb1", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "cf65fae3-5f86-4ea1-bceb-33325302f3e2", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "6d8e6ef5-9785-47ba-848d-4508aeeedb69", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "e0555aed-b51e-4667-90f9-2903dbb23214", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "99a427d6-b6a1-4686-af11-0bc3b356fc24", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "0b58a12a-1287-456d-9239-b8ca79e1a73c", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "view-users", + "manage-realm", + "view-events", + "view-realm", + "manage-users", + "view-identity-providers", + "impersonation", + "query-realms", + "manage-events", + "manage-identity-providers", + "query-groups", + "create-client", + "query-clients", + "query-users", + "view-clients", + "view-authorization", + "manage-authorization", + "manage-clients" + ] + } + }, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "d89e74b6-4f7a-4bf3-9d53-1bd85d35ea79", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "a1ff67b5-6e6b-4945-a284-fc89d20c93c7", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + }, + { + "id": "9b688021-d7c0-4440-bb11-763ed68f5c9e", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "attributes": {} + } + ], + "client-with-service-account-with-recursive-sensitive-role": [], + "client-with-recursive-sensitive-composite-role": [], + "broker": [ + { + "id": "917332ab-41b6-4415-b5be-6fadcc7751d6", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "ee542144-1f00-4717-87c0-9999db6f7502", + "attributes": {} + } + ], + "client-with-client-role-containing-sensitive-realm-role": [ + { + "id": "c4367bdf-4fd6-4ca3-ab43-09f4f7b48df9", + "name": "client-role-containing-sensitive-realm-role", + "description": "", + "composite": true, + "composites": { + "realm": [ + "sensitive-role" + ] + }, + "clientRole": true, + "containerId": "d251464e-b499-4928-a2e9-cc3f6f401069", + "attributes": {} + } + ], + "client-with-sensitive-composite-role": [], + "client-with-service-account-with-benign-role": [], + "public-client-with-pkce-enforced": [], + "service-account-client-with-client-role": [], + "service-account-client-with-service-account-in-sensitive-subgroup": [], + "client-with-benign-scope": [], + "client-with-client-roles": [ + { + "id": "6fe0899a-93d4-4228-8197-7cb5d7d62bcd", + "name": "benign-client-role", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "3416a760-736d-46f5-89d2-d3bab2b3cd05", + "attributes": {} + }, + { + "id": "703731c8-c383-41a8-bfb3-ea277a5283ff", + "name": "sensitive-client-role", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "3416a760-736d-46f5-89d2-d3bab2b3cd05", + "attributes": {} + } + ], + "account": [ + { + "id": "23a089ff-e73d-450e-9657-c47b69682ffe", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "03028b07-6ba3-4140-8408-e77f210576e6", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "6fe82c1d-23ac-4169-ab46-5701be688d75", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "ca0f44a0-bbe8-4cc9-9564-612b67f85b27", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "d85fa730-d5b7-4be5-babb-0b2a4cf6791c", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "0559d3d8-8008-4680-ab6b-f712c6be7338", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "037ab056-7c6e-48a0-a5d6-c7328e596a66", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + }, + { + "id": "e29f8555-f055-4979-8066-964a87a92f26", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "c44212fc-d442-4afd-a48d-335d0d030708", + "attributes": {} + } + ], + "client-with-service-account-in-sensitive-group": [] + } + }, + "groups": [ + { + "id": "d406001b-aada-4999-b68c-30188ca0fac8", + "name": "benign-group", + "path": "/benign-group", + "attributes": {}, + "realmRoles": [ + "normal_role" + ], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "71f4ec07-96c5-4a43-bd2d-3da010cede4a", + "name": "group-with-sensitive-child-group", + "path": "/group-with-sensitive-child-group", + "attributes": {}, + "realmRoles": [ + "sensitive_composite_role" + ], + "clientRoles": {}, + "subGroups": [ + { + "id": "0f130934-933d-4ac1-8033-2646c4dd6bde", + "name": "sensitive-child-group", + "path": "/group-with-sensitive-child-group/sensitive-child-group", + "attributes": {}, + "realmRoles": [ + "sensitive-role" + ], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "9f936e3d-8457-4b68-8ce5-f03799e8394e", + "name": "composite-sensitive-child-group", + "path": "/group-with-sensitive-child-group/composite-sensitive-child-group", + "attributes": {}, + "realmRoles": [ + "sensitive_composite_role" + ], + "clientRoles": {}, + "subGroups": [] + } + ] + }, + { + "id": "6b30e703-c45b-42c4-9ed2-d899e04294ff", + "name": "group-with/in-the-name", + "path": "/group-with/in-the-name", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [ + { + "id": "53bb302b-b8a6-4bf6-b791-3fd8dc3e09d9", + "name": "child-group-with/in-the-name", + "path": "/group-with/in-the-name/child-group-with/in-the-name", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ] + }, + { + "id": "93dbce5e-178d-430b-a597-333e36f75c64", + "name": "recursive-sensitive-composite-group", + "path": "/recursive-sensitive-composite-group", + "attributes": {}, + "realmRoles": [ + "recursive_sensitive_composite_role" + ], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "597868df-75ae-430f-aef8-e64f82a69209", + "name": "sensitive-composite-group", + "path": "/sensitive-composite-group", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + }, + { + "id": "88b84b69-7d62-4999-ac71-64f3344f09c4", + "name": "sensitive-group", + "path": "/sensitive-group", + "attributes": {}, + "realmRoles": [ + "sensitive-role" + ], + "clientRoles": {}, + "subGroups": [] + } + ], + "defaultRole": { + "id": "ad58b3aa-5b79-47f0-a0c5-9a1b00159cca", + "name": "default-roles-lint-test", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "9b8bf6b3-0cea-44aa-9deb-ddc2d331e3c7" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppMicrosoftAuthenticatorName", + "totpAppGoogleName" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "70e53fb6-afb1-4408-95d5-8c537f23b35d", + "createdTimestamp": 1695980755094, + "username": "service-account-client-with-service-account-in-recursive-sensitive-group", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "client-with-service-account-in-recursive-sensitive-group", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test" + ], + "notBefore": 0, + "groups": [ + "/recursive-sensitive-composite-group" + ] + }, + { + "id": "2ee0608b-1eee-4b6b-a2bb-09a6ce046b8c", + "createdTimestamp": 1695980696305, + "username": "service-account-client-with-service-account-in-sensitive-group", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "client-with-service-account-in-sensitive-group", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test" + ], + "notBefore": 0, + "groups": [ + "/sensitive-composite-group" + ] + }, + { + "id": "9adff800-5c39-4670-bb42-305eb55bceb8", + "createdTimestamp": 1695978083059, + "username": "service-account-client-with-service-account-with-benign-role", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "client-with-service-account-with-benign-role", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test", + "normal_role" + ], + "notBefore": 0, + "groups": [] + }, + { + "id": "a9765b36-eb06-4642-bf95-b52f907533f5", + "createdTimestamp": 1695978205441, + "username": "service-account-client-with-service-account-with-recursive-sensitive-role", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "client-with-service-account-with-recursive-sensitive-role", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test", + "recursive_sensitive_composite_role" + ], + "notBefore": 0, + "groups": [] + }, + { + "id": "e015a88b-98c9-4c50-a3d7-db70edfbf25e", + "createdTimestamp": 1695978103347, + "username": "service-account-client-with-service-account-with-sensitive-composite-role", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "client-with-service-account-with-sensitive-composite-role", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "sensitive_composite_role", + "default-roles-lint-test" + ], + "notBefore": 0, + "groups": [] + }, + { + "id": "05af194c-86a3-4557-83de-d2789b3b1d49", + "createdTimestamp": 1695977117265, + "username": "service-account-client-with-service-account-with-sensitive-role", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "client-with-service-account-with-sensitive-role", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test", + "sensitive-role" + ], + "notBefore": 0, + "groups": [] + }, + { + "id": "8cbc5918-40bf-4be4-b7bd-44500abf8a15", + "createdTimestamp": 1695986518227, + "username": "service-account-service-account-client-with-client-role", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "service-account-client-with-client-role", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test" + ], + "clientRoles": { + "client-with-client-roles": [ + "sensitive-client-role" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "42d2e47e-f019-4d83-9e08-4de5f025044a", + "createdTimestamp": 1695980778690, + "username": "service-account-service-account-client-with-service-account-in-sensitive-subgroup", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "service-account-client-with-service-account-in-sensitive-subgroup", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lint-test" + ], + "notBefore": 0, + "groups": [ + "/group-with-sensitive-child-group/composite-sensitive-child-group" + ] + } + ], + "scopeMappings": [ + { + "client": "client-with-explicitly-defined-roles-in-client-scope", + "roles": [ + "sensitive-role" + ] + }, + { + "clientScope": "scope-with-sensitive-role", + "roles": [ + "normal_role", + "sensitive-role" + ] + }, + { + "clientScope": "scope-with-recursive-sensitive-composite-role", + "roles": [ + "recursive_sensitive_composite_role" + ] + }, + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + }, + { + "clientScope": "scope-with-sensitive-composite-role", + "roles": [ + "sensitive_composite_role" + ] + }, + { + "clientScope": "benign-scope", + "roles": [ + "normal_role" + ] + } + ], + "clientScopeMappings": { + "client-with-client-roles": [ + { + "clientScope": "client-scope-with-client-role", + "roles": [ + "sensitive-client-role" + ] + } + ], + "client-with-client-role-containing-sensitive-realm-role": [ + { + "clientScope": "scope-with-client-role-containing-sensitive-realm-role", + "roles": [ + "client-role-containing-sensitive-realm-role" + ] + } + ], + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + }, + { + "client": "client-with-explicitly-defined-roles-in-client-scope", + "roles": [ + "view-profile" + ] + } + ] + }, + "clients": [ + { + "id": "c44212fc-d442-4afd-a48d-335d0d030708", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/lint-test/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/lint-test/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d859358d-8dba-4bc4-9f13-906ac5c5ecc4", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/lint-test/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/lint-test/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "6b5722f5-61ce-4764-ab71-82fa306d82c6", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "b695446e-4efe-48c2-aa0f-d2ec556405f8", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "ee542144-1f00-4717-87c0-9999db6f7502", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d9d8f517-68cd-4873-b664-b975b6cb1742", + "clientId": "client-with-benign-scope", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "benign-scope", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d251464e-b499-4928-a2e9-cc3f6f401069", + "clientId": "client-with-client-role-containing-sensitive-realm-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "3416a760-736d-46f5-89d2-d3bab2b3cd05", + "clientId": "client-with-client-roles", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "b73ab937-db4d-4be3-88b9-8191fc93d5ae", + "clientId": "client-with-device-flow-active", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "true", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e147121b-57bb-4136-8f24-4d5ee67a8c50", + "clientId": "client-with-direct-access-grants-enabled", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "eb449631-cdfb-4232-b146-e4df9b6981a4", + "clientId": "client-with-explicitly-defined-roles-in-client-scope", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "client.secret.creation.time": "1698147835", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "277f1aef-2ca2-4992-92b3-7823941db631", + "clientId": "client-with-recursive-sensitive-composite-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "scope-with-recursive-sensitive-composite-role", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "85cc89e6-0442-42e6-94bf-416b820509e1", + "clientId": "client-with-scope-containing-client-role-with-sensitive-realm-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "scope-with-client-role-containing-sensitive-realm-role", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f98350ee-b5a6-4cbc-abcd-2b4b4c401e67", + "clientId": "client-with-sensitive-composite-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "scope-with-sensitive-composite-role", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "de747528-5977-455b-912d-e344a46a61f4", + "clientId": "client-with-sensitive-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "scope-with-sensitive-role", + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f7fbe532-7013-4926-9e86-4a50bea003b1", + "clientId": "client-with-service-account-in-recursive-sensitive-group", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695980755", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "75fc5b55-fd55-4be0-a6f9-4a167ddc5770", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "664ee270-a02f-4ad9-92dc-eefede5a8edf", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "30db8649-5eee-4bc3-b85d-91e4545f4d4c", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "62db65fe-cb5c-4ffd-862e-7ccd4316e992", + "clientId": "client-with-service-account-in-sensitive-group", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695980696", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "45294427-e012-4a4f-98fc-da54b3c877b8", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "d40f2cba-1d21-448e-8d6f-797a18bf3a26", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "03488d10-aa65-48e5-b097-00e1686ffe8d", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "b93d4986-ed28-4941-9d6b-246d3779071e", + "clientId": "client-with-service-account-with-benign-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695978083", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "f6030bfe-bb77-44a7-81f9-2ebdbe3b438e", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "d6fb0f30-5c6f-4de7-a8f3-e6ed2e525031", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "04d3bde6-6b91-4cbb-bc3e-3b818f2080da", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "8f156fde-008b-417f-9249-8ca4dc016fc5", + "clientId": "client-with-service-account-with-recursive-sensitive-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695978205", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "e39aa177-7be5-42e4-a0f5-891928d7f799", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "b44e0d70-2a7f-4eef-9a19-836815ce5cf8", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "8d631f44-3b23-494b-9912-5d243a914e1f", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "97417081-8270-4ae2-b427-39d771d0bd25", + "clientId": "client-with-service-account-with-sensitive-composite-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695978103", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "1c87be2e-16c3-47fb-8894-cb2ae82c48f1", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "973bd647-524c-4ef3-9f04-1f2cc6f642e0", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "4d32755f-cecb-4823-98a2-49c9a7cad787", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "1e973e44-4088-48a1-93e3-3fed0fca72f2", + "clientId": "client-with-service-account-with-sensitive-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695977117", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "f09b21f9-aa48-4611-8f83-060fbe6be83e", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "fef0986e-fcd5-4bc6-ad56-df443281c4fb", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "bef4cda5-6d68-4465-8932-ede17246a194", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "48153426-9f91-4d3f-adc7-151c6e4f7c0d", + "clientId": "client-with-userattribute-mapper", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "78890c6c-5dfb-4c1c-a469-4f21d170f702", + "name": "user-id-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "user-id", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "user-id", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "3e12f66b-9376-49ec-82f7-dfb0a869876d", + "clientId": "public-client-with-pkce-enforced", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "acr.loa.map": "{}", + "display.on.consent.screen": "false", + "pkce.code.challenge.method": "S256", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "c159c414-1fcb-4bd1-95ad-c9b412987c28", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "dd4f1270-9d1a-40e2-a3d1-2261a1153080", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/lint-test/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/lint-test/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "cd91f63e-e149-447a-b75e-86fc32a570d3", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "5eb7d533-6f32-40a9-b9a4-7d4d19644803", + "clientId": "service-account-client-with-client-role", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695986518", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "71185be8-1071-49b3-be44-74f23b8c1d36", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "7a0450b4-5428-42b4-9ea6-02beb6510fa0", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "af7a9c2d-768a-4d34-ae5d-e532ea2b1611", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e9f172c2-7c8c-455e-8eaa-2ae89a19cecc", + "clientId": "service-account-client-with-service-account-in-sensitive-subgroup", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1695980778", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "97adebde-724d-4e1f-9f92-3981062b5426", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "bd918698-46ce-4950-b033-c03ff2760760", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "c086ff09-541c-472d-9668-b0998e646e28", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "d393d49d-65ec-4ace-a957-5d94d470124a", + "name": "scope-with-sensitive-role", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "b1261941-93bd-4c7c-819f-326b99c8f7f1", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "2df747f5-3357-4d84-b648-a6772b386973", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "926f9c0c-69a0-45f1-8e75-42a7987a85c7", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "0399894e-ff43-42b3-896b-b3df2a3be079", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "6b797a34-a333-4e24-845a-b05ad3d3d926", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "d5b44d1d-323d-4ee0-962f-b8d6f18b92a7", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "a8535b33-a3e2-4073-bc29-c762d4a207a5", + "name": "scope-with-recursive-sensitive-composite-role", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "08c909b5-0659-427c-a3bc-ead225e2d69a", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "45c29390-384f-4973-8d6b-549dbff57c40", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "052407b5-826c-4265-98d8-81f0dbb88ec5", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "81a87329-ea2a-4777-b356-b02cd436e182", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "4bac6bdc-56de-4323-ab33-9da8883ce915", + "name": "client-scope-with-client-role", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "23ef966c-0d88-40bf-9933-86f2d2cfdc7d", + "name": "scope-with-sensitive-composite-role", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "dc4c6d4e-117c-40a0-b537-e3c3dc793f92", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "df474d36-6f96-491b-b486-9c3bd4b7dfc8", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "8ee707bf-4ee1-4027-8200-248d145338be", + "name": "scope-with-client-role-containing-sensitive-realm-role", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "3625d9f0-5738-4dc9-919d-55c1ec3233cf", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "4f834ed3-cd81-4f94-a422-0731e04d03da", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "da95707d-f5d0-48ad-8187-873ee887d596", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "bd1daa9e-153c-4784-9390-a608c31782e0", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "8ba7ced0-f1c2-4eb4-9ae7-ce6c5159a3d3", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "49005902-119c-41c2-8b44-45e836b04423", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a6fec3cc-b474-4955-82f4-e31e0a4aacd0", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "b8437a02-6e4a-434f-8f90-d571b767f72c", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "3dce4938-d32e-4e9e-96df-039b107b8ade", + "name": "benign-scope", + "description": "A scope with no sensitive roles", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "0e3b06d0-e990-47d1-8b8b-f73674f5b0a4", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f828dd94-82c2-412d-8cea-0a67809642bc", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "848ff264-605e-4b04-bb37-6316e6892156", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "73ce6a5e-9da7-4ab4-a909-9b199b1c348c", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9c92af55-0fb6-4946-b459-f58e2543f6b7", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "987fdb3b-7ffe-47ca-8ab4-d5640a111279", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "7a122562-1576-4ce2-a33a-bf9c889492b5", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "b7d3c393-a7ad-4178-b5f8-7d60a6bc15dc", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "7552c330-8e2c-4cd6-9406-0faaa29ebb88", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5bd104ed-a8d9-4dc7-9937-3e0aecfa5ee2", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "2a0cdf71-5ee0-4faf-b614-f2c9a51de961", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "35141084-4183-4a54-8156-d0ce4668e927", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "67390da9-920c-4548-ae8e-597c8b594778", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "c325fdb5-a5e3-4150-bf3f-8c6a48d30b09", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "c4514c27-991b-4a53-bd1e-8a86efdb420e", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "8f9480e3-6058-494b-9b5c-fbaeded501b1", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "332cda34-bf53-4ec7-9510-8fc35d0e068a", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "1f3fe607-fb42-4a91-9b0a-a7639480dd50", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "df8adf58-419f-49dc-87bb-7b5ddd61558b", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "1ece71fd-3c69-456a-ab8e-c693a3cb9b37", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "35e00855-a09d-4874-8f40-a5d653161b95", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "8edbd30f-b6b8-48fa-98f7-ef7618a0f2cb", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper" + ] + } + }, + { + "id": "82490f19-d561-4441-bab4-90657e9c53dd", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "ceabf4d0-6188-44c2-851d-055564f120fd", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "f200601a-9573-4cd3-b12f-33104bf580a0", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "df76d8e4-6180-4633-b722-29ff73c05321", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "801fe8c9-c15a-4c5d-8a90-d3ffa1c44a4b", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "887135e3-3f75-4824-b4d9-fcb01196c347", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "393a8ae3-93d8-4856-80fb-1f48cf3d84b6", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "88d1f330-1c1d-44c1-a104-00394d1efb98", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "4cd0d4df-b30e-4432-8c0c-700118297e66", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "b097207a-dfb4-4842-a595-15cd628c7f03", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "34ffb2d3-2ac5-49a0-8342-0912a5184c2d", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "2bdad19b-3ead-4c8a-85a3-681844bd894b", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "48d3b346-f380-4400-bab8-e357924e1cd3", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "8ff22bd5-15fe-49d1-b714-2c81007569f9", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "d4843be2-acb4-42d9-bd82-d8924a3f6006", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "7777c7d5-8266-4a17-8e11-531f62d7c980", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "92a0d79d-93c0-4a55-a7a0-00fc78db3657", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "c7415167-6593-4541-8b7d-b0da847dc19e", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "65cc0e39-043c-4889-88a5-9ed8b69af1ce", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d5c32b41-edf9-442f-980e-7c4ba1e9df89", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "0e7c1c91-0514-4f5c-a3bc-d6ddf5633acd", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "c30f276c-d2a4-4d2c-82dd-2e328af36879", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "0f9fb741-0026-4ae9-b43a-234b8e9a930d", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "50781bd7-6019-48a2-a3d0-169757898293", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "8b722af5-5683-441a-bda0-b61ffd7f48fb", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "db42ead7-649f-4126-8b63-13acfe2b304c", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "9a7fec23-87cb-407c-bdb9-fe46dced0b9a", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "21.1.1", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..be866cc --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,24 @@ +import logging +import os + +import pytest +from testcontainers.keycloak import KeycloakContainer + +KEYCLOAK_VERSIONS_ENV_VARIABLE = "INTEGRATION_TEST_KEYCLOAK_VERSIONS" +KEYCLOAK_VERSIONS_ENV_VALUE = os.environ.get(KEYCLOAK_VERSIONS_ENV_VARIABLE) +KEYCLOAK_VERSIONS_TO_TEST = ( + KEYCLOAK_VERSIONS_ENV_VALUE.split(" ") if KEYCLOAK_VERSIONS_ENV_VALUE is not None else ["latest", "22.0", "18.0"] +) + + +@pytest.fixture(scope="module", params=KEYCLOAK_VERSIONS_TO_TEST) +def keycloak(request): + """ + This fixture provides a KeycloakAdmin instance for testing purposes. + It automatically creates instances of the consuming tests for multiple Keycloak versions. + Currently, we use the latest version, the base version of Red Hat Build of Keycloak and + the base version of Red Hat Single Sign-On. + """ + logging.getLogger("testcontainers.core.waiting_utils").setLevel(logging.WARNING) + with KeycloakContainer(image=f"quay.io/keycloak/keycloak:{request.param}") as kc: + yield kc.get_client() diff --git a/tests/integration/subcommands/__init__.py b/tests/integration/subcommands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/subcommands/test_download.py b/tests/integration/subcommands/test_download.py new file mode 100644 index 0000000..c96667c --- /dev/null +++ b/tests/integration/subcommands/test_download.py @@ -0,0 +1,31 @@ +import json +import os +from pathlib import Path + +import pytest +from keycloak import KeycloakAdmin + +from kcwarden import cli + + +@pytest.mark.integration +def test_download_config(keycloak: KeycloakAdmin, tmp_path: Path): + output_path = tmp_path / "config.json" + test_args = [ + "download", + keycloak.connection.server_url, + "--user", + keycloak.connection.username, + "--output", + str(output_path), + "--realm", + "master", + ] + os.environ["KEYCLOAK_PASSWORD"] = keycloak.connection.password + cli.main(test_args) + + with output_path.open() as f: + config = json.load(f) + + assert config["realm"] == "master" + assert len(config["clients"]) == 6 diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_data/__init__.py b/tests/utils/test_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_data/plugin1.py b/tests/utils/test_data/plugin1.py new file mode 100644 index 0000000..7c583c1 --- /dev/null +++ b/tests/utils/test_data/plugin1.py @@ -0,0 +1,30 @@ +from typing import Generator + +from kcwarden.api import Auditor +from kcwarden.custom_types.result import Result, Severity + + +class NonPlugin: + pass + + +def non_plugin(): + pass + + +class Plugin1(Auditor): + def audit(self) -> Generator[Result, None, None]: + yield self.generate_finding(self._DB.get_all_realms()[0], {}, "IMPORTANT", override_severity=Severity.Critical) + + @classmethod + def get_custom_config_template(cls) -> list[dict] | None: + return None + + +class Plugin2(Auditor): + def audit(self) -> Generator[Result, None, None]: + pass + + @classmethod + def get_custom_config_template(cls) -> list[dict] | None: + return None diff --git a/tests/utils/test_data/plugin2.py b/tests/utils/test_data/plugin2.py new file mode 100644 index 0000000..54d47d4 --- /dev/null +++ b/tests/utils/test_data/plugin2.py @@ -0,0 +1,20 @@ +from collections.abc import Generator + +from kcwarden.api import Auditor, Monitor +from kcwarden.custom_types.result import Result + + +class Plugin3(Auditor): + def audit(self) -> Generator[Result, None, None]: + pass + + @classmethod + def get_custom_config_template(cls) -> list[dict] | None: + return None + + +class Plugin4(Monitor): + CUSTOM_CONFIG_TEMPLATE = {"a": "b"} + + def audit(self) -> Generator[Result, None, None]: + pass diff --git a/tests/utils/test_plugins.py b/tests/utils/test_plugins.py new file mode 100644 index 0000000..beffd9d --- /dev/null +++ b/tests/utils/test_plugins.py @@ -0,0 +1,23 @@ +from pathlib import Path +from typing import Type + +from kcwarden.api import Monitor, Auditor +from kcwarden.utils.plugins import get_auditors + + +def test_get_auditors(): + test_data_path = Path(__file__).parent / "test_data" + result = get_auditors(test_data_path) + assert len(result) == 4 + assert_class_exits_in_list("Plugin1", result) + assert_class_exits_in_list("Plugin2", result) + assert_class_exits_in_list("Plugin3", result) + assert_class_exits_in_list("Plugin4", result) + for clz in result: + assert issubclass(clz, Auditor) + if clz.__name__ == "Plugin4": + assert issubclass(clz, Monitor) + + +def assert_class_exits_in_list(clz_name: str, class_list: list[Type]): + assert any(True for clz in class_list if clz.__name__ == clz_name)