Browse Source

Merge remote-tracking branch 'upstream/master' into feature/get-realm-roles-by-query

pull/277/head
Salem Wafi 1 year ago
parent
commit
3b772487ea
No known key found for this signature in database GPG Key ID: 6451BA63EAE5EFC8
  1. 37
      .circleci/config.yml
  2. 32
      .github/workflows/bump.yaml
  3. 31
      .github/workflows/daily.yaml
  4. 102
      .github/workflows/lint.yaml
  5. 44
      .github/workflows/publish.yaml
  6. 5
      .gitignore
  7. 17
      .pre-commit-config.yaml
  8. 11
      .readthedocs.yaml
  9. 8
      .releaserc.json
  10. 472
      CHANGELOG.md
  11. 1
      CODEOWNERS
  12. 95
      CONTRIBUTING.md
  13. 2
      LICENSE
  14. 1
      MANIFEST.in
  15. 15
      Pipfile
  16. 107
      Pipfile.lock
  17. 239
      README.md
  18. 2
      docs/Makefile
  19. 1
      docs/source/changelog.rst
  20. 84
      docs/source/conf.py
  21. 304
      docs/source/index.rst
  22. 1
      docs/source/readme.rst
  23. 229
      keycloak/connection.py
  24. 2374
      keycloak/keycloak_admin.py
  25. 433
      keycloak/keycloak_openid.py
  26. 0
      keycloak/tests/__init__.py
  27. 191
      keycloak/tests/test_connection.py
  28. 2139
      poetry.lock
  29. 89
      pyproject.toml
  30. 7
      requirements.txt
  31. 2
      setup.cfg
  32. 31
      setup.py
  33. 68
      src/keycloak/__init__.py
  34. 5
      src/keycloak/_version.py
  35. 75
      src/keycloak/authorization/__init__.py
  36. 91
      src/keycloak/authorization/permission.py
  37. 112
      src/keycloak/authorization/policy.py
  38. 29
      src/keycloak/authorization/role.py
  39. 281
      src/keycloak/connection.py
  40. 102
      src/keycloak/exceptions.py
  41. 4397
      src/keycloak/keycloak_admin.py
  42. 713
      src/keycloak/keycloak_openid.py
  43. 417
      src/keycloak/keycloak_uma.py
  44. 406
      src/keycloak/openid_connection.py
  45. 276
      src/keycloak/uma_permissions.py
  46. 138
      src/keycloak/urls_patterns.py
  47. 38
      test_keycloak_init.sh
  48. 1
      tests/__init__.py
  49. 530
      tests/conftest.py
  50. 45
      tests/data/authz_settings.json
  51. BIN
      tests/providers/asm-7.3.1.jar
  52. BIN
      tests/providers/asm-commons-7.3.1.jar
  53. BIN
      tests/providers/asm-tree-7.3.1.jar
  54. BIN
      tests/providers/asm-util-7.3.1.jar
  55. BIN
      tests/providers/nashorn-core-15.4.jar
  56. 42
      tests/test_authorization.py
  57. 41
      tests/test_connection.py
  58. 20
      tests/test_exceptions.py
  59. 2760
      tests/test_keycloak_admin.py
  60. 472
      tests/test_keycloak_openid.py
  61. 311
      tests/test_keycloak_uma.py
  62. 14
      tests/test_license.py
  63. 212
      tests/test_uma_permissions.py
  64. 36
      tests/test_urls_patterns.py
  65. 4
      tox.env
  66. 54
      tox.ini

37
.circleci/config.yml

@ -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

32
.github/workflows/bump.yaml

@ -0,0 +1,32 @@
name: Bump version
on:
workflow_run:
workflows: ["Lint"]
branches: [master]
types:
- completed
jobs:
tag-version:
runs-on: ubuntu-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 }}

31
.github/workflows/daily.yaml

@ -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

102
.github/workflows/lint.yaml

@ -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

44
.github/workflows/publish.yaml

@ -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

5
.gitignore

@ -45,6 +45,7 @@ nosetests.xml
coverage.xml
*.cover
.hypothesis/
keycloak_test_logs.txt
# Translations
*.mo
@ -103,4 +104,6 @@ ENV/
.idea/
main.py
main2.py
s3air-authz-config.json
s3air-authz-config.json
.vscode
_build

17
.pre-commit-config.yaml

@ -0,0 +1,17 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=10000"]
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v1.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [] # optional: list of Conventional Commits types to allow

11
.readthedocs.yaml

@ -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

8
.releaserc.json

@ -0,0 +1,8 @@
{
"plugins": ["@semantic-release/commit-analyzer"],
"verifyConditions": false,
"npmPublish": false,
"publish": false,
"fail": false,
"success": false
}

472
CHANGELOG.md

@ -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)

1
CODEOWNERS

@ -0,0 +1 @@
* @ryshoooo @marcospereirampj

95
CONTRIBUTING.md

@ -0,0 +1,95 @@
# Contributing
Welcome to the Python Keycloak contributing guidelines. We are all more than happy to receive
any contributions to the repository and want to thank you in advance for your contributions!
This document outlines the process and the guidelines on how contributions work for this repository.
## Setting up the dev environment
The development environment is mainly up to the developer. Our recommendations are to create a python
virtual environment and install the necessary requirements. Example
```sh
# Install and upgrade pip & poetry
python -m pip install --upgrade pip poetry
# Create virtualenv
python -m poetry env use <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.

2
LICENSE

@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

1
MANIFEST.in

@ -1 +0,0 @@
include LICENSE

15
Pipfile

@ -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"

107
Pipfile.lock

@ -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": {}
}

239
README.md

@ -1,9 +1,7 @@
[![CircleCI](https://circleci.com/gh/marcospereirampj/python-keycloak/tree/master.svg?style=svg)](https://circleci.com/gh/marcospereirampj/python-keycloak/tree/master)
[![CircleCI](https://github.com/marcospereirampj/python-keycloak/actions/workflows/daily.yaml/badge.svg)](https://github.com/marcospereirampj/python-keycloak/)
[![Documentation Status](https://readthedocs.org/projects/python-keycloak/badge/?version=latest)](http://python-keycloak.readthedocs.io/en/latest/?badge=latest)
Python Keycloak
====================
# Python Keycloak
For review- see https://github.com/marcospereirampj/python-keycloak
@ -13,24 +11,27 @@ For review- see https://github.com/marcospereirampj/python-keycloak
### Via Pypi Package:
``` $ pip install python-keycloak ```
`$ pip install python-keycloak`
### Manually
``` $ python setup.py install ```
`$ python setup.py install`
## Dependencies
python-keycloak depends on:
* Python 3
* [requests](https://requests.readthedocs.io)
* [python-jose](http://python-jose.readthedocs.io/en/latest/)
- Python 3
- [requests](https://requests.readthedocs.io)
- [python-jose](http://python-jose.readthedocs.io/en/latest/)
- [urllib3](https://urllib3.readthedocs.io/en/stable/)
### Tests Dependencies
* unittest
* [httmock](https://github.com/patrys/httmock)
- [tox](https://tox.readthedocs.io/)
- [pytest](https://docs.pytest.org/en/latest/)
- [pytest-cov](https://github.com/pytest-dev/pytest-cov)
- [wheel](https://github.com/pypa/wheel)
## Bug reports
@ -43,18 +44,19 @@ The documentation for python-keycloak is available on [readthedocs](http://pytho
## Contributors
* [Agriness Team](http://www.agriness.com/pt/)
* [Marcos Pereira](marcospereira.mpj@gmail.com)
* [Martin Devlin](https://bitbucket.org/devlinmpearson/)
* [Shon T. Urbas](https://bitbucket.org/surbas/)
* [Markus Spanier](https://bitbucket.org/spanierm/)
* [Remco Kranenburg](https://bitbucket.org/Remco47/)
* [Armin](https://bitbucket.org/arminfelder/)
* [njordr](https://bitbucket.org/njordr/)
* [Josha Inglis](https://bitbucket.org/joshainglis/)
* [Alex](https://bitbucket.org/alex_zel/)
* [Ewan Jone](https://bitbucket.org/kisamoto/)
* [Lukas Martini](https://github.com/lutoma)
- [Agriness Team](http://www.agriness.com/pt/)
- [Marcos Pereira](marcospereira.mpj@gmail.com)
- [Martin Devlin](https://bitbucket.org/devlinmpearson/)
- [Shon T. Urbas](https://bitbucket.org/surbas/)
- [Markus Spanier](https://bitbucket.org/spanierm/)
- [Remco Kranenburg](https://bitbucket.org/Remco47/)
- [Armin](https://bitbucket.org/arminfelder/)
- [njordr](https://bitbucket.org/njordr/)
- [Josha Inglis](https://bitbucket.org/joshainglis/)
- [Alex](https://bitbucket.org/alex_zel/)
- [Ewan Jone](https://bitbucket.org/kisamoto/)
- [Lukas Martini](https://github.com/lutoma)
- [Adamatics](https://www.adamatics.com)
## Usage
@ -63,17 +65,33 @@ from keycloak import KeycloakOpenID
# Configure client
keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/",
client_id="example_client",
realm_name="example_realm",
client_secret_key="secret")
client_id="example_client",
realm_name="example_realm",
client_secret_key="secret")
# Get WellKnown
config_well_known = keycloak_openid.well_known()
# Get Code With Oauth Authorization Request
auth_url = keycloak_openid.auth_url(
redirect_uri="your_call_back_url",
scope="email",
state="your_state_info")
# Get Access Token With Code
access_token = keycloak_openid.token(
grant_type='authorization_code',
code='the_code_you_get_from_auth_url_callback',
redirect_uri="your_call_back_url")
# Get WellKnow
config_well_know = keycloak_openid.well_know()
# Get Token
token = keycloak_openid.token("user", "password")
token = keycloak_openid.token("user", "password", totp="012345")
# Get token using Token Exchange
token = keycloak_openid.exchange_token(token['access_token'], "my_client", "other_client", "some_user")
# Get Userinfo
userinfo = keycloak_openid.userinfo(token['access_token'])
@ -90,9 +108,9 @@ certs = keycloak_openid.certs()
token = keycloak_openid.token("user", "password")
rpt = keycloak_openid.entitlement(token['access_token'], "resource_id")
# Instropect RPT
# Introspect RPT
token_rpt_info = keycloak_openid.introspect(keycloak_openid.introspect(token['access_token'], rpt=rpt['rpt'],
token_type_hint="requesting_party_token"))
token_type_hint="requesting_party_token"))
# Introspect Token
token_info = keycloak_openid.introspect(token['access_token'])
@ -108,51 +126,69 @@ keycloak_openid.load_authorization_config("example-authz-config.json")
policies = keycloak_openid.get_policies(token['access_token'], method_token_info='decode', key=KEYCLOAK_PUBLIC_KEY)
permissions = keycloak_openid.get_permissions(token['access_token'], method_token_info='introspect')
# Get UMA-permissions by token
token = keycloak_openid.token("user", "password")
permissions = keycloak_openid.uma_permissions(token['access_token'])
# Get UMA-permissions by token with specific resource and scope requested
token = keycloak_openid.token("user", "password")
permissions = keycloak_openid.uma_permissions(token['access_token'], permissions="Resource#Scope")
# Get auth status for a specific resource and scope by token
token = keycloak_openid.token("user", "password")
auth_status = keycloak_openid.has_uma_access(token['access_token'], "Resource#Scope")
# KEYCLOAK ADMIN
from keycloak import KeycloakAdmin
from keycloak import KeycloakOpenIDConnection
keycloak_connection = KeycloakOpenIDConnection(
server_url="http://localhost:8080/",
username='example-admin',
password='secret',
realm_name="master",
user_realm_name="only_if_other_realm_than_master",
client_id="my_client",
client_secret_key="client-secret",
verify=True)
keycloak_admin = KeycloakAdmin(server_url="http://localhost:8080/auth/",
username='example-admin',
password='secret',
realm_name="master",
user_realm_name="only_if_other_realm_than_master",
client_secret_key="client-secret",
verify=True)
# Add user
keycloak_admin = KeycloakAdmin(connection=keycloak_connection)
# Add user
new_user = keycloak_admin.create_user({"email": "example@example.com",
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example"})
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example"})
# Add user and raise exception if username already exists
# exist_ok currently defaults to True for backwards compatibility reasons
new_user = keycloak_admin.create_user({"email": "example@example.com",
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example"},
exist_ok=False)
# Add user and set password
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example"},
exist_ok=False)
# Add user and set password
new_user = keycloak_admin.create_user({"email": "example@example.com",
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example",
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example",
"credentials": [{"value": "secret","type": "password",}]})
# Add user and specify a locale
# Add user and specify a locale
new_user = keycloak_admin.create_user({"email": "example@example.fr",
"username": "example@example.fr",
"enabled": True,
"firstName": "Example",
"lastName": "Example",
"attributes": {
"locale": ["fr"]
})
"username": "example@example.fr",
"enabled": True,
"firstName": "Example",
"lastName": "Example",
"attributes": {
"locale": ["fr"]
}})
# User counter
count_users = keycloak_admin.users_count()
@ -160,14 +196,14 @@ count_users = keycloak_admin.users_count()
# Get users Returns a list of users, filtered according to query parameters
users = keycloak_admin.get_users({})
# Get user ID from name
user_id_keycloak = keycloak_admin.get_user_id("example@example.com")
# Get user ID from username
user_id_keycloak = keycloak_admin.get_user_id("username-keycloak")
# Get User
user = keycloak_admin.get_user("user-id-keycloak")
# Update User
response = keycloak_admin.update_user(user_id="user-id-keycloak",
response = keycloak_admin.update_user(user_id="user-id-keycloak",
payload={'firstName': 'Example Update'})
# Update User Password
@ -181,7 +217,7 @@ credential = keycloak_admin.get_credential(user_id='user_id', credential_id='cre
# Delete User Credential
response = keycloak_admin.delete_credential(user_id='user_id', credential_id='credential_id')
# Delete User
response = keycloak_admin.delete_user(user_id="user-id-keycloak")
@ -189,8 +225,8 @@ response = keycloak_admin.delete_user(user_id="user-id-keycloak")
consents = keycloak_admin.consents_user(user_id="user-id-keycloak")
# Send User Action
response = keycloak_admin.send_update_account(user_id="user-id-keycloak",
payload=json.dumps(['UPDATE_PASSWORD']))
response = keycloak_admin.send_update_account(user_id="user-id-keycloak",
payload=['UPDATE_PASSWORD'])
# Send Verify Email
response = keycloak_admin.send_verify_email(user_id="user-id-keycloak")
@ -227,7 +263,7 @@ role = keycloak_admin.get_client_role(client_id="client_id", role_name="role_nam
role_id = keycloak_admin.get_client_role_id(client_id="client_id", role_name="test")
# Create client role
keycloak_admin.create_client_role(client_role_id='client_id', {'name': 'roleName', 'clientRole': True})
keycloak_admin.create_client_role(client_role_id='client_id', payload={'name': 'roleName', 'clientRole': True})
# Assign client role to user. Note that BOTH role_name and role_id appear to be required.
keycloak_admin.assign_client_role(client_id="client_id", user_id="user_id", role_id="role_id", role_name="test")
@ -245,6 +281,9 @@ keycloak_admin.get_composite_client_roles_of_user(user_id="user_id", client_id="
keycloak_admin.delete_client_roles_of_user(client_id="client_id", user_id="user_id", roles={"id": "role-id"})
keycloak_admin.delete_client_roles_of_user(client_id="client_id", user_id="user_id", roles=[{"id": "role-id_1"}, {"id": "role-id_2"}])
# Get the client authorization settings
client_authz_settings = get_client_authz_settings(client_id="client_id")
# Get all client authorization resources
client_resources = get_client_authz_resources(client_id="client_id")
@ -263,7 +302,7 @@ group = keycloak_admin.create_group({"name": "Example Group"})
# Get all groups
groups = keycloak_admin.get_groups()
# Get group
# Get group
group = keycloak_admin.get_group(group_id='group_id')
# Get group by name
@ -275,15 +314,31 @@ sync_users(storage_id="storage_di", action="action")
# Get client role id from name
role_id = keycloak_admin.get_client_role_id(client_id=client_id, role_name="test")
# Get all roles for the realm or client
realm_roles = keycloak_admin.get_roles()
# Assign client role to user. Note that BOTH role_name and role_id appear to be required.
keycloak_admin.assign_client_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test")
# Assign realm roles to user
keycloak_admin.assign_realm_roles(user_id=user_id, roles=realm_roles)
# Assign realm roles to client's scope
keycloak_admin.assign_realm_roles_to_client_scope(client_id=client_id, roles=realm_roles)
# Get realm roles assigned to client's scope
keycloak_admin.get_realm_roles_of_client_scope(client_id=client_id)
# Remove realm roles assigned to client's scope
keycloak_admin.delete_realm_roles_of_client_scope(client_id=client_id, roles=realm_roles)
another_client_id = keycloak_admin.get_client_id("my-client-2")
# Assign client roles to client's scope
keycloak_admin.assign_client_roles_to_client_scope(client_id=another_client_id, client_roles_owner_id=client_id, roles=client_roles)
# Get client roles assigned to client's scope
keycloak_admin.get_client_roles_of_client_scope(client_id=another_client_id, client_roles_owner_id=client_id)
# Remove client roles assigned to client's scope
keycloak_admin.delete_client_roles_of_client_scope(client_id=another_client_id, client_roles_owner_id=client_id, roles=client_roles)
# Get all ID Providers
idps = keycloak_admin.get_idps()
@ -291,4 +346,42 @@ idps = keycloak_admin.get_idps()
# Create a new Realm
keycloak_admin.create_realm(payload={"realm": "demo"}, skip_exists=False)
# Changing Realm
keycloak_admin = KeycloakAdmin(realm_name="main", ...)
keycloak_admin.get_users() # Get user in main realm
keycloak_admin.realm_name = "demo" # Change realm to 'demo'
keycloak_admin.get_users() # Get users in realm 'demo'
keycloak_admin.create_user(...) # Creates a new user in 'demo'
# KEYCLOAK UMA
from keycloak import KeycloakOpenIDConnection
from keycloak import KeycloakUMA
keycloak_connection = KeycloakOpenIDConnection(
server_url="http://localhost:8080/",
realm_name="master",
client_id="my_client",
client_secret_key="client-secret")
keycloak_uma = KeycloakUMA(connection=keycloak_connection)
# Create a resource set
resource_set = keycloak_uma.resource_set_create({
"name": "example_resource",
"scopes": ["example:read", "example:write"],
"type": "urn:example"})
# List resource sets
resource_sets = uma.resource_set_list()
# get resource set
latest_resource = uma.resource_set_read(resource_set["_id"])
# update resource set
latest_resource["name"] = "New Resource Name"
uma.resource_set_update(resource_set["_id"], latest_resource)
# delete resource set
uma.resource_set_delete(resource_id=resource_set["_id"])
```

2
docs/Makefile

@ -17,4 +17,4 @@ help:
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

1
docs/source/changelog.rst

@ -0,0 +1 @@
.. mdinclude:: ../../CHANGELOG.md

84
docs/source/conf.py

@ -20,6 +20,9 @@
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
"""Sphinx documentation configuration."""
import sphinx_rtd_theme
# -- General configuration ------------------------------------------------
@ -32,55 +35,64 @@ import sphinx_rtd_theme
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.todo',
'sphinx.ext.viewcode',
"sphinx.ext.autodoc",
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
"sphinx.ext.viewcode",
"m2r2",
"autoapi.extension",
]
autoapi_type = "python"
autoapi_dirs = ["../../src/keycloak"]
autoapi_root = "reference"
autoapi_keep_files = False
autoapi_add_toctree_entry = False
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
source_suffix = ".rst"
# The master toctree document.
master_doc = 'index'
master_doc = "index"
# General information about the project.
project = 'python-keycloak'
copyright = '2017, Marcos Pereira'
author = 'Marcos Pereira'
project = "python-keycloak"
copyright = "2017, Marcos Pereira"
author = "Marcos Pereira"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.27.0'
version = "0.0.0"
# The full version, including alpha/beta/rc tags.
release = '0.27.0'
release = "0.0.0"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
suppress_warnings = ["ref.python"]
add_function_parentheses = False
add_module_names = True
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
pygments_style = "sphinx"
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
@ -91,7 +103,7 @@ todo_include_todos = True
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
html_theme = "sphinx_rtd_theme"
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Theme options are theme-specific and customize the look and feel of a theme
@ -103,7 +115,7 @@ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# html_static_path = ["_static"]
html_use_smartypants = False
@ -116,7 +128,7 @@ html_show_copyright = True
#
# This is required for the alabaster theme
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
#html_sidebars = {
# html_sidebars = {
# '**': [
# 'about.html',
# 'navigation.html',
@ -124,13 +136,13 @@ html_show_copyright = True
# 'searchbox.html',
# 'donate.html',
# ]
#}
# }
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'python-keycloakdoc'
htmlhelp_basename = "python-keycloakdoc"
# -- Options for LaTeX output ---------------------------------------------
@ -139,15 +151,12 @@ latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
@ -157,8 +166,13 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'python-keycloak.tex', 'python-keycloak Documentation',
'Marcos Pereira', 'manual'),
(
master_doc,
"python-keycloak.tex",
"python-keycloak Documentation",
"Marcos Pereira",
"manual",
)
]
@ -166,10 +180,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'python-keycloak', 'python-keycloak Documentation',
[author], 1)
]
man_pages = [(master_doc, "python-keycloak", "python-keycloak Documentation", [author], 1)]
# -- Options for Texinfo output -------------------------------------------
@ -178,10 +189,13 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'python-keycloak', 'python-keycloak Documentation',
author, 'python-keycloak', 'One line description of project.',
'Miscellaneous'),
(
master_doc,
"python-keycloak",
"python-keycloak Documentation",
author,
"python-keycloak",
"One line description of project.",
"Miscellaneous",
)
]

304
docs/source/index.rst

@ -3,305 +3,13 @@
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
.. image:: https://readthedocs.org/projects/adamatics-keycloak/badge/?version=latest
:target: https://adamatics-keycloak.readthedocs.io/en/latest/?badge=latest
.. mdinclude:: ../../README.md
.. toctree::
:maxdepth: 2
:caption: Contents:
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
.. image:: https://readthedocs.org/projects/python-keycloak/badge/?version=latest
:target: http://python-keycloak.readthedocs.io/en/latest/?badge=latest
Welcome to python-keycloak's documentation!
===========================================
**python-keycloak** is a Python package providing access to the Keycloak API.
Installation
==================
Via Pypi Package::
$ pip install python-keycloak
Manually::
$ python setup.py install
Dependencies
==================
python-keycloak depends on:
* Python 3
* `requests <http://docs.python-requests.org/en/master/>`_
* `python-jose <http://python-jose.readthedocs.io/en/latest/>`_
Tests Dependencies
------------------
* unittest
* `httmock <https://github.com/patrys/httmock>`_
Bug reports
==================
Please report bugs and feature requests at
`https://github.com/marcospereirampj/python-keycloak/issues <https://github.com/marcospereirampj/python-keycloak/issues>`_
Documentation
==================
The documentation for python-keycloak is available on `readthedocs <http://python-keycloak.readthedocs.io>`_.
Contributors
==================
* `Agriness Team <http://www.agriness.com/pt/>`_
* `Marcos Pereira <marcospereira.mpj@gmail.com>`_
* `Martin Devlin <martin.devlin@pearson.com>`_
* `Shon T. Urbas <shon.urbas@gmail.com>`_
* `Markus Spanier <https://bitbucket.org/spanierm/>`_
* `Remco Kranenburg <https://bitbucket.org/Remco47/>`_
* `Armin <https://bitbucket.org/arminfelder/>`_
* `Njordr <https://bitbucket.org/njordr/>`_
* `Josha Inglis <https://bitbucket.org/joshainglis/>`_
* `Alex <https://bitbucket.org/alex_zel/>`_
* `Ewan Jone <https://bitbucket.org/kisamoto/>`_
Usage
=====
Main methods::
# KEYCLOAK OPENID
from keycloak import KeycloakOpenID
# Configure client
keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/",
client_id="example_client",
realm_name="example_realm",
client_secret_key="secret",
verify=True)
# Optionally, you can pass custom headers that will be added to all HTTP calls
# keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/",
# client_id="example_client",
# realm_name="example_realm",
# client_secret_key="secret",
# verify=True,
# custom_headers={'CustomHeader': 'value'})
# Optionally, you can pass proxies as well that will be used in all HTTP calls. See requests documentation for more details_
# `requests-proxies <https://2.python-requests.org/en/master/user/advanced/#id10>`_.
# keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/",
# client_id="example_client",
# realm_name="example_realm",
# client_secret_key="secret",
# verify=True,
# proxies={'http': 'http://10.10.1.10:3128', 'https': 'http://10.10.1.10:1080'})
# Get WellKnow
config_well_know = keycloak_openid.well_know()
# Get Token
token = keycloak_openid.token("user", "password")
token = keycloak_openid.token("user", "password", totp="012345")
# Get Userinfo
userinfo = keycloak_openid.userinfo(token['access_token'])
# Refresh token
token = keycloak_openid.refresh_token(token['refresh_token'])
# Logout
keycloak_openid.logout(token['refresh_token'])
# Get Certs
certs = keycloak_openid.certs()
# Get RPT (Entitlement)
token = keycloak_openid.token("user", "password")
rpt = keycloak_openid.entitlement(token['access_token'], "resource_id")
# Instropect RPT
token_rpt_info = keycloak_openid.introspect(keycloak_openid.introspect(token['access_token'], rpt=rpt['rpt'],
token_type_hint="requesting_party_token"))
# Introspect Token
token_info = keycloak_openid.introspect(token['access_token']))
# Decode Token
KEYCLOAK_PUBLIC_KEY = "secret"
options = {"verify_signature": True, "verify_aud": True, "verify_exp": True}
token_info = keycloak_openid.decode_token(token['access_token'], key=KEYCLOAK_PUBLIC_KEY, options=options)
# Get permissions by token
token = keycloak_openid.token("user", "password")
keycloak_openid.load_authorization_config("example-authz-config.json")
policies = keycloak_openid.get_policies(token['access_token'], method_token_info='decode', key=KEYCLOAK_PUBLIC_KEY)
permissions = keycloak_openid.get_permissions(token['access_token'], method_token_info='introspect')
# KEYCLOAK ADMIN
from keycloak import KeycloakAdmin
keycloak_admin = KeycloakAdmin(server_url="http://localhost:8080/auth/",
username='example-admin',
password='secret',
realm_name="example_realm",
verify=True)
# Optionally, you can pass custom headers that will be added to all HTTP calls
#keycloak_admin = KeycloakAdmin(server_url="http://localhost:8080/auth/",
# username='example-admin',
# password='secret',
# realm_name="example_realm",
# verify=True,
# custom_headers={'CustomHeader': 'value'})
#
# You can also authenticate with client_id and client_secret
#keycloak_admin = KeycloakAdmin(server_url="http://localhost:8080/auth/",
# client_id="example_client",
# client_secret_key="secret",
# realm_name="example_realm",
# verify=True,
# custom_headers={'CustomHeader': 'value'})
# Add user
new_user = keycloak_admin.create_user({"email": "example@example.com",
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example",
"realmRoles": ["user_default", ],
"attributes": {"example": "1,2,3,3,"}})
# Add user and set password
new_user = keycloak_admin.create_user({"email": "example@example.com",
"username": "example@example.com",
"enabled": True,
"firstName": "Example",
"lastName": "Example",
"credentials": [{"value": "secret","type": "password",}],
"realmRoles": ["user_default", ],
"attributes": {"example": "1,2,3,3,"}})
# User counter
count_users = keycloak_admin.users_count()
# Get users Returns a list of users, filtered according to query parameters
users = keycloak_admin.get_users({})
# Get user ID from name
user-id-keycloak = keycloak_admin.get_user_id("example@example.com")
# Get User
user = keycloak_admin.get_user("user-id-keycloak")
# Update User
response = keycloak_admin.update_user(user_id="user-id-keycloak",
payload={'firstName': 'Example Update'})
# Update User Password
response = set_user_password(user_id="user-id-keycloak", password="secret", temporary=True)
# Delete User
response = keycloak_admin.delete_user(user_id="user-id-keycloak")
# Get consents granted by the user
consents = keycloak_admin.consents_user(user_id="user-id-keycloak")
# Send User Action
response = keycloak_admin.send_update_account(user_id="user-id-keycloak",
payload=json.dumps(['UPDATE_PASSWORD']))
# Send Verify Email
response = keycloak_admin.send_verify_email(user_id="user-id-keycloak")
# Get sessions associated with the user
sessions = keycloak_admin.get_sessions(user_id="user-id-keycloak")
# Get themes, social providers, auth providers, and event listeners available on this server
server_info = keycloak_admin.get_server_info()
# Get clients belonging to the realm Returns a list of clients belonging to the realm
clients = keycloak_admin.get_clients()
# Get client - id (not client-id) from client by name
client_id=keycloak_admin.get_client_id("my-client")
# Get representation of the client - id of client (not client-id)
client = keycloak_admin.get_client(client_id="client_id")
# Get all roles for the realm or client
realm_roles = keycloak_admin.get_realm_roles()
# Get all roles for the client
client_roles = keycloak_admin.get_client_roles(client_id="client_id")
# Get client role
role = keycloak_admin.get_client_role(client_id="client_id", role_name="role_name")
# Warning: Deprecated
# Get client role id from name
role_id = keycloak_admin.get_client_role_id(client_id="client_id", role_name="test")
# Create client role
keycloak_admin.create_client_role(client_id="client_id", {'name': 'roleName', 'clientRole': True})
# Get client role id from name
role_id = keycloak_admin.get_client_role_id(client_id=client_id, role_name="test")
# Get all roles for the realm or client
realm_roles = keycloak_admin.get_roles()
# Assign client role to user. Note that BOTH role_name and role_id appear to be required.
keycloak_admin.assign_client_role(client_id="client_id", user_id="user_id", role_id="role_id", role_name="test")
# Assign realm roles to user. Note that BOTH role_name and role_id appear to be required.
keycloak_admin.assign_realm_roles(client_id="client_id", user_id="user_id", roles=[{"roles_representation"}])
# Delete realm roles of user. Note that BOTH role_name and role_id appear to be required.
keycloak_admin.deletes_realm_roles_of_user(user_id="user_id", roles=[{"roles_representation"}])
# Create new group
group = keycloak_admin.create_group(name="Example Group")
# Get all groups
groups = keycloak_admin.get_groups()
# Get group
group = keycloak_admin.get_group(group_id='group_id')
# Get group by path
group = keycloak_admin.get_group_by_path(path='/group/subgroup', search_in_subgroups=True)
# Function to trigger user sync from provider
sync_users(storage_id="storage_di", action="action")
# List public RSA keys
components = keycloak_admin.keys
# List all keys
components = keycloak_admin.get_components(query={"parent":"example_realm", "type":"org.keycloak.keys.KeyProvider"})
# Create a new RSA key
component = keycloak_admin.create_component({"name":"rsa-generated","providerId":"rsa-generated","providerType":"org.keycloak.keys.KeyProvider","parentId":"example_realm","config":{"priority":["100"],"enabled":["true"],"active":["true"],"algorithm":["RS256"],"keySize":["2048"]}})
# Update the key
component_details['config']['active'] = ["false"]
keycloak_admin.update_component(component['id'])
# Delete the key
keycloak_admin.delete_component(component['id'])
readme
changelog
reference/keycloak/index

1
docs/source/readme.rst

@ -0,0 +1 @@
.. mdinclude:: ../../README.md

229
keycloak/connection.py

@ -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

433
keycloak/keycloak_openid.py

@ -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))

0
keycloak/tests/__init__.py

191
keycloak/tests/test_connection.py

@ -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

89
pyproject.toml

@ -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"

7
requirements.txt

@ -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

2
setup.cfg

@ -1,2 +0,0 @@
[metadata]
description-file = README.md

31
setup.py

@ -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'
]
)

68
src/keycloak/__init__.py

@ -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",
]

5
keycloak/__init__.py → src/keycloak/_version.py

@ -21,5 +21,6 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from .keycloak_admin import *
from .keycloak_openid import *
import pkg_resources
__version__ = pkg_resources.get_distribution("python-keycloak").version

75
keycloak/authorization/__init__.py → src/keycloak/authorization/__init__.py

@ -21,6 +21,8 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Authorization module."""
import ast
import json
@ -30,18 +32,23 @@ from .role import Role
class Authorization:
"""
Keycloak Authorization (policies, roles, scopes and resources).
"""Keycloak Authorization (policies, roles, scopes and resources).
https://keycloak.gitbooks.io/documentation/authorization_services/index.html
"""
def __init__(self):
self._policies = {}
"""Init method."""
self.policies = {}
@property
def policies(self):
"""Get policies.
:returns: Policies
:rtype: dict
"""
return self._policies
@policies.setter
@ -49,45 +56,51 @@ class Authorization:
self._policies = value
def load_config(self, data):
"""
Load policies, roles and permissions (scope/resources).
"""Load policies, roles and permissions (scope/resources).
:param data: keycloak authorization data (dict)
:return:
:type data: dict
"""
for pol in data['policies']:
if pol['type'] == 'role':
policy = Policy(name=pol['name'],
type=pol['type'],
logic=pol['logic'],
decision_strategy=pol['decisionStrategy'])
config_roles = json.loads(pol['config']['roles'])
for pol in data["policies"]:
if pol["type"] == "role":
policy = Policy(
name=pol["name"],
type=pol["type"],
logic=pol["logic"],
decision_strategy=pol["decisionStrategy"],
)
config_roles = json.loads(pol["config"]["roles"])
for role in config_roles:
policy.add_role(Role(name=role['id'],
required=role['required']))
policy.add_role(Role(name=role["id"], required=role["required"]))
self.policies[policy.name] = policy
if pol['type'] == 'scope':
permission = Permission(name=pol['name'],
type=pol['type'],
logic=pol['logic'],
decision_strategy=pol['decisionStrategy'])
if pol["type"] == "scope":
permission = Permission(
name=pol["name"],
type=pol["type"],
logic=pol["logic"],
decision_strategy=pol["decisionStrategy"],
)
permission.scopes = ast.literal_eval(pol['config']['scopes'])
permission.scopes = ast.literal_eval(pol["config"]["scopes"])
for policy_name in ast.literal_eval(pol['config']['applyPolicies']):
self.policies[policy_name].add_permission(permission)
if "applyPolicies" in pol["config"]:
for policy_name in ast.literal_eval(pol["config"]["applyPolicies"]):
if self.policies.get(policy_name) is not None:
self.policies[policy_name].add_permission(permission)
if pol['type'] == 'resource':
permission = Permission(name=pol['name'],
type=pol['type'],
logic=pol['logic'],
decision_strategy=pol['decisionStrategy'])
if pol["type"] == "resource":
permission = Permission(
name=pol["name"],
type=pol["type"],
logic=pol["logic"],
decision_strategy=pol["decisionStrategy"],
)
permission.resources = ast.literal_eval(pol['config'].get('resources', "[]"))
permission.resources = ast.literal_eval(pol["config"].get("resources", "[]"))
for policy_name in ast.literal_eval(pol['config']['applyPolicies']):
for policy_name in ast.literal_eval(pol["config"]["applyPolicies"]):
if self.policies.get(policy_name) is not None:
self.policies[policy_name].add_permission(permission)

91
keycloak/authorization/permission.py → src/keycloak/authorization/permission.py

@ -21,41 +21,83 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Keycloak authorization Permission module."""
class Permission:
"""
"""Base permission class.
Consider this simple and very common permission:
A permission associates the object being protected with the policies that must be evaluated to determine whether access is granted.
A permission associates the object being protected with the policies that must be evaluated to
determine whether access is granted.
X CAN DO Y ON RESOURCE Z
where
X represents one or more users, roles, or groups, or a combination of them. You can
where
- X represents one or more users, roles, or groups, or a combination of them. You can
also use claims and context here.
Y represents an action to be performed, for example, write, view, and so on.
Z represents a protected resource, for example, "/accounts".
- Y represents an action to be performed, for example, write, view, and so on.
- Z represents a protected resource, for example, "/accounts".
https://keycloak.gitbooks.io/documentation/authorization_services/topics/permission/overview.html
:param name: Name
:type name: str
:param type: Type
:type type: str
:param logic: Logic
:type logic: str
:param decision_strategy: Decision strategy
:type decision_strategy: str
"""
def __init__(self, name, type, logic, decision_strategy):
self._name = name
self._type = type
self._logic = logic
self._decision_strategy = decision_strategy
self._resources = []
self._scopes = []
"""Init method.
:param name: Name
:type name: str
:param type: Type
:type type: str
:param logic: Logic
:type logic: str
:param decision_strategy: Decision strategy
:type decision_strategy: str
"""
self.name = name
self.type = type
self.logic = logic
self.decision_strategy = decision_strategy
self.resources = []
self.scopes = []
def __repr__(self):
"""Repr method.
:returns: Class representation
:rtype: str
"""
return "<Permission: %s (%s)>" % (self.name, self.type)
def __str__(self):
"""Str method.
:returns: Class string representation
:rtype: str
"""
return "Permission: %s (%s)" % (self.name, self.type)
@property
def name(self):
"""Get name.
:returns: name
:rtype: str
"""
return self._name
@name.setter
@ -64,6 +106,11 @@ class Permission:
@property
def type(self):
"""Get type.
:returns: type
:rtype: str
"""
return self._type
@type.setter
@ -72,6 +119,11 @@ class Permission:
@property
def logic(self):
"""Get logic.
:returns: Logic
:rtype: str
"""
return self._logic
@logic.setter
@ -80,6 +132,11 @@ class Permission:
@property
def decision_strategy(self):
"""Get decision strategy.
:returns: Decision strategy
:rtype: str
"""
return self._decision_strategy
@decision_strategy.setter
@ -88,6 +145,11 @@ class Permission:
@property
def resources(self):
"""Get resources.
:returns: Resources
:rtype: list
"""
return self._resources
@resources.setter
@ -96,6 +158,11 @@ class Permission:
@property
def scopes(self):
"""Get scopes.
:returns: Scopes
:rtype: list
"""
return self._scopes
@scopes.setter

112
keycloak/authorization/policy.py → src/keycloak/authorization/policy.py

@ -21,38 +21,77 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Keycloak authorization Policy module."""
from ..exceptions import KeycloakAuthorizationConfigError
class Policy:
"""
"""Base policy class.
A policy defines the conditions that must be satisfied to grant access to an object.
Unlike permissions, you do not specify the object being protected but rather the conditions
that must be satisfied for access to a given object (for example, resource, scope, or both).
Policies are strongly related to the different access control mechanisms (ACMs) that you can use to
protect your resources. With policies, you can implement strategies for attribute-based access control
(ABAC), role-based access control (RBAC), context-based access control, or any combination of these.
Policies are strongly related to the different access control mechanisms (ACMs) that you can
use to protect your resources. With policies, you can implement strategies for attribute-based
access control (ABAC), role-based access control (RBAC), context-based access control, or any
combination of these.
https://keycloak.gitbooks.io/documentation/authorization_services/topics/policy/overview.html
:param name: Name
:type name: str
:param type: Type
:type type: str
:param logic: Logic
:type logic: str
:param decision_strategy: Decision strategy
:type decision_strategy: str
"""
def __init__(self, name, type, logic, decision_strategy):
self._name = name
self._type = type
self._logic = logic
self._decision_strategy = decision_strategy
self._roles = []
self._permissions = []
"""Init method.
:param name: Name
:type name: str
:param type: Type
:type type: str
:param logic: Logic
:type logic: str
:param decision_strategy: Decision strategy
:type decision_strategy: str
"""
self.name = name
self.type = type
self.logic = logic
self.decision_strategy = decision_strategy
self.roles = []
self.permissions = []
def __repr__(self):
"""Repr method.
:returns: Class representation
:rtype: str
"""
return "<Policy: %s (%s)>" % (self.name, self.type)
def __str__(self):
"""Str method.
:returns: Class string representation
:rtype: str
"""
return "Policy: %s (%s)" % (self.name, self.type)
@property
def name(self):
"""Get name.
:returns: Name
:rtype: str
"""
return self._name
@name.setter
@ -61,6 +100,11 @@ class Policy:
@property
def type(self):
"""Get type.
:returns: Type
:rtype: str
"""
return self._type
@type.setter
@ -69,6 +113,11 @@ class Policy:
@property
def logic(self):
"""Get logic.
:returns: Logic
:rtype: str
"""
return self._logic
@logic.setter
@ -77,6 +126,11 @@ class Policy:
@property
def decision_strategy(self):
"""Get decision strategy.
:returns: Decision strategy
:rtype: str
"""
return self._decision_strategy
@decision_strategy.setter
@ -85,29 +139,47 @@ class Policy:
@property
def roles(self):
"""Get roles.
:returns: Roles
:rtype: list
"""
return self._roles
@roles.setter
def roles(self, value):
self._roles = value
@property
def permissions(self):
"""Get permissions.
:returns: Permissions
:rtype: list
"""
return self._permissions
@permissions.setter
def permissions(self, value):
self._permissions = value
def add_role(self, role):
"""
Add keycloak role in policy.
"""Add keycloak role in policy.
:param role: keycloak role.
:return:
:param role: Keycloak role
:type role: keycloak.authorization.Role
:raises KeycloakAuthorizationConfigError: In case of misconfigured policy type
"""
if self.type != 'role':
if self.type != "role":
raise KeycloakAuthorizationConfigError(
"Can't add role. Policy type is different of role")
"Can't add role. Policy type is different of role"
)
self._roles.append(role)
def add_permission(self, permission):
"""
Add keycloak permission in policy.
"""Add keycloak permission in policy.
:param permission: keycloak permission.
:return:
:param permission: Keycloak permission
:type permission: keycloak.authorization.Permission
"""
self._permissions.append(permission)

29
keycloak/authorization/role.py → src/keycloak/authorization/role.py

@ -21,25 +21,50 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""The authorization Role module."""
class Role:
"""
"""Authorization Role base class.
Roles identify a type or category of user. Admin, user,
manager, and employee are all typical roles that may exist in an organization.
https://keycloak.gitbooks.io/documentation/server_admin/topics/roles.html
:param name: Name
:type name: str
:param required: Required role indicator
:type required: bool
"""
def __init__(self, name, required=False):
"""Init method.
:param name: Name
:type name: str
:param required: Required role indicator
:type required: bool
"""
self.name = name
self.required = required
@property
def get_name(self):
"""Get name.
:returns: Name
:rtype: str
"""
return self.name
def __eq__(self, other):
"""Eq method.
:param other: The other object
:type other: str
:returns: Equality bool
:rtype: bool | NotImplemented
"""
if isinstance(other, str):
return self.name == other
return NotImplemented

281
src/keycloak/connection.py

@ -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)

102
keycloak/exceptions.py → src/keycloak/exceptions.py

@ -21,13 +21,30 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Keycloak custom exceptions module."""
import requests
class KeycloakError(Exception):
def __init__(self, error_message="", response_code=None,
response_body=None):
"""Base class for custom Keycloak errors.
:param error_message: The error message
:type error_message: str
:param response_code: The response status code
:type response_code: int
"""
def __init__(self, error_message="", response_code=None, response_body=None):
"""Init method.
:param error_message: The error message
:type error_message: str
:param response_code: The code of the response
:type response_code: int
:param response_body: Body of the response
:type response_body: bytes
"""
Exception.__init__(self, error_message)
self.response_code = response_code
@ -35,6 +52,11 @@ class KeycloakError(Exception):
self.error_message = error_message
def __str__(self):
"""Str method.
:returns: String representation of the object
:rtype: str
"""
if self.response_code is not None:
return "{0}: {1}".format(self.response_code, self.error_message)
else:
@ -42,41 +64,105 @@ class KeycloakError(Exception):
class KeycloakAuthenticationError(KeycloakError):
"""Keycloak authentication error exception."""
pass
class KeycloakConnectionError(KeycloakError):
"""Keycloak connection error exception."""
pass
class KeycloakOperationError(KeycloakError):
"""Keycloak operation error exception."""
pass
class KeycloakDeprecationError(KeycloakError):
"""Keycloak deprecation error exception."""
pass
class KeycloakGetError(KeycloakOperationError):
"""Keycloak request get error exception."""
pass
class KeycloakPostError(KeycloakOperationError):
"""Keycloak request post error exception."""
pass
class KeycloakPutError(KeycloakOperationError):
"""Keycloak request put error exception."""
pass
class KeycloakDeleteError(KeycloakOperationError):
"""Keycloak request delete error exception."""
pass
class KeycloakSecretNotFound(KeycloakOperationError):
"""Keycloak secret not found exception."""
pass
class KeycloakRPTNotFound(KeycloakOperationError):
"""Keycloak RPT not found exception."""
pass
class KeycloakAuthorizationConfigError(KeycloakOperationError):
"""Keycloak authorization config exception."""
pass
class KeycloakInvalidTokenError(KeycloakOperationError):
"""Keycloak invalid token exception."""
pass
class KeycloakPermissionFormatError(KeycloakOperationError):
"""Keycloak permission format exception."""
pass
class PermissionDefinitionError(Exception):
"""Keycloak permission definition exception."""
pass
def raise_error_from_response(response, error, expected_codes=None, skip_exists=False):
"""Raise an exception for the response.
:param response: The response object
:type response: Response
:param error: Error object to raise
:type error: dict or Exception
:param expected_codes: Set of expected codes, which should not raise the exception
:type expected_codes: Sequence[int]
:param skip_exists: Indicates whether the response on already existing object should be ignored
:type skip_exists: bool
:returns: Content of the response message
:type: bytes or dict
:raises KeycloakError: In case of unexpected status codes
""" # noqa: DAR401,DAR402
if expected_codes is None:
expected_codes = [200, 201, 204]
@ -90,10 +176,10 @@ def raise_error_from_response(response, error, expected_codes=None, skip_exists=
return response.content
if skip_exists and response.status_code == 409:
return {"Already exists"}
return {"msg": "Already exists"}
try:
message = response.json()['message']
message = response.json()["message"]
except (KeyError, ValueError):
message = response.content
@ -103,6 +189,6 @@ def raise_error_from_response(response, error, expected_codes=None, skip_exists=
if response.status_code == 401:
error = KeycloakAuthenticationError
raise error(error_message=message,
response_code=response.status_code,
response_body=response.content)
raise error(
error_message=message, response_code=response.status_code, response_body=response.content
)

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

713
src/keycloak/keycloak_openid.py

@ -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)

417
src/keycloak/keycloak_uma.py

@ -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)

406
src/keycloak/openid_connection.py

@ -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

276
src/keycloak/uma_permissions.py

@ -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

138
keycloak/urls_patterns.py → src/keycloak/urls_patterns.py

@ -21,16 +21,25 @@
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Keycloak URL patterns."""
# OPENID URLS
URL_REALM = "realms/{realm-name}"
URL_WELL_KNOWN = "realms/{realm-name}/.well-known/openid-configuration"
URL_WELL_KNOWN_BASE = "realms/{realm-name}/.well-known"
URL_WELL_KNOWN = URL_WELL_KNOWN_BASE + "/openid-configuration"
URL_TOKEN = "realms/{realm-name}/protocol/openid-connect/token"
URL_USERINFO = "realms/{realm-name}/protocol/openid-connect/userinfo"
URL_LOGOUT = "realms/{realm-name}/protocol/openid-connect/logout"
URL_CERTS = "realms/{realm-name}/protocol/openid-connect/certs"
URL_INTROSPECT = "realms/{realm-name}/protocol/openid-connect/token/introspect"
URL_ENTITLEMENT = "realms/{realm-name}/authz/entitlement/{resource-server-id}"
URL_AUTH = "{authorization-endpoint}?client_id={client-id}&response_type=code&redirect_uri={redirect-uri}"
URL_AUTH = (
"{authorization-endpoint}?client_id={client-id}&response_type=code&redirect_uri={redirect-uri}"
"&scope={scope}&state={state}"
)
URL_CLIENT_REGISTRATION = URL_REALM + "/clients-registrations/default"
URL_CLIENT_UPDATE = URL_CLIENT_REGISTRATION + "/{client-id}"
# ADMIN URLS
URL_ADMIN_USERS = "admin/realms/{realm-name}/users"
@ -41,17 +50,28 @@ URL_ADMIN_SEND_UPDATE_ACCOUNT = "admin/realms/{realm-name}/users/{id}/execute-ac
URL_ADMIN_SEND_VERIFY_EMAIL = "admin/realms/{realm-name}/users/{id}/send-verify-email"
URL_ADMIN_RESET_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password"
URL_ADMIN_GET_SESSIONS = "admin/realms/{realm-name}/users/{id}/sessions"
URL_ADMIN_USER_CLIENT_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}"
URL_ADMIN_USER_CLIENT_ROLES = (
"admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}"
)
URL_ADMIN_USER_REALM_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/realm"
URL_ADMIN_USER_REALM_ROLES_AVAILABLE = "admin/realms/{realm-name}/users/{id}/role-mappings/realm/available"
URL_ADMIN_USER_REALM_ROLES_COMPOSITE = "admin/realms/{realm-name}/users/{id}/role-mappings/realm/composite"
URL_ADMIN_USER_REALM_ROLES_AVAILABLE = (
"admin/realms/{realm-name}/users/{id}/role-mappings/realm/available"
)
URL_ADMIN_USER_REALM_ROLES_COMPOSITE = (
"admin/realms/{realm-name}/users/{id}/role-mappings/realm/composite"
)
URL_ADMIN_GROUPS_REALM_ROLES = "admin/realms/{realm-name}/groups/{id}/role-mappings/realm"
URL_ADMIN_GROUPS_CLIENT_ROLES = "admin/realms/{realm-name}/groups/{id}/role-mappings/clients/{client-id}"
URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/available"
URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/composite"
URL_ADMIN_GROUPS_CLIENT_ROLES = (
"admin/realms/{realm-name}/groups/{id}/role-mappings/clients/{client-id}"
)
URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE = (
"admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/available"
)
URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE = (
"admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/composite"
)
URL_ADMIN_USER_GROUP = "admin/realms/{realm-name}/users/{id}/groups/{group-id}"
URL_ADMIN_USER_GROUPS = "admin/realms/{realm-name}/users/{id}/groups"
URL_ADMIN_USER_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password"
URL_ADMIN_USER_CREDENTIALS = "admin/realms/{realm-name}/users/{id}/credentials"
URL_ADMIN_USER_CREDENTIAL = "admin/realms/{realm-name}/users/{id}/credentials/{credential_id}"
URL_ADMIN_USER_LOGOUT = "admin/realms/{realm-name}/users/{id}/logout"
@ -61,10 +81,12 @@ URL_ADMIN_SERVER_INFO = "admin/serverinfo"
URL_ADMIN_GROUPS = "admin/realms/{realm-name}/groups"
URL_ADMIN_GROUP = "admin/realms/{realm-name}/groups/{id}"
URL_ADMIN_GROUP_BY_PATH = "admin/realms/{realm-name}/group-by-path/{path}"
URL_ADMIN_GROUP_CHILD = "admin/realms/{realm-name}/groups/{id}/children"
URL_ADMIN_GROUP_PERMISSIONS = "admin/realms/{realm-name}/groups/{id}/management/permissions"
URL_ADMIN_GROUP_MEMBERS = "admin/realms/{realm-name}/groups/{id}/members"
URL_ADMIN_CLIENT_INITIAL_ACCESS = "admin/realms/{realm-name}/clients-initial-access"
URL_ADMIN_CLIENTS = "admin/realms/{realm-name}/clients"
URL_ADMIN_CLIENT = URL_ADMIN_CLIENTS + "/{id}"
URL_ADMIN_CLIENT_ALL_SESSIONS = URL_ADMIN_CLIENT + "/user-sessions"
@ -73,14 +95,38 @@ URL_ADMIN_CLIENT_ROLES = URL_ADMIN_CLIENT + "/roles"
URL_ADMIN_CLIENT_ROLE = URL_ADMIN_CLIENT + "/roles/{role-name}"
URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE = URL_ADMIN_CLIENT_ROLE + "/composites"
URL_ADMIN_CLIENT_ROLE_MEMBERS = URL_ADMIN_CLIENT + "/roles/{role-name}/users"
URL_ADMIN_CLIENT_AUTHZ_SETTINGS = URL_ADMIN_CLIENT + "/authz/resource-server/settings"
URL_ADMIN_CLIENT_AUTHZ_RESOURCES = URL_ADMIN_CLIENT + "/authz/resource-server/resource?max=-1"
URL_ADMIN_CLIENT_AUTHZ_SCOPES = URL_ADMIN_CLIENT + "/authz/resource-server/scope?max=-1"
URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS = URL_ADMIN_CLIENT + "/authz/resource-server/permission?max=-1"
URL_ADMIN_CLIENT_AUTHZ_POLICIES = URL_ADMIN_CLIENT + "/authz/resource-server/policy?max=-1"
URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY = URL_ADMIN_CLIENT + "/authz/resource-server/policy/role?max=-1"
URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION = URL_ADMIN_CLIENT + "/authz/resource-server/permission/resource?max=-1"
URL_ADMIN_CLIENT_ROLE_GROUPS = URL_ADMIN_CLIENT + "/roles/{role-name}/groups"
URL_ADMIN_CLIENT_MANAGEMENT_PERMISSIONS = URL_ADMIN_CLIENT + "/management/permissions"
URL_ADMIN_CLIENT_SCOPE_MAPPINGS_REALM_ROLES = URL_ADMIN_CLIENT + "/scope-mappings/realm"
URL_ADMIN_CLIENT_SCOPE_MAPPINGS_CLIENT_ROLES = (
URL_ADMIN_CLIENT + "/scope-mappings/clients/{client}"
)
URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES = URL_ADMIN_CLIENT + "/optional-client-scopes"
URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPE = (
URL_ADMIN_CLIENT_OPTIONAL_CLIENT_SCOPES + "/{client_scope_id}"
)
URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES = URL_ADMIN_CLIENT + "/default-client-scopes"
URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPE = (
URL_ADMIN_CLIENT_DEFAULT_CLIENT_SCOPES + "/{client_scope_id}"
)
URL_ADMIN_CLIENT_AUTHZ = URL_ADMIN_CLIENT + "/authz/resource-server"
URL_ADMIN_CLIENT_AUTHZ_SETTINGS = URL_ADMIN_CLIENT_AUTHZ + "/settings"
URL_ADMIN_CLIENT_AUTHZ_RESOURCE = URL_ADMIN_CLIENT_AUTHZ + "/resource/{resource-id}"
URL_ADMIN_CLIENT_AUTHZ_RESOURCES = URL_ADMIN_CLIENT_AUTHZ + "/resource?max=-1"
URL_ADMIN_CLIENT_AUTHZ_SCOPES = URL_ADMIN_CLIENT_AUTHZ + "/scope?max=-1"
URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS = URL_ADMIN_CLIENT_AUTHZ + "/permission?max=-1"
URL_ADMIN_CLIENT_AUTHZ_POLICIES = URL_ADMIN_CLIENT_AUTHZ + "/policy?max=-1&permission=false"
URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY = URL_ADMIN_CLIENT_AUTHZ + "/policy/role?max=-1"
URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION = (
URL_ADMIN_CLIENT_AUTHZ + "/permission/resource?max=-1"
)
URL_ADMIN_CLIENT_AUTHZ_SCOPE_BASED_PERMISSION = URL_ADMIN_CLIENT_AUTHZ + "/permission/scope?max=-1"
URL_ADMIN_CLIENT_AUTHZ_POLICY = URL_ADMIN_CLIENT_AUTHZ + "/policy/{policy-id}"
URL_ADMIN_CLIENT_AUTHZ_POLICY_SCOPES = URL_ADMIN_CLIENT_AUTHZ_POLICY + "/scopes"
URL_ADMIN_CLIENT_AUTHZ_POLICY_RESOURCES = URL_ADMIN_CLIENT_AUTHZ_POLICY + "/resources"
URL_ADMIN_CLIENT_AUTHZ_SCOPE_PERMISSION = URL_ADMIN_CLIENT_AUTHZ + "/permission/scope/{scope-id}"
URL_ADMIN_CLIENT_AUTHZ_CLIENT_POLICY = URL_ADMIN_CLIENT_AUTHZ + "/policy/client"
URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER = URL_ADMIN_CLIENT + "/service-account-user"
URL_ADMIN_CLIENT_CERTS = URL_ADMIN_CLIENT + "/certificates/{attr}"
@ -100,10 +146,16 @@ URL_ADMIN_REALMS = "admin/realms"
URL_ADMIN_REALM = "admin/realms/{realm-name}"
URL_ADMIN_IDPS = "admin/realms/{realm-name}/identity-provider/instances"
URL_ADMIN_IDP_MAPPERS = "admin/realms/{realm-name}/identity-provider/instances/{idp-alias}/mappers"
URL_ADMIN_IDP = "admin/realms//{realm-name}/identity-provider/instances/{alias}"
URL_ADMIN_IDP_MAPPER_UPDATE = URL_ADMIN_IDP_MAPPERS + "/{mapper-id}"
URL_ADMIN_IDP = "admin/realms/{realm-name}/identity-provider/instances/{alias}"
URL_ADMIN_REALM_ROLES_ROLE_BY_NAME = "admin/realms/{realm-name}/roles/{role-name}"
URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE = "admin/realms/{realm-name}/roles/{role-name}/composites"
URL_ADMIN_REALM_EXPORT = "admin/realms/{realm-name}/partial-export?exportClients={export-clients}&exportGroupsAndRoles={export-groups-and-roles}"
URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE = (
"admin/realms/{realm-name}/roles/{role-name}/composites"
)
URL_ADMIN_REALM_EXPORT = (
"admin/realms/{realm-name}/partial-export?exportClients={export-clients}&"
+ "exportGroupsAndRoles={export-groups-and-roles}"
)
URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES = URL_ADMIN_REALM + "/default-default-client-scopes"
URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE = URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES + "/{id}"
@ -114,10 +166,22 @@ URL_ADMIN_FLOWS = "admin/realms/{realm-name}/authentication/flows"
URL_ADMIN_FLOW = URL_ADMIN_FLOWS + "/{id}"
URL_ADMIN_FLOWS_ALIAS = "admin/realms/{realm-name}/authentication/flows/{flow-id}"
URL_ADMIN_FLOWS_COPY = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/copy"
URL_ADMIN_FLOWS_EXECUTIONS = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions"
URL_ADMIN_FLOWS_EXECUTIONS = (
"admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions"
)
URL_ADMIN_FLOWS_EXECUTION = "admin/realms/{realm-name}/authentication/executions/{id}"
URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/execution"
URL_ADMIN_FLOWS_EXECUTIONS_FLOW = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/flow"
URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION = (
"admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/execution"
)
URL_ADMIN_FLOWS_EXECUTIONS_FLOW = (
"admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/flow"
)
URL_ADMIN_AUTHENTICATOR_PROVIDERS = (
"admin/realms/{realm-name}/authentication/authenticator-providers"
)
URL_ADMIN_AUTHENTICATOR_CONFIG_DESCRIPTION = (
"admin/realms/{realm-name}/authentication/config-description/{provider-id}"
)
URL_ADMIN_AUTHENTICATOR_CONFIG = "admin/realms/{realm-name}/authentication/config/{id}"
URL_ADMIN_COMPONENTS = "admin/realms/{realm-name}/components"
@ -125,10 +189,30 @@ URL_ADMIN_COMPONENT = "admin/realms/{realm-name}/components/{component-id}"
URL_ADMIN_KEYS = "admin/realms/{realm-name}/keys"
URL_ADMIN_USER_FEDERATED_IDENTITIES = "admin/realms/{realm-name}/users/{id}/federated-identity"
URL_ADMIN_USER_FEDERATED_IDENTITY = "admin/realms/{realm-name}/users/{id}/federated-identity/{provider}"
URL_ADMIN_EVENTS = 'admin/realms/{realm-name}/events'
URL_ADMIN_USER_FEDERATED_IDENTITY = (
"admin/realms/{realm-name}/users/{id}/federated-identity/{provider}"
)
URL_ADMIN_DELETE_USER_ROLE = "admin/realms/{realm-name}/users/{id}/role-mappings/realm"
URL_ADMIN_EVENTS = "admin/realms/{realm-name}/events"
URL_ADMIN_EVENTS_CONFIG = URL_ADMIN_EVENTS + "/config"
URL_ADMIN_CLIENT_SESSION_STATS = "admin/realms/{realm-name}/client-session-stats"
URL_ADMIN_GROUPS_CLIENT_ROLES_COMPOSITE = URL_ADMIN_GROUPS_CLIENT_ROLES + "/composite"
URL_ADMIN_REALM_ROLE_COMPOSITES = "admin/realms/{realm-name}/roles-by-id/{role-id}/composites"
URL_ADMIN_REALM_ROLE_COMPOSITES_REALM = URL_ADMIN_REALM_ROLE_COMPOSITES + "/realm"
URL_ADMIN_CLIENT_ROLE_CHILDREN = URL_ADMIN_REALM_ROLE_COMPOSITES + "/clients/{client-id}"
URL_ADMIN_CLIENT_CERT_UPLOAD = URL_ADMIN_CLIENT_CERTS + "/upload-certificate"
URL_ADMIN_REQUIRED_ACTIONS = URL_ADMIN_REALM + "/authentication/required-actions"
URL_ADMIN_REQUIRED_ACTIONS_ALIAS = URL_ADMIN_REQUIRED_ACTIONS + "/{action-alias}"
URL_ADMIN_ATTACK_DETECTION = "admin/realms/{realm-name}/attack-detection/brute-force/users"
URL_ADMIN_ATTACK_DETECTION_USER = (
"admin/realms/{realm-name}/attack-detection/brute-force/users/{id}"
)
URL_ADMIN_CLEAR_KEYS_CACHE = URL_ADMIN_REALM + "/clear-keys-cache"
URL_ADMIN_CLEAR_REALM_CACHE = URL_ADMIN_REALM + "/clear-realm-cache"
URL_ADMIN_CLEAR_USER_CACHE = URL_ADMIN_REALM + "/clear-user-cache"
# UMA URLS
URL_UMA_WELL_KNOWN = URL_WELL_KNOWN_BASE + "/uma2-configuration"

38
test_keycloak_init.sh

@ -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}

1
tests/__init__.py

@ -0,0 +1 @@
"""Tests module."""

530
tests/conftest.py

@ -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)

45
tests/data/authz_settings.json

@ -0,0 +1,45 @@
{
"allowRemoteResourceManagement": true,
"policyEnforcementMode": "ENFORCING",
"policies": [
{
"name": "Default Policy",
"type": "js",
"logic": "POSITIVE",
"decisionStrategy": "AFFIRMATIVE",
"config": {
"code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n"
}
},
{
"name": "test-authz-rb-policy",
"type": "role",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"roles": "[{\"id\":\"offline_access\",\"required\":false}]"
}
},
{
"name": "Default Permission",
"type": "resource",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"applyPolicies": "[\"test-authz-rb-policy\"]"
}
},
{
"name": "Test scope",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"scopes": "[]",
"applyPolicies": "[\"test-authz-rb-policy\"]"
}
}
],
"scopes": [],
"decisionStrategy": "UNANIMOUS"
}

BIN
tests/providers/asm-7.3.1.jar

BIN
tests/providers/asm-commons-7.3.1.jar

BIN
tests/providers/asm-tree-7.3.1.jar

BIN
tests/providers/asm-util-7.3.1.jar

BIN
tests/providers/nashorn-core-15.4.jar

42
tests/test_authorization.py

@ -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"

41
tests/test_connection.py

@ -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={})

20
tests/test_exceptions.py

@ -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

472
tests/test_keycloak_openid.py

@ -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'})"
)

311
tests/test_keycloak_uma.py

@ -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"])

14
tests/test_license.py

@ -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#"
)

212
tests/test_uma_permissions.py

@ -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"}

36
tests/test_urls_patterns.py

@ -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)

4
tox.env

@ -0,0 +1,4 @@
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=admin
KEYCLOAK_HOST={env:KEYCLOAK_HOST:localhost}
KEYCLOAK_PORT=8080

54
tox.ini

@ -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
Loading…
Cancel
Save