Browse Source
Merge remote-tracking branch 'upstream/master' into feature/get-realm-roles-by-query
pull/277/head
Merge remote-tracking branch 'upstream/master' into feature/get-realm-roles-by-query
pull/277/head
No known key found for this signature in database
GPG Key ID: 6451BA63EAE5EFC8
66 changed files with 14832 additions and 3964 deletions
-
37.circleci/config.yml
-
32.github/workflows/bump.yaml
-
31.github/workflows/daily.yaml
-
102.github/workflows/lint.yaml
-
44.github/workflows/publish.yaml
-
5.gitignore
-
17.pre-commit-config.yaml
-
11.readthedocs.yaml
-
8.releaserc.json
-
472CHANGELOG.md
-
1CODEOWNERS
-
95CONTRIBUTING.md
-
2LICENSE
-
1MANIFEST.in
-
15Pipfile
-
107Pipfile.lock
-
239README.md
-
2docs/Makefile
-
1docs/source/changelog.rst
-
84docs/source/conf.py
-
304docs/source/index.rst
-
1docs/source/readme.rst
-
229keycloak/connection.py
-
2374keycloak/keycloak_admin.py
-
433keycloak/keycloak_openid.py
-
0keycloak/tests/__init__.py
-
191keycloak/tests/test_connection.py
-
2139poetry.lock
-
89pyproject.toml
-
7requirements.txt
-
2setup.cfg
-
31setup.py
-
68src/keycloak/__init__.py
-
5src/keycloak/_version.py
-
75src/keycloak/authorization/__init__.py
-
91src/keycloak/authorization/permission.py
-
112src/keycloak/authorization/policy.py
-
29src/keycloak/authorization/role.py
-
281src/keycloak/connection.py
-
102src/keycloak/exceptions.py
-
4397src/keycloak/keycloak_admin.py
-
713src/keycloak/keycloak_openid.py
-
417src/keycloak/keycloak_uma.py
-
406src/keycloak/openid_connection.py
-
276src/keycloak/uma_permissions.py
-
138src/keycloak/urls_patterns.py
-
38test_keycloak_init.sh
-
1tests/__init__.py
-
530tests/conftest.py
-
45tests/data/authz_settings.json
-
BINtests/providers/asm-7.3.1.jar
-
BINtests/providers/asm-commons-7.3.1.jar
-
BINtests/providers/asm-tree-7.3.1.jar
-
BINtests/providers/asm-util-7.3.1.jar
-
BINtests/providers/nashorn-core-15.4.jar
-
42tests/test_authorization.py
-
41tests/test_connection.py
-
20tests/test_exceptions.py
-
2760tests/test_keycloak_admin.py
-
472tests/test_keycloak_openid.py
-
311tests/test_keycloak_uma.py
-
14tests/test_license.py
-
212tests/test_uma_permissions.py
-
36tests/test_urls_patterns.py
-
4tox.env
-
54tox.ini
@ -1,37 +0,0 @@ |
|||
version: 2 |
|||
jobs: |
|||
build: |
|||
docker: |
|||
- image: circleci/python:3.6.1 |
|||
|
|||
working_directory: ~/repo |
|||
|
|||
steps: |
|||
- checkout |
|||
- restore_cache: |
|||
keys: |
|||
- v1-dependencies-{{ checksum "requirements.txt" }} |
|||
# fallback to using the latest cache if no exact match is found |
|||
- v1-dependencies- |
|||
|
|||
- run: |
|||
name: install dependencies |
|||
command: | |
|||
python3 -m venv venv |
|||
. venv/bin/activate |
|||
pip install -r requirements.txt |
|||
|
|||
- save_cache: |
|||
paths: |
|||
- ./venv |
|||
key: v1-dependencies-{{ checksum "requirements.txt" }} |
|||
|
|||
- run: |
|||
name: run tests |
|||
command: | |
|||
. venv/bin/activate |
|||
python3 -m unittest discover |
|||
|
|||
- store_artifacts: |
|||
path: test-reports |
|||
destination: test-reports |
@ -0,0 +1,32 @@ |
|||
name: Bump version |
|||
|
|||
on: |
|||
workflow_run: |
|||
workflows: ["Lint"] |
|||
branches: [master] |
|||
types: |
|||
- completed |
|||
|
|||
jobs: |
|||
tag-version: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
with: |
|||
token: ${{ secrets.PAT_TOKEN }} |
|||
- uses: actions/setup-node@v3 |
|||
with: |
|||
node-version: 18 |
|||
- name: determine-version |
|||
run: | |
|||
VERSION=$(npx semantic-release --branches master --dry-run | { grep -i 'the next release version is' || test $? = 1; } | sed -E 's/.* ([[:digit:].]+)$/\1/') |
|||
echo "VERSION=$VERSION" >> $GITHUB_ENV |
|||
id: version |
|||
- uses: rickstaa/action-create-tag@v1 |
|||
continue-on-error: true |
|||
env: |
|||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} |
|||
with: |
|||
tag: v${{ env.VERSION }} |
|||
message: "Releasing v${{ env.VERSION }}" |
|||
github_token: ${{ secrets.PAT_TOKEN }} |
@ -0,0 +1,31 @@ |
|||
name: Daily check |
|||
|
|||
on: |
|||
schedule: |
|||
- cron: "0 4 * * *" |
|||
|
|||
jobs: |
|||
test: |
|||
runs-on: ubuntu-latest |
|||
strategy: |
|||
fail-fast: false |
|||
matrix: |
|||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] |
|||
keycloak-version: ["20.0", "21.0", "latest"] |
|||
env: |
|||
KEYCLOAK_DOCKER_IMAGE_TAG: ${{ matrix.keycloak-version }} |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
- name: Set up Python ${{ matrix.python-version }} |
|||
uses: actions/setup-python@v3 |
|||
with: |
|||
python-version: ${{ matrix.python-version }} |
|||
- uses: docker-practice/actions-setup-docker@master |
|||
- name: Install dependencies |
|||
run: | |
|||
python -m pip install --upgrade pip |
|||
python -m pip install poetry |
|||
poetry install |
|||
- name: Run tests |
|||
run: | |
|||
poetry run tox -e tests |
@ -0,0 +1,102 @@ |
|||
name: Lint |
|||
|
|||
on: |
|||
push: |
|||
branches: [master] |
|||
pull_request: |
|||
branches: [master] |
|||
|
|||
jobs: |
|||
check-commits: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
- uses: webiny/action-conventional-commits@v1.0.3 |
|||
|
|||
check-linting: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
- name: Set up Python 3.10 |
|||
uses: actions/setup-python@v3 |
|||
with: |
|||
python-version: "3.10" |
|||
- name: Install dependencies |
|||
run: | |
|||
python -m pip install --upgrade pip |
|||
python -m pip install poetry |
|||
poetry install |
|||
- name: Check linting, formatting |
|||
run: | |
|||
poetry run tox -e check |
|||
|
|||
check-docs: |
|||
runs-on: ubuntu-latest |
|||
needs: |
|||
- check-commits |
|||
- check-linting |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
- name: Set up Python 3.10 |
|||
uses: actions/setup-python@v3 |
|||
with: |
|||
python-version: "3.10" |
|||
- name: Install dependencies |
|||
run: | |
|||
python -m pip install --upgrade pip |
|||
python -m pip install poetry |
|||
poetry install |
|||
- name: Check documentation build |
|||
run: | |
|||
poetry run tox -e docs |
|||
|
|||
test: |
|||
runs-on: ubuntu-latest |
|||
strategy: |
|||
fail-fast: false |
|||
matrix: |
|||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] |
|||
keycloak-version: ["20.0", "21.0", "latest"] |
|||
needs: |
|||
- check-commits |
|||
- check-linting |
|||
env: |
|||
KEYCLOAK_DOCKER_IMAGE_TAG: ${{ matrix.keycloak-version }} |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
- name: Set up Python ${{ matrix.python-version }} |
|||
uses: actions/setup-python@v3 |
|||
with: |
|||
python-version: ${{ matrix.python-version }} |
|||
- uses: docker-practice/actions-setup-docker@master |
|||
- name: Install dependencies |
|||
run: | |
|||
python -m pip install --upgrade pip |
|||
python -m pip install poetry |
|||
poetry install |
|||
- name: Run tests |
|||
run: | |
|||
poetry run tox -e tests |
|||
- name: Keycloak logs |
|||
run: | |
|||
cat keycloak_test_logs.txt |
|||
|
|||
build: |
|||
runs-on: ubuntu-latest |
|||
needs: |
|||
- test |
|||
- check-docs |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
- name: Set up Python 3.10 |
|||
uses: actions/setup-python@v3 |
|||
with: |
|||
python-version: "3.10" |
|||
- name: Install dependencies |
|||
run: | |
|||
python -m pip install --upgrade pip |
|||
python -m pip install poetry |
|||
poetry install |
|||
- name: Run build |
|||
run: | |
|||
poetry run tox -e build |
@ -0,0 +1,44 @@ |
|||
name: Publish |
|||
|
|||
on: |
|||
push: |
|||
tags: |
|||
- "v*" |
|||
|
|||
jobs: |
|||
publish: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v3 |
|||
with: |
|||
fetch-depth: "0" |
|||
- name: Set up Python 3.10 |
|||
uses: actions/setup-python@v3 |
|||
with: |
|||
python-version: "3.10" |
|||
- name: Install dependencies |
|||
run: | |
|||
python -m pip install --upgrade pip |
|||
python -m pip install poetry |
|||
poetry install |
|||
- name: Apply the tag version |
|||
run: | |
|||
version=${{ github.ref_name }} |
|||
sed -Ei '/^version = /s|= "[0-9.]+"$|= "'${version:-1}'"|' pyproject.toml |
|||
- name: Run build |
|||
run: | |
|||
poetry run tox -e build |
|||
- name: Publish to PyPi |
|||
env: |
|||
TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} |
|||
TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} |
|||
run: | |
|||
poetry run twine upload -u $TWINE_USERNAME -p $TWINE_PASSWORD dist/* |
|||
- name: Run changelog |
|||
run: | |
|||
poetry run tox -e changelog |
|||
- uses: stefanzweifel/git-auto-commit-action@v4 |
|||
with: |
|||
commit_message: "docs: changelog update" |
|||
branch: master |
|||
file_pattern: CHANGELOG.md |
@ -0,0 +1,17 @@ |
|||
# See https://pre-commit.com for more information |
|||
# See https://pre-commit.com/hooks.html for more hooks |
|||
repos: |
|||
- repo: https://github.com/pre-commit/pre-commit-hooks |
|||
rev: v3.2.0 |
|||
hooks: |
|||
- id: trailing-whitespace |
|||
- id: end-of-file-fixer |
|||
- id: check-yaml |
|||
- id: check-added-large-files |
|||
args: ["--maxkb=10000"] |
|||
- repo: https://github.com/compilerla/conventional-pre-commit |
|||
rev: v1.2.0 |
|||
hooks: |
|||
- id: conventional-pre-commit |
|||
stages: [commit-msg] |
|||
args: [] # optional: list of Conventional Commits types to allow |
@ -0,0 +1,11 @@ |
|||
version: 2 |
|||
|
|||
build: |
|||
os: "ubuntu-20.04" |
|||
tools: |
|||
python: "3.10" |
|||
jobs: |
|||
post_install: |
|||
- pip install -U poetry |
|||
- poetry config virtualenvs.create false |
|||
- poetry install -E docs |
@ -0,0 +1,8 @@ |
|||
{ |
|||
"plugins": ["@semantic-release/commit-analyzer"], |
|||
"verifyConditions": false, |
|||
"npmPublish": false, |
|||
"publish": false, |
|||
"fail": false, |
|||
"success": false |
|||
} |
@ -1,45 +1,465 @@ |
|||
Changelog |
|||
============ |
|||
## v3.3.0 (2023-06-28) |
|||
|
|||
All notable changes to this project will be documented in this file. |
|||
### Feat |
|||
|
|||
## [0.5.0] - 2017-08-21 |
|||
- added KeycloakAdmin.update_client_authz_resource() (#462) |
|||
|
|||
* Basic functions for Keycloak API (well_know, token, userinfo, logout, certs, |
|||
entitlement, instropect) |
|||
## v3.2.0 (2023-06-23) |
|||
|
|||
## [0.6.0] - 2017-08-23 |
|||
### Feat |
|||
|
|||
* Added load authorization settings |
|||
- Implement missing admin method create_client_authz_scope_based_permission() and create_client_authz_policy() (#460) |
|||
|
|||
## [0.7.0] - 2017-08-23 |
|||
## v3.1.1 (2023-06-23) |
|||
|
|||
* Added polices |
|||
### Fix |
|||
|
|||
## [0.8.0] - 2017-08-23 |
|||
- remove duplicate slash in URL_ADMIN_IDP (#459) |
|||
|
|||
* Added permissions |
|||
## v3.1.0 (2023-06-23) |
|||
|
|||
## [0.9.0] - 2017-09-05 |
|||
### Feat |
|||
|
|||
* Added functions for Admin Keycloak API |
|||
- Add query to get users group method and permit pagination (#444) |
|||
|
|||
## [0.10.0] - 2017-10-23 |
|||
## v3.0.0 (2023-05-28) |
|||
|
|||
* Updated libraries versions |
|||
* Updated Docs |
|||
### BREAKING CHANGE |
|||
|
|||
## [0.11.0] - 2017-12-12 |
|||
- Changes the exchange token API |
|||
|
|||
* Changed Instropect RPT |
|||
### Refactor |
|||
|
|||
## [0.12.0] - 2018-01-25 |
|||
- Exchange token method |
|||
|
|||
* Add groups functions |
|||
* Add Admin Tasks for user and client role management |
|||
* Function to trigger user sync from provider |
|||
## v2.16.6 (2023-05-28) |
|||
|
|||
## [0.12.1] - 2018-08-04 |
|||
### Fix |
|||
|
|||
* Add get_idps |
|||
* Rework group functions |
|||
- relax the version constraints |
|||
|
|||
## v2.16.5 (2023-05-28) |
|||
|
|||
### Fix |
|||
|
|||
- do not swap realm for user_realm when logging in with a client service account (#447) |
|||
|
|||
## v2.16.4 (2023-05-28) |
|||
|
|||
### Perf |
|||
|
|||
- improve performance of get_user_id (#449) |
|||
|
|||
## v2.16.3 (2023-05-15) |
|||
|
|||
### Fix |
|||
|
|||
- Fixes `Authorization.load_config` breaking if a scope based permission is linked with anything other than a role based policy. Fixes #445 (#446) |
|||
|
|||
## v2.16.2 (2023-05-09) |
|||
|
|||
### Fix |
|||
|
|||
- issue with app engine reported in #440 (#442) |
|||
|
|||
## v2.16.1 (2023-05-01) |
|||
|
|||
### Fix |
|||
|
|||
- Initializing KeycloakAdmin without server_url (#439) |
|||
|
|||
## v2.16.0 (2023-04-28) |
|||
|
|||
### Feat |
|||
|
|||
- Add get and delete methods for client authz resources (#435) |
|||
|
|||
## v2.15.4 (2023-04-28) |
|||
|
|||
### Fix |
|||
|
|||
- **pyproject.toml**: loose requests pgk and remove urllib3 as dependency (#434) |
|||
|
|||
## v2.15.3 (2023-04-06) |
|||
|
|||
### Fix |
|||
|
|||
- Check if _s exists in ConnectionManager before deleting it (#429) |
|||
|
|||
## v2.15.2 (2023-04-05) |
|||
|
|||
### Fix |
|||
|
|||
- deprecation warnings in keycloak_admin.py (#425) |
|||
|
|||
## v2.15.1 (2023-04-05) |
|||
|
|||
### Fix |
|||
|
|||
- improved type-hints (#427) |
|||
|
|||
## v2.15.0 (2023-04-05) |
|||
|
|||
### Feat |
|||
|
|||
- Add UMA policy management and permission tickets (#426) |
|||
|
|||
## v2.14.0 (2023-03-17) |
|||
|
|||
### Feat |
|||
|
|||
- add initial access token support and policy delete method |
|||
|
|||
## v2.13.2 (2023-03-06) |
|||
|
|||
### Fix |
|||
|
|||
- Refactor auto refresh (#415) |
|||
|
|||
## v2.13.1 (2023-03-05) |
|||
|
|||
### Fix |
|||
|
|||
- Check if applyPolicies exists in the config (#367) |
|||
|
|||
## v2.13.0 (2023-03-05) |
|||
|
|||
### Feat |
|||
|
|||
- implement cache clearing API (#414) |
|||
|
|||
## v2.12.2 (2023-03-05) |
|||
|
|||
### Fix |
|||
|
|||
- get_group_by_path uses Keycloak API to load (#417) |
|||
|
|||
## v2.12.1 (2023-03-05) |
|||
|
|||
### Fix |
|||
|
|||
- tests and upgraded deps (#419) |
|||
|
|||
## v2.12.0 (2023-02-10) |
|||
|
|||
### Feat |
|||
|
|||
- add Keycloak UMA client (#403) |
|||
|
|||
## v2.11.1 (2023-02-08) |
|||
|
|||
### Fix |
|||
|
|||
- do not include CODEOWNERS (#407) |
|||
|
|||
## v2.11.0 (2023-02-08) |
|||
|
|||
### Feat |
|||
|
|||
- Add Client Scopes of Client |
|||
|
|||
## v2.10.0 (2023-02-08) |
|||
|
|||
### Feat |
|||
|
|||
- update header if token is given |
|||
- init KeycloakAdmin with token |
|||
|
|||
## v2.9.0 (2023-01-11) |
|||
|
|||
### Feat |
|||
|
|||
- added default realm roles handlers |
|||
|
|||
## v2.8.0 (2022-12-29) |
|||
|
|||
### Feat |
|||
|
|||
- **api**: add tests for create_authz_scopes |
|||
|
|||
### Fix |
|||
|
|||
- fix testing create_client_authz_scopes parameters |
|||
- fix linting |
|||
- add testcase for invalid client id |
|||
- create authz clients test case |
|||
- create authz clients test case |
|||
|
|||
## v2.7.0 (2022-12-24) |
|||
|
|||
### Refactor |
|||
|
|||
- code formatting after tox checks |
|||
- remove print statements |
|||
|
|||
## v2.6.1 (2022-12-13) |
|||
|
|||
### Feat |
|||
|
|||
- option for enabling users |
|||
- helping functions for disabling users |
|||
|
|||
### Fix |
|||
|
|||
- use version from the package |
|||
- default scope to openid |
|||
|
|||
## v2.6.0 (2022-10-03) |
|||
|
|||
### Feat |
|||
|
|||
- attack detection API implementation |
|||
|
|||
## v2.5.0 (2022-08-19) |
|||
|
|||
### Feat |
|||
|
|||
- added missing functionality to include attributes when returning realm roles according to specifications |
|||
|
|||
## v2.4.0 (2022-08-19) |
|||
|
|||
### Feat |
|||
|
|||
- add client scope-mappings client roles operations |
|||
|
|||
## v2.3.0 (2022-08-13) |
|||
|
|||
### Feat |
|||
|
|||
- Add token_type/scope to token exchange api |
|||
|
|||
## v2.2.0 (2022-08-12) |
|||
|
|||
### Feat |
|||
|
|||
- add client scope-mappings realm roles operations |
|||
|
|||
## v2.1.1 (2022-07-19) |
|||
|
|||
### Fix |
|||
|
|||
- removed whitespace from urls |
|||
|
|||
### Refactor |
|||
|
|||
- applied linting |
|||
|
|||
## v2.1.0 (2022-07-18) |
|||
|
|||
### Feat |
|||
|
|||
- add unit tests |
|||
- add docstrings |
|||
- add functions covering some missing REST API calls |
|||
|
|||
### Fix |
|||
|
|||
- linting |
|||
- now get_required_action_by_alias now returns None if action does not exist |
|||
- moved imports at the top of the file |
|||
- remove duplicate function |
|||
- applied tox -e docs |
|||
- applied flake linting checks |
|||
- applied tox linting check |
|||
|
|||
## v2.0.0 (2022-07-17) |
|||
|
|||
### BREAKING CHANGE |
|||
|
|||
- Renamed parameter client_name to client_id in get_client_id method |
|||
|
|||
### Fix |
|||
|
|||
- check client existence based on clientId |
|||
|
|||
## v1.9.1 (2022-07-13) |
|||
|
|||
### Fix |
|||
|
|||
- turn get_name into a method, use setters in connection manager |
|||
|
|||
### Refactor |
|||
|
|||
- no need to try if the type check is performed |
|||
|
|||
## v1.9.0 (2022-07-13) |
|||
|
|||
### Refactor |
|||
|
|||
- merge master branch into local |
|||
|
|||
## v1.8.1 (2022-07-13) |
|||
|
|||
### Feat |
|||
|
|||
- added flake8-docstrings and upgraded dependencies |
|||
|
|||
### Fix |
|||
|
|||
- Support the auth_url method called with scope & state params now |
|||
- raise correct exceptions |
|||
|
|||
### Refactor |
|||
|
|||
- slight restructure of the base fixtures |
|||
|
|||
## v1.8.0 (2022-06-22) |
|||
|
|||
### Feat |
|||
|
|||
- Ability to set custom timeout for KCOpenId and KCAdmin |
|||
|
|||
## v1.7.0 (2022-06-16) |
|||
|
|||
### Feat |
|||
|
|||
- Allow fetching existing policies before calling create_client_authz_client_policy() |
|||
|
|||
## v1.6.0 (2022-06-13) |
|||
|
|||
### Feat |
|||
|
|||
- support token exchange config via admin API |
|||
|
|||
## v1.5.0 (2022-06-03) |
|||
|
|||
### Feat |
|||
|
|||
- Add update_idp |
|||
|
|||
## v1.4.0 (2022-06-02) |
|||
|
|||
### Feat |
|||
|
|||
- Add update_mapper_in_idp |
|||
|
|||
## v1.3.0 (2022-05-31) |
|||
|
|||
## v1.2.0 (2022-05-31) |
|||
|
|||
### Feat |
|||
|
|||
- Support Token Exchange. Fixes #305 |
|||
- Add get_idp_mappers, fix #329 |
|||
|
|||
## v1.1.1 (2022-05-27) |
|||
|
|||
### Fix |
|||
|
|||
- fixed bugs in events methods |
|||
- fixed components bugs |
|||
- use param for update client mapper |
|||
|
|||
## v1.1.0 (2022-05-26) |
|||
|
|||
### Feat |
|||
|
|||
- added new methods for client scopes |
|||
|
|||
## v1.0.1 (2022-05-25) |
|||
|
|||
### Fix |
|||
|
|||
- allow query parameters for users count |
|||
|
|||
## v1.0.0 (2022-05-25) |
|||
|
|||
### BREAKING CHANGE |
|||
|
|||
- Renames `KeycloakOpenID.well_know` to `KeycloakOpenID.well_known` |
|||
|
|||
### Fix |
|||
|
|||
- correct spelling of public API method |
|||
|
|||
## v0.29.1 (2022-05-24) |
|||
|
|||
### Fix |
|||
|
|||
- allow client_credentials token if username and password not specified |
|||
|
|||
## v0.29.0 (2022-05-23) |
|||
|
|||
### Feat |
|||
|
|||
- added UMA-permission request functionality |
|||
|
|||
### Fix |
|||
|
|||
- added fixes based on feedback |
|||
|
|||
## v0.28.3 (2022-05-23) |
|||
|
|||
### Fix |
|||
|
|||
- import classes in the base module |
|||
|
|||
## v0.28.2 (2022-05-19) |
|||
|
|||
### Fix |
|||
|
|||
- escape when get role fails |
|||
|
|||
## v0.28.1 (2022-05-19) |
|||
|
|||
### Fix |
|||
|
|||
- Add missing keycloak.authorization package |
|||
|
|||
## v0.28.0 (2022-05-19) |
|||
|
|||
### Feat |
|||
|
|||
- added authenticator providers getters |
|||
- fixed admin client to pass the tests |
|||
- initial setup of CICD and linting |
|||
|
|||
### Fix |
|||
|
|||
- full tox fix ready |
|||
- raise correct errors |
|||
|
|||
### Refactor |
|||
|
|||
- isort conf.py |
|||
- Merge branch 'master' into feature/cicd |
|||
|
|||
## v0.27.1 (2022-05-18) |
|||
|
|||
### Fix |
|||
|
|||
- **release**: version bumps for hotfix release |
|||
|
|||
## v0.27.0 (2022-02-16) |
|||
|
|||
### Fix |
|||
|
|||
- handle refresh_token error "Session not active" |
|||
|
|||
## v0.26.1 (2021-08-30) |
|||
|
|||
### Feat |
|||
|
|||
- add KeycloakAdmin.set_events |
|||
|
|||
## v0.25.0 (2021-05-05) |
|||
|
|||
## v0.24.0 (2020-12-18) |
|||
|
|||
## 0.23.0 (2020-11-19) |
|||
|
|||
## v0.22.0 (2020-08-16) |
|||
|
|||
## v0.21.0 (2020-06-30) |
|||
|
|||
### Feat |
|||
|
|||
- add components |
|||
|
|||
## v0.20.0 (2020-04-11) |
|||
|
|||
## v0.19.0 (2020-02-18) |
|||
|
|||
## v0.18.0 (2019-12-10) |
|||
|
|||
## v0.17.6 (2019-10-10) |
@ -0,0 +1 @@ |
|||
* @ryshoooo @marcospereirampj |
@ -0,0 +1,95 @@ |
|||
# Contributing |
|||
|
|||
Welcome to the Python Keycloak contributing guidelines. We are all more than happy to receive |
|||
any contributions to the repository and want to thank you in advance for your contributions! |
|||
This document outlines the process and the guidelines on how contributions work for this repository. |
|||
|
|||
## Setting up the dev environment |
|||
|
|||
The development environment is mainly up to the developer. Our recommendations are to create a python |
|||
virtual environment and install the necessary requirements. Example |
|||
|
|||
```sh |
|||
# Install and upgrade pip & poetry |
|||
python -m pip install --upgrade pip poetry |
|||
|
|||
# Create virtualenv |
|||
python -m poetry env use <PATH_TO_PYTHON_VERSION> |
|||
|
|||
# install package dependencies including dev dependencies |
|||
python -m poetry install |
|||
|
|||
# Activate virtualenv |
|||
python -m poetry shell |
|||
``` |
|||
|
|||
## Running checks and tests |
|||
|
|||
We're utilizing `tox` for most of the testing workflows. However we also have an external dependency on `docker`. |
|||
We're using docker to spin up a local keycloak instance which we run our test cases against. This is to avoid |
|||
a lot of unnecessary mocking and yet have immediate feedback from the actual Keycloak instance. All of the setup |
|||
is done for you with the tox environments, all you need is to have both tox and docker installed |
|||
(`tox` is included in the `dev-requirements.txt`). |
|||
|
|||
To run the unit tests, simply run |
|||
|
|||
```sh |
|||
tox -e tests |
|||
``` |
|||
|
|||
The project is also adhering to strict linting (flake8) and formatting (black + isort). You can always check that |
|||
your code changes adhere to the format by running |
|||
|
|||
```sh |
|||
tox -e check |
|||
``` |
|||
|
|||
If the check fails, you'll see an error message specifying what went wrong. To simplify things, you can also run |
|||
|
|||
```sh |
|||
tox -e apply-check |
|||
``` |
|||
|
|||
which will apply isort and black formatting for you in the repository. The flake8 problems however need to be resolved |
|||
manually by the developer. |
|||
|
|||
Additionally we require that the documentation pages are built without warnings. This check is also run via tox, using |
|||
the command |
|||
|
|||
```sh |
|||
tox -e docs |
|||
``` |
|||
|
|||
The check is also run in the CICD pipelines. We require that the documentation pages built from the code docstrings |
|||
do not create visually "bad" pages. |
|||
|
|||
## Conventional commits |
|||
|
|||
Commits to this project must adhere to the [Conventional Commits |
|||
specification](https://www.conventionalcommits.org/en/v1.0.0/) that will allow |
|||
us to automate version bumps and changelog entry creation. |
|||
|
|||
After cloning this repository, you must install the pre-commit hook for |
|||
conventional commits (this is included in the `dev-requirements.txt`) |
|||
|
|||
```sh |
|||
# Create virtualenv |
|||
python -m poetry env use <PATH_TO_PYTHON_VERSION> |
|||
|
|||
# Activate virtualenv |
|||
python -m poetry shell |
|||
|
|||
pre-commit install --install-hooks -t pre-commit -t pre-push -t commit-msg |
|||
``` |
|||
|
|||
## How to contribute |
|||
|
|||
1. Fork this repository, develop and test your changes |
|||
2. Make sure that your changes do not decrease the test coverage |
|||
3. Make sure you're commits follow the conventional commits |
|||
4. Submit a pull request |
|||
|
|||
## How to release |
|||
|
|||
The CICD pipelines are set up for the repository. When a PR is merged, a new version of the library |
|||
will be automatically deployed to the PyPi server, meaning you'll be able to see your changes immediately. |
@ -1 +0,0 @@ |
|||
include LICENSE |
@ -1,15 +0,0 @@ |
|||
[[source]] |
|||
url = "https://pypi.org/simple" |
|||
verify_ssl = true |
|||
name = "pypi" |
|||
|
|||
[packages] |
|||
requests = ">=2.20.0" |
|||
httmock = ">=1.2.5" |
|||
python-jose = ">=1.4.0" |
|||
urllib3 = ">=1.26.5" |
|||
|
|||
[dev-packages] |
|||
|
|||
[requires] |
|||
python_version = "3.7" |
@ -1,107 +0,0 @@ |
|||
{ |
|||
"_meta": { |
|||
"hash": { |
|||
"sha256": "8c12705e89c665da92fc69ef0d312a9ca313703c839c15d18fcc833dcb87d7f7" |
|||
}, |
|||
"pipfile-spec": 6, |
|||
"requires": { |
|||
"python_version": "3.7" |
|||
}, |
|||
"sources": [ |
|||
{ |
|||
"name": "pypi", |
|||
"url": "https://pypi.org/simple", |
|||
"verify_ssl": true |
|||
} |
|||
] |
|||
}, |
|||
"default": { |
|||
"certifi": { |
|||
"hashes": [ |
|||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", |
|||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" |
|||
], |
|||
"version": "==2020.12.5" |
|||
}, |
|||
"chardet": { |
|||
"hashes": [ |
|||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", |
|||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" |
|||
], |
|||
"version": "==3.0.4" |
|||
}, |
|||
"ecdsa": { |
|||
"hashes": [ |
|||
"sha256:881fa5e12bb992972d3d1b3d4dfbe149ab76a89f13da02daa5ea1ec7dea6e747", |
|||
"sha256:cfc046a2ddd425adbd1a78b3c46f0d1325c657811c0f45ecc3a0a6236c1e50ff" |
|||
], |
|||
"version": "==0.16.1" |
|||
}, |
|||
"future": { |
|||
"hashes": [ |
|||
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" |
|||
], |
|||
"version": "==0.18.2" |
|||
}, |
|||
"httmock": { |
|||
"hashes": [ |
|||
"sha256:4696306d1ff835c3ca865fdef2684d7e130b4120cc00126f862ba4797b1602ac" |
|||
], |
|||
"index": "pypi", |
|||
"version": "==1.2.6" |
|||
}, |
|||
"idna": { |
|||
"hashes": [ |
|||
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", |
|||
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" |
|||
], |
|||
"version": "==2.7" |
|||
}, |
|||
"pyasn1": { |
|||
"hashes": [ |
|||
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", |
|||
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" |
|||
], |
|||
"version": "==0.4.8" |
|||
}, |
|||
"python-jose": { |
|||
"hashes": [ |
|||
"sha256:29701d998fe560e52f17246c3213a882a4a39da7e42c7015bcc1f7823ceaff1c", |
|||
"sha256:ed7387f0f9af2ea0ddc441d83a6eb47a5909bd0c8a72ac3250e75afec2cc1371" |
|||
], |
|||
"index": "pypi", |
|||
"version": "==3.0.1" |
|||
}, |
|||
"requests": { |
|||
"hashes": [ |
|||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", |
|||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" |
|||
], |
|||
"index": "pypi", |
|||
"version": "==2.19.1" |
|||
}, |
|||
"rsa": { |
|||
"hashes": [ |
|||
"sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4", |
|||
"sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b" |
|||
], |
|||
"index": "pypi", |
|||
"version": "==4.7" |
|||
}, |
|||
"six": { |
|||
"hashes": [ |
|||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", |
|||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" |
|||
], |
|||
"version": "==1.15.0" |
|||
}, |
|||
"urllib3": { |
|||
"hashes": [ |
|||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", |
|||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" |
|||
], |
|||
"version": "==1.23" |
|||
} |
|||
}, |
|||
"develop": {} |
|||
} |
@ -0,0 +1 @@ |
|||
.. mdinclude:: ../../CHANGELOG.md |
@ -0,0 +1 @@ |
|||
.. mdinclude:: ../../README.md |
@ -1,229 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
try: |
|||
from urllib.parse import urljoin |
|||
except ImportError: |
|||
from urlparse import urljoin |
|||
|
|||
import requests |
|||
from requests.adapters import HTTPAdapter |
|||
|
|||
from .exceptions import (KeycloakConnectionError) |
|||
|
|||
|
|||
class ConnectionManager(object): |
|||
""" Represents a simple server connection. |
|||
Args: |
|||
base_url (str): The server URL. |
|||
headers (dict): The header parameters of the requests to the server. |
|||
timeout (int): Timeout to use for requests to the server. |
|||
verify (bool): Verify server SSL. |
|||
proxies (dict): The proxies servers requests is sent by. |
|||
""" |
|||
|
|||
def __init__(self, base_url, headers={}, timeout=60, verify=True, proxies=None): |
|||
self._base_url = base_url |
|||
self._headers = headers |
|||
self._timeout = timeout |
|||
self._verify = verify |
|||
self._s = requests.Session() |
|||
self._s.auth = lambda x: x # don't let requests add auth headers |
|||
|
|||
# retry once to reset connection with Keycloak after tomcat's ConnectionTimeout |
|||
# see https://github.com/marcospereirampj/python-keycloak/issues/36 |
|||
for protocol in ('https://', 'http://'): |
|||
adapter = HTTPAdapter(max_retries=1) |
|||
# adds POST to retry whitelist |
|||
allowed_methods = set(adapter.max_retries.allowed_methods) |
|||
allowed_methods.add('POST') |
|||
adapter.max_retries.allowed_methods = frozenset(allowed_methods) |
|||
|
|||
self._s.mount(protocol, adapter) |
|||
|
|||
if proxies: |
|||
self._s.proxies.update(proxies) |
|||
|
|||
def __del__(self): |
|||
self._s.close() |
|||
|
|||
@property |
|||
def base_url(self): |
|||
""" Return base url in use for requests to the server. """ |
|||
return self._base_url |
|||
|
|||
@base_url.setter |
|||
def base_url(self, value): |
|||
""" """ |
|||
self._base_url = value |
|||
|
|||
@property |
|||
def timeout(self): |
|||
""" Return timeout in use for request to the server. """ |
|||
return self._timeout |
|||
|
|||
@timeout.setter |
|||
def timeout(self, value): |
|||
""" """ |
|||
self._timeout = value |
|||
|
|||
@property |
|||
def verify(self): |
|||
""" Return verify in use for request to the server. """ |
|||
return self._verify |
|||
|
|||
@verify.setter |
|||
def verify(self, value): |
|||
""" """ |
|||
self._verify = value |
|||
|
|||
@property |
|||
def headers(self): |
|||
""" Return header request to the server. """ |
|||
return self._headers |
|||
|
|||
@headers.setter |
|||
def headers(self, value): |
|||
""" """ |
|||
self._headers = value |
|||
|
|||
def param_headers(self, key): |
|||
""" Return a specific header parameter. |
|||
:arg |
|||
key (str): Header parameters key. |
|||
:return: |
|||
If the header parameters exist, return its value. |
|||
""" |
|||
return self.headers.get(key) |
|||
|
|||
def clean_headers(self): |
|||
""" Clear header parameters. """ |
|||
self.headers = {} |
|||
|
|||
def exist_param_headers(self, key): |
|||
""" Check if the parameter exists in the header. |
|||
:arg |
|||
key (str): Header parameters key. |
|||
:return: |
|||
If the header parameters exist, return True. |
|||
""" |
|||
return self.param_headers(key) is not None |
|||
|
|||
def add_param_headers(self, key, value): |
|||
""" Add a single parameter inside the header. |
|||
:arg |
|||
key (str): Header parameters key. |
|||
value (str): Value to be added. |
|||
""" |
|||
self.headers[key] = value |
|||
|
|||
def del_param_headers(self, key): |
|||
""" Remove a specific parameter. |
|||
:arg |
|||
key (str): Key of the header parameters. |
|||
""" |
|||
self.headers.pop(key, None) |
|||
|
|||
def raw_get(self, path, **kwargs): |
|||
""" Submit get request to the path. |
|||
:arg |
|||
path (str): Path for request. |
|||
:return |
|||
Response the request. |
|||
:exception |
|||
HttpError: Can't connect to server. |
|||
""" |
|||
|
|||
try: |
|||
return self._s.get(urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError( |
|||
"Can't connect to server (%s)" % e) |
|||
|
|||
def raw_post(self, path, data, **kwargs): |
|||
""" Submit post request to the path. |
|||
:arg |
|||
path (str): Path for request. |
|||
data (dict): Payload for request. |
|||
:return |
|||
Response the request. |
|||
:exception |
|||
HttpError: Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.post(urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
data=data, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError( |
|||
"Can't connect to server (%s)" % e) |
|||
|
|||
def raw_put(self, path, data, **kwargs): |
|||
""" Submit put request to the path. |
|||
:arg |
|||
path (str): Path for request. |
|||
data (dict): Payload for request. |
|||
:return |
|||
Response the request. |
|||
:exception |
|||
HttpError: Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.put(urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
data=data, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError( |
|||
"Can't connect to server (%s)" % e) |
|||
|
|||
def raw_delete(self, path, data={}, **kwargs): |
|||
""" Submit delete request to the path. |
|||
|
|||
:arg |
|||
path (str): Path for request. |
|||
data (dict): Payload for request. |
|||
:return |
|||
Response the request. |
|||
:exception |
|||
HttpError: Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.delete(urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
data=data, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError( |
|||
"Can't connect to server (%s)" % e) |
2374
keycloak/keycloak_admin.py
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,433 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
import json |
|||
|
|||
from jose import jwt |
|||
|
|||
from .authorization import Authorization |
|||
from .connection import ConnectionManager |
|||
from .exceptions import raise_error_from_response, KeycloakGetError, \ |
|||
KeycloakRPTNotFound, KeycloakAuthorizationConfigError, KeycloakInvalidTokenError, KeycloakDeprecationError |
|||
from .urls_patterns import ( |
|||
URL_REALM, |
|||
URL_AUTH, |
|||
URL_TOKEN, |
|||
URL_USERINFO, |
|||
URL_WELL_KNOWN, |
|||
URL_LOGOUT, |
|||
URL_CERTS, |
|||
URL_ENTITLEMENT, |
|||
URL_INTROSPECT |
|||
) |
|||
|
|||
|
|||
class KeycloakOpenID: |
|||
|
|||
def __init__(self, server_url, realm_name, client_id, client_secret_key=None, verify=True, custom_headers=None, proxies=None): |
|||
""" |
|||
|
|||
:param server_url: Keycloak server url |
|||
:param client_id: client id |
|||
:param realm_name: realm name |
|||
:param client_secret_key: client secret key |
|||
:param verify: True if want check connection SSL |
|||
:param custom_headers: dict of custom header to pass to each HTML request |
|||
:param proxies: dict of proxies to sent the request by. |
|||
""" |
|||
self._client_id = client_id |
|||
self._client_secret_key = client_secret_key |
|||
self._realm_name = realm_name |
|||
headers = dict() |
|||
if custom_headers is not None: |
|||
# merge custom headers to main headers |
|||
headers.update(custom_headers) |
|||
self._connection = ConnectionManager(base_url=server_url, |
|||
headers=headers, |
|||
timeout=60, |
|||
verify=verify, |
|||
proxies=proxies) |
|||
|
|||
self._authorization = Authorization() |
|||
|
|||
@property |
|||
def client_id(self): |
|||
return self._client_id |
|||
|
|||
@client_id.setter |
|||
def client_id(self, value): |
|||
self._client_id = value |
|||
|
|||
@property |
|||
def client_secret_key(self): |
|||
return self._client_secret_key |
|||
|
|||
@client_secret_key.setter |
|||
def client_secret_key(self, value): |
|||
self._client_secret_key = value |
|||
|
|||
@property |
|||
def realm_name(self): |
|||
return self._realm_name |
|||
|
|||
@realm_name.setter |
|||
def realm_name(self, value): |
|||
self._realm_name = value |
|||
|
|||
@property |
|||
def connection(self): |
|||
return self._connection |
|||
|
|||
@connection.setter |
|||
def connection(self, value): |
|||
self._connection = value |
|||
|
|||
@property |
|||
def authorization(self): |
|||
return self._authorization |
|||
|
|||
@authorization.setter |
|||
def authorization(self, value): |
|||
self._authorization = value |
|||
|
|||
def _add_secret_key(self, payload): |
|||
""" |
|||
Add secret key if exist. |
|||
|
|||
:param payload: |
|||
:return: |
|||
""" |
|||
if self.client_secret_key: |
|||
payload.update({"client_secret": self.client_secret_key}) |
|||
|
|||
return payload |
|||
|
|||
def _build_name_role(self, role): |
|||
""" |
|||
|
|||
:param role: |
|||
:return: |
|||
""" |
|||
return self.client_id + "/" + role |
|||
|
|||
def _token_info(self, token, method_token_info, **kwargs): |
|||
""" |
|||
|
|||
:param token: |
|||
:param method_token_info: |
|||
:param kwargs: |
|||
:return: |
|||
""" |
|||
if method_token_info == 'introspect': |
|||
token_info = self.introspect(token) |
|||
else: |
|||
token_info = self.decode_token(token, **kwargs) |
|||
|
|||
return token_info |
|||
|
|||
def well_know(self): |
|||
""" The most important endpoint to understand is the well-known configuration |
|||
endpoint. It lists endpoints and other configuration options relevant to |
|||
the OpenID Connect implementation in Keycloak. |
|||
|
|||
:return It lists endpoints and other configuration options relevant. |
|||
""" |
|||
|
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path)) |
|||
|
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def auth_url(self, redirect_uri): |
|||
""" |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint |
|||
|
|||
:return: |
|||
""" |
|||
params_path = {"authorization-endpoint": self.well_know()['authorization_endpoint'], |
|||
"client-id": self.client_id, |
|||
"redirect-uri": redirect_uri} |
|||
return URL_AUTH.format(**params_path) |
|||
|
|||
def token(self, username="", password="", grant_type=["password"], code="", redirect_uri="", totp=None, **extra): |
|||
""" |
|||
The token endpoint is used to obtain tokens. Tokens can either be obtained by |
|||
exchanging an authorization code or by supplying credentials directly depending on |
|||
what flow is used. The token endpoint is also used to obtain new access tokens |
|||
when they expire. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint |
|||
|
|||
:param username: |
|||
:param password: |
|||
:param grant_type: |
|||
:param code: |
|||
:param redirect_uri |
|||
:param totp |
|||
:return: |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = {"username": username, "password": password, |
|||
"client_id": self.client_id, "grant_type": grant_type, |
|||
"code": code, "redirect_uri": redirect_uri} |
|||
if extra: |
|||
payload.update(extra) |
|||
|
|||
if totp: |
|||
payload["totp"] = totp |
|||
|
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), |
|||
data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def refresh_token(self, refresh_token, grant_type=["refresh_token"]): |
|||
""" |
|||
The token endpoint is used to obtain tokens. Tokens can either be obtained by |
|||
exchanging an authorization code or by supplying credentials directly depending on |
|||
what flow is used. The token endpoint is also used to obtain new access tokens |
|||
when they expire. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint |
|||
|
|||
:param refresh_token: |
|||
:param grant_type: |
|||
:return: |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = {"client_id": self.client_id, "grant_type": grant_type, "refresh_token": refresh_token} |
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), |
|||
data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def userinfo(self, token): |
|||
""" |
|||
The userinfo endpoint returns standard claims about the authenticated user, |
|||
and is protected by a bearer token. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#UserInfo |
|||
|
|||
:param token: |
|||
:return: |
|||
""" |
|||
|
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
params_path = {"realm-name": self.realm_name} |
|||
|
|||
data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path)) |
|||
|
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def logout(self, refresh_token): |
|||
""" |
|||
The logout endpoint logs out the authenticated user. |
|||
:param refresh_token: |
|||
:return: |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = {"client_id": self.client_id, "refresh_token": refresh_token} |
|||
|
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), |
|||
data=payload) |
|||
|
|||
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) |
|||
|
|||
def certs(self): |
|||
""" |
|||
The certificate endpoint returns the public keys enabled by the realm, encoded as a |
|||
JSON Web Key (JWK). Depending on the realm settings there can be one or more keys enabled |
|||
for verifying tokens. |
|||
|
|||
https://tools.ietf.org/html/rfc7517 |
|||
|
|||
:return: |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def public_key(self): |
|||
""" |
|||
The public key is exposed by the realm page directly. |
|||
|
|||
:return: |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_REALM.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError)['public_key'] |
|||
|
|||
|
|||
def entitlement(self, token, resource_server_id): |
|||
""" |
|||
Client applications can use a specific endpoint to obtain a special security token |
|||
called a requesting party token (RPT). This token consists of all the entitlements |
|||
(or permissions) for a user as a result of the evaluation of the permissions and authorization |
|||
policies associated with the resources being requested. With an RPT, client applications can |
|||
gain access to protected resources at the resource server. |
|||
|
|||
:return: |
|||
""" |
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} |
|||
data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) |
|||
|
|||
if data_raw.status_code == 404: |
|||
return raise_error_from_response(data_raw, KeycloakDeprecationError) |
|||
|
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def introspect(self, token, rpt=None, token_type_hint=None): |
|||
""" |
|||
The introspection endpoint is used to retrieve the active state of a token. It is can only be |
|||
invoked by confidential clients. |
|||
|
|||
https://tools.ietf.org/html/rfc7662 |
|||
|
|||
:param token: |
|||
:param rpt: |
|||
:param token_type_hint: |
|||
|
|||
:return: |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
|
|||
payload = {"client_id": self.client_id, "token": token} |
|||
|
|||
if token_type_hint == 'requesting_party_token': |
|||
if rpt: |
|||
payload.update({"token": rpt, "token_type_hint": token_type_hint}) |
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
else: |
|||
raise KeycloakRPTNotFound("Can't found RPT.") |
|||
|
|||
payload = self._add_secret_key(payload) |
|||
|
|||
data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), |
|||
data=payload) |
|||
|
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def decode_token(self, token, key, algorithms=['RS256'], **kwargs): |
|||
""" |
|||
A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data |
|||
structure that represents a cryptographic key. This specification |
|||
also defines a JWK Set JSON data structure that represents a set of |
|||
JWKs. Cryptographic algorithms and identifiers for use with this |
|||
specification are described in the separate JSON Web Algorithms (JWA) |
|||
specification and IANA registries established by that specification. |
|||
|
|||
https://tools.ietf.org/html/rfc7517 |
|||
|
|||
:param token: |
|||
:param key: |
|||
:param algorithms: |
|||
:return: |
|||
""" |
|||
|
|||
return jwt.decode(token, key, algorithms=algorithms, |
|||
audience=self.client_id, **kwargs) |
|||
|
|||
def load_authorization_config(self, path): |
|||
""" |
|||
Load Keycloak settings (authorization) |
|||
|
|||
:param path: settings file (json) |
|||
:return: |
|||
""" |
|||
authorization_file = open(path, 'r') |
|||
authorization_json = json.loads(authorization_file.read()) |
|||
self.authorization.load_config(authorization_json) |
|||
authorization_file.close() |
|||
|
|||
def get_policies(self, token, method_token_info='introspect', **kwargs): |
|||
""" |
|||
Get policies by user token |
|||
|
|||
:param token: user token |
|||
:return: policies list |
|||
""" |
|||
|
|||
if not self.authorization.policies: |
|||
raise KeycloakAuthorizationConfigError( |
|||
"Keycloak settings not found. Load Authorization Keycloak settings." |
|||
) |
|||
|
|||
token_info = self._token_info(token, method_token_info, **kwargs) |
|||
|
|||
if method_token_info == 'introspect' and not token_info['active']: |
|||
raise KeycloakInvalidTokenError( |
|||
"Token expired or invalid." |
|||
) |
|||
|
|||
user_resources = token_info['resource_access'].get(self.client_id) |
|||
|
|||
if not user_resources: |
|||
return None |
|||
|
|||
policies = [] |
|||
|
|||
for policy_name, policy in self.authorization.policies.items(): |
|||
for role in user_resources['roles']: |
|||
if self._build_name_role(role) in policy.roles: |
|||
policies.append(policy) |
|||
|
|||
return list(set(policies)) |
|||
|
|||
def get_permissions(self, token, method_token_info='introspect', **kwargs): |
|||
""" |
|||
Get permission by user token |
|||
|
|||
:param token: user token |
|||
:param method_token_info: Decode token method |
|||
:param kwargs: parameters for decode |
|||
:return: permissions list |
|||
""" |
|||
|
|||
if not self.authorization.policies: |
|||
raise KeycloakAuthorizationConfigError( |
|||
"Keycloak settings not found. Load Authorization Keycloak settings." |
|||
) |
|||
|
|||
token_info = self._token_info(token, method_token_info, **kwargs) |
|||
|
|||
if method_token_info == 'introspect' and not token_info['active']: |
|||
raise KeycloakInvalidTokenError( |
|||
"Token expired or invalid." |
|||
) |
|||
|
|||
user_resources = token_info['resource_access'].get(self.client_id) |
|||
|
|||
if not user_resources: |
|||
return None |
|||
|
|||
permissions = [] |
|||
|
|||
for policy_name, policy in self.authorization.policies.items(): |
|||
for role in user_resources['roles']: |
|||
if self._build_name_role(role) in policy.roles: |
|||
permissions += policy.permissions |
|||
|
|||
return list(set(permissions)) |
@ -1,191 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# This program is free software: you can redistribute it and/or modify |
|||
# it under the terms of the GNU Lesser General Public License as published by |
|||
# the Free Software Foundation, either version 3 of the License, or |
|||
# (at your option) any later version. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU Lesser General Public License for more details. |
|||
# |
|||
# You should have received a copy of the GNU Lesser General Public License |
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
from unittest import mock |
|||
|
|||
from httmock import urlmatch, response, HTTMock, all_requests |
|||
|
|||
from keycloak import KeycloakAdmin, KeycloakOpenID |
|||
from ..connection import ConnectionManager |
|||
|
|||
try: |
|||
import unittest |
|||
except ImportError: |
|||
import unittest2 as unittest |
|||
|
|||
|
|||
class TestConnection(unittest.TestCase): |
|||
|
|||
def setUp(self): |
|||
self._conn = ConnectionManager( |
|||
base_url="http://localhost/", |
|||
headers={}, |
|||
timeout=60) |
|||
|
|||
@all_requests |
|||
def response_content_success(self, url, request): |
|||
headers = {'content-type': 'application/json'} |
|||
content = b'response_ok' |
|||
return response(200, content, headers, None, 5, request) |
|||
|
|||
def test_raw_get(self): |
|||
with HTTMock(self.response_content_success): |
|||
resp = self._conn.raw_get("/known_path") |
|||
self.assertEqual(resp.content, b'response_ok') |
|||
self.assertEqual(resp.status_code, 200) |
|||
|
|||
def test_raw_post(self): |
|||
@urlmatch(path="/known_path", method="post") |
|||
def response_post_success(url, request): |
|||
headers = {'content-type': 'application/json'} |
|||
content = 'response'.encode("utf-8") |
|||
return response(201, content, headers, None, 5, request) |
|||
|
|||
with HTTMock(response_post_success): |
|||
resp = self._conn.raw_post("/known_path", |
|||
{'field': 'value'}) |
|||
self.assertEqual(resp.content, b'response') |
|||
self.assertEqual(resp.status_code, 201) |
|||
|
|||
def test_raw_put(self): |
|||
@urlmatch(netloc="localhost", path="/known_path", method="put") |
|||
def response_put_success(url, request): |
|||
headers = {'content-type': 'application/json'} |
|||
content = 'response'.encode("utf-8") |
|||
return response(200, content, headers, None, 5, request) |
|||
|
|||
with HTTMock(response_put_success): |
|||
resp = self._conn.raw_put("/known_path", |
|||
{'field': 'value'}) |
|||
self.assertEqual(resp.content, b'response') |
|||
self.assertEqual(resp.status_code, 200) |
|||
|
|||
def test_raw_get_fail(self): |
|||
@urlmatch(netloc="localhost", path="/known_path", method="get") |
|||
def response_get_fail(url, request): |
|||
headers = {'content-type': 'application/json'} |
|||
content = "404 page not found".encode("utf-8") |
|||
return response(404, content, headers, None, 5, request) |
|||
|
|||
with HTTMock(response_get_fail): |
|||
resp = self._conn.raw_get("/known_path") |
|||
|
|||
self.assertEqual(resp.content, b"404 page not found") |
|||
self.assertEqual(resp.status_code, 404) |
|||
|
|||
def test_raw_post_fail(self): |
|||
@urlmatch(netloc="localhost", path="/known_path", method="post") |
|||
def response_post_fail(url, request): |
|||
headers = {'content-type': 'application/json'} |
|||
content = str(["Start can't be blank"]).encode("utf-8") |
|||
return response(404, content, headers, None, 5, request) |
|||
|
|||
with HTTMock(response_post_fail): |
|||
resp = self._conn.raw_post("/known_path", |
|||
{'field': 'value'}) |
|||
self.assertEqual(resp.content, str(["Start can't be blank"]).encode("utf-8")) |
|||
self.assertEqual(resp.status_code, 404) |
|||
|
|||
def test_raw_put_fail(self): |
|||
@urlmatch(netloc="localhost", path="/known_path", method="put") |
|||
def response_put_fail(url, request): |
|||
headers = {'content-type': 'application/json'} |
|||
content = str(["Start can't be blank"]).encode("utf-8") |
|||
return response(404, content, headers, None, 5, request) |
|||
|
|||
with HTTMock(response_put_fail): |
|||
resp = self._conn.raw_put("/known_path", |
|||
{'field': 'value'}) |
|||
self.assertEqual(resp.content, str(["Start can't be blank"]).encode("utf-8")) |
|||
self.assertEqual(resp.status_code, 404) |
|||
|
|||
def test_add_param_headers(self): |
|||
self._conn.add_param_headers("test", "value") |
|||
self.assertEqual(self._conn.headers, |
|||
{"test": "value"}) |
|||
|
|||
def test_del_param_headers(self): |
|||
self._conn.add_param_headers("test", "value") |
|||
self._conn.del_param_headers("test") |
|||
self.assertEqual(self._conn.headers, {}) |
|||
|
|||
def test_clean_param_headers(self): |
|||
self._conn.add_param_headers("test", "value") |
|||
self.assertEqual(self._conn.headers, |
|||
{"test": "value"}) |
|||
self._conn.clean_headers() |
|||
self.assertEqual(self._conn.headers, {}) |
|||
|
|||
def test_exist_param_headers(self): |
|||
self._conn.add_param_headers("test", "value") |
|||
self.assertTrue(self._conn.exist_param_headers("test")) |
|||
self.assertFalse(self._conn.exist_param_headers("test_no")) |
|||
|
|||
def test_get_param_headers(self): |
|||
self._conn.add_param_headers("test", "value") |
|||
self.assertTrue(self._conn.exist_param_headers("test")) |
|||
self.assertFalse(self._conn.exist_param_headers("test_no")) |
|||
|
|||
def test_get_headers(self): |
|||
self._conn.add_param_headers("test", "value") |
|||
self.assertEqual(self._conn.headers, |
|||
{"test": "value"}) |
|||
|
|||
def test_KeycloakAdmin_custom_header(self): |
|||
|
|||
class FakeToken: |
|||
@staticmethod |
|||
def get(string_val): |
|||
return "faketoken" |
|||
|
|||
fake_token = FakeToken() |
|||
|
|||
with mock.patch.object(KeycloakOpenID, "__init__", return_value=None) as mock_keycloak_open_id: |
|||
with mock.patch("keycloak.keycloak_openid.KeycloakOpenID.token", return_value=fake_token): |
|||
with mock.patch("keycloak.connection.ConnectionManager.__init__", return_value=None) as mock_connection_manager: |
|||
with mock.patch("keycloak.connection.ConnectionManager.__del__", return_value=None) as mock_connection_manager_delete: |
|||
server_url = "https://localhost/auth/" |
|||
username = "admin" |
|||
password = "secret" |
|||
realm_name = "master" |
|||
|
|||
headers = { |
|||
'Custom': 'test-custom-header' |
|||
} |
|||
KeycloakAdmin(server_url=server_url, |
|||
username=username, |
|||
password=password, |
|||
realm_name=realm_name, |
|||
verify=False, |
|||
custom_headers=headers) |
|||
|
|||
mock_keycloak_open_id.assert_called_with(server_url=server_url, |
|||
realm_name=realm_name, |
|||
client_id='admin-cli', |
|||
client_secret_key=None, |
|||
verify=False, |
|||
custom_headers=headers) |
|||
|
|||
expected_header = {'Authorization': 'Bearer faketoken', |
|||
'Content-Type': 'application/json', |
|||
'Custom': 'test-custom-header' |
|||
} |
|||
|
|||
mock_connection_manager.assert_called_with(base_url=server_url, |
|||
headers=expected_header, |
|||
timeout=60, |
|||
verify=False) |
|||
mock_connection_manager_delete.assert_called_once_with() |
2139
poetry.lock
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,89 @@ |
|||
[tool.poetry] |
|||
name = "python-keycloak" |
|||
version = "0.0.0" |
|||
description = "python-keycloak is a Python package providing access to the Keycloak API." |
|||
license = "MIT" |
|||
readme = "README.md" |
|||
keywords = [ "keycloak", "openid", "oidc" ] |
|||
authors = [ |
|||
"Marcos Pereira <marcospereira.mpj@gmail.com>", |
|||
"Richard Nemeth <ryshoooo@gmail.com>" |
|||
] |
|||
classifiers=[ |
|||
"Programming Language :: Python :: 3", |
|||
"License :: OSI Approved :: MIT License", |
|||
"Development Status :: 3 - Alpha", |
|||
"Operating System :: MacOS", |
|||
"Operating System :: Unix", |
|||
"Operating System :: Microsoft :: Windows", |
|||
"Topic :: Utilities", |
|||
] |
|||
packages = [ |
|||
{ include = "keycloak", from = "src/" }, |
|||
{ include = "keycloak/**/*.py", from = "src/" }, |
|||
] |
|||
include = ["LICENSE", "CHANGELOG.md", "CONTRIBUTING.md"] |
|||
|
|||
[tool.poetry.urls] |
|||
Documentation = "https://python-keycloak.readthedocs.io/en/latest/" |
|||
"Issue tracker" = "https://github.com/marcospereirampj/python-keycloak/issues" |
|||
|
|||
[tool.poetry.dependencies] |
|||
python = ">=3.7,<4.0" |
|||
requests = ">=2.20.0" |
|||
python-jose = ">=3.3.0" |
|||
mock = {version = "^4.0.3", optional = true} |
|||
alabaster = {version = "^0.7.12", optional = true} |
|||
commonmark = {version = "^0.9.1", optional = true} |
|||
recommonmark = {version = "^0.7.1", optional = true} |
|||
Sphinx = {version = "^5.3.0", optional = true} |
|||
sphinx-rtd-theme = {version = "^1.0.0", optional = true} |
|||
readthedocs-sphinx-ext = {version = "^2.1.9", optional = true} |
|||
m2r2 = {version = "^0.3.2", optional = true} |
|||
sphinx-autoapi = {version = "^2.0.0", optional = true} |
|||
requests-toolbelt = ">=1.0.0" |
|||
deprecation = ">=2.1.0" |
|||
|
|||
[tool.poetry.extras] |
|||
docs = [ |
|||
"mock", |
|||
"alabaster", |
|||
"commonmark", |
|||
"recommonmark", |
|||
"sphinx", |
|||
"sphinx-rtd-theme", |
|||
"readthedocs-sphinx-ext", |
|||
"m2r2", |
|||
"sphinx-autoapi", |
|||
] |
|||
|
|||
[tool.poetry.group.dev.dependencies] |
|||
tox = ">=4.0.0" |
|||
pytest = ">=7.1.2" |
|||
pytest-cov = ">=3.0.0" |
|||
wheel = ">=0.38.4" |
|||
pre-commit = ">=2.19.0" |
|||
isort = ">=5.10.1" |
|||
black = ">=22.3.0" |
|||
flake8 = ">=3.5.0" |
|||
flake8-docstrings = ">=1.6.0" |
|||
commitizen = ">=2.28.0" |
|||
cryptography = ">=37.0.4" |
|||
codespell = ">=2.1.0" |
|||
darglint = ">=1.8.1" |
|||
twine = ">=4.0.2" |
|||
freezegun = ">=1.2.2" |
|||
|
|||
[build-system] |
|||
requires = ["poetry-core>=1.0.0"] |
|||
build-backend = "poetry.core.masonry.api" |
|||
|
|||
[tool.black] |
|||
line-length = 99 |
|||
|
|||
[tool.isort] |
|||
line_length = 99 |
|||
profile = "black" |
|||
|
|||
[tool.darglint] |
|||
enable = "DAR104" |
@ -1,7 +0,0 @@ |
|||
requests>=2.20.0 |
|||
httmock>=1.2.5 |
|||
python-jose>=1.4.0 |
|||
twine==1.13.0 |
|||
jose~=1.0.0 |
|||
setuptools~=54.2.0 |
|||
urllib3>=1.26.5 |
@ -1,2 +0,0 @@ |
|||
[metadata] |
|||
description-file = README.md |
@ -1,31 +0,0 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
from setuptools import setup |
|||
|
|||
with open("README.md", "r") as fh: |
|||
long_description = fh.read() |
|||
|
|||
setup( |
|||
name='python-keycloak', |
|||
version='0.27.0', |
|||
url='https://github.com/marcospereirampj/python-keycloak', |
|||
license='The MIT License', |
|||
author='Marcos Pereira', |
|||
author_email='marcospereira.mpj@gmail.com', |
|||
keywords='keycloak openid', |
|||
description='python-keycloak is a Python package providing access to the Keycloak API.', |
|||
long_description=long_description, |
|||
long_description_content_type="text/markdown", |
|||
packages=['keycloak', 'keycloak.authorization', 'keycloak.tests'], |
|||
install_requires=['requests>=2.20.0', 'python-jose>=1.4.0'], |
|||
tests_require=['httmock>=1.2.5'], |
|||
classifiers=[ |
|||
'Programming Language :: Python :: 3', |
|||
'License :: OSI Approved :: MIT License', |
|||
'Development Status :: 3 - Alpha', |
|||
'Operating System :: MacOS', |
|||
'Operating System :: Unix', |
|||
'Operating System :: Microsoft :: Windows', |
|||
'Topic :: Utilities' |
|||
] |
|||
) |
@ -0,0 +1,68 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
"""Python-Keycloak library.""" |
|||
|
|||
from ._version import __version__ |
|||
from .connection import ConnectionManager |
|||
from .exceptions import ( |
|||
KeycloakAuthenticationError, |
|||
KeycloakAuthorizationConfigError, |
|||
KeycloakConnectionError, |
|||
KeycloakDeleteError, |
|||
KeycloakDeprecationError, |
|||
KeycloakError, |
|||
KeycloakGetError, |
|||
KeycloakInvalidTokenError, |
|||
KeycloakOperationError, |
|||
KeycloakPostError, |
|||
KeycloakPutError, |
|||
KeycloakRPTNotFound, |
|||
KeycloakSecretNotFound, |
|||
) |
|||
from .keycloak_admin import KeycloakAdmin |
|||
from .keycloak_openid import KeycloakOpenID |
|||
from .keycloak_uma import KeycloakUMA |
|||
from .openid_connection import KeycloakOpenIDConnection |
|||
|
|||
__all__ = [ |
|||
"__version__", |
|||
"ConnectionManager", |
|||
"KeycloakAuthenticationError", |
|||
"KeycloakAuthorizationConfigError", |
|||
"KeycloakConnectionError", |
|||
"KeycloakDeleteError", |
|||
"KeycloakDeprecationError", |
|||
"KeycloakError", |
|||
"KeycloakGetError", |
|||
"KeycloakInvalidTokenError", |
|||
"KeycloakOperationError", |
|||
"KeycloakPostError", |
|||
"KeycloakPutError", |
|||
"KeycloakRPTNotFound", |
|||
"KeycloakSecretNotFound", |
|||
"KeycloakAdmin", |
|||
"KeycloakOpenID", |
|||
"KeycloakOpenIDConnection", |
|||
"KeycloakUMA", |
|||
] |
@ -0,0 +1,281 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
"""Connection manager module.""" |
|||
|
|||
try: |
|||
from urllib.parse import urljoin |
|||
except ImportError: # pragma: no cover |
|||
from urlparse import urljoin |
|||
|
|||
import requests |
|||
from requests.adapters import HTTPAdapter |
|||
|
|||
from .exceptions import KeycloakConnectionError |
|||
|
|||
|
|||
class ConnectionManager(object): |
|||
"""Represents a simple server connection. |
|||
|
|||
:param base_url: The server URL. |
|||
:type base_url: str |
|||
:param headers: The header parameters of the requests to the server. |
|||
:type headers: dict |
|||
:param timeout: Timeout to use for requests to the server. |
|||
:type timeout: int |
|||
:param verify: Verify server SSL. |
|||
:type verify: bool |
|||
:param proxies: The proxies servers requests is sent by. |
|||
:type proxies: dict |
|||
""" |
|||
|
|||
def __init__(self, base_url, headers={}, timeout=60, verify=True, proxies=None): |
|||
"""Init method. |
|||
|
|||
:param base_url: The server URL. |
|||
:type base_url: str |
|||
:param headers: The header parameters of the requests to the server. |
|||
:type headers: dict |
|||
:param timeout: Timeout to use for requests to the server. |
|||
:type timeout: int |
|||
:param verify: Verify server SSL. |
|||
:type verify: bool |
|||
:param proxies: The proxies servers requests is sent by. |
|||
:type proxies: dict |
|||
""" |
|||
self.base_url = base_url |
|||
self.headers = headers |
|||
self.timeout = timeout |
|||
self.verify = verify |
|||
self._s = requests.Session() |
|||
self._s.auth = lambda x: x # don't let requests add auth headers |
|||
|
|||
# retry once to reset connection with Keycloak after tomcat's ConnectionTimeout |
|||
# see https://github.com/marcospereirampj/python-keycloak/issues/36 |
|||
for protocol in ("https://", "http://"): |
|||
adapter = HTTPAdapter(max_retries=1) |
|||
# adds POST to retry whitelist |
|||
allowed_methods = set(adapter.max_retries.allowed_methods) |
|||
allowed_methods.add("POST") |
|||
adapter.max_retries.allowed_methods = frozenset(allowed_methods) |
|||
|
|||
self._s.mount(protocol, adapter) |
|||
|
|||
if proxies: |
|||
self._s.proxies.update(proxies) |
|||
|
|||
def __del__(self): |
|||
"""Del method.""" |
|||
if hasattr(self, "_s"): |
|||
self._s.close() |
|||
|
|||
@property |
|||
def base_url(self): |
|||
"""Return base url in use for requests to the server. |
|||
|
|||
:returns: Base URL |
|||
:rtype: str |
|||
""" |
|||
return self._base_url |
|||
|
|||
@base_url.setter |
|||
def base_url(self, value): |
|||
self._base_url = value |
|||
|
|||
@property |
|||
def timeout(self): |
|||
"""Return timeout in use for request to the server. |
|||
|
|||
:returns: Timeout |
|||
:rtype: int |
|||
""" |
|||
return self._timeout |
|||
|
|||
@timeout.setter |
|||
def timeout(self, value): |
|||
self._timeout = value |
|||
|
|||
@property |
|||
def verify(self): |
|||
"""Return verify in use for request to the server. |
|||
|
|||
:returns: Verify indicator |
|||
:rtype: bool |
|||
""" |
|||
return self._verify |
|||
|
|||
@verify.setter |
|||
def verify(self, value): |
|||
self._verify = value |
|||
|
|||
@property |
|||
def headers(self): |
|||
"""Return header request to the server. |
|||
|
|||
:returns: Request headers |
|||
:rtype: dict |
|||
""" |
|||
return self._headers |
|||
|
|||
@headers.setter |
|||
def headers(self, value): |
|||
self._headers = value |
|||
|
|||
def param_headers(self, key): |
|||
"""Return a specific header parameter. |
|||
|
|||
:param key: Header parameters key. |
|||
:type key: str |
|||
:returns: If the header parameters exist, return its value. |
|||
:rtype: str |
|||
""" |
|||
return self.headers.get(key) |
|||
|
|||
def clean_headers(self): |
|||
"""Clear header parameters.""" |
|||
self.headers = {} |
|||
|
|||
def exist_param_headers(self, key): |
|||
"""Check if the parameter exists in the header. |
|||
|
|||
:param key: Header parameters key. |
|||
:type key: str |
|||
:returns: If the header parameters exist, return True. |
|||
:rtype: bool |
|||
""" |
|||
return self.param_headers(key) is not None |
|||
|
|||
def add_param_headers(self, key, value): |
|||
"""Add a single parameter inside the header. |
|||
|
|||
:param key: Header parameters key. |
|||
:type key: str |
|||
:param value: Value to be added. |
|||
:type value: str |
|||
""" |
|||
self.headers[key] = value |
|||
|
|||
def del_param_headers(self, key): |
|||
"""Remove a specific parameter. |
|||
|
|||
:param key: Key of the header parameters. |
|||
:type key: str |
|||
""" |
|||
self.headers.pop(key, None) |
|||
|
|||
def raw_get(self, path, **kwargs): |
|||
"""Submit get request to the path. |
|||
|
|||
:param path: Path for request. |
|||
:type path: str |
|||
:param kwargs: Additional arguments |
|||
:type kwargs: dict |
|||
:returns: Response the request. |
|||
:rtype: Response |
|||
:raises KeycloakConnectionError: HttpError Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.get( |
|||
urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify, |
|||
) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError("Can't connect to server (%s)" % e) |
|||
|
|||
def raw_post(self, path, data, **kwargs): |
|||
"""Submit post request to the path. |
|||
|
|||
:param path: Path for request. |
|||
:type path: str |
|||
:param data: Payload for request. |
|||
:type data: dict |
|||
:param kwargs: Additional arguments |
|||
:type kwargs: dict |
|||
:returns: Response the request. |
|||
:rtype: Response |
|||
:raises KeycloakConnectionError: HttpError Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.post( |
|||
urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
data=data, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify, |
|||
) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError("Can't connect to server (%s)" % e) |
|||
|
|||
def raw_put(self, path, data, **kwargs): |
|||
"""Submit put request to the path. |
|||
|
|||
:param path: Path for request. |
|||
:type path: str |
|||
:param data: Payload for request. |
|||
:type data: dict |
|||
:param kwargs: Additional arguments |
|||
:type kwargs: dict |
|||
:returns: Response the request. |
|||
:rtype: Response |
|||
:raises KeycloakConnectionError: HttpError Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.put( |
|||
urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
data=data, |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify, |
|||
) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError("Can't connect to server (%s)" % e) |
|||
|
|||
def raw_delete(self, path, data=None, **kwargs): |
|||
"""Submit delete request to the path. |
|||
|
|||
:param path: Path for request. |
|||
:type path: str |
|||
:param data: Payload for request. |
|||
:type data: dict | None |
|||
:param kwargs: Additional arguments |
|||
:type kwargs: dict |
|||
:returns: Response the request. |
|||
:rtype: Response |
|||
:raises KeycloakConnectionError: HttpError Can't connect to server. |
|||
""" |
|||
try: |
|||
return self._s.delete( |
|||
urljoin(self.base_url, path), |
|||
params=kwargs, |
|||
data=data or dict(), |
|||
headers=self.headers, |
|||
timeout=self.timeout, |
|||
verify=self.verify, |
|||
) |
|||
except Exception as e: |
|||
raise KeycloakConnectionError("Can't connect to server (%s)" % e) |
4397
src/keycloak/keycloak_admin.py
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,713 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
"""Keycloak OpenID module. |
|||
|
|||
The module contains mainly the implementation of KeycloakOpenID class, the main |
|||
class to handle authentication and token manipulation. |
|||
""" |
|||
|
|||
import json |
|||
from typing import Optional |
|||
|
|||
from jose import jwt |
|||
|
|||
from .authorization import Authorization |
|||
from .connection import ConnectionManager |
|||
from .exceptions import ( |
|||
KeycloakAuthenticationError, |
|||
KeycloakAuthorizationConfigError, |
|||
KeycloakDeprecationError, |
|||
KeycloakGetError, |
|||
KeycloakInvalidTokenError, |
|||
KeycloakPostError, |
|||
KeycloakRPTNotFound, |
|||
raise_error_from_response, |
|||
) |
|||
from .uma_permissions import AuthStatus, build_permission_param |
|||
from .urls_patterns import ( |
|||
URL_AUTH, |
|||
URL_CERTS, |
|||
URL_CLIENT_REGISTRATION, |
|||
URL_ENTITLEMENT, |
|||
URL_INTROSPECT, |
|||
URL_LOGOUT, |
|||
URL_REALM, |
|||
URL_TOKEN, |
|||
URL_USERINFO, |
|||
URL_WELL_KNOWN, |
|||
) |
|||
|
|||
|
|||
class KeycloakOpenID: |
|||
"""Keycloak OpenID client. |
|||
|
|||
:param server_url: Keycloak server url |
|||
:param client_id: client id |
|||
:param realm_name: realm name |
|||
:param client_secret_key: client secret key |
|||
:param verify: True if want check connection SSL |
|||
:param custom_headers: dict of custom header to pass to each HTML request |
|||
:param proxies: dict of proxies to sent the request by. |
|||
:param timeout: connection timeout in seconds |
|||
""" |
|||
|
|||
def __init__( |
|||
self, |
|||
server_url, |
|||
realm_name, |
|||
client_id, |
|||
client_secret_key=None, |
|||
verify=True, |
|||
custom_headers=None, |
|||
proxies=None, |
|||
timeout=60, |
|||
): |
|||
"""Init method. |
|||
|
|||
:param server_url: Keycloak server url |
|||
:type server_url: str |
|||
:param client_id: client id |
|||
:type client_id: str |
|||
:param realm_name: realm name |
|||
:type realm_name: str |
|||
:param client_secret_key: client secret key |
|||
:type client_secret_key: str |
|||
:param verify: True if want check connection SSL |
|||
:type verify: bool |
|||
:param custom_headers: dict of custom header to pass to each HTML request |
|||
:type custom_headers: dict |
|||
:param proxies: dict of proxies to sent the request by. |
|||
:type proxies: dict |
|||
:param timeout: connection timeout in seconds |
|||
:type timeout: int |
|||
""" |
|||
self.client_id = client_id |
|||
self.client_secret_key = client_secret_key |
|||
self.realm_name = realm_name |
|||
headers = custom_headers if custom_headers is not None else dict() |
|||
self.connection = ConnectionManager( |
|||
base_url=server_url, headers=headers, timeout=timeout, verify=verify, proxies=proxies |
|||
) |
|||
|
|||
self.authorization = Authorization() |
|||
|
|||
@property |
|||
def client_id(self): |
|||
"""Get client id. |
|||
|
|||
:returns: Client id |
|||
:rtype: str |
|||
""" |
|||
return self._client_id |
|||
|
|||
@client_id.setter |
|||
def client_id(self, value): |
|||
self._client_id = value |
|||
|
|||
@property |
|||
def client_secret_key(self): |
|||
"""Get the client secret key. |
|||
|
|||
:returns: Client secret key |
|||
:rtype: str |
|||
""" |
|||
return self._client_secret_key |
|||
|
|||
@client_secret_key.setter |
|||
def client_secret_key(self, value): |
|||
self._client_secret_key = value |
|||
|
|||
@property |
|||
def realm_name(self): |
|||
"""Get the realm name. |
|||
|
|||
:returns: Realm name |
|||
:rtype: str |
|||
""" |
|||
return self._realm_name |
|||
|
|||
@realm_name.setter |
|||
def realm_name(self, value): |
|||
self._realm_name = value |
|||
|
|||
@property |
|||
def connection(self): |
|||
"""Get connection. |
|||
|
|||
:returns: Connection manager object |
|||
:rtype: ConnectionManager |
|||
""" |
|||
return self._connection |
|||
|
|||
@connection.setter |
|||
def connection(self, value): |
|||
self._connection = value |
|||
|
|||
@property |
|||
def authorization(self): |
|||
"""Get authorization. |
|||
|
|||
:returns: The authorization manager |
|||
:rtype: Authorization |
|||
""" |
|||
return self._authorization |
|||
|
|||
@authorization.setter |
|||
def authorization(self, value): |
|||
self._authorization = value |
|||
|
|||
def _add_secret_key(self, payload): |
|||
"""Add secret key if exists. |
|||
|
|||
:param payload: Payload |
|||
:type payload: dict |
|||
:returns: Payload with the secret key |
|||
:rtype: dict |
|||
""" |
|||
if self.client_secret_key: |
|||
payload.update({"client_secret": self.client_secret_key}) |
|||
|
|||
return payload |
|||
|
|||
def _build_name_role(self, role): |
|||
"""Build name of a role. |
|||
|
|||
:param role: Role name |
|||
:type role: str |
|||
:returns: Role path |
|||
:rtype: str |
|||
""" |
|||
return self.client_id + "/" + role |
|||
|
|||
def _token_info(self, token, method_token_info, **kwargs): |
|||
"""Getter for the token data. |
|||
|
|||
:param token: Token |
|||
:type token: str |
|||
:param method_token_info: Token info method to use |
|||
:type method_token_info: str |
|||
:param kwargs: Additional keyword arguments |
|||
:type kwargs: dict |
|||
:returns: Token info |
|||
:rtype: dict |
|||
""" |
|||
if method_token_info == "introspect": |
|||
token_info = self.introspect(token) |
|||
else: |
|||
token_info = self.decode_token(token, **kwargs) |
|||
|
|||
return token_info |
|||
|
|||
def well_known(self): |
|||
"""Get the well_known object. |
|||
|
|||
The most important endpoint to understand is the well-known configuration |
|||
endpoint. It lists endpoints and other configuration options relevant to |
|||
the OpenID Connect implementation in Keycloak. |
|||
|
|||
:returns: It lists endpoints and other configuration options relevant |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_WELL_KNOWN.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def auth_url(self, redirect_uri, scope="email", state=""): |
|||
"""Get authorization URL endpoint. |
|||
|
|||
:param redirect_uri: Redirect url to receive oauth code |
|||
:type redirect_uri: str |
|||
:param scope: Scope of authorization request, split with the blank space |
|||
:type scope: str |
|||
:param state: State will be returned to the redirect_uri |
|||
:type state: str |
|||
:returns: Authorization URL Full Build |
|||
:rtype: str |
|||
""" |
|||
params_path = { |
|||
"authorization-endpoint": self.well_known()["authorization_endpoint"], |
|||
"client-id": self.client_id, |
|||
"redirect-uri": redirect_uri, |
|||
"scope": scope, |
|||
"state": state, |
|||
} |
|||
return URL_AUTH.format(**params_path) |
|||
|
|||
def token( |
|||
self, |
|||
username="", |
|||
password="", |
|||
grant_type=["password"], |
|||
code="", |
|||
redirect_uri="", |
|||
totp=None, |
|||
scope="openid", |
|||
**extra |
|||
): |
|||
"""Retrieve user token. |
|||
|
|||
The token endpoint is used to obtain tokens. Tokens can either be obtained by |
|||
exchanging an authorization code or by supplying credentials directly depending on |
|||
what flow is used. The token endpoint is also used to obtain new access tokens |
|||
when they expire. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint |
|||
|
|||
:param username: Username |
|||
:type username: str |
|||
:param password: Password |
|||
:type password: str |
|||
:param grant_type: Grant type |
|||
:type grant_type: str |
|||
:param code: Code |
|||
:type code: str |
|||
:param redirect_uri: Redirect URI |
|||
:type redirect_uri: str |
|||
:param totp: Time-based one-time password |
|||
:type totp: int |
|||
:param scope: Scope, defaults to openid |
|||
:type scope: str |
|||
:param extra: Additional extra arguments |
|||
:type extra: dict |
|||
:returns: Keycloak token |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = { |
|||
"username": username, |
|||
"password": password, |
|||
"client_id": self.client_id, |
|||
"grant_type": grant_type, |
|||
"code": code, |
|||
"redirect_uri": redirect_uri, |
|||
"scope": scope, |
|||
} |
|||
if extra: |
|||
payload.update(extra) |
|||
|
|||
if totp: |
|||
payload["totp"] = totp |
|||
|
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def refresh_token(self, refresh_token, grant_type=["refresh_token"]): |
|||
"""Refresh the user token. |
|||
|
|||
The token endpoint is used to obtain tokens. Tokens can either be obtained by |
|||
exchanging an authorization code or by supplying credentials directly depending on |
|||
what flow is used. The token endpoint is also used to obtain new access tokens |
|||
when they expire. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint |
|||
|
|||
:param refresh_token: Refresh token from Keycloak |
|||
:type refresh_token: str |
|||
:param grant_type: Grant type |
|||
:type grant_type: str |
|||
:returns: New token |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = { |
|||
"client_id": self.client_id, |
|||
"grant_type": grant_type, |
|||
"refresh_token": refresh_token, |
|||
} |
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def exchange_token( |
|||
self, |
|||
token: str, |
|||
audience: str, |
|||
subject: Optional[str] = None, |
|||
subject_token_type: Optional[str] = None, |
|||
subject_issuer: Optional[str] = None, |
|||
requested_issuer: Optional[str] = None, |
|||
requested_token_type: str = "urn:ietf:params:oauth:token-type:refresh_token", |
|||
scope: str = "openid", |
|||
) -> dict: |
|||
"""Exchange user token. |
|||
|
|||
Use a token to obtain an entirely different token. See |
|||
https://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange |
|||
|
|||
:param token: Access token |
|||
:type token: str |
|||
:param audience: Audience |
|||
:type audience: str |
|||
:param subject: Subject |
|||
:type subject: str |
|||
:param subject_token_type: Token Type specification |
|||
:type subject_token_type: Optional[str] |
|||
:param subject_issuer: Issuer |
|||
:type subject_issuer: Optional[str] |
|||
:param requested_issuer: Issuer |
|||
:type requested_issuer: Optional[str] |
|||
:param requested_token_type: Token type specification |
|||
:type requested_token_type: str |
|||
:param scope: Scope, defaults to openid |
|||
:type scope: str |
|||
:returns: Exchanged token |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = { |
|||
"grant_type": ["urn:ietf:params:oauth:grant-type:token-exchange"], |
|||
"client_id": self.client_id, |
|||
"subject_token": token, |
|||
"subject_token_type": subject_token_type, |
|||
"subject_issuer": subject_issuer, |
|||
"requested_token_type": requested_token_type, |
|||
"audience": audience, |
|||
"requested_subject": subject, |
|||
"requested_issuer": requested_issuer, |
|||
"scope": scope, |
|||
} |
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def userinfo(self, token): |
|||
"""Get the user info object. |
|||
|
|||
The userinfo endpoint returns standard claims about the authenticated user, |
|||
and is protected by a bearer token. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#UserInfo |
|||
|
|||
:param token: Access token |
|||
:type token: str |
|||
:returns: Userinfo object |
|||
:rtype: dict |
|||
""" |
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_USERINFO.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def logout(self, refresh_token): |
|||
"""Log out the authenticated user. |
|||
|
|||
:param refresh_token: Refresh token from Keycloak |
|||
:type refresh_token: str |
|||
:returns: Keycloak server response |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = {"client_id": self.client_id, "refresh_token": refresh_token} |
|||
payload = self._add_secret_key(payload) |
|||
data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) |
|||
|
|||
def certs(self): |
|||
"""Get certificates. |
|||
|
|||
The certificate endpoint returns the public keys enabled by the realm, encoded as a |
|||
JSON Web Key (JWK). Depending on the realm settings there can be one or more keys enabled |
|||
for verifying tokens. |
|||
|
|||
https://tools.ietf.org/html/rfc7517 |
|||
|
|||
:returns: Certificates |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
def public_key(self): |
|||
"""Retrieve the public key. |
|||
|
|||
The public key is exposed by the realm page directly. |
|||
|
|||
:returns: The public key |
|||
:rtype: str |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
data_raw = self.connection.raw_get(URL_REALM.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError)["public_key"] |
|||
|
|||
def entitlement(self, token, resource_server_id): |
|||
"""Get entitlements from the token. |
|||
|
|||
Client applications can use a specific endpoint to obtain a special security token |
|||
called a requesting party token (RPT). This token consists of all the entitlements |
|||
(or permissions) for a user as a result of the evaluation of the permissions and |
|||
authorization policies associated with the resources being requested. With an RPT, |
|||
client applications can gain access to protected resources at the resource server. |
|||
|
|||
:param token: Access token |
|||
:type token: str |
|||
:param resource_server_id: Resource server ID |
|||
:type resource_server_id: str |
|||
:returns: Entitlements |
|||
:rtype: dict |
|||
""" |
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} |
|||
data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) |
|||
|
|||
if data_raw.status_code == 404: |
|||
return raise_error_from_response(data_raw, KeycloakDeprecationError) |
|||
|
|||
return raise_error_from_response(data_raw, KeycloakGetError) # pragma: no cover |
|||
|
|||
def introspect(self, token, rpt=None, token_type_hint=None): |
|||
"""Introspect the user token. |
|||
|
|||
The introspection endpoint is used to retrieve the active state of a token. |
|||
It is can only be invoked by confidential clients. |
|||
|
|||
https://tools.ietf.org/html/rfc7662 |
|||
|
|||
:param token: Access token |
|||
:type token: str |
|||
:param rpt: Requesting party token |
|||
:type rpt: str |
|||
:param token_type_hint: Token type hint |
|||
:type token_type_hint: str |
|||
|
|||
:returns: Token info |
|||
:rtype: dict |
|||
:raises KeycloakRPTNotFound: In case of RPT not specified |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = {"client_id": self.client_id, "token": token} |
|||
|
|||
if token_type_hint == "requesting_party_token": |
|||
if rpt: |
|||
payload.update({"token": rpt, "token_type_hint": token_type_hint}) |
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
else: |
|||
raise KeycloakRPTNotFound("Can't found RPT.") |
|||
|
|||
payload = self._add_secret_key(payload) |
|||
|
|||
data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def decode_token(self, token, key, algorithms=["RS256"], **kwargs): |
|||
"""Decode user token. |
|||
|
|||
A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data |
|||
structure that represents a cryptographic key. This specification |
|||
also defines a JWK Set JSON data structure that represents a set of |
|||
JWKs. Cryptographic algorithms and identifiers for use with this |
|||
specification are described in the separate JSON Web Algorithms (JWA) |
|||
specification and IANA registries established by that specification. |
|||
|
|||
https://tools.ietf.org/html/rfc7517 |
|||
|
|||
:param token: Keycloak token |
|||
:type token: str |
|||
:param key: Decode key |
|||
:type key: str |
|||
:param algorithms: Algorithms to use for decoding |
|||
:type algorithms: list[str] |
|||
:param kwargs: Keyword arguments |
|||
:type kwargs: dict |
|||
:returns: Decoded token |
|||
:rtype: dict |
|||
""" |
|||
return jwt.decode(token, key, algorithms=algorithms, audience=self.client_id, **kwargs) |
|||
|
|||
def load_authorization_config(self, path): |
|||
"""Load Keycloak settings (authorization). |
|||
|
|||
:param path: settings file (json) |
|||
:type path: str |
|||
""" |
|||
with open(path, "r") as fp: |
|||
authorization_json = json.load(fp) |
|||
|
|||
self.authorization.load_config(authorization_json) |
|||
|
|||
def get_policies(self, token, method_token_info="introspect", **kwargs): |
|||
"""Get policies by user token. |
|||
|
|||
:param token: User token |
|||
:type token: str |
|||
:param method_token_info: Method for token info decoding |
|||
:type method_token_info: str |
|||
:param kwargs: Additional keyword arguments |
|||
:type kwargs: dict |
|||
:return: Policies |
|||
:rtype: dict |
|||
:raises KeycloakAuthorizationConfigError: In case of bad authorization configuration |
|||
:raises KeycloakInvalidTokenError: In case of bad token |
|||
""" |
|||
if not self.authorization.policies: |
|||
raise KeycloakAuthorizationConfigError( |
|||
"Keycloak settings not found. Load Authorization Keycloak settings." |
|||
) |
|||
|
|||
token_info = self._token_info(token, method_token_info, **kwargs) |
|||
|
|||
if method_token_info == "introspect" and not token_info["active"]: |
|||
raise KeycloakInvalidTokenError("Token expired or invalid.") |
|||
|
|||
user_resources = token_info["resource_access"].get(self.client_id) |
|||
|
|||
if not user_resources: |
|||
return None |
|||
|
|||
policies = [] |
|||
|
|||
for policy_name, policy in self.authorization.policies.items(): |
|||
for role in user_resources["roles"]: |
|||
if self._build_name_role(role) in policy.roles: |
|||
policies.append(policy) |
|||
|
|||
return list(set(policies)) |
|||
|
|||
def get_permissions(self, token, method_token_info="introspect", **kwargs): |
|||
"""Get permission by user token. |
|||
|
|||
:param token: user token |
|||
:type token: str |
|||
:param method_token_info: Decode token method |
|||
:type method_token_info: str |
|||
:param kwargs: parameters for decode |
|||
:type kwargs: dict |
|||
:returns: permissions list |
|||
:rtype: list |
|||
:raises KeycloakAuthorizationConfigError: In case of bad authorization configuration |
|||
:raises KeycloakInvalidTokenError: In case of bad token |
|||
""" |
|||
if not self.authorization.policies: |
|||
raise KeycloakAuthorizationConfigError( |
|||
"Keycloak settings not found. Load Authorization Keycloak settings." |
|||
) |
|||
|
|||
token_info = self._token_info(token, method_token_info, **kwargs) |
|||
|
|||
if method_token_info == "introspect" and not token_info["active"]: |
|||
raise KeycloakInvalidTokenError("Token expired or invalid.") |
|||
|
|||
user_resources = token_info["resource_access"].get(self.client_id) |
|||
|
|||
if not user_resources: |
|||
return None |
|||
|
|||
permissions = [] |
|||
|
|||
for policy_name, policy in self.authorization.policies.items(): |
|||
for role in user_resources["roles"]: |
|||
if self._build_name_role(role) in policy.roles: |
|||
permissions += policy.permissions |
|||
|
|||
return list(set(permissions)) |
|||
|
|||
def uma_permissions(self, token, permissions=""): |
|||
"""Get UMA permissions by user token with requested permissions. |
|||
|
|||
The token endpoint is used to retrieve UMA permissions from Keycloak. It can only be |
|||
invoked by confidential clients. |
|||
|
|||
http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint |
|||
|
|||
:param token: user token |
|||
:type token: str |
|||
:param permissions: list of uma permissions list(resource:scope) requested by the user |
|||
:type permissions: str |
|||
:returns: Keycloak server response |
|||
:rtype: dict |
|||
""" |
|||
permission = build_permission_param(permissions) |
|||
|
|||
params_path = {"realm-name": self.realm_name} |
|||
payload = { |
|||
"grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", |
|||
"permission": permission, |
|||
"response_mode": "permissions", |
|||
"audience": self.client_id, |
|||
} |
|||
|
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def has_uma_access(self, token, permissions): |
|||
"""Determine whether user has uma permissions with specified user token. |
|||
|
|||
:param token: user token |
|||
:type token: str |
|||
:param permissions: list of uma permissions (resource:scope) |
|||
:type permissions: str |
|||
:return: Authentication status |
|||
:rtype: AuthStatus |
|||
:raises KeycloakAuthenticationError: In case of failed authentication |
|||
:raises KeycloakPostError: In case of failed request to Keycloak |
|||
""" |
|||
needed = build_permission_param(permissions) |
|||
try: |
|||
granted = self.uma_permissions(token, permissions) |
|||
except (KeycloakPostError, KeycloakAuthenticationError) as e: |
|||
if e.response_code == 403: # pragma: no cover |
|||
return AuthStatus( |
|||
is_logged_in=True, is_authorized=False, missing_permissions=needed |
|||
) |
|||
elif e.response_code == 401: |
|||
return AuthStatus( |
|||
is_logged_in=False, is_authorized=False, missing_permissions=needed |
|||
) |
|||
raise |
|||
|
|||
for resource_struct in granted: |
|||
resource = resource_struct["rsname"] |
|||
scopes = resource_struct.get("scopes", None) |
|||
if not scopes: |
|||
needed.discard(resource) |
|||
continue |
|||
for scope in scopes: # pragma: no cover |
|||
needed.discard("{}#{}".format(resource, scope)) |
|||
|
|||
return AuthStatus( |
|||
is_logged_in=True, is_authorized=len(needed) == 0, missing_permissions=needed |
|||
) |
|||
|
|||
def register_client(self, token: str, payload: dict): |
|||
"""Create a client. |
|||
|
|||
ClientRepresentation: |
|||
https://www.keycloak.org/docs-api/18.0/rest-api/index.html#_clientrepresentation |
|||
|
|||
:param token: Initial access token |
|||
:type token: str |
|||
:param payload: ClientRepresentation |
|||
:type payload: dict |
|||
:return: Client Representation |
|||
:rtype: dict |
|||
""" |
|||
params_path = {"realm-name": self.realm_name} |
|||
self.connection.add_param_headers("Authorization", "Bearer " + token) |
|||
self.connection.add_param_headers("Content-Type", "application/json") |
|||
data_raw = self.connection.raw_post( |
|||
URL_CLIENT_REGISTRATION.format(**params_path), data=json.dumps(payload) |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
@ -0,0 +1,417 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
"""Keycloak UMA module. |
|||
|
|||
The module contains a UMA compatible client for keycloak: |
|||
https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-federated-authz-2.0.html |
|||
""" |
|||
import json |
|||
from typing import Iterable |
|||
from urllib.parse import quote_plus |
|||
|
|||
from .connection import ConnectionManager |
|||
from .exceptions import ( |
|||
KeycloakDeleteError, |
|||
KeycloakGetError, |
|||
KeycloakPostError, |
|||
KeycloakPutError, |
|||
raise_error_from_response, |
|||
) |
|||
from .openid_connection import KeycloakOpenIDConnection |
|||
from .uma_permissions import UMAPermission |
|||
from .urls_patterns import URL_UMA_WELL_KNOWN |
|||
|
|||
|
|||
class KeycloakUMA: |
|||
"""Keycloak UMA client. |
|||
|
|||
:param connection: OpenID connection manager |
|||
""" |
|||
|
|||
def __init__(self, connection: KeycloakOpenIDConnection): |
|||
"""Init method. |
|||
|
|||
:param connection: OpenID connection manager |
|||
:type connection: KeycloakOpenIDConnection |
|||
""" |
|||
self.connection = connection |
|||
custom_headers = self.connection.custom_headers or {} |
|||
custom_headers.update({"Content-Type": "application/json"}) |
|||
self.connection.custom_headers = custom_headers |
|||
self._well_known = None |
|||
|
|||
def _fetch_well_known(self): |
|||
params_path = {"realm-name": self.connection.realm_name} |
|||
data_raw = self.connection.raw_get(URL_UMA_WELL_KNOWN.format(**params_path)) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
|||
|
|||
@staticmethod |
|||
def format_url(url, **kwargs): |
|||
"""Substitute url path parameters. |
|||
|
|||
Given a parameterized url string, returns the string after url encoding and substituting |
|||
the given params. For example, |
|||
`format_url("https://myserver/{my_resource}/{id}", my_resource="hello world", id="myid")` |
|||
would produce `https://myserver/hello+world/myid`. |
|||
|
|||
:param url: url string to format |
|||
:type url: str |
|||
:param kwargs: dict containing kwargs to substitute |
|||
:type kwargs: dict |
|||
:return: formatted string |
|||
:rtype: str |
|||
""" |
|||
return url.format(**{k: quote_plus(v) for k, v in kwargs.items()}) |
|||
|
|||
@property |
|||
def uma_well_known(self): |
|||
"""Get the well_known UMA2 config. |
|||
|
|||
:returns: It lists endpoints and other configuration options relevant |
|||
:rtype: dict |
|||
""" |
|||
# per instance cache |
|||
if not self._well_known: |
|||
self._well_known = self._fetch_well_known() |
|||
return self._well_known |
|||
|
|||
def resource_set_create(self, payload): |
|||
"""Create a resource set. |
|||
|
|||
Spec |
|||
https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#rfc.section.2.2.1 |
|||
|
|||
ResourceRepresentation |
|||
https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation |
|||
|
|||
:param payload: ResourceRepresentation |
|||
:type payload: dict |
|||
:return: ResourceRepresentation with the _id property assigned |
|||
:rtype: dict |
|||
""" |
|||
data_raw = self.connection.raw_post( |
|||
self.uma_well_known["resource_registration_endpoint"], data=json.dumps(payload) |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201]) |
|||
|
|||
def resource_set_update(self, resource_id, payload): |
|||
"""Update a resource set. |
|||
|
|||
Spec |
|||
https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#update-resource-set |
|||
|
|||
ResourceRepresentation |
|||
https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation |
|||
|
|||
:param resource_id: id of the resource |
|||
:type resource_id: str |
|||
:param payload: ResourceRepresentation |
|||
:type payload: dict |
|||
:return: Response dict (empty) |
|||
:rtype: dict |
|||
""" |
|||
url = self.format_url( |
|||
self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id |
|||
) |
|||
data_raw = self.connection.raw_put(url, data=json.dumps(payload)) |
|||
return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) |
|||
|
|||
def resource_set_read(self, resource_id): |
|||
"""Read a resource set. |
|||
|
|||
Spec |
|||
https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#read-resource-set |
|||
|
|||
ResourceRepresentation |
|||
https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation |
|||
|
|||
:param resource_id: id of the resource |
|||
:type resource_id: str |
|||
:return: ResourceRepresentation |
|||
:rtype: dict |
|||
""" |
|||
url = self.format_url( |
|||
self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id |
|||
) |
|||
data_raw = self.connection.raw_get(url) |
|||
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) |
|||
|
|||
def resource_set_delete(self, resource_id): |
|||
"""Delete a resource set. |
|||
|
|||
Spec |
|||
https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#delete-resource-set |
|||
|
|||
:param resource_id: id of the resource |
|||
:type resource_id: str |
|||
:return: Response dict (empty) |
|||
:rtype: dict |
|||
""" |
|||
url = self.format_url( |
|||
self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id |
|||
) |
|||
data_raw = self.connection.raw_delete(url) |
|||
return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) |
|||
|
|||
def resource_set_list_ids( |
|||
self, |
|||
name: str = "", |
|||
exact_name: bool = False, |
|||
uri: str = "", |
|||
owner: str = "", |
|||
resource_type: str = "", |
|||
scope: str = "", |
|||
first: int = 0, |
|||
maximum: int = -1, |
|||
): |
|||
"""Query for list of resource set ids. |
|||
|
|||
Spec |
|||
https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets |
|||
|
|||
:param name: query resource name |
|||
:type name: str |
|||
:param exact_name: query exact match for resource name |
|||
:type exact_name: bool |
|||
:param uri: query resource uri |
|||
:type uri: str |
|||
:param owner: query resource owner |
|||
:type owner: str |
|||
:param resource_type: query resource type |
|||
:type resource_type: str |
|||
:param scope: query resource scope |
|||
:type scope: str |
|||
:param first: index of first matching resource to return |
|||
:type first: int |
|||
:param maximum: maximum number of resources to return (-1 for all) |
|||
:type maximum: int |
|||
:return: List of ids |
|||
:rtype: List[str] |
|||
""" |
|||
query = dict() |
|||
if name: |
|||
query["name"] = name |
|||
if exact_name: |
|||
query["exactName"] = "true" |
|||
if uri: |
|||
query["uri"] = uri |
|||
if owner: |
|||
query["owner"] = owner |
|||
if resource_type: |
|||
query["type"] = resource_type |
|||
if scope: |
|||
query["scope"] = scope |
|||
if first > 0: |
|||
query["first"] = first |
|||
if maximum >= 0: |
|||
query["max"] = maximum |
|||
|
|||
data_raw = self.connection.raw_get( |
|||
self.uma_well_known["resource_registration_endpoint"], **query |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200]) |
|||
|
|||
def resource_set_list(self): |
|||
"""List all resource sets. |
|||
|
|||
Spec |
|||
https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets |
|||
|
|||
ResourceRepresentation |
|||
https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation |
|||
|
|||
:yields: Iterator over a list of ResourceRepresentations |
|||
:rtype: Iterator[dict] |
|||
""" |
|||
for resource_id in self.resource_set_list_ids(): |
|||
resource = self.resource_set_read(resource_id) |
|||
yield resource |
|||
|
|||
def permission_ticket_create(self, permissions: Iterable[UMAPermission]): |
|||
"""Create a permission ticket. |
|||
|
|||
:param permissions: Iterable of uma permissions to validate the token against |
|||
:type permissions: Iterable[UMAPermission] |
|||
:returns: Keycloak decision |
|||
:rtype: boolean |
|||
:raises KeycloakPostError: In case permission resource not found |
|||
""" |
|||
resources = dict() |
|||
for permission in permissions: |
|||
resource_id = getattr(permission, "resource_id", None) |
|||
|
|||
if resource_id is None: |
|||
resource_ids = self.resource_set_list_ids( |
|||
exact_name=True, name=permission.resource, first=0, maximum=1 |
|||
) |
|||
|
|||
if not resource_ids: |
|||
raise KeycloakPostError("Invalid resource specified") |
|||
|
|||
setattr(permission, "resource_id", resource_ids[0]) |
|||
|
|||
resources.setdefault(resource_id, set()) |
|||
if permission.scope: |
|||
resources[resource_id].add(permission.scope) |
|||
|
|||
payload = [ |
|||
{"resource_id": resource_id, "resource_scopes": list(scopes)} |
|||
for resource_id, scopes in resources.items() |
|||
] |
|||
|
|||
data_raw = self.connection.raw_post( |
|||
self.uma_well_known["permission_endpoint"], data=json.dumps(payload) |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def permissions_check(self, token, permissions: Iterable[UMAPermission]): |
|||
"""Check UMA permissions by user token with requested permissions. |
|||
|
|||
The token endpoint is used to check UMA permissions from Keycloak. It can only be |
|||
invoked by confidential clients. |
|||
|
|||
https://www.keycloak.org/docs/latest/authorization_services/#_service_authorization_api |
|||
|
|||
:param token: user token |
|||
:type token: str |
|||
:param permissions: Iterable of uma permissions to validate the token against |
|||
:type permissions: Iterable[UMAPermission] |
|||
:returns: Keycloak decision |
|||
:rtype: boolean |
|||
""" |
|||
payload = { |
|||
"grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", |
|||
"permission": ",".join(str(permission) for permission in permissions), |
|||
"response_mode": "decision", |
|||
"audience": self.connection.client_id, |
|||
} |
|||
|
|||
# Everyone always has the null set of permissions |
|||
# However keycloak cannot evaluate the null set |
|||
if len(payload["permission"]) == 0: |
|||
return True |
|||
|
|||
connection = ConnectionManager(self.connection.base_url) |
|||
connection.add_param_headers("Authorization", "Bearer " + token) |
|||
connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded") |
|||
data_raw = connection.raw_post(self.uma_well_known["token_endpoint"], data=payload) |
|||
try: |
|||
data = raise_error_from_response(data_raw, KeycloakPostError) |
|||
except KeycloakPostError: |
|||
return False |
|||
return data.get("result", False) |
|||
|
|||
def policy_resource_create(self, resource_id, payload): |
|||
"""Create permission policy for resource. |
|||
|
|||
Supports name, description, scopes, roles, groups, clients |
|||
|
|||
https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource |
|||
|
|||
:param resource_id: _id of resource |
|||
:type resource_id: str |
|||
:param payload: permission configuration |
|||
:type payload: dict |
|||
:return: PermissionRepresentation |
|||
:rtype: dict |
|||
""" |
|||
data_raw = self.connection.raw_post( |
|||
self.uma_well_known["policy_endpoint"] + f"/{resource_id}", data=json.dumps(payload) |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakPostError) |
|||
|
|||
def policy_update(self, policy_id, payload): |
|||
"""Update permission policy. |
|||
|
|||
https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource |
|||
https://www.keycloak.org/docs-api/21.0.1/rest-api/index.html#_policyrepresentation |
|||
|
|||
:param policy_id: id of policy permission |
|||
:type policy_id: str |
|||
:param payload: policy permission configuration |
|||
:type payload: dict |
|||
:return: PermissionRepresentation |
|||
:rtype: dict |
|||
""" |
|||
data_raw = self.connection.raw_put( |
|||
self.uma_well_known["policy_endpoint"] + f"/{policy_id}", data=json.dumps(payload) |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakPutError) |
|||
|
|||
def policy_delete(self, policy_id): |
|||
"""Delete permission policy. |
|||
|
|||
https://www.keycloak.org/docs/latest/authorization_services/#removing-a-permission |
|||
https://www.keycloak.org/docs-api/21.0.1/rest-api/index.html#_policyrepresentation |
|||
|
|||
:param policy_id: id of permission policy |
|||
:type policy_id: str |
|||
:return: PermissionRepresentation |
|||
:rtype: dict |
|||
""" |
|||
data_raw = self.connection.raw_delete( |
|||
self.uma_well_known["policy_endpoint"] + f"/{policy_id}" |
|||
) |
|||
return raise_error_from_response(data_raw, KeycloakDeleteError) |
|||
|
|||
def policy_query( |
|||
self, |
|||
resource: str = "", |
|||
name: str = "", |
|||
scope: str = "", |
|||
first: int = 0, |
|||
maximum: int = -1, |
|||
): |
|||
"""Query permission policies. |
|||
|
|||
https://www.keycloak.org/docs/latest/authorization_services/#querying-permission |
|||
|
|||
:param resource: query resource id |
|||
:type resource: str |
|||
:param name: query resource name |
|||
:type name: str |
|||
:param scope: query resource scope |
|||
:type scope: str |
|||
:param first: index of first matching resource to return |
|||
:type first: int |
|||
:param maximum: maximum number of resources to return (-1 for all) |
|||
:type maximum: int |
|||
:return: List of ids |
|||
:return: List of ids |
|||
:rtype: List[str] |
|||
""" |
|||
query = dict() |
|||
if name: |
|||
query["name"] = name |
|||
if resource: |
|||
query["resource"] = resource |
|||
if scope: |
|||
query["scope"] = scope |
|||
if first > 0: |
|||
query["first"] = first |
|||
if maximum >= 0: |
|||
query["max"] = maximum |
|||
|
|||
data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query) |
|||
return raise_error_from_response(data_raw, KeycloakGetError) |
@ -0,0 +1,406 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
"""Keycloak OpenID Connection Manager module. |
|||
|
|||
The module contains mainly the implementation of KeycloakOpenIDConnection class. |
|||
This is an extension of the ConnectionManager class, and handles the automatic refresh |
|||
of openid tokens when required. |
|||
""" |
|||
|
|||
from datetime import datetime, timedelta |
|||
|
|||
from .connection import ConnectionManager |
|||
from .exceptions import KeycloakPostError |
|||
from .keycloak_openid import KeycloakOpenID |
|||
|
|||
|
|||
class KeycloakOpenIDConnection(ConnectionManager): |
|||
"""A class to help with OpenID connections which can auto refresh tokens. |
|||
|
|||
:param object: _description_ |
|||
:type object: _type_ |
|||
""" |
|||
|
|||
_server_url = None |
|||
_username = None |
|||
_password = None |
|||
_totp = None |
|||
_realm_name = None |
|||
_client_id = None |
|||
_verify = None |
|||
_client_secret_key = None |
|||
_connection = None |
|||
_custom_headers = None |
|||
_user_realm_name = None |
|||
_expires_at = None |
|||
|
|||
def __init__( |
|||
self, |
|||
server_url, |
|||
username=None, |
|||
password=None, |
|||
token=None, |
|||
totp=None, |
|||
realm_name="master", |
|||
client_id="admin-cli", |
|||
verify=True, |
|||
client_secret_key=None, |
|||
custom_headers=None, |
|||
user_realm_name=None, |
|||
timeout=60, |
|||
): |
|||
"""Init method. |
|||
|
|||
:param server_url: Keycloak server url |
|||
:type server_url: str |
|||
:param username: admin username |
|||
:type username: str |
|||
:param password: admin password |
|||
:type password: str |
|||
:param token: access and refresh tokens |
|||
:type token: dict |
|||
:param totp: Time based OTP |
|||
:type totp: str |
|||
:param realm_name: realm name |
|||
:type realm_name: str |
|||
:param client_id: client id |
|||
:type client_id: str |
|||
:param verify: True if want check connection SSL |
|||
:type verify: bool |
|||
:param client_secret_key: client secret key |
|||
(optional, required only for access type confidential) |
|||
:type client_secret_key: str |
|||
:param custom_headers: dict of custom header to pass to each HTML request |
|||
:type custom_headers: dict |
|||
:param user_realm_name: The realm name of the user, if different from realm_name |
|||
:type user_realm_name: str |
|||
:param timeout: connection timeout in seconds |
|||
:type timeout: int |
|||
""" |
|||
# token is renewed when it hits 90% of its lifetime. This is to account for any possible |
|||
# clock skew. |
|||
self.token_lifetime_fraction = 0.9 |
|||
self.server_url = server_url |
|||
self.username = username |
|||
self.password = password |
|||
self.token = token |
|||
self.totp = totp |
|||
self.realm_name = realm_name |
|||
self.client_id = client_id |
|||
self.verify = verify |
|||
self.client_secret_key = client_secret_key |
|||
self.user_realm_name = user_realm_name |
|||
self.timeout = timeout |
|||
|
|||
if self.token is None: |
|||
self.get_token() |
|||
|
|||
self.headers = ( |
|||
{ |
|||
"Authorization": "Bearer " + self.token.get("access_token"), |
|||
"Content-Type": "application/json", |
|||
} |
|||
if self.token is not None |
|||
else {} |
|||
) |
|||
self.custom_headers = custom_headers |
|||
|
|||
super().__init__( |
|||
base_url=self.server_url, headers=self.headers, timeout=60, verify=self.verify |
|||
) |
|||
|
|||
@property |
|||
def server_url(self): |
|||
"""Get server url. |
|||
|
|||
:returns: Keycloak server url |
|||
:rtype: str |
|||
""" |
|||
return self.base_url |
|||
|
|||
@server_url.setter |
|||
def server_url(self, value): |
|||
self.base_url = value |
|||
|
|||
@property |
|||
def realm_name(self): |
|||
"""Get realm name. |
|||
|
|||
:returns: Realm name |
|||
:rtype: str |
|||
""" |
|||
return self._realm_name |
|||
|
|||
@realm_name.setter |
|||
def realm_name(self, value): |
|||
self._realm_name = value |
|||
|
|||
@property |
|||
def client_id(self): |
|||
"""Get client id. |
|||
|
|||
:returns: Client id |
|||
:rtype: str |
|||
""" |
|||
return self._client_id |
|||
|
|||
@client_id.setter |
|||
def client_id(self, value): |
|||
self._client_id = value |
|||
|
|||
@property |
|||
def client_secret_key(self): |
|||
"""Get client secret key. |
|||
|
|||
:returns: Client secret key |
|||
:rtype: str |
|||
""" |
|||
return self._client_secret_key |
|||
|
|||
@client_secret_key.setter |
|||
def client_secret_key(self, value): |
|||
self._client_secret_key = value |
|||
|
|||
@property |
|||
def username(self): |
|||
"""Get username. |
|||
|
|||
:returns: Admin username |
|||
:rtype: str |
|||
""" |
|||
return self._username |
|||
|
|||
@username.setter |
|||
def username(self, value): |
|||
self._username = value |
|||
|
|||
@property |
|||
def password(self): |
|||
"""Get password. |
|||
|
|||
:returns: Admin password |
|||
:rtype: str |
|||
""" |
|||
return self._password |
|||
|
|||
@password.setter |
|||
def password(self, value): |
|||
self._password = value |
|||
|
|||
@property |
|||
def totp(self): |
|||
"""Get totp. |
|||
|
|||
:returns: TOTP |
|||
:rtype: str |
|||
""" |
|||
return self._totp |
|||
|
|||
@totp.setter |
|||
def totp(self, value): |
|||
self._totp = value |
|||
|
|||
@property |
|||
def token(self): |
|||
"""Get token. |
|||
|
|||
:returns: Access and refresh token |
|||
:rtype: dict |
|||
""" |
|||
return self._token |
|||
|
|||
@token.setter |
|||
def token(self, value): |
|||
self._token = value |
|||
self._expires_at = datetime.now() + timedelta( |
|||
seconds=int(self.token_lifetime_fraction * self.token["expires_in"] if value else 0) |
|||
) |
|||
|
|||
@property |
|||
def expires_at(self): |
|||
"""Get token expiry time. |
|||
|
|||
:returns: Datetime at which the current token will expire |
|||
:rtype: datetime |
|||
""" |
|||
return self._expires_at |
|||
|
|||
@property |
|||
def user_realm_name(self): |
|||
"""Get user realm name. |
|||
|
|||
:returns: User realm name |
|||
:rtype: str |
|||
""" |
|||
return self._user_realm_name |
|||
|
|||
@user_realm_name.setter |
|||
def user_realm_name(self, value): |
|||
self._user_realm_name = value |
|||
|
|||
@property |
|||
def custom_headers(self): |
|||
"""Get custom headers. |
|||
|
|||
:returns: Custom headers |
|||
:rtype: dict |
|||
""" |
|||
return self._custom_headers |
|||
|
|||
@custom_headers.setter |
|||
def custom_headers(self, value): |
|||
self._custom_headers = value |
|||
if self.custom_headers is not None: |
|||
# merge custom headers to main headers |
|||
self.headers.update(self.custom_headers) |
|||
|
|||
def get_token(self): |
|||
"""Get admin token. |
|||
|
|||
The admin token is then set in the `token` attribute. |
|||
""" |
|||
if self.user_realm_name: |
|||
token_realm_name = self.user_realm_name |
|||
elif self.realm_name: |
|||
token_realm_name = self.realm_name |
|||
else: |
|||
token_realm_name = "master" |
|||
|
|||
self.keycloak_openid = KeycloakOpenID( |
|||
server_url=self.server_url, |
|||
client_id=self.client_id, |
|||
realm_name=token_realm_name, |
|||
verify=self.verify, |
|||
client_secret_key=self.client_secret_key, |
|||
timeout=self.timeout, |
|||
) |
|||
|
|||
grant_type = [] |
|||
if self.client_secret_key: |
|||
grant_type.append("client_credentials") |
|||
elif self.username and self.password: |
|||
grant_type.append("password") |
|||
|
|||
if grant_type: |
|||
self.token = self.keycloak_openid.token( |
|||
self.username, self.password, grant_type=grant_type, totp=self.totp |
|||
) |
|||
else: |
|||
self.token = None |
|||
|
|||
def refresh_token(self): |
|||
"""Refresh the token. |
|||
|
|||
:raises KeycloakPostError: In case the refresh token request failed. |
|||
""" |
|||
refresh_token = self.token.get("refresh_token", None) if self.token else None |
|||
if refresh_token is None: |
|||
self.get_token() |
|||
else: |
|||
try: |
|||
self.token = self.keycloak_openid.refresh_token(refresh_token) |
|||
except KeycloakPostError as e: |
|||
list_errors = [ |
|||
b"Refresh token expired", |
|||
b"Token is not active", |
|||
b"Session not active", |
|||
] |
|||
if e.response_code == 400 and any(err in e.response_body for err in list_errors): |
|||
self.get_token() |
|||
else: |
|||
raise |
|||
|
|||
self.add_param_headers("Authorization", "Bearer " + self.token.get("access_token")) |
|||
|
|||
def _refresh_if_required(self): |
|||
if datetime.now() >= self.expires_at: |
|||
self.refresh_token() |
|||
|
|||
def raw_get(self, *args, **kwargs): |
|||
"""Call connection.raw_get. |
|||
|
|||
If auto_refresh is set for *get* and *access_token* is expired, it will refresh the token |
|||
and try *get* once more. |
|||
|
|||
:param args: Additional arguments |
|||
:type args: tuple |
|||
:param kwargs: Additional keyword arguments |
|||
:type kwargs: dict |
|||
:returns: Response |
|||
:rtype: Response |
|||
""" |
|||
self._refresh_if_required() |
|||
r = super().raw_get(*args, **kwargs) |
|||
return r |
|||
|
|||
def raw_post(self, *args, **kwargs): |
|||
"""Call connection.raw_post. |
|||
|
|||
If auto_refresh is set for *post* and *access_token* is expired, it will refresh the token |
|||
and try *post* once more. |
|||
|
|||
:param args: Additional arguments |
|||
:type args: tuple |
|||
:param kwargs: Additional keyword arguments |
|||
:type kwargs: dict |
|||
:returns: Response |
|||
:rtype: Response |
|||
""" |
|||
self._refresh_if_required() |
|||
r = super().raw_post(*args, **kwargs) |
|||
return r |
|||
|
|||
def raw_put(self, *args, **kwargs): |
|||
"""Call connection.raw_put. |
|||
|
|||
If auto_refresh is set for *put* and *access_token* is expired, it will refresh the token |
|||
and try *put* once more. |
|||
|
|||
:param args: Additional arguments |
|||
:type args: tuple |
|||
:param kwargs: Additional keyword arguments |
|||
:type kwargs: dict |
|||
:returns: Response |
|||
:rtype: Response |
|||
""" |
|||
self._refresh_if_required() |
|||
r = super().raw_put(*args, **kwargs) |
|||
return r |
|||
|
|||
def raw_delete(self, *args, **kwargs): |
|||
"""Call connection.raw_delete. |
|||
|
|||
If auto_refresh is set for *delete* and *access_token* is expired, |
|||
it will refresh the token and try *delete* once more. |
|||
|
|||
:param args: Additional arguments |
|||
:type args: tuple |
|||
:param kwargs: Additional keyword arguments |
|||
:type kwargs: dict |
|||
:returns: Response |
|||
:rtype: Response |
|||
""" |
|||
self._refresh_if_required() |
|||
r = super().raw_delete(*args, **kwargs) |
|||
return r |
@ -0,0 +1,276 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# The MIT License (MIT) |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person obtaining a copy of |
|||
# this software and associated documentation files (the "Software"), to deal in |
|||
# the Software without restriction, including without limitation the rights to |
|||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|||
# the Software, and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be included in all |
|||
# copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|||
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|||
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|||
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|||
|
|||
"""User-managed access permissions module.""" |
|||
|
|||
from keycloak.exceptions import KeycloakPermissionFormatError, PermissionDefinitionError |
|||
|
|||
|
|||
class UMAPermission: |
|||
"""A class to conveniently assemble permissions. |
|||
|
|||
The class itself is callable, and will return the assembled permission. |
|||
|
|||
Usage example: |
|||
|
|||
>>> r = Resource("Users") |
|||
>>> s = Scope("delete") |
|||
>>> permission = r(s) |
|||
>>> print(permission) |
|||
'Users#delete' |
|||
|
|||
:param permission: Permission |
|||
:type permission: UMAPermission |
|||
:param resource: Resource |
|||
:type resource: str |
|||
:param scope: Scope |
|||
:type scope: str |
|||
""" |
|||
|
|||
def __init__(self, permission=None, resource="", scope=""): |
|||
"""Init method. |
|||
|
|||
:param permission: Permission |
|||
:type permission: UMAPermission |
|||
:param resource: Resource |
|||
:type resource: str |
|||
:param scope: Scope |
|||
:type scope: str |
|||
:raises PermissionDefinitionError: In case bad permission definition |
|||
""" |
|||
self.resource = resource |
|||
self.scope = scope |
|||
|
|||
if permission: |
|||
if not isinstance(permission, UMAPermission): |
|||
raise PermissionDefinitionError( |
|||
"can't determine if '{}' is a resource or scope".format(permission) |
|||
) |
|||
if permission.resource: |
|||
self.resource = str(permission.resource) |
|||
if permission.scope: |
|||
self.scope = str(permission.scope) |
|||
|
|||
def __str__(self): |
|||
"""Str method. |
|||
|
|||
:returns: String representation |
|||
:rtype: str |
|||
""" |
|||
scope = self.scope |
|||
if scope: |
|||
scope = "#" + scope |
|||
return "{}{}".format(self.resource, scope) |
|||
|
|||
def __eq__(self, __o: object) -> bool: |
|||
"""Eq method. |
|||
|
|||
:param __o: The other object |
|||
:type __o: object |
|||
:returns: Equality boolean |
|||
:rtype: bool |
|||
""" |
|||
return str(self) == str(__o) |
|||
|
|||
def __repr__(self) -> str: |
|||
"""Repr method. |
|||
|
|||
:returns: The object representation |
|||
:rtype: str |
|||
""" |
|||
return self.__str__() |
|||
|
|||
def __hash__(self) -> int: |
|||
"""Hash method. |
|||
|
|||
:returns: Hash of the object |
|||
:rtype: int |
|||
""" |
|||
return hash(str(self)) |
|||
|
|||
def __call__(self, permission=None, resource="", scope="") -> "UMAPermission": |
|||
"""Call method. |
|||
|
|||
:param permission: Permission |
|||
:type permission: UMAPermission |
|||
:param resource: Resource |
|||
:type resource: str |
|||
:param scope: Scope |
|||
:type scope: str |
|||
:returns: The combined UMA permission |
|||
:rtype: UMAPermission |
|||
:raises PermissionDefinitionError: In case bad permission definition |
|||
""" |
|||
result_resource = self.resource |
|||
result_scope = self.scope |
|||
|
|||
if resource: |
|||
result_resource = str(resource) |
|||
if scope: |
|||
result_scope = str(scope) |
|||
|
|||
if permission: |
|||
if not isinstance(permission, UMAPermission): |
|||
raise PermissionDefinitionError( |
|||
"can't determine if '{}' is a resource or scope".format(permission) |
|||
) |
|||
if permission.resource: |
|||
result_resource = str(permission.resource) |
|||
if permission.scope: |
|||
result_scope = str(permission.scope) |
|||
|
|||
return UMAPermission(resource=result_resource, scope=result_scope) |
|||
|
|||
|
|||
class Resource(UMAPermission): |
|||
"""A UMAPermission Resource class to conveniently assemble permissions. |
|||
|
|||
The class itself is callable, and will return the assembled permission. |
|||
|
|||
:param resource: Resource |
|||
:type resource: str |
|||
""" |
|||
|
|||
def __init__(self, resource): |
|||
"""Init method. |
|||
|
|||
:param resource: Resource |
|||
:type resource: str |
|||
""" |
|||
super().__init__(resource=resource) |
|||
|
|||
|
|||
class Scope(UMAPermission): |
|||
"""A UMAPermission Scope class to conveniently assemble permissions. |
|||
|
|||
The class itself is callable, and will return the assembled permission. |
|||
|
|||
:param scope: Scope |
|||
:type scope: str |
|||
""" |
|||
|
|||
def __init__(self, scope): |
|||
"""Init method. |
|||
|
|||
:param scope: Scope |
|||
:type scope: str |
|||
""" |
|||
super().__init__(scope=scope) |
|||
|
|||
|
|||
class AuthStatus: |
|||
"""A class that represents the authorization/login status of a user associated with a token. |
|||
|
|||
This has to evaluate to True if and only if the user is properly authorized |
|||
for the requested resource. |
|||
|
|||
:param is_logged_in: Is logged in indicator |
|||
:type is_logged_in: bool |
|||
:param is_authorized: Is authorized indicator |
|||
:type is_authorized: bool |
|||
:param missing_permissions: Missing permissions |
|||
:type missing_permissions: set |
|||
""" |
|||
|
|||
def __init__(self, is_logged_in, is_authorized, missing_permissions): |
|||
"""Init method. |
|||
|
|||
:param is_logged_in: Is logged in indicator |
|||
:type is_logged_in: bool |
|||
:param is_authorized: Is authorized indicator |
|||
:type is_authorized: bool |
|||
:param missing_permissions: Missing permissions |
|||
:type missing_permissions: set |
|||
""" |
|||
self.is_logged_in = is_logged_in |
|||
self.is_authorized = is_authorized |
|||
self.missing_permissions = missing_permissions |
|||
|
|||
def __bool__(self): |
|||
"""Bool method. |
|||
|
|||
:returns: Boolean representation |
|||
:rtype: bool |
|||
""" |
|||
return self.is_authorized |
|||
|
|||
def __repr__(self): |
|||
"""Repr method. |
|||
|
|||
:returns: The object representation |
|||
:rtype: str |
|||
""" |
|||
return ( |
|||
f"AuthStatus(" |
|||
f"is_authorized={self.is_authorized}, " |
|||
f"is_logged_in={self.is_logged_in}, " |
|||
f"missing_permissions={self.missing_permissions})" |
|||
) |
|||
|
|||
|
|||
def build_permission_param(permissions): |
|||
"""Transform permissions to a set, so they are usable for requests. |
|||
|
|||
:param permissions: Permissions |
|||
:type permissions: str | Iterable[str] | dict[str, str] | dict[str, Iterabble[str]] |
|||
:returns: Permission parameters |
|||
:rtype: set |
|||
:raises KeycloakPermissionFormatError: In case of bad permission format |
|||
""" |
|||
if permissions is None or permissions == "": |
|||
return set() |
|||
if isinstance(permissions, str): |
|||
return set((permissions,)) |
|||
if isinstance(permissions, UMAPermission): |
|||
return set((str(permissions),)) |
|||
|
|||
try: # treat as dictionary of permissions |
|||
result = set() |
|||
for resource, scopes in permissions.items(): |
|||
print(f"resource={resource}scopes={scopes}") |
|||
if scopes is None: |
|||
result.add(resource) |
|||
elif isinstance(scopes, str): |
|||
result.add("{}#{}".format(resource, scopes)) |
|||
else: |
|||
try: |
|||
for scope in scopes: |
|||
if not isinstance(scope, str): |
|||
raise KeycloakPermissionFormatError( |
|||
"misbuilt permission {}".format(permissions) |
|||
) |
|||
result.add("{}#{}".format(resource, scope)) |
|||
except TypeError: |
|||
raise KeycloakPermissionFormatError( |
|||
"misbuilt permission {}".format(permissions) |
|||
) |
|||
return result |
|||
except AttributeError: |
|||
pass |
|||
|
|||
result = set() |
|||
for permission in permissions: |
|||
if not isinstance(permission, (str, UMAPermission)): |
|||
raise KeycloakPermissionFormatError("misbuilt permission {}".format(permissions)) |
|||
result.add(str(permission)) |
|||
return result |
@ -0,0 +1,38 @@ |
|||
#!/usr/bin/env bash |
|||
|
|||
CMD_ARGS=$1 |
|||
KEYCLOAK_DOCKER_IMAGE_TAG="${KEYCLOAK_DOCKER_IMAGE_TAG:-latest}" |
|||
KEYCLOAK_DOCKER_IMAGE="quay.io/keycloak/keycloak:$KEYCLOAK_DOCKER_IMAGE_TAG" |
|||
|
|||
function keycloak_stop() { |
|||
if [ "$(docker ps -q -f name=unittest_keycloak)" ]; then |
|||
docker logs unittest_keycloak > keycloak_test_logs.txt |
|||
docker stop unittest_keycloak &> /dev/null |
|||
docker rm unittest_keycloak &> /dev/null |
|||
fi |
|||
} |
|||
|
|||
function keycloak_start() { |
|||
echo "Starting keycloak docker container" |
|||
PWD=$(pwd) |
|||
docker run -d --name unittest_keycloak -e KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN}" -e KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD}" -e KC_FEATURES="token-exchange,admin-fine-grained-authz" -p "${KEYCLOAK_PORT}:8080" -v $PWD/tests/providers:/opt/keycloak/providers "${KEYCLOAK_DOCKER_IMAGE}" start-dev |
|||
SECONDS=0 |
|||
until curl --silent --output /dev/null localhost:$KEYCLOAK_PORT; do |
|||
sleep 5; |
|||
if [ ${SECONDS} -gt 180 ]; then |
|||
echo "Timeout exceeded"; |
|||
exit 1; |
|||
fi |
|||
done |
|||
} |
|||
|
|||
# Ensuring that keycloak is stopped in case of CTRL-C |
|||
trap keycloak_stop err exit |
|||
|
|||
keycloak_stop # In case it did not shut down correctly last time. |
|||
keycloak_start |
|||
|
|||
eval ${CMD_ARGS} |
|||
RETURN_VALUE=$? |
|||
|
|||
exit ${RETURN_VALUE} |
@ -0,0 +1 @@ |
|||
"""Tests module.""" |
@ -0,0 +1,530 @@ |
|||
"""Fixtures for tests.""" |
|||
|
|||
import ipaddress |
|||
import os |
|||
import uuid |
|||
from datetime import datetime, timedelta |
|||
from typing import Tuple |
|||
|
|||
import freezegun |
|||
import pytest |
|||
from cryptography import x509 |
|||
from cryptography.hazmat.backends import default_backend |
|||
from cryptography.hazmat.primitives import hashes, serialization |
|||
from cryptography.hazmat.primitives.asymmetric import rsa |
|||
from cryptography.x509.oid import NameOID |
|||
|
|||
from keycloak import KeycloakAdmin, KeycloakOpenID, KeycloakOpenIDConnection, KeycloakUMA |
|||
|
|||
|
|||
class KeycloakTestEnv(object): |
|||
"""Wrapper for test Keycloak connection configuration. |
|||
|
|||
:param host: Hostname |
|||
:type host: str |
|||
:param port: Port |
|||
:type port: str |
|||
:param username: Admin username |
|||
:type username: str |
|||
:param password: Admin password |
|||
:type password: str |
|||
""" |
|||
|
|||
def __init__( |
|||
self, |
|||
host: str = os.environ["KEYCLOAK_HOST"], |
|||
port: str = os.environ["KEYCLOAK_PORT"], |
|||
username: str = os.environ["KEYCLOAK_ADMIN"], |
|||
password: str = os.environ["KEYCLOAK_ADMIN_PASSWORD"], |
|||
): |
|||
"""Init method. |
|||
|
|||
:param host: Hostname |
|||
:type host: str |
|||
:param port: Port |
|||
:type port: str |
|||
:param username: Admin username |
|||
:type username: str |
|||
:param password: Admin password |
|||
:type password: str |
|||
""" |
|||
self.KEYCLOAK_HOST = host |
|||
self.KEYCLOAK_PORT = port |
|||
self.KEYCLOAK_ADMIN = username |
|||
self.KEYCLOAK_ADMIN_PASSWORD = password |
|||
|
|||
@property |
|||
def KEYCLOAK_HOST(self): |
|||
"""Hostname getter. |
|||
|
|||
:returns: Keycloak host |
|||
:rtype: str |
|||
""" |
|||
return self._KEYCLOAK_HOST |
|||
|
|||
@KEYCLOAK_HOST.setter |
|||
def KEYCLOAK_HOST(self, value: str): |
|||
"""Hostname setter. |
|||
|
|||
:param value: Keycloak host |
|||
:type value: str |
|||
""" |
|||
self._KEYCLOAK_HOST = value |
|||
|
|||
@property |
|||
def KEYCLOAK_PORT(self): |
|||
"""Port getter. |
|||
|
|||
:returns: Keycloak port |
|||
:rtype: str |
|||
""" |
|||
return self._KEYCLOAK_PORT |
|||
|
|||
@KEYCLOAK_PORT.setter |
|||
def KEYCLOAK_PORT(self, value: str): |
|||
"""Port setter. |
|||
|
|||
:param value: Keycloak port |
|||
:type value: str |
|||
""" |
|||
self._KEYCLOAK_PORT = value |
|||
|
|||
@property |
|||
def KEYCLOAK_ADMIN(self): |
|||
"""Admin username getter. |
|||
|
|||
:returns: Admin username |
|||
:rtype: str |
|||
""" |
|||
return self._KEYCLOAK_ADMIN |
|||
|
|||
@KEYCLOAK_ADMIN.setter |
|||
def KEYCLOAK_ADMIN(self, value: str): |
|||
"""Admin username setter. |
|||
|
|||
:param value: Admin username |
|||
:type value: str |
|||
""" |
|||
self._KEYCLOAK_ADMIN = value |
|||
|
|||
@property |
|||
def KEYCLOAK_ADMIN_PASSWORD(self): |
|||
"""Admin password getter. |
|||
|
|||
:returns: Admin password |
|||
:rtype: str |
|||
""" |
|||
return self._KEYCLOAK_ADMIN_PASSWORD |
|||
|
|||
@KEYCLOAK_ADMIN_PASSWORD.setter |
|||
def KEYCLOAK_ADMIN_PASSWORD(self, value: str): |
|||
"""Admin password setter. |
|||
|
|||
:param value: Admin password |
|||
:type value: str |
|||
""" |
|||
self._KEYCLOAK_ADMIN_PASSWORD = value |
|||
|
|||
|
|||
@pytest.fixture |
|||
def env(): |
|||
"""Fixture for getting the test environment configuration object. |
|||
|
|||
:returns: Keycloak test environment object |
|||
:rtype: KeycloakTestEnv |
|||
""" |
|||
return KeycloakTestEnv() |
|||
|
|||
|
|||
@pytest.fixture |
|||
def admin(env: KeycloakTestEnv): |
|||
"""Fixture for initialized KeycloakAdmin class. |
|||
|
|||
:param env: Keycloak test environment |
|||
:type env: KeycloakTestEnv |
|||
:returns: Keycloak admin |
|||
:rtype: KeycloakAdmin |
|||
""" |
|||
return KeycloakAdmin( |
|||
server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", |
|||
username=env.KEYCLOAK_ADMIN, |
|||
password=env.KEYCLOAK_ADMIN_PASSWORD, |
|||
) |
|||
|
|||
|
|||
@pytest.fixture |
|||
@freezegun.freeze_time("2023-02-25 10:00:00") |
|||
def admin_frozen(env: KeycloakTestEnv): |
|||
"""Fixture for initialized KeycloakAdmin class, with time frozen. |
|||
|
|||
:param env: Keycloak test environment |
|||
:type env: KeycloakTestEnv |
|||
:returns: Keycloak admin |
|||
:rtype: KeycloakAdmin |
|||
""" |
|||
return KeycloakAdmin( |
|||
server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", |
|||
username=env.KEYCLOAK_ADMIN, |
|||
password=env.KEYCLOAK_ADMIN_PASSWORD, |
|||
) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def oid(env: KeycloakTestEnv, realm: str, admin: KeycloakAdmin): |
|||
"""Fixture for initialized KeycloakOpenID class. |
|||
|
|||
:param env: Keycloak test environment |
|||
:type env: KeycloakTestEnv |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:yields: Keycloak OpenID client |
|||
:rtype: KeycloakOpenID |
|||
""" |
|||
# Set the realm |
|||
admin.realm_name = realm |
|||
# Create client |
|||
client = str(uuid.uuid4()) |
|||
client_id = admin.create_client( |
|||
payload={ |
|||
"name": client, |
|||
"clientId": client, |
|||
"enabled": True, |
|||
"publicClient": True, |
|||
"protocol": "openid-connect", |
|||
} |
|||
) |
|||
# Return OID |
|||
yield KeycloakOpenID( |
|||
server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", |
|||
realm_name=realm, |
|||
client_id=client, |
|||
) |
|||
# Cleanup |
|||
admin.delete_client(client_id=client_id) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def oid_with_credentials(env: KeycloakTestEnv, realm: str, admin: KeycloakAdmin): |
|||
"""Fixture for an initialized KeycloakOpenID class and a random user credentials. |
|||
|
|||
:param env: Keycloak test environment |
|||
:type env: KeycloakTestEnv |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:yields: Keycloak OpenID client with user credentials |
|||
:rtype: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
# Set the realm |
|||
admin.realm_name = realm |
|||
# Create client |
|||
client = str(uuid.uuid4()) |
|||
secret = str(uuid.uuid4()) |
|||
client_id = admin.create_client( |
|||
payload={ |
|||
"name": client, |
|||
"clientId": client, |
|||
"enabled": True, |
|||
"publicClient": False, |
|||
"protocol": "openid-connect", |
|||
"secret": secret, |
|||
"clientAuthenticatorType": "client-secret", |
|||
} |
|||
) |
|||
# Create user |
|||
username = str(uuid.uuid4()) |
|||
password = str(uuid.uuid4()) |
|||
user_id = admin.create_user( |
|||
payload={ |
|||
"username": username, |
|||
"email": f"{username}@test.test", |
|||
"enabled": True, |
|||
"credentials": [{"type": "password", "value": password}], |
|||
} |
|||
) |
|||
|
|||
yield ( |
|||
KeycloakOpenID( |
|||
server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", |
|||
realm_name=realm, |
|||
client_id=client, |
|||
client_secret_key=secret, |
|||
), |
|||
username, |
|||
password, |
|||
) |
|||
|
|||
# Cleanup |
|||
admin.delete_client(client_id=client_id) |
|||
admin.delete_user(user_id=user_id) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def oid_with_credentials_authz(env: KeycloakTestEnv, realm: str, admin: KeycloakAdmin): |
|||
"""Fixture for an initialized KeycloakOpenID class and a random user credentials. |
|||
|
|||
:param env: Keycloak test environment |
|||
:type env: KeycloakTestEnv |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:yields: Keycloak OpenID client configured as an authorization server with client credentials |
|||
:rtype: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
# Set the realm |
|||
admin.realm_name = realm |
|||
# Create client |
|||
client = str(uuid.uuid4()) |
|||
secret = str(uuid.uuid4()) |
|||
client_id = admin.create_client( |
|||
payload={ |
|||
"name": client, |
|||
"clientId": client, |
|||
"enabled": True, |
|||
"publicClient": False, |
|||
"protocol": "openid-connect", |
|||
"secret": secret, |
|||
"clientAuthenticatorType": "client-secret", |
|||
"authorizationServicesEnabled": True, |
|||
"serviceAccountsEnabled": True, |
|||
} |
|||
) |
|||
admin.create_client_authz_role_based_policy( |
|||
client_id=client_id, |
|||
payload={ |
|||
"name": "test-authz-rb-policy", |
|||
"roles": [{"id": admin.get_realm_role(role_name="offline_access")["id"]}], |
|||
}, |
|||
) |
|||
# Create user |
|||
username = str(uuid.uuid4()) |
|||
password = str(uuid.uuid4()) |
|||
user_id = admin.create_user( |
|||
payload={ |
|||
"username": username, |
|||
"email": f"{username}@test.test", |
|||
"enabled": True, |
|||
"credentials": [{"type": "password", "value": password}], |
|||
} |
|||
) |
|||
|
|||
yield ( |
|||
KeycloakOpenID( |
|||
server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", |
|||
realm_name=realm, |
|||
client_id=client, |
|||
client_secret_key=secret, |
|||
), |
|||
username, |
|||
password, |
|||
) |
|||
|
|||
# Cleanup |
|||
admin.delete_client(client_id=client_id) |
|||
admin.delete_user(user_id=user_id) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def realm(admin: KeycloakAdmin) -> str: |
|||
"""Fixture for a new random realm. |
|||
|
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:yields: Keycloak realm |
|||
:rtype: str |
|||
""" |
|||
realm_name = str(uuid.uuid4()) |
|||
admin.create_realm(payload={"realm": realm_name, "enabled": True}) |
|||
yield realm_name |
|||
admin.delete_realm(realm_name=realm_name) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def user(admin: KeycloakAdmin, realm: str) -> str: |
|||
"""Fixture for a new random user. |
|||
|
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:yields: Keycloak user |
|||
:rtype: str |
|||
""" |
|||
admin.realm_name = realm |
|||
username = str(uuid.uuid4()) |
|||
user_id = admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) |
|||
yield user_id |
|||
admin.delete_user(user_id=user_id) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def group(admin: KeycloakAdmin, realm: str) -> str: |
|||
"""Fixture for a new random group. |
|||
|
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:yields: Keycloak group |
|||
:rtype: str |
|||
""" |
|||
admin.realm_name = realm |
|||
group_name = str(uuid.uuid4()) |
|||
group_id = admin.create_group(payload={"name": group_name}) |
|||
yield group_id |
|||
admin.delete_group(group_id=group_id) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def client(admin: KeycloakAdmin, realm: str) -> str: |
|||
"""Fixture for a new random client. |
|||
|
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:yields: Keycloak client id |
|||
:rtype: str |
|||
""" |
|||
admin.realm_name = realm |
|||
client = str(uuid.uuid4()) |
|||
client_id = admin.create_client(payload={"name": client, "clientId": client}) |
|||
yield client_id |
|||
admin.delete_client(client_id=client_id) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def client_role(admin: KeycloakAdmin, realm: str, client: str) -> str: |
|||
"""Fixture for a new random client role. |
|||
|
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:param client: Keycloak client |
|||
:type client: str |
|||
:yields: Keycloak client role |
|||
:rtype: str |
|||
""" |
|||
admin.realm_name = realm |
|||
role = str(uuid.uuid4()) |
|||
admin.create_client_role(client, {"name": role, "composite": False}) |
|||
yield role |
|||
admin.delete_client_role(client, role) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def composite_client_role(admin: KeycloakAdmin, realm: str, client: str, client_role: str) -> str: |
|||
"""Fixture for a new random composite client role. |
|||
|
|||
:param admin: Keycloak admin |
|||
:type admin: KeycloakAdmin |
|||
:param realm: Keycloak realm |
|||
:type realm: str |
|||
:param client: Keycloak client |
|||
:type client: str |
|||
:param client_role: Keycloak client role |
|||
:type client_role: str |
|||
:yields: Composite client role |
|||
:rtype: str |
|||
""" |
|||
admin.realm_name = realm |
|||
role = str(uuid.uuid4()) |
|||
admin.create_client_role(client, {"name": role, "composite": True}) |
|||
role_repr = admin.get_client_role(client, client_role) |
|||
admin.add_composite_client_roles_to_role(client, role, roles=[role_repr]) |
|||
yield role |
|||
admin.delete_client_role(client, role) |
|||
|
|||
|
|||
@pytest.fixture |
|||
def selfsigned_cert(): |
|||
"""Generate self signed certificate for a hostname, and optional IP addresses. |
|||
|
|||
:returns: Selfsigned certificate |
|||
:rtype: Tuple[str, str] |
|||
""" |
|||
hostname = "testcert" |
|||
ip_addresses = None |
|||
key = None |
|||
# Generate our key |
|||
if key is None: |
|||
key = rsa.generate_private_key( |
|||
public_exponent=65537, key_size=2048, backend=default_backend() |
|||
) |
|||
|
|||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) |
|||
alt_names = [x509.DNSName(hostname)] |
|||
|
|||
# allow addressing by IP, for when you don't have real DNS (common in most testing scenarios |
|||
if ip_addresses: |
|||
for addr in ip_addresses: |
|||
# openssl wants DNSnames for ips... |
|||
alt_names.append(x509.DNSName(addr)) |
|||
# ... whereas golang's crypto/tls is stricter, and needs IPAddresses |
|||
# note: older versions of cryptography do not understand ip_address objects |
|||
alt_names.append(x509.IPAddress(ipaddress.ip_address(addr))) |
|||
|
|||
san = x509.SubjectAlternativeName(alt_names) |
|||
|
|||
# path_len=0 means this cert can only sign itself, not other certs. |
|||
basic_contraints = x509.BasicConstraints(ca=True, path_length=0) |
|||
now = datetime.utcnow() |
|||
cert = ( |
|||
x509.CertificateBuilder() |
|||
.subject_name(name) |
|||
.issuer_name(name) |
|||
.public_key(key.public_key()) |
|||
.serial_number(1000) |
|||
.not_valid_before(now) |
|||
.not_valid_after(now + timedelta(days=10 * 365)) |
|||
.add_extension(basic_contraints, False) |
|||
.add_extension(san, False) |
|||
.sign(key, hashes.SHA256(), default_backend()) |
|||
) |
|||
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM) |
|||
key_pem = key.private_bytes( |
|||
encoding=serialization.Encoding.PEM, |
|||
format=serialization.PrivateFormat.TraditionalOpenSSL, |
|||
encryption_algorithm=serialization.NoEncryption(), |
|||
) |
|||
|
|||
return cert_pem, key_pem |
|||
|
|||
|
|||
@pytest.fixture |
|||
def oid_connection_with_authz(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): |
|||
"""Fixture for initialized KeycloakUMA class. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client with pre-configured user credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
:yields: Keycloak OpenID connection manager |
|||
:rtype: KeycloakOpenIDConnection |
|||
""" |
|||
oid, _, _ = oid_with_credentials_authz |
|||
connection = KeycloakOpenIDConnection( |
|||
server_url=oid.connection.base_url, |
|||
realm_name=oid.realm_name, |
|||
client_id=oid.client_id, |
|||
client_secret_key=oid.client_secret_key, |
|||
timeout=60, |
|||
) |
|||
yield connection |
|||
|
|||
|
|||
@pytest.fixture |
|||
def uma(oid_connection_with_authz: KeycloakOpenIDConnection): |
|||
"""Fixture for initialized KeycloakUMA class. |
|||
|
|||
:param oid_connection_with_authz: Keycloak open id connection with pre-configured authz client |
|||
:type oid_connection_with_authz: KeycloakOpenIDConnection |
|||
:yields: Keycloak OpenID client |
|||
:rtype: KeycloakOpenID |
|||
""" |
|||
connection = oid_connection_with_authz |
|||
# Return UMA |
|||
yield KeycloakUMA(connection=connection) |
@ -0,0 +1,45 @@ |
|||
{ |
|||
"allowRemoteResourceManagement": true, |
|||
"policyEnforcementMode": "ENFORCING", |
|||
"policies": [ |
|||
{ |
|||
"name": "Default Policy", |
|||
"type": "js", |
|||
"logic": "POSITIVE", |
|||
"decisionStrategy": "AFFIRMATIVE", |
|||
"config": { |
|||
"code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" |
|||
} |
|||
}, |
|||
{ |
|||
"name": "test-authz-rb-policy", |
|||
"type": "role", |
|||
"logic": "POSITIVE", |
|||
"decisionStrategy": "UNANIMOUS", |
|||
"config": { |
|||
"roles": "[{\"id\":\"offline_access\",\"required\":false}]" |
|||
} |
|||
}, |
|||
{ |
|||
"name": "Default Permission", |
|||
"type": "resource", |
|||
"logic": "POSITIVE", |
|||
"decisionStrategy": "UNANIMOUS", |
|||
"config": { |
|||
"applyPolicies": "[\"test-authz-rb-policy\"]" |
|||
} |
|||
}, |
|||
{ |
|||
"name": "Test scope", |
|||
"type": "scope", |
|||
"logic": "POSITIVE", |
|||
"decisionStrategy": "UNANIMOUS", |
|||
"config": { |
|||
"scopes": "[]", |
|||
"applyPolicies": "[\"test-authz-rb-policy\"]" |
|||
} |
|||
} |
|||
], |
|||
"scopes": [], |
|||
"decisionStrategy": "UNANIMOUS" |
|||
} |
@ -0,0 +1,42 @@ |
|||
"""Test authorization module.""" |
|||
import pytest |
|||
|
|||
from keycloak.authorization import Permission, Policy, Role |
|||
from keycloak.exceptions import KeycloakAuthorizationConfigError |
|||
|
|||
|
|||
def test_authorization_objects(): |
|||
"""Test authorization objects.""" |
|||
# Test permission |
|||
p = Permission(name="test", type="test", logic="test", decision_strategy="test") |
|||
assert p.name == "test" |
|||
assert p.type == "test" |
|||
assert p.logic == "test" |
|||
assert p.decision_strategy == "test" |
|||
p.resources = ["test"] |
|||
assert p.resources == ["test"] |
|||
p.scopes = ["test"] |
|||
assert p.scopes == ["test"] |
|||
|
|||
# Test policy |
|||
p = Policy(name="test", type="test", logic="test", decision_strategy="test") |
|||
assert p.name == "test" |
|||
assert p.type == "test" |
|||
assert p.logic == "test" |
|||
assert p.decision_strategy == "test" |
|||
p.roles = ["test"] |
|||
assert p.roles == ["test"] |
|||
p.permissions = ["test"] |
|||
assert p.permissions == ["test"] |
|||
p.add_permission(permission="test2") |
|||
assert p.permissions == ["test", "test2"] |
|||
with pytest.raises(KeycloakAuthorizationConfigError): |
|||
p.add_role(role="test2") |
|||
|
|||
# Test role |
|||
r = Role(name="test") |
|||
assert r.name == "test" |
|||
assert not r.required |
|||
assert r.get_name() == "test" |
|||
assert r == r |
|||
assert r == "test" |
@ -0,0 +1,41 @@ |
|||
"""Connection test module.""" |
|||
|
|||
import pytest |
|||
|
|||
from keycloak.connection import ConnectionManager |
|||
from keycloak.exceptions import KeycloakConnectionError |
|||
|
|||
|
|||
def test_connection_proxy(): |
|||
"""Test proxies of connection manager.""" |
|||
cm = ConnectionManager( |
|||
base_url="http://test.test", proxies={"http://test.test": "localhost:8080"} |
|||
) |
|||
assert cm._s.proxies == {"http://test.test": "localhost:8080"} |
|||
|
|||
|
|||
def test_headers(): |
|||
"""Test headers manipulation.""" |
|||
cm = ConnectionManager(base_url="http://test.test", headers={"H": "A"}) |
|||
assert cm.param_headers(key="H") == "A" |
|||
assert cm.param_headers(key="A") is None |
|||
cm.clean_headers() |
|||
assert cm.headers == dict() |
|||
cm.add_param_headers(key="H", value="B") |
|||
assert cm.exist_param_headers(key="H") |
|||
assert not cm.exist_param_headers(key="B") |
|||
cm.del_param_headers(key="H") |
|||
assert not cm.exist_param_headers(key="H") |
|||
|
|||
|
|||
def test_bad_connection(): |
|||
"""Test bad connection.""" |
|||
cm = ConnectionManager(base_url="http://not.real.domain") |
|||
with pytest.raises(KeycloakConnectionError): |
|||
cm.raw_get(path="bad") |
|||
with pytest.raises(KeycloakConnectionError): |
|||
cm.raw_delete(path="bad") |
|||
with pytest.raises(KeycloakConnectionError): |
|||
cm.raw_post(path="bad", data={}) |
|||
with pytest.raises(KeycloakConnectionError): |
|||
cm.raw_put(path="bad", data={}) |
@ -0,0 +1,20 @@ |
|||
"""Test the exceptions module.""" |
|||
|
|||
from unittest.mock import Mock |
|||
|
|||
import pytest |
|||
|
|||
from keycloak.exceptions import KeycloakOperationError, raise_error_from_response |
|||
|
|||
|
|||
def test_raise_error_from_response_from_dict(): |
|||
"""Test raise error from response using a dictionary.""" |
|||
response = Mock() |
|||
response.json.return_value = {"key": "value"} |
|||
response.status_code = 408 |
|||
response.content = "Error" |
|||
|
|||
with pytest.raises(KeycloakOperationError): |
|||
raise_error_from_response( |
|||
response=response, error=dict(), expected_codes=[200], skip_exists=False |
|||
) |
2760
tests/test_keycloak_admin.py
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,472 @@ |
|||
"""Test module for KeycloakOpenID.""" |
|||
from typing import Tuple |
|||
from unittest import mock |
|||
|
|||
import pytest |
|||
|
|||
from keycloak import KeycloakAdmin, KeycloakOpenID |
|||
from keycloak.authorization import Authorization |
|||
from keycloak.authorization.permission import Permission |
|||
from keycloak.authorization.policy import Policy |
|||
from keycloak.authorization.role import Role |
|||
from keycloak.connection import ConnectionManager |
|||
from keycloak.exceptions import ( |
|||
KeycloakAuthenticationError, |
|||
KeycloakAuthorizationConfigError, |
|||
KeycloakDeprecationError, |
|||
KeycloakInvalidTokenError, |
|||
KeycloakPostError, |
|||
KeycloakRPTNotFound, |
|||
) |
|||
|
|||
|
|||
def test_keycloak_openid_init(env): |
|||
"""Test KeycloakOpenId's init method. |
|||
|
|||
:param env: Environment fixture |
|||
:type env: KeycloakTestEnv |
|||
""" |
|||
oid = KeycloakOpenID( |
|||
server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", |
|||
realm_name="master", |
|||
client_id="admin-cli", |
|||
) |
|||
|
|||
assert oid.client_id == "admin-cli" |
|||
assert oid.client_secret_key is None |
|||
assert oid.realm_name == "master" |
|||
assert isinstance(oid.connection, ConnectionManager) |
|||
assert isinstance(oid.authorization, Authorization) |
|||
|
|||
|
|||
def test_well_known(oid: KeycloakOpenID): |
|||
"""Test the well_known method. |
|||
|
|||
:param oid: Keycloak OpenID client |
|||
:type oid: KeycloakOpenID |
|||
""" |
|||
res = oid.well_known() |
|||
assert res is not None |
|||
assert res != dict() |
|||
for key in [ |
|||
"acr_values_supported", |
|||
"authorization_encryption_alg_values_supported", |
|||
"authorization_encryption_enc_values_supported", |
|||
"authorization_endpoint", |
|||
"authorization_signing_alg_values_supported", |
|||
"backchannel_authentication_endpoint", |
|||
"backchannel_authentication_request_signing_alg_values_supported", |
|||
"backchannel_logout_session_supported", |
|||
"backchannel_logout_supported", |
|||
"backchannel_token_delivery_modes_supported", |
|||
"check_session_iframe", |
|||
"claim_types_supported", |
|||
"claims_parameter_supported", |
|||
"claims_supported", |
|||
"code_challenge_methods_supported", |
|||
"device_authorization_endpoint", |
|||
"end_session_endpoint", |
|||
"frontchannel_logout_session_supported", |
|||
"frontchannel_logout_supported", |
|||
"grant_types_supported", |
|||
"id_token_encryption_alg_values_supported", |
|||
"id_token_encryption_enc_values_supported", |
|||
"id_token_signing_alg_values_supported", |
|||
"introspection_endpoint", |
|||
"introspection_endpoint_auth_methods_supported", |
|||
"introspection_endpoint_auth_signing_alg_values_supported", |
|||
"issuer", |
|||
"jwks_uri", |
|||
"mtls_endpoint_aliases", |
|||
"pushed_authorization_request_endpoint", |
|||
"registration_endpoint", |
|||
"request_object_encryption_alg_values_supported", |
|||
"request_object_encryption_enc_values_supported", |
|||
"request_object_signing_alg_values_supported", |
|||
"request_parameter_supported", |
|||
"request_uri_parameter_supported", |
|||
"require_pushed_authorization_requests", |
|||
"require_request_uri_registration", |
|||
"response_modes_supported", |
|||
"response_types_supported", |
|||
"revocation_endpoint", |
|||
"revocation_endpoint_auth_methods_supported", |
|||
"revocation_endpoint_auth_signing_alg_values_supported", |
|||
"scopes_supported", |
|||
"subject_types_supported", |
|||
"tls_client_certificate_bound_access_tokens", |
|||
"token_endpoint", |
|||
"token_endpoint_auth_methods_supported", |
|||
"token_endpoint_auth_signing_alg_values_supported", |
|||
"userinfo_encryption_alg_values_supported", |
|||
"userinfo_encryption_enc_values_supported", |
|||
"userinfo_endpoint", |
|||
"userinfo_signing_alg_values_supported", |
|||
]: |
|||
assert key in res |
|||
|
|||
|
|||
def test_auth_url(env, oid: KeycloakOpenID): |
|||
"""Test the auth_url method. |
|||
|
|||
:param env: Environment fixture |
|||
:type env: KeycloakTestEnv |
|||
:param oid: Keycloak OpenID client |
|||
:type oid: KeycloakOpenID |
|||
""" |
|||
res = oid.auth_url(redirect_uri="http://test.test/*") |
|||
assert ( |
|||
res |
|||
== f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}/realms/{oid.realm_name}" |
|||
+ f"/protocol/openid-connect/auth?client_id={oid.client_id}&response_type=code" |
|||
+ "&redirect_uri=http://test.test/*&scope=email&state=" |
|||
) |
|||
|
|||
|
|||
def test_token(oid_with_credentials: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test the token method. |
|||
|
|||
:param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials |
|||
:type oid_with_credentials: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials |
|||
token = oid.token(username=username, password=password) |
|||
assert token == { |
|||
"access_token": mock.ANY, |
|||
"expires_in": mock.ANY, |
|||
"id_token": mock.ANY, |
|||
"not-before-policy": 0, |
|||
"refresh_expires_in": mock.ANY, |
|||
"refresh_token": mock.ANY, |
|||
"scope": mock.ANY, |
|||
"session_state": mock.ANY, |
|||
"token_type": "Bearer", |
|||
} |
|||
|
|||
# Test with dummy totp |
|||
token = oid.token(username=username, password=password, totp="123456") |
|||
assert token == { |
|||
"access_token": mock.ANY, |
|||
"expires_in": mock.ANY, |
|||
"id_token": mock.ANY, |
|||
"not-before-policy": 0, |
|||
"refresh_expires_in": mock.ANY, |
|||
"refresh_token": mock.ANY, |
|||
"scope": mock.ANY, |
|||
"session_state": mock.ANY, |
|||
"token_type": "Bearer", |
|||
} |
|||
|
|||
# Test with extra param |
|||
token = oid.token(username=username, password=password, extra_param="foo") |
|||
assert token == { |
|||
"access_token": mock.ANY, |
|||
"expires_in": mock.ANY, |
|||
"id_token": mock.ANY, |
|||
"not-before-policy": 0, |
|||
"refresh_expires_in": mock.ANY, |
|||
"refresh_token": mock.ANY, |
|||
"scope": mock.ANY, |
|||
"session_state": mock.ANY, |
|||
"token_type": "Bearer", |
|||
} |
|||
|
|||
|
|||
def test_exchange_token( |
|||
oid_with_credentials: Tuple[KeycloakOpenID, str, str], admin: KeycloakAdmin |
|||
): |
|||
"""Test the exchange token method. |
|||
|
|||
:param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials |
|||
:type oid_with_credentials: Tuple[KeycloakOpenID, str, str] |
|||
:param admin: Keycloak Admin client |
|||
:type admin: KeycloakAdmin |
|||
""" |
|||
# Verify existing user |
|||
oid, username, password = oid_with_credentials |
|||
|
|||
# Allow impersonation |
|||
admin.realm_name = oid.realm_name |
|||
admin.assign_client_role( |
|||
user_id=admin.get_user_id(username=username), |
|||
client_id=admin.get_client_id(client_id="realm-management"), |
|||
roles=[ |
|||
admin.get_client_role( |
|||
client_id=admin.get_client_id(client_id="realm-management"), |
|||
role_name="impersonation", |
|||
) |
|||
], |
|||
) |
|||
|
|||
token = oid.token(username=username, password=password) |
|||
assert oid.userinfo(token=token["access_token"]) == { |
|||
"email": f"{username}@test.test", |
|||
"email_verified": False, |
|||
"preferred_username": username, |
|||
"sub": mock.ANY, |
|||
} |
|||
|
|||
# Exchange token with the new user |
|||
new_token = oid.exchange_token( |
|||
token=token["access_token"], |
|||
audience=oid.client_id, |
|||
subject=username, |
|||
) |
|||
assert oid.userinfo(token=new_token["access_token"]) == { |
|||
"email": f"{username}@test.test", |
|||
"email_verified": False, |
|||
"preferred_username": username, |
|||
"sub": mock.ANY, |
|||
} |
|||
assert token != new_token |
|||
|
|||
|
|||
def test_logout(oid_with_credentials): |
|||
"""Test logout. |
|||
|
|||
:param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials |
|||
:type oid_with_credentials: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials |
|||
|
|||
token = oid.token(username=username, password=password) |
|||
assert oid.userinfo(token=token["access_token"]) != dict() |
|||
assert oid.logout(refresh_token=token["refresh_token"]) == dict() |
|||
|
|||
with pytest.raises(KeycloakAuthenticationError): |
|||
oid.userinfo(token=token["access_token"]) |
|||
|
|||
|
|||
def test_certs(oid: KeycloakOpenID): |
|||
"""Test certificates. |
|||
|
|||
:param oid: Keycloak OpenID client |
|||
:type oid: KeycloakOpenID |
|||
""" |
|||
assert len(oid.certs()["keys"]) == 2 |
|||
|
|||
|
|||
def test_public_key(oid: KeycloakOpenID): |
|||
"""Test public key. |
|||
|
|||
:param oid: Keycloak OpenID client |
|||
:type oid: KeycloakOpenID |
|||
""" |
|||
assert oid.public_key() is not None |
|||
|
|||
|
|||
def test_entitlement( |
|||
oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str], admin: KeycloakAdmin |
|||
): |
|||
"""Test entitlement. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization |
|||
server with client credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
:param admin: Keycloak Admin client |
|||
:type admin: KeycloakAdmin |
|||
""" |
|||
oid, username, password = oid_with_credentials_authz |
|||
token = oid.token(username=username, password=password) |
|||
resource_server_id = admin.get_client_authz_resources( |
|||
client_id=admin.get_client_id(oid.client_id) |
|||
)[0]["_id"] |
|||
|
|||
with pytest.raises(KeycloakDeprecationError): |
|||
oid.entitlement(token=token["access_token"], resource_server_id=resource_server_id) |
|||
|
|||
|
|||
def test_introspect(oid_with_credentials: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test introspect. |
|||
|
|||
:param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials |
|||
:type oid_with_credentials: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials |
|||
token = oid.token(username=username, password=password) |
|||
|
|||
assert oid.introspect(token=token["access_token"])["active"] |
|||
assert oid.introspect( |
|||
token=token["access_token"], rpt="some", token_type_hint="requesting_party_token" |
|||
) == {"active": False} |
|||
|
|||
with pytest.raises(KeycloakRPTNotFound): |
|||
oid.introspect(token=token["access_token"], token_type_hint="requesting_party_token") |
|||
|
|||
|
|||
def test_decode_token(oid_with_credentials: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test decode token. |
|||
|
|||
:param oid_with_credentials: Keycloak OpenID client with pre-configured user credentials |
|||
:type oid_with_credentials: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials |
|||
token = oid.token(username=username, password=password) |
|||
|
|||
assert ( |
|||
oid.decode_token( |
|||
token=token["access_token"], |
|||
key="-----BEGIN PUBLIC KEY-----\n" + oid.public_key() + "\n-----END PUBLIC KEY-----", |
|||
options={"verify_aud": False}, |
|||
)["preferred_username"] |
|||
== username |
|||
) |
|||
|
|||
|
|||
def test_load_authorization_config(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test load authorization config. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization |
|||
server with client credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials_authz |
|||
|
|||
oid.load_authorization_config(path="tests/data/authz_settings.json") |
|||
assert "test-authz-rb-policy" in oid.authorization.policies |
|||
assert isinstance(oid.authorization.policies["test-authz-rb-policy"], Policy) |
|||
assert len(oid.authorization.policies["test-authz-rb-policy"].roles) == 1 |
|||
assert isinstance(oid.authorization.policies["test-authz-rb-policy"].roles[0], Role) |
|||
assert len(oid.authorization.policies["test-authz-rb-policy"].permissions) == 2 |
|||
assert isinstance( |
|||
oid.authorization.policies["test-authz-rb-policy"].permissions[0], Permission |
|||
) |
|||
|
|||
|
|||
def test_get_policies(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test get policies. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization |
|||
server with client credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials_authz |
|||
token = oid.token(username=username, password=password) |
|||
|
|||
with pytest.raises(KeycloakAuthorizationConfigError): |
|||
oid.get_policies(token=token["access_token"]) |
|||
|
|||
oid.load_authorization_config(path="tests/data/authz_settings.json") |
|||
assert oid.get_policies(token=token["access_token"]) is None |
|||
|
|||
key = "-----BEGIN PUBLIC KEY-----\n" + oid.public_key() + "\n-----END PUBLIC KEY-----" |
|||
orig_client_id = oid.client_id |
|||
oid.client_id = "account" |
|||
assert oid.get_policies(token=token["access_token"], method_token_info="decode", key=key) == [] |
|||
policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") |
|||
policy.add_role(role="account/view-profile") |
|||
oid.authorization.policies["test"] = policy |
|||
assert [ |
|||
str(x) |
|||
for x in oid.get_policies(token=token["access_token"], method_token_info="decode", key=key) |
|||
] == ["Policy: test (role)"] |
|||
assert [ |
|||
repr(x) |
|||
for x in oid.get_policies(token=token["access_token"], method_token_info="decode", key=key) |
|||
] == ["<Policy: test (role)>"] |
|||
oid.client_id = orig_client_id |
|||
|
|||
oid.logout(refresh_token=token["refresh_token"]) |
|||
with pytest.raises(KeycloakInvalidTokenError): |
|||
oid.get_policies(token=token["access_token"]) |
|||
|
|||
|
|||
def test_get_permissions(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test get policies. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization |
|||
server with client credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials_authz |
|||
token = oid.token(username=username, password=password) |
|||
|
|||
with pytest.raises(KeycloakAuthorizationConfigError): |
|||
oid.get_permissions(token=token["access_token"]) |
|||
|
|||
oid.load_authorization_config(path="tests/data/authz_settings.json") |
|||
assert oid.get_permissions(token=token["access_token"]) is None |
|||
|
|||
key = "-----BEGIN PUBLIC KEY-----\n" + oid.public_key() + "\n-----END PUBLIC KEY-----" |
|||
orig_client_id = oid.client_id |
|||
oid.client_id = "account" |
|||
assert ( |
|||
oid.get_permissions(token=token["access_token"], method_token_info="decode", key=key) == [] |
|||
) |
|||
policy = Policy(name="test", type="role", logic="POSITIVE", decision_strategy="UNANIMOUS") |
|||
policy.add_role(role="account/view-profile") |
|||
policy.add_permission( |
|||
permission=Permission( |
|||
name="test-perm", type="resource", logic="POSITIVE", decision_strategy="UNANIMOUS" |
|||
) |
|||
) |
|||
oid.authorization.policies["test"] = policy |
|||
assert [ |
|||
str(x) |
|||
for x in oid.get_permissions( |
|||
token=token["access_token"], method_token_info="decode", key=key |
|||
) |
|||
] == ["Permission: test-perm (resource)"] |
|||
assert [ |
|||
repr(x) |
|||
for x in oid.get_permissions( |
|||
token=token["access_token"], method_token_info="decode", key=key |
|||
) |
|||
] == ["<Permission: test-perm (resource)>"] |
|||
oid.client_id = orig_client_id |
|||
|
|||
oid.logout(refresh_token=token["refresh_token"]) |
|||
with pytest.raises(KeycloakInvalidTokenError): |
|||
oid.get_permissions(token=token["access_token"]) |
|||
|
|||
|
|||
def test_uma_permissions(oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str]): |
|||
"""Test UMA permissions. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization |
|||
server with client credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
""" |
|||
oid, username, password = oid_with_credentials_authz |
|||
token = oid.token(username=username, password=password) |
|||
|
|||
assert len(oid.uma_permissions(token=token["access_token"])) == 1 |
|||
assert oid.uma_permissions(token=token["access_token"])[0]["rsname"] == "Default Resource" |
|||
|
|||
|
|||
def test_has_uma_access( |
|||
oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str], admin: KeycloakAdmin |
|||
): |
|||
"""Test has UMA access. |
|||
|
|||
:param oid_with_credentials_authz: Keycloak OpenID client configured as an authorization |
|||
server with client credentials |
|||
:type oid_with_credentials_authz: Tuple[KeycloakOpenID, str, str] |
|||
:param admin: Keycloak Admin client |
|||
:type admin: KeycloakAdmin |
|||
""" |
|||
oid, username, password = oid_with_credentials_authz |
|||
token = oid.token(username=username, password=password) |
|||
|
|||
assert ( |
|||
str(oid.has_uma_access(token=token["access_token"], permissions="")) |
|||
== "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" |
|||
) |
|||
assert ( |
|||
str(oid.has_uma_access(token=token["access_token"], permissions="Default Resource")) |
|||
== "AuthStatus(is_authorized=True, is_logged_in=True, missing_permissions=set())" |
|||
) |
|||
|
|||
with pytest.raises(KeycloakPostError): |
|||
oid.has_uma_access(token=token["access_token"], permissions="Does not exist") |
|||
|
|||
oid.logout(refresh_token=token["refresh_token"]) |
|||
assert ( |
|||
str(oid.has_uma_access(token=token["access_token"], permissions="")) |
|||
== "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=set())" |
|||
) |
|||
assert ( |
|||
str(oid.has_uma_access(token=admin.token["access_token"], permissions="Default Resource")) |
|||
== "AuthStatus(is_authorized=False, is_logged_in=False, missing_permissions=" |
|||
+ "{'Default Resource'})" |
|||
) |
@ -0,0 +1,311 @@ |
|||
"""Test module for KeycloakUMA.""" |
|||
import re |
|||
|
|||
import pytest |
|||
|
|||
from keycloak import KeycloakAdmin, KeycloakOpenIDConnection, KeycloakUMA |
|||
from keycloak.exceptions import ( |
|||
KeycloakDeleteError, |
|||
KeycloakGetError, |
|||
KeycloakPostError, |
|||
KeycloakPutError, |
|||
) |
|||
from keycloak.uma_permissions import UMAPermission |
|||
|
|||
|
|||
def test_keycloak_uma_init(oid_connection_with_authz: KeycloakOpenIDConnection): |
|||
"""Test KeycloakUMA's init method. |
|||
|
|||
:param oid_connection_with_authz: Keycloak OpenID connection manager with preconfigured authz |
|||
:type oid_connection_with_authz: KeycloakOpenIDConnection |
|||
""" |
|||
connection = oid_connection_with_authz |
|||
uma = KeycloakUMA(connection=connection) |
|||
|
|||
assert isinstance(uma.connection, KeycloakOpenIDConnection) |
|||
# should initially be empty |
|||
assert uma._well_known is None |
|||
assert uma.uma_well_known |
|||
# should be cached after first reference |
|||
assert uma._well_known is not None |
|||
|
|||
|
|||
def test_uma_well_known(uma: KeycloakUMA): |
|||
"""Test the well_known method. |
|||
|
|||
:param uma: Keycloak UMA client |
|||
:type uma: KeycloakUMA |
|||
""" |
|||
res = uma.uma_well_known |
|||
assert res is not None |
|||
assert res != dict() |
|||
for key in ["resource_registration_endpoint"]: |
|||
assert key in res |
|||
|
|||
|
|||
def test_uma_resource_sets(uma: KeycloakUMA): |
|||
"""Test resource sets. |
|||
|
|||
:param uma: Keycloak UMA client |
|||
:type uma: KeycloakUMA |
|||
""" |
|||
# Check that only the default resource is present |
|||
resource_sets = uma.resource_set_list() |
|||
resource_set_list = list(resource_sets) |
|||
assert len(resource_set_list) == 1, resource_set_list |
|||
assert resource_set_list[0]["name"] == "Default Resource", resource_set_list[0]["name"] |
|||
|
|||
# Test query for resource sets |
|||
resource_set_list_ids = uma.resource_set_list_ids() |
|||
assert len(resource_set_list_ids) == 1 |
|||
|
|||
resource_set_list_ids2 = uma.resource_set_list_ids(name="Default") |
|||
assert resource_set_list_ids2 == resource_set_list_ids |
|||
|
|||
resource_set_list_ids2 = uma.resource_set_list_ids(name="Default Resource") |
|||
assert resource_set_list_ids2 == resource_set_list_ids |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(name="Default", exact_name=True) |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(first=1) |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(scope="Invalid") |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(owner="Invalid") |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(resource_type="Invalid") |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(name="Invalid") |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(uri="Invalid") |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
resource_set_list_ids = uma.resource_set_list_ids(maximum=0) |
|||
assert len(resource_set_list_ids) == 0 |
|||
|
|||
# Test create resource set |
|||
resource_to_create = { |
|||
"name": "mytest", |
|||
"scopes": ["test:read", "test:write"], |
|||
"type": "urn:test", |
|||
} |
|||
created_resource = uma.resource_set_create(resource_to_create) |
|||
assert created_resource |
|||
assert created_resource["_id"], created_resource |
|||
assert set(resource_to_create).issubset(set(created_resource)), created_resource |
|||
|
|||
# Test create the same resource set |
|||
with pytest.raises(KeycloakPostError) as err: |
|||
uma.resource_set_create(resource_to_create) |
|||
assert err.match( |
|||
re.escape( |
|||
'409: b\'{"error":"invalid_request","error_description":' |
|||
'"Resource with name [mytest] already exists."}\'' |
|||
) |
|||
) |
|||
|
|||
# Test get resource set |
|||
latest_resource = uma.resource_set_read(created_resource["_id"]) |
|||
assert latest_resource["name"] == created_resource["name"] |
|||
|
|||
# Test update resource set |
|||
latest_resource["name"] = "New Resource Name" |
|||
res = uma.resource_set_update(created_resource["_id"], latest_resource) |
|||
assert res == dict(), res |
|||
updated_resource = uma.resource_set_read(created_resource["_id"]) |
|||
assert updated_resource["name"] == "New Resource Name" |
|||
|
|||
# Test update resource set fail |
|||
with pytest.raises(KeycloakPutError) as err: |
|||
uma.resource_set_update(resource_id=created_resource["_id"], payload={"wrong": "payload"}) |
|||
assert err.match('400: b\'{"error":"Unrecognized field') |
|||
|
|||
# Test delete resource set |
|||
res = uma.resource_set_delete(resource_id=created_resource["_id"]) |
|||
assert res == dict(), res |
|||
with pytest.raises(KeycloakGetError) as err: |
|||
uma.resource_set_read(created_resource["_id"]) |
|||
err.match("404: b''") |
|||
|
|||
# Test delete fail |
|||
with pytest.raises(KeycloakDeleteError) as err: |
|||
uma.resource_set_delete(resource_id=created_resource["_id"]) |
|||
assert err.match("404: b''") |
|||
|
|||
|
|||
def test_uma_policy(uma: KeycloakUMA, admin: KeycloakAdmin): |
|||
"""Test policies. |
|||
|
|||
:param uma: Keycloak UMA client |
|||
:type uma: KeycloakUMA |
|||
:param admin: Keycloak Admin client |
|||
:type admin: KeycloakAdmin |
|||
""" |
|||
# Create some required test data |
|||
resource_to_create = { |
|||
"name": "mytest", |
|||
"scopes": ["test:read", "test:write"], |
|||
"type": "urn:test", |
|||
"ownerManagedAccess": True, |
|||
} |
|||
created_resource = uma.resource_set_create(resource_to_create) |
|||
group_id = admin.create_group({"name": "UMAPolicyGroup"}) |
|||
role_id = admin.create_realm_role(payload={"name": "roleUMAPolicy"}) |
|||
other_client_id = admin.create_client({"name": "UMAOtherClient"}) |
|||
client = admin.get_client(other_client_id) |
|||
|
|||
resource_id = created_resource["_id"] |
|||
|
|||
# Create a role policy |
|||
policy_to_create = { |
|||
"name": "TestPolicyRole", |
|||
"description": "Test resource policy description", |
|||
"scopes": ["test:read", "test:write"], |
|||
"roles": ["roleUMAPolicy"], |
|||
} |
|||
policy = uma.policy_resource_create(resource_id=resource_id, payload=policy_to_create) |
|||
assert policy |
|||
|
|||
# Create a client policy |
|||
policy_to_create = { |
|||
"name": "TestPolicyClient", |
|||
"description": "Test resource policy description", |
|||
"scopes": ["test:read"], |
|||
"clients": [client["clientId"]], |
|||
} |
|||
policy = uma.policy_resource_create(resource_id=resource_id, payload=policy_to_create) |
|||
assert policy |
|||
|
|||
policy_to_create = { |
|||
"name": "TestPolicyGroup", |
|||
"description": "Test resource policy description", |
|||
"scopes": ["test:read"], |
|||
"groups": ["/UMAPolicyGroup"], |
|||
} |
|||
policy = uma.policy_resource_create(resource_id=resource_id, payload=policy_to_create) |
|||
assert policy |
|||
|
|||
policies = uma.policy_query() |
|||
assert len(policies) == 3 |
|||
|
|||
policies = uma.policy_query(name="TestPolicyGroup") |
|||
assert len(policies) == 1 |
|||
|
|||
policy_id = policy["id"] |
|||
uma.policy_delete(policy_id) |
|||
with pytest.raises(KeycloakDeleteError) as err: |
|||
uma.policy_delete(policy_id) |
|||
assert err.match( |
|||
'404: b\'{"error":"invalid_request","error_description":"Policy with .* does not exist"}\'' |
|||
) |
|||
|
|||
policies = uma.policy_query() |
|||
assert len(policies) == 2 |
|||
|
|||
policy = policies[0] |
|||
uma.policy_update(policy_id=policy["id"], payload=policy) |
|||
|
|||
policies = uma.policy_query() |
|||
assert len(policies) == 2 |
|||
|
|||
policies = uma.policy_query(name="Invalid") |
|||
assert len(policies) == 0 |
|||
policies = uma.policy_query(scope="Invalid") |
|||
assert len(policies) == 0 |
|||
policies = uma.policy_query(resource="Invalid") |
|||
assert len(policies) == 0 |
|||
policies = uma.policy_query(first=3) |
|||
assert len(policies) == 0 |
|||
policies = uma.policy_query(maximum=0) |
|||
assert len(policies) == 0 |
|||
|
|||
policies = uma.policy_query(name=policy["name"]) |
|||
assert len(policies) == 1 |
|||
policies = uma.policy_query(scope=policy["scopes"][0]) |
|||
assert len(policies) == 2 |
|||
policies = uma.policy_query(resource=resource_id) |
|||
assert len(policies) == 2 |
|||
|
|||
uma.resource_set_delete(resource_id) |
|||
admin.delete_client(other_client_id) |
|||
admin.delete_realm_role(role_id) |
|||
admin.delete_group(group_id) |
|||
|
|||
|
|||
def test_uma_access(uma: KeycloakUMA): |
|||
"""Test permission access checks. |
|||
|
|||
:param uma: Keycloak UMA client |
|||
:type uma: KeycloakUMA |
|||
""" |
|||
resource_to_create = { |
|||
"name": "mytest", |
|||
"scopes": ["read", "write"], |
|||
"type": "urn:test", |
|||
"ownerManagedAccess": True, |
|||
} |
|||
resource = uma.resource_set_create(resource_to_create) |
|||
|
|||
policy_to_create = { |
|||
"name": "TestPolicy", |
|||
"description": "Test resource policy description", |
|||
"scopes": [resource_to_create["scopes"][0]], |
|||
"clients": [uma.connection.client_id], |
|||
} |
|||
uma.policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) |
|||
|
|||
token = uma.connection.token |
|||
permissions = list() |
|||
assert uma.permissions_check(token["access_token"], permissions) |
|||
|
|||
permissions.append(UMAPermission(resource=resource_to_create["name"])) |
|||
assert uma.permissions_check(token["access_token"], permissions) |
|||
|
|||
permissions.append(UMAPermission(resource="not valid")) |
|||
assert not uma.permissions_check(token["access_token"], permissions) |
|||
uma.resource_set_delete(resource["_id"]) |
|||
|
|||
|
|||
def test_uma_permission_ticket(uma: KeycloakUMA): |
|||
"""Test permission ticket generation. |
|||
|
|||
:param uma: Keycloak UMA client |
|||
:type uma: KeycloakUMA |
|||
""" |
|||
resource_to_create = { |
|||
"name": "mytest", |
|||
"scopes": ["read", "write"], |
|||
"type": "urn:test", |
|||
"ownerManagedAccess": True, |
|||
} |
|||
resource = uma.resource_set_create(resource_to_create) |
|||
|
|||
policy_to_create = { |
|||
"name": "TestPolicy", |
|||
"description": "Test resource policy description", |
|||
"scopes": [resource_to_create["scopes"][0]], |
|||
"clients": [uma.connection.client_id], |
|||
} |
|||
uma.policy_resource_create(resource_id=resource["_id"], payload=policy_to_create) |
|||
permissions = ( |
|||
UMAPermission(resource=resource_to_create["name"], scope=resource_to_create["scopes"][0]), |
|||
) |
|||
response = uma.permission_ticket_create(permissions) |
|||
|
|||
rpt = uma.connection.keycloak_openid.token( |
|||
grant_type="urn:ietf:params:oauth:grant-type:uma-ticket", ticket=response["ticket"] |
|||
) |
|||
assert rpt |
|||
assert "access_token" in rpt |
|||
|
|||
permissions = (UMAPermission(resource="invalid"),) |
|||
with pytest.raises(KeycloakPostError): |
|||
uma.permission_ticket_create(permissions) |
|||
|
|||
uma.resource_set_delete(resource["_id"]) |
@ -0,0 +1,14 @@ |
|||
"""Tests for license.""" |
|||
import os |
|||
|
|||
|
|||
def test_license_present(): |
|||
"""Test that the MIT license is present in the header of each module file.""" |
|||
for path, _, files in os.walk("src/keycloak"): |
|||
for _file in files: |
|||
if _file.endswith(".py"): |
|||
with open(os.path.join(path, _file), "r") as fp: |
|||
content = fp.read() |
|||
assert content.startswith( |
|||
"# -*- coding: utf-8 -*-\n#\n# The MIT License (MIT)\n#\n#" |
|||
) |
@ -0,0 +1,212 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com> |
|||
# |
|||
# This program is free software: you can redistribute it and/or modify |
|||
# it under the terms of the GNU Lesser General Public License as published by |
|||
# the Free Software Foundation, either version 3 of the License, or |
|||
# (at your option) any later version. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU Lesser General Public License for more details. |
|||
# |
|||
# You should have received a copy of the GNU Lesser General Public License |
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
|
|||
"""Test uma permissions.""" |
|||
|
|||
import re |
|||
|
|||
import pytest |
|||
|
|||
from keycloak.exceptions import KeycloakPermissionFormatError, PermissionDefinitionError |
|||
from keycloak.uma_permissions import ( |
|||
AuthStatus, |
|||
Resource, |
|||
Scope, |
|||
UMAPermission, |
|||
build_permission_param, |
|||
) |
|||
|
|||
|
|||
def test_uma_permission_obj(): |
|||
"""Test generic UMA permission.""" |
|||
with pytest.raises(PermissionDefinitionError): |
|||
UMAPermission(permission="bad") |
|||
|
|||
p1 = UMAPermission(permission=Resource("Resource")) |
|||
assert p1.resource == "Resource" |
|||
assert p1.scope == "" |
|||
assert repr(p1) == "Resource" |
|||
assert str(p1) == "Resource" |
|||
|
|||
p2 = UMAPermission(permission=Scope("Scope")) |
|||
assert p2.resource == "" |
|||
assert p2.scope == "Scope" |
|||
assert repr(p2) == "#Scope" |
|||
assert str(p2) == "#Scope" |
|||
assert {p1, p1} != {p2, p2} |
|||
|
|||
|
|||
def test_resource_with_scope_obj(): |
|||
"""Test resource with scope.""" |
|||
r = Resource("Resource1") |
|||
s = Scope("Scope1") |
|||
assert r(s) == "Resource1#Scope1" |
|||
|
|||
|
|||
def test_scope_with_resource_obj(): |
|||
"""Test scope with resource.""" |
|||
r = Resource("Resource1") |
|||
s = Scope("Scope1") |
|||
assert s(r) == "Resource1#Scope1" |
|||
|
|||
|
|||
def test_resource_scope_str(): |
|||
"""Test resource scope as string.""" |
|||
r = Resource("Resource1") |
|||
s = "Scope1" |
|||
assert r(scope=s) == "Resource1#Scope1" |
|||
|
|||
|
|||
def test_scope_resource_str(): |
|||
"""Test scope resource as string.""" |
|||
r = "Resource1" |
|||
s = Scope("Scope1") |
|||
assert s(resource=r) == "Resource1#Scope1" |
|||
|
|||
|
|||
def test_resource_scope_list(): |
|||
"""Test resource scope as list.""" |
|||
r = Resource("Resource1") |
|||
s = ["Scope1"] |
|||
with pytest.raises(PermissionDefinitionError) as err: |
|||
r(s) |
|||
assert err.match(re.escape("can't determine if '['Scope1']' is a resource or scope")) |
|||
|
|||
|
|||
def test_build_permission_none(): |
|||
"""Test build permission param with None.""" |
|||
assert build_permission_param(None) == set() |
|||
|
|||
|
|||
def test_build_permission_empty_str(): |
|||
"""Test build permission param with an empty string.""" |
|||
assert build_permission_param("") == set() |
|||
|
|||
|
|||
def test_build_permission_empty_list(): |
|||
"""Test build permission param with an empty list.""" |
|||
assert build_permission_param([]) == set() |
|||
|
|||
|
|||
def test_build_permission_empty_tuple(): |
|||
"""Test build permission param with an empty tuple.""" |
|||
assert build_permission_param(()) == set() |
|||
|
|||
|
|||
def test_build_permission_empty_set(): |
|||
"""Test build permission param with an empty set.""" |
|||
assert build_permission_param(set()) == set() |
|||
|
|||
|
|||
def test_build_permission_empty_dict(): |
|||
"""Test build permission param with an empty dict.""" |
|||
assert build_permission_param({}) == set() |
|||
|
|||
|
|||
def test_build_permission_str(): |
|||
"""Test build permission param as string.""" |
|||
assert build_permission_param("resource1") == {"resource1"} |
|||
|
|||
|
|||
def test_build_permission_list_str(): |
|||
"""Test build permission param with list of strings.""" |
|||
assert build_permission_param(["res1#scope1", "res1#scope2"]) == {"res1#scope1", "res1#scope2"} |
|||
|
|||
|
|||
def test_build_permission_tuple_str(): |
|||
"""Test build permission param with tuple of strings.""" |
|||
assert build_permission_param(("res1#scope1", "res1#scope2")) == {"res1#scope1", "res1#scope2"} |
|||
|
|||
|
|||
def test_build_permission_set_str(): |
|||
"""Test build permission param with set of strings.""" |
|||
assert build_permission_param({"res1#scope1", "res1#scope2"}) == {"res1#scope1", "res1#scope2"} |
|||
|
|||
|
|||
def test_build_permission_tuple_dict_str_str(): |
|||
"""Test build permission param with dictionary.""" |
|||
assert build_permission_param({"res1": "scope1"}) == {"res1#scope1"} |
|||
|
|||
|
|||
def test_build_permission_tuple_dict_str_list_str(): |
|||
"""Test build permission param with dictionary of list.""" |
|||
assert build_permission_param({"res1": ["scope1", "scope2"]}) == {"res1#scope1", "res1#scope2"} |
|||
|
|||
|
|||
def test_build_permission_tuple_dict_str_list_str2(): |
|||
"""Test build permission param with mutliple-keyed dictionary.""" |
|||
assert build_permission_param( |
|||
{"res1": ["scope1", "scope2"], "res2": ["scope2", "scope3"]} |
|||
) == {"res1#scope1", "res1#scope2", "res2#scope2", "res2#scope3"} |
|||
|
|||
|
|||
def test_build_permission_uma(): |
|||
"""Test build permission param with UMA.""" |
|||
assert build_permission_param(Resource("res1")(Scope("scope1"))) == {"res1#scope1"} |
|||
|
|||
|
|||
def test_build_permission_uma_list(): |
|||
"""Test build permission param with list of UMAs.""" |
|||
assert build_permission_param( |
|||
[Resource("res1")(Scope("scope1")), Resource("res1")(Scope("scope2"))] |
|||
) == {"res1#scope1", "res1#scope2"} |
|||
|
|||
|
|||
def test_build_permission_misbuilt_dict_str_list_list_str(): |
|||
"""Test bad build of permission param from dictionary.""" |
|||
with pytest.raises(KeycloakPermissionFormatError) as err: |
|||
build_permission_param({"res1": [["scope1", "scope2"]]}) |
|||
assert err.match(re.escape("misbuilt permission {'res1': [['scope1', 'scope2']]}")) |
|||
|
|||
|
|||
def test_build_permission_misbuilt_list_list_str(): |
|||
"""Test bad build of permission param from list.""" |
|||
with pytest.raises(KeycloakPermissionFormatError) as err: |
|||
print(build_permission_param([["scope1", "scope2"]])) |
|||
assert err.match(re.escape("misbuilt permission [['scope1', 'scope2']]")) |
|||
|
|||
|
|||
def test_build_permission_misbuilt_list_set_str(): |
|||
"""Test bad build of permission param from set.""" |
|||
with pytest.raises(KeycloakPermissionFormatError) as err: |
|||
build_permission_param([{"scope1", "scope2"}]) |
|||
assert err.match("misbuilt permission.*") |
|||
|
|||
|
|||
def test_build_permission_misbuilt_set_set_str(): |
|||
"""Test bad build of permission param from list of set.""" |
|||
with pytest.raises(KeycloakPermissionFormatError) as err: |
|||
build_permission_param([{"scope1"}]) |
|||
assert err.match(re.escape("misbuilt permission [{'scope1'}]")) |
|||
|
|||
|
|||
def test_build_permission_misbuilt_dict_non_iterable(): |
|||
"""Test bad build of permission param from non-iterable.""" |
|||
with pytest.raises(KeycloakPermissionFormatError) as err: |
|||
build_permission_param({"res1": 5}) |
|||
assert err.match(re.escape("misbuilt permission {'res1': 5}")) |
|||
|
|||
|
|||
def test_auth_status_bool(): |
|||
"""Test bool method of AuthStatus.""" |
|||
assert not bool(AuthStatus(is_logged_in=True, is_authorized=False, missing_permissions="")) |
|||
assert bool(AuthStatus(is_logged_in=True, is_authorized=True, missing_permissions="")) |
|||
|
|||
|
|||
def test_build_permission_without_scopes(): |
|||
"""Test build permission param with scopes.""" |
|||
assert build_permission_param(permissions={"Resource": None}) == {"Resource"} |
@ -0,0 +1,36 @@ |
|||
"""Test URL patterns.""" |
|||
import inspect |
|||
|
|||
from keycloak import urls_patterns |
|||
|
|||
|
|||
def test_correctness_of_patterns(): |
|||
"""Test that there are no duplicate url patterns.""" |
|||
# Test that the patterns are present |
|||
urls = [x for x in dir(urls_patterns) if not x.startswith("__")] |
|||
assert len(urls) >= 0 |
|||
|
|||
# Test that all patterns start with URL_ |
|||
for url in urls: |
|||
assert url.startswith("URL_"), f"The url pattern {url} does not begin with URL_" |
|||
|
|||
# Test that the patterns have unique names |
|||
seen_urls = list() |
|||
urls_from_src = [ |
|||
x.split("=")[0].strip() |
|||
for x in inspect.getsource(urls_patterns).splitlines() |
|||
if x.startswith("URL_") |
|||
] |
|||
for url in urls_from_src: |
|||
assert url not in seen_urls, f"The url pattern {url} is present twice." |
|||
seen_urls.append(url) |
|||
|
|||
# Test that the pattern values are unique |
|||
seen_url_values = list() |
|||
for url in urls: |
|||
url_value = urls_patterns.__dict__[url] |
|||
assert url_value not in seen_url_values, f"The url {url} has a duplicate value {url_value}" |
|||
assert ( |
|||
url_value == url_value.strip() |
|||
), f"The url {url} with value '{url_value}' has whitespace values" |
|||
seen_url_values.append(url_value) |
@ -0,0 +1,4 @@ |
|||
KEYCLOAK_ADMIN=admin |
|||
KEYCLOAK_ADMIN_PASSWORD=admin |
|||
KEYCLOAK_HOST={env:KEYCLOAK_HOST:localhost} |
|||
KEYCLOAK_PORT=8080 |
@ -0,0 +1,54 @@ |
|||
[tox] |
|||
isolated_build = true |
|||
skipsdist = true |
|||
envlist = check, apply-check, docs, tests, build, changelog |
|||
|
|||
[testenv] |
|||
allowlist_externals = poetry, ./test_keycloak_init.sh |
|||
commands_pre = |
|||
poetry install --sync |
|||
|
|||
[testenv:check] |
|||
commands = |
|||
black --check --diff src/keycloak tests docs |
|||
isort -c --df src/keycloak tests docs |
|||
flake8 src/keycloak tests docs |
|||
codespell src tests docs |
|||
|
|||
[testenv:apply-check] |
|||
commands = |
|||
black -C src/keycloak tests docs |
|||
black src/keycloak tests docs |
|||
isort src/keycloak tests docs |
|||
|
|||
[testenv:docs] |
|||
commands_pre = |
|||
poetry install --no-root --sync -E docs |
|||
commands = |
|||
sphinx-build -T -E -W -b html -d _build/doctrees -D language=en ./docs/source _build/html |
|||
|
|||
[testenv:tests] |
|||
setenv = file|tox.env |
|||
passenv = CONTAINER_HOST,KEYCLOAK_DOCKER_IMAGE_TAG |
|||
commands = |
|||
./test_keycloak_init.sh "pytest -vv --cov=keycloak --cov-report term-missing {posargs}" |
|||
|
|||
[testenv:build] |
|||
commands = |
|||
poetry build --format sdist |
|||
poetry build --format wheel |
|||
|
|||
[testenv:changelog] |
|||
setenv = file|tox.env |
|||
passenv = CONTAINER_HOST |
|||
commands = |
|||
cz changelog |
|||
|
|||
[flake8] |
|||
max-line-length = 99 |
|||
docstring-convention = all |
|||
ignore = D203, D213, W503 |
|||
docstring_style = sphinx |
|||
|
|||
[darglint] |
|||
enable = DAR104 |
Write
Preview
Loading…
Cancel
Save
Reference in new issue