diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..5b00f61 Binary files /dev/null and b/.coverage differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..3c7be7f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + */setup* \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4b16f59 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 0000000..29af702 --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,39 @@ +name: Test-Suite + +on: + workflow_dispatch: + push: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: "Set up Python 3.8" + uses: actions/setup-python@v2 + with: + python-version: 3.8 + +# - name: Install Pip +# run: | +# python -m pip install --upgrade pip +# +# - name: "Install Test Dependencies" +# run: | +# python -m pip install wheel +# python -m pip install pytest +# python -m pip install pytest-cov +# +# - name: "Install dependencies" +# run: | +# python -m pip install -r requirements.txt +# +# - name: Test with pytest +# run: | +# pytest tests + + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v2 + with: + fail_ci_if_error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41be6be --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# IDE +.idea + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.cache +nosetests.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..91af91d --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# FastAPI Keycloak Integration + +[![CodeFactor](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak/badge)](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak) +[![codecov](https://codecov.io/gh/code-specialist/fastapi-keycloak/branch/master/graph/badge.svg?token=PX6NJBDUJ9)](https://codecov.io/gh/code-specialist/fastapi-keycloak) +--- + +## Introduction + +Welcome to `fastapi-keycloak`. This projects goal is to ease the integration of Keycloak (OpenID Connect) with Python, especially FastAPI. FastAPI is not necessary but is +encouraged due to specific features. Currently, this package supports only the `password` and the `authorization_code`. However, the `get_current_user()` method accepts any JWT +that was signed using Keycloak's private key. + +**This package is currently under development and is not yet officially released. However, you may still use it and contribute to it.** + +## TLDR; + +FastAPI Keycloak enables you to do the following things without writing a single line of additional code: + +- Verify identities and roles of users with Keycloak +- Get a list of available identity providers +- Create/read/delete users +- Create/read/delete roles +- Assign/remove roles from users +- Implement the `password` or the `authorization_code` flow (login/callback/logout) + +## Example + +This example assumes you use a frontend technology (such as React, Vue, or whatever suits you) to render your pages and merely depicts a `protected backend` + +### app.py + +```python +import uvicorn +from fastapi import FastAPI, Depends + +from fastapi_keycloak import FastAPIKeycloak, OIDCUser + +app = FastAPI() +idp = FastAPIKeycloak( + server_url="https://auth.some-domain.com/auth", + client_id="some-client", + client_secret="some-client-secret", + admin_client_secret="admin-cli-secret", + realm="some-realm-name", + callback_uri="http://localhost:8081/callback" +) +idp.add_swagger_config(app) + +@app.get("/admin") +def admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): + return f'Hi premium user {user}' + + +@app.get("/user/roles") +def user_roles(user: OIDCUser = Depends(idp.get_current_user)): + return f'{user.roles}' + + +if __name__ == '__main__': + uvicorn.run('app:app', host="127.0.0.1", port=8081) +``` diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..a6fb23d --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +fastapi-keycloak.code-specialist.com \ No newline at end of file diff --git a/docs/apple-m1.md b/docs/apple-m1.md new file mode 100644 index 0000000..9d4e9f3 --- /dev/null +++ b/docs/apple-m1.md @@ -0,0 +1,14 @@ +# Apple MacBook M1 issues + +In case you're using a current Apple MacBook with M1 CPU, you might encounter the issue that Keycloak just won't start (local testing purposes). We resolved this issues +ourselves by rebuilding the image locally. Doing so might look like the following: + +```shell +#!/bin/zsh + +cd /tmp +git clone git@github.com:keycloak/keycloak-containers.git +cd keycloak-containers/server +git checkout 16.1.0 +docker build -t "jboss/keycloak:16.1.0" . +``` \ No newline at end of file diff --git a/docs/css/styles.css b/docs/css/styles.css new file mode 100644 index 0000000..378ba9e --- /dev/null +++ b/docs/css/styles.css @@ -0,0 +1,10 @@ +:root { + --md-primary-fg-color: #3498db; + --md-primary-fg-color--light: #5ea6d7; + --md-primary-fg-color--dark: #0782d5; +} + +.md-header__button.md-logo img { + height: 40px; + width: auto !important; +} diff --git a/docs/full_example.md b/docs/full_example.md new file mode 100644 index 0000000..3436404 --- /dev/null +++ b/docs/full_example.md @@ -0,0 +1,163 @@ +# Example Usage + +```python +from typing import List, Optional + +import uvicorn +from fastapi import FastAPI, Depends, Query, Body +from pydantic import SecretStr + +from fastapi_keycloak import FastAPIKeycloak, OIDCUser, UsernamePassword, HTTPMethod + +app = FastAPI() +idp = FastAPIKeycloak( + server_url="https://auth.some-domain.com/auth", + client_id="some-client", + client_secret="some-client-secret", + admin_client_secret="admin-cli-secret", + realm="some-realm-name", + callback_uri="http://localhost:8081/callback" +) +idp.add_swagger_config(app) + + +# Admin + +@app.post("/proxy", tags=["admin-cli"]) +def proxy_admin_request(relative_path: str, method: HTTPMethod, additional_headers: dict = Body(None), payload: dict = Body(None)): + return idp.proxy( + additional_headers=additional_headers, + relative_path=relative_path, + method=method, + payload=payload + ) + + +@app.get("/identity-providers", tags=["admin-cli"]) +def get_identity_providers(): + return idp.get_identity_providers() + + +@app.get("/idp-configuration", tags=["admin-cli"]) +def get_idp_config(): + return idp.open_id_configuration + + +# User Management + +@app.get("/users", tags=["user-management"]) +def get_users(): + return idp.get_all_users() + + +@app.get("/user", tags=["user-management"]) +def get_user_by_query(query: str = None): + return idp.get_user(query=query) + + +@app.post("/users", tags=["user-management"]) +def create_user(first_name: str, last_name: str, email: str, password: SecretStr, id: str = None): + return idp.create_user(first_name=first_name, last_name=last_name, username=email, email=email, password=password.get_secret_value(), id=id) + + +@app.get("/user/{user_id}", tags=["user-management"]) +def get_user(user_id: str = None): + return idp.get_user(user_id=user_id) + + +@app.delete("/user/{user_id}", tags=["user-management"]) +def delete_user(user_id: str): + return idp.delete_user(user_id=user_id) + + +@app.put("/user/{user_id}/change-password", tags=["user-management"]) +def change_password(user_id: str, new_password: SecretStr): + return idp.change_password(user_id=user_id, new_password=new_password) + + +@app.put("/user/{user_id}/send-email-verification", tags=["user-management"]) +def send_email_verification(user_id: str): + return idp.send_email_verification(user_id=user_id) + + +# Role Management + +@app.get("/roles", tags=["role-management"]) +def get_all_roles(): + return idp.get_all_roles() + + +@app.get("/role/{role_name}", tags=["role-management"]) +def get_role(role_name: str): + return idp.get_roles([role_name]) + + +@app.post("/roles", tags=["role-management"]) +def add_role(role_name: str): + return idp.create_role(role_name=role_name) + + +@app.delete("/roles", tags=["role-management"]) +def delete_roles(role_name: str): + return idp.delete_role(role_name=role_name) + + +# User Roles + +@app.post("/users/{user_id}/roles", tags=["user-roles"]) +def add_roles_to_user(user_id: str, roles: Optional[List[str]] = Query(None)): + return idp.add_user_roles(user_id=user_id, roles=roles) + + +@app.get("/users/{user_id}/roles", tags=["user-roles"]) +def get_user_roles(user_id: str): + return idp.get_user_roles(user_id=user_id) + + +@app.delete("/users/{user_id}/roles", tags=["user-roles"]) +def delete_roles_from_user(user_id: str, roles: Optional[List[str]] = Query(None)): + return idp.remove_user_roles(user_id=user_id, roles=roles) + + +# Example User Requests + +@app.get("/protected", tags=["example-user-request"]) +def protected(user: OIDCUser = Depends(idp.get_current_user())): + return user + + +@app.get("/current_user/roles", tags=["example-user-request"]) +def get_current_users_roles(user: OIDCUser = Depends(idp.get_current_user())): + return user.roles + + +@app.get("/admin", tags=["example-user-request"]) +def company_admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): + return f'Hi admin {user}' + + +@app.get("/login", tags=["example-user-request"]) +def login(user: UsernamePassword = Depends()): + return idp.user_login(username=user.username, password=user.password.get_secret_value()) + + +# Auth Flow + +@app.get("/login-link", tags=["auth-flow"]) +def login_redirect(): + return idp.login_uri + + +@app.get("/callback", tags=["auth-flow"]) +def callback(session_state: str, code: str): + return idp.exchange_authorization_code(session_state=session_state, code=code) + + +@app.get("/logout", tags=["auth-flow"]) +def logout(): + return idp.logout_uri + + +if __name__ == '__main__': + uvicorn.run('example_app:app', host="127.0.0.1", port=8081) +``` \ No newline at end of file diff --git a/docs/img/favicon.svg b/docs/img/favicon.svg new file mode 100755 index 0000000..3900cb0 --- /dev/null +++ b/docs/img/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/img/logo.png b/docs/img/logo.png new file mode 100755 index 0000000..881f209 Binary files /dev/null and b/docs/img/logo.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1bb6b74 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,65 @@ +# FastAPI Keycloak Integration + +[![CodeFactor](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak/badge)](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak) +[![codecov](https://codecov.io/gh/code-specialist/fastapi-keycloak/branch/master/graph/badge.svg?token=PX6NJBDUJ9)](https://codecov.io/gh/code-specialist/fastapi-keycloak) + +--- + +## Introduction + +Welcome to `fastapi-keycloak`. This projects goal is to ease the integration of Keycloak (OpenID Connect) with Python, especially FastAPI. FastAPI is not necessary but is +encouraged due to specific features. Currently, this package supports only the `password` and the `authorization_code`. However, the `get_current_user()` method accepts any JWT +that was signed +using +Keycloak's private key. + +!!! Caution + This package is currently under development and is not yet officially released. However, you may still use it and contribute to it. + +## TLDR; + +FastAPI Keycloak enables you to do the following things without writing a single line of additional code: + +- Verify identities and roles of users with Keycloak +- Get a list of available identity providers +- Create/read/delete users +- Create/read/delete roles +- Assign/remove roles from users +- Implement the `password` or the `authorization_code` flow (login/callback/logout) + +## Example + +This example assumes you use a frontend technology (such as React, Vue, or whatever suits you) to render your pages and merely depicts a `protected backend` + +### app.py + +```python +import uvicorn +from fastapi import FastAPI, Depends + +from fastapi_keycloak import FastAPIKeycloak, OIDCUser + +app = FastAPI() +idp = FastAPIKeycloak( + server_url="https://auth.some-domain.com/auth", + client_id="some-client", + client_secret="some-client-secret", + admin_client_secret="admin-cli-secret", + realm="some-realm-name", + callback_uri="http://localhost:8081/callback" +) +idp.add_swagger_config(app) + +@app.get("/admin") +def admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): + return f'Hi premium user {user}' + + +@app.get("/user/roles") +def user_roles(user: OIDCUser = Depends(idp.get_current_user)): + return f'{user.roles}' + + +if __name__ == '__main__': + uvicorn.run('app:app', host="127.0.0.1", port=8081) +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c2c9113 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,8 @@ +# Installation + +```shell +pip install fastapi-keycloak +``` + +!!! Caution + This package is currently under development and is not yet officially released. However, you may still use it and contribute to it. \ No newline at end of file diff --git a/docs/keycloak_configuration.md b/docs/keycloak_configuration.md new file mode 100644 index 0000000..2f7a171 --- /dev/null +++ b/docs/keycloak_configuration.md @@ -0,0 +1,14 @@ +# Keycloak sample configuration + +1. Create a new realm + 1. **General**: Enabled, OpenID Endpoint Configuration + 2. **Login**: User registration enabled +2. Create a new client + 1. **Settings**: Enabled, Direct Access Granted, Service Accounts Enabled, Authorization Enabled + 2. **Scope**: Full Scope Allowed (Will automatically grant all available roles to all users using this client, you may want to disable this and assign the roles to the client + manually) + 3. Valid Redirect URIs: `http://localhost:8081/callback` (Or whatever you configure in your python app) +3. Modify the `admin-cli` client + 1. **Settings**: Service Accounts Enabled + 2. **Scope**: Full Scope Allowed + 3. **Service Account Roles**: Select all Client Roles available for `account` and `realm_management` \ No newline at end of file diff --git a/docs/quick_start.md b/docs/quick_start.md new file mode 100644 index 0000000..24f2154 --- /dev/null +++ b/docs/quick_start.md @@ -0,0 +1,122 @@ +# Quickstart + +In order to just get started, we prepared some containers and configs for you. + +## 1. Configure the Containers + +**docker-compose.yaml** + +```yaml hl_lines="16 18" +version: '3' + +services: + postgres: + image: postgres + environment: + POSTGRES_DB: testkeycloakdb + POSTGRES_USER: testkeycloakuser + POSTGRES_PASSWORD: testkeycloakpassword + restart: + always + + keycloak: + image: jboss/keycloak:16.0.1 + volumes: + - ./realm-export.json:/opt/jboss/keycloak/imports/realm-export.json + command: + - "-b 0.0.0.0 -Dkeycloak.profile.feature.upload_scripts=enabled -Dkeycloak.import=/opt/jboss/keycloak/imports/realm-export.json" + environment: + DB_VENDOR: POSTGRES + DB_ADDR: postgres + DB_DATABASE: testkeycloakdb + DB_USER: testkeycloakuser + DB_SCHEMA: public + DB_PASSWORD: testkeycloakpassword + KEYCLOAK_USER: keycloakuser + KEYCLOAK_PASSWORD: keycloakpassword + PROXY_ADDRESS_FORWARDING: "true" + KEYCLOAK_LOGLEVEL: DEBUG + ports: + - '8085:8080' + depends_on: + - postgres + restart: + always +``` + +This will create a Postgres and a Keycloak container ready to use. Make sure to download the [realm-export.json](./downloads/realm-export.json) and keep it in the same folder as +the docker compose file to bind the configuration. + +!!! Caution + These containers are stateless and non-persistent. Data will be lost on restart. + +## 2. Start the Containers + +Start the containers by applying the `docker-compose.yaml`: + +```shell +docker-compose up -d +``` + +!!! info + When you want to delete the containers you may use `docker-compose down` in the same directory to kill the containers created with the `docker-compose.yaml` + +## 3. The FastAPI App + +You may use the code below without altering it, the imported config will match these values: + +```python +import uvicorn +from fastapi import FastAPI, Depends +from fastapi.responses import RedirectResponse +from fastapi_keycloak import FastAPIKeycloak, OIDCUser + +app = FastAPI() +idp = FastAPIKeycloak( + server_url="http://localhost:8085/auth", + client_id="test-client", + client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", + admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", + realm="Test", + callback_uri="http://localhost:8081/callback" +) +idp.add_swagger_config(app) + + +@app.get("/") # Unprotected +def root(): + return 'Hello World' + + +@app.get("/user") # Requires logged in +def current_users(user: OIDCUser = Depends(idp.get_current_user())): + return user + + +@app.get("/admin") # Requires the admin role +def company_admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): + return f'Hi admin {user}' + + +@app.get("/login") +def login_redirect(): + return RedirectResponse(idp.login_uri) + + +@app.get("/callback") +def callback(session_state: str, code: str): + return idp.exchange_authorization_code(session_state=session_state, code=code) # This will return an access token + + +if __name__ == '__main__': + uvicorn.run('app:app', host="127.0.0.1", port=8081) +``` + +## 4. Usage + +You may now use any of the [APIs exposed endpoints](reference.md) as everything is configured for testing all the features. + +After you call the `/login` endpoint of your app, you will be redirected to the login screen of Keycloak. You may open the Keycloak Frontend at [http://localhost:8085/auth](http://localhost:8085/auth) and create a user. To +log into your Keycloak instance, the username is `keycloakuser` and the password is `keycloakpassword` as described in the `docker-compose.yaml` above. + +To utilize this fully you need a way to store the Access-Token provided by the callback route and append it to the preceding requests as `Authorization` Bearer. \ No newline at end of file diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..f01a394 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,8 @@ +## FastAPIKeycloak +::: fastapi_keycloak.FastAPIKeycloak + +## KeycloakError +::: fastapi_keycloak.exceptions.KeycloakError + +## Models +::: fastapi_keycloak.model \ No newline at end of file diff --git a/fastapi_keycloak/.coverage b/fastapi_keycloak/.coverage new file mode 100644 index 0000000..1e44f57 Binary files /dev/null and b/fastapi_keycloak/.coverage differ diff --git a/fastapi_keycloak/__init__.py b/fastapi_keycloak/__init__.py new file mode 100644 index 0000000..7a16336 --- /dev/null +++ b/fastapi_keycloak/__init__.py @@ -0,0 +1,5 @@ +from fastapi_keycloak.api import FastAPIKeycloak +from fastapi_keycloak.model import OIDCUser, UsernamePassword, HTTPMethod, KeycloakError, KeycloakUser, KeycloakToken, KeycloakRole, KeycloakIdentityProvider + +__all__ = [FastAPIKeycloak.__name__, OIDCUser.__name__, UsernamePassword.__name__, HTTPMethod.__name__, KeycloakError.__name__, KeycloakUser.__name__, KeycloakToken.__name__, + KeycloakRole.__name__, KeycloakIdentityProvider.__name__] diff --git a/fastapi_keycloak/api.py b/fastapi_keycloak/api.py new file mode 100644 index 0000000..0c2e389 --- /dev/null +++ b/fastapi_keycloak/api.py @@ -0,0 +1,755 @@ +from __future__ import annotations + +import functools +import json +from json import JSONDecodeError +from typing import List, Type + +import requests +from fastapi import Depends, HTTPException, FastAPI +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, ExpiredSignatureError, JWTError +from jose.exceptions import JWTClaimsError +from pydantic import BaseModel +from requests import Response + +from fastapi_keycloak.exceptions import KeycloakError +from fastapi_keycloak.model import HTTPMethod, KeycloakUser, OIDCUser, KeycloakToken, KeycloakRole, KeycloakIdentityProvider + + +def result_or_error(response_model: Type[BaseModel] = None, is_list: bool = False) -> List[BaseModel] or BaseModel or KeycloakError: + """ Decorator used to ease the handling of responses from Keycloak. + + Args: + response_model (Type[BaseModel]): Object that should be returned based on the payload + is_list (bool): True if the return value should be a list of the response model provided + + Returns: + BaseModel or List[BaseModel]: Based on the given signature and response circumstances + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + + Notes: + - Keycloak sometimes returns empty payloads but describes the error in its content (byte encoded) + which is why this function checks for JSONDecode exceptions. + - Keycloak often does not expose the real error for security measures. You will most likely encounter: + {'error': 'unknown_error'} as a result. If so, please check the logs of your Keycloak instance to get error details, + the RestAPI doesnt provide any. + """ + + def inner(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + + def create_list(json: List[dict]): + items = list() + for entry in json: + items.append(response_model.parse_obj(entry)) + return items + + def create_object(json: dict): + return response_model.parse_obj(json) + + result: Response = f(*args, **kwargs) # The actual call + + if type(result) != Response: # If the object given is not a response object, directly return it. + return result + + if result.status_code in range(100, 299): # Successful + if response_model is None: # No model given + + try: + return result.json() + except JSONDecodeError: + return result.content.decode('utf-8') + + else: # Response model given + if is_list: + return create_list(result.json()) + else: + return create_object(result.json()) + + else: # Not Successful, forward status code and error + try: + raise KeycloakError(status_code=result.status_code, reason=result.json()) + except JSONDecodeError: + raise KeycloakError(status_code=result.status_code, reason=result.content.decode('utf-8')) + + return wrapper + + return inner + + +class FastAPIKeycloak: + """ Instance to wrap the Keycloak API with FastAPI + + Attributes: + _admin_token (KeycloakToken): A KeycloakToken instance, containing the access token that is used for any admin related request + + Example: + ```python + app = FastAPI() + idp = KeycloakFastAPI( + server_url="https://auth.some-domain.com/auth", + client_id="some-test-client", + client_secret="some-secret", + admin_client_secret="some-admin-cli-secret", + realm="Test", + callback_uri=f"http://localhost:8081/callback" + ) + idp.add_swagger_config(app) + ``` + """ + _admin_token: str + + def __init__(self, server_url: str, client_id: str, client_secret: str, realm: str, admin_client_secret: str, callback_uri: str): + """ FastAPIKeycloak constructor + + Args: + server_url (str): The URL of the Keycloak server, with `/auth` suffix + client_id (str): The id of the client used for users + client_secret (str): The client secret + realm (str): The realm (name) + admin_client_secret (str): Secret for the `admin-cli` client + callback_uri (str): Callback URL of the instance, used for auth flows. Must match at least one `Valid Redirect URIs` of Keycloak and should point to an endpoint + that utilizes the authorization_code flow. + """ + self.server_url = server_url + self.realm = realm + self.client_id = client_id + self.client_secret = client_secret + self.admin_client_secret = admin_client_secret + self.callback_uri = callback_uri + self._get_admin_token() # Requests an admin access token on startup + + @property + def admin_token(self): + """ Holds an AccessToken for the `admin-cli` client + + Returns: + KeycloakToken: A token, valid to perform admin actions + + Notes: + - This might result in an infinite recursion if something unforeseen goes wrong + """ + if self.token_is_valid(token=self._admin_token): + return self._admin_token + else: + self._get_admin_token() + return self.admin_token + + @admin_token.setter + def admin_token(self, value: str): + """ Setter for the admin_token + + Args: + value (str): An access Token + + Returns: + None: Inplace method, updates the _admin_token + """ + decoded_token = self._decode_token(token=value) + if not decoded_token.get('resource_access').get('realm-management') or not decoded_token.get('resource_access').get('account'): + raise AssertionError("""The access required was not contained in the access token for the `admin-cli`. + Possibly a Keycloak misconfiguration. Check if the admin-cli client has `Full Scope Allowed` + and that the `Service Account Roles` contain all roles from `account` and `realm_management`""") + self._admin_token = value + + def add_swagger_config(self, app: FastAPI): + """ Adds the client id and secret securely to the swagger ui. + Enabling Swagger ui users to perform actions they usually need the client credentials, without exposing them. + + Args: + app (FastAPI): Optional FastAPI app to add the config to swagger + + Returns: + None: Inplace method + """ + app.swagger_ui_init_oauth = { + "usePkceWithAuthorizationCodeGrant": True, + "clientId": self.client_id, + "clientSecret": self.client_secret + } + + @functools.cached_property + def user_auth_scheme(self) -> OAuth2PasswordBearer: + """ Returns the auth scheme to register the endpoints with swagger + + Returns: + OAuth2PasswordBearer: Auth scheme for swagger + """ + return OAuth2PasswordBearer(tokenUrl=self.token_uri) + + def get_current_user(self, required_roles: List[str] = None) -> OIDCUser: + """ Returns the current user based on an access token in the HTTP-header. Optionally verifies roles are possessed by the user + + Args: + required_roles List[str]: List of role names required for this endpoint + + Returns: + OIDCUser: Decoded JWT content + + Raises: + ExpiredSignatureError: If the token is expired (exp > datetime.now()) + JWTError: If decoding fails or the signature is invalid + JWTClaimsError: If any claim is invalid + HTTPException: If any role required is not contained within the roles of the users + """ + + def current_user(token: OAuth2PasswordBearer = Depends(self.user_auth_scheme)) -> OIDCUser: + """ Decodes and verifies a JWT to get the current user + + Args: + token OAuth2PasswordBearer: Access token in `Authorization` HTTP-header + + Returns: + OIDCUser: Decoded JWT content + + Raises: + ExpiredSignatureError: If the token is expired (exp > datetime.now()) + JWTError: If decoding fails or the signature is invalid + JWTClaimsError: If any claim is invalid + HTTPException: If any role required is not contained within the roles of the users + """ + decoded_token = self._decode_token(token=token, audience="account") + user = OIDCUser.parse_obj(decoded_token) + if required_roles: + for role in required_roles: + if role not in user.roles: + raise HTTPException(status_code=403, detail=f'Role "{role}" is required to perform this action') + return user + + return current_user + + @functools.cached_property + def open_id_configuration(self) -> dict: + """ Returns Keycloaks Open ID Connect configuration + + Returns: + dict: Open ID Configuration + """ + response = requests.get(url=f'{self.realm_uri}/.well-known/openid-configuration') + return response.json() + + def proxy(self, relative_path: str, method: HTTPMethod, additional_headers: dict = None, payload: dict = None) -> Response: + """ Proxies a request to Keycloak and automatically adds the required Authorization header. Should not be exposed under any circumstances. Grants full API admin access. + + Args: + + relative_path (str): The relative path of the request. Requests will be sent to: `[server_url]/[relative_path]` + method (HTTPMethod): The HTTP-verb to be used + additional_headers (dict): Optional headers besides the Authorization to add to the request + payload (dict): Optional payload to send + + Returns: + Response: Proxied response + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + headers = {"Authorization": f"Bearer {self.admin_token}"} + if additional_headers is not None: + headers = {**headers, **additional_headers} + + return requests.request( + method=method.name, + url=f'{self.server_url}{relative_path}', + data=json.dumps(payload), + headers=headers + ) + + def _get_admin_token(self) -> None: + """ Exchanges client credentials (admin-cli) for an access token. + + Returns: + None: Inplace method that updated the class attribute `_admin_token` + + Raises: + KeycloakError: If fetching an admin access token fails, or the response does not contain an access_token at all + + Notes: + - Is executed on startup and may be executed again if the token validation fails + """ + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + data = { + "client_id": "admin-cli", + "client_secret": self.admin_client_secret, + "grant_type": "client_credentials" + } + response = requests.post(url=self.token_uri, headers=headers, data=data) + try: + self.admin_token = response.json()['access_token'] + except JSONDecodeError: + raise KeycloakError(reason=response.content.decode('utf-8'), status_code=response.status_code) + except KeyError: + raise KeycloakError(reason=f"The response did not contain an access_token: {response.json()}", status_code=403) + + @functools.cached_property + def public_key(self) -> str: + """ Returns the Keycloak public key + + Returns: + str: Public key for JWT decoding + """ + response = requests.get(url=self.realm_uri) + public_key = response.json()["public_key"] + return f"-----BEGIN PUBLIC KEY-----\n{public_key}\n-----END PUBLIC KEY-----" + + @result_or_error() + def add_user_roles(self, roles: List[str], user_id: str) -> dict: + """ Adds roles to a specific user + + Args: + roles List[str]: Roles to add (name) + user_id str: ID of the user the roles should be added to + + Returns: + dict: Proxied response payload + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + keycloak_roles = self.get_roles(roles) + return self._admin_request( + url=f'{self.users_uri}/{user_id}/role-mappings/realm', + data=[role.__dict__ for role in keycloak_roles], + method=HTTPMethod.POST + ) + + @result_or_error() + def remove_user_roles(self, roles: List[str], user_id: str) -> dict: + """ Removes roles from a specific user + + Args: + roles List[str]: Roles to remove (name) + user_id str: ID of the user the roles should be removed from + + Returns: + dict: Proxied response payload + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + keycloak_roles = self.get_roles(roles) + return self._admin_request( + url=f'{self.users_uri}/{user_id}/role-mappings/realm', + data=[role.__dict__ for role in keycloak_roles], + method=HTTPMethod.DELETE + ) + + @result_or_error(response_model=KeycloakRole, is_list=True) + def get_roles(self, role_names: List[str]) -> List[KeycloakRole] or None: + """ Returns full entries of Roles based on role names + + Args: + role_names List[str]: Roles that should be looked up (names) + + Returns: + List[KeycloakRole]: Full entries stored at Keycloak. Or None if the list of requested roles is None + + Notes: + - The Keycloak RestAPI will only identify RoleRepresentations that + use name AND id which is the only reason for existence of this function + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + if role_names is None: + return None + roles = self.get_all_roles() + return list(filter(lambda role: role.name in role_names, roles)) + + @result_or_error(response_model=KeycloakRole, is_list=True) + def get_user_roles(self, user_id: str) -> List[KeycloakRole]: + """ Gets all roles of an user + + Args: + user_id (str): ID of the user of interest + + Returns: + List[KeycloakRole]: All roles possessed by the user + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + return self._admin_request(url=f'{self.users_uri}/{user_id}/role-mappings/realm', method=HTTPMethod.GET) + + @result_or_error(response_model=KeycloakRole) + def create_role(self, role_name: str) -> KeycloakRole: + """ Create a role on the realm + + Args: + role_name (str): Name of the new role + + Returns: + KeycloakRole: If creation succeeded, else it will return the error + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + response = self._admin_request(url=self.roles_uri, data={'name': role_name}, method=HTTPMethod.POST) + if response.status_code == 201: + return self.get_roles(role_names=[role_name])[0] + else: + return response + + @result_or_error(response_model=KeycloakRole, is_list=True) + def get_all_roles(self) -> List[KeycloakRole]: + """ Get all roles of the Keycloak realm + + Returns: + List[KeycloakRole]: All roles of the realm + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + return self._admin_request(url=self.roles_uri, method=HTTPMethod.GET) + + @result_or_error() + def delete_role(self, role_name: str) -> dict: + """ Deletes a role on the realm + + Args: + role_name (str): The role (name) to delte + + Returns: + dict: Proxied response payload + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + return self._admin_request(url=f'{self.roles_uri}/{role_name}', method=HTTPMethod.DELETE) + + @result_or_error(response_model=KeycloakUser) + def create_user( + self, + first_name: str, + last_name: str, + username: str, + email: str, + password: str, + enabled: bool = True, + initial_roles: List[str] = None, + send_email_verification: bool = True + ) -> KeycloakUser: + """ + + Args: + first_name (str): The first name of the new user + last_name (str): The last name of the new user + username (str): The username of the new user + email (str): The email of the new user + password (str): The password of the new user + initial_roles List[str]: The roles the user should posses. Defaults to `None` + enabled (bool): True if the user should be able to be used. Defaults to `True` + send_email_verification (bool): If true, the email verification will be added as an required + action and the email triggered - if the user was created successfully. Defaults to `True` + + Returns: + KeycloakUser: If the creation succeeded + + Notes: + - Also triggers the email verification email + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + data = { + "email": email, + "username": username, + "firstName": first_name, + "lastName": last_name, + "enabled": enabled, + "clientRoles": self.get_roles(initial_roles), + "credentials": [ + { + "temporary": False, + "type": "password", + "value": password + } + ], + "requiredActions": ["VERIFY_EMAIL" if send_email_verification else None] + } + response = self._admin_request(url=self.users_uri, data=data, method=HTTPMethod.POST) + if response.status_code == 201: + user = self.get_user(query=f'username={username}') + if send_email_verification: + self.send_email_verification(user.id) + return user + else: + return response + + @result_or_error() + def change_password(self, user_id: str, new_password: str) -> dict: + """ Exchanges a users password. + + Args: + user_id (str): The user ID of interest + new_password (str): The new password + + Returns: + dict: Proxied response payload + + Notes: + - Possibly should be extended by an old password check + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + credentials = {"temporary": False, "type": "password", "value": new_password} + return self._admin_request(url=f'{self.users_uri}/{user_id}/reset-password', data=credentials, method=HTTPMethod.PUT) + + @result_or_error() + def send_email_verification(self, user_id: str) -> dict: + """ Sends the email to verify the email address + + Args: + user_id (str): The user ID of interest + + Returns: + dict: Proxied response payload + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + return self._admin_request(url=f'{self.users_uri}/{user_id}/send-verify-email', method=HTTPMethod.PUT) + + @result_or_error(response_model=KeycloakUser) + def get_user(self, user_id: str = None, query: str = "") -> KeycloakUser: + """ Queries the keycloak API for a specific user either based on its ID or any **native** attribute + + Args: + user_id (str): The user ID of interest + query: Query string. e.g. `email=testuser@codespecialist.com` or `username=codespecialist` + + Returns: + KeycloakUser: If the user was found + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + if user_id is None: + response = self._admin_request(url=f'{self.users_uri}?{query}', method=HTTPMethod.GET) + return KeycloakUser(**response.json()[0]) + else: + response = self._admin_request(url=f'{self.users_uri}/{user_id}', method=HTTPMethod.GET) + return KeycloakUser(**response.json()) + + @result_or_error() + def delete_user(self, user_id: str) -> dict: + """ Deletes an user + + Args: + user_id (str): The user ID of interest + + Returns: + dict: Proxied response payload + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + return self._admin_request(url=f'{self.users_uri}/{user_id}', method=HTTPMethod.DELETE) + + @result_or_error(response_model=KeycloakUser, is_list=True) + def get_all_users(self) -> List[KeycloakUser]: + """ Returns all users of the realm + + Returns: + List[KeycloakUser]: All Keycloak users of the realm + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + response = self._admin_request(url=self.users_uri, method=HTTPMethod.GET) + return response + + @result_or_error(response_model=KeycloakIdentityProvider, is_list=True) + def get_identity_providers(self) -> List[KeycloakIdentityProvider]: + """ Returns all configured identity Providers + + Returns: + List[KeycloakIdentityProvider]: All configured identity providers + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + return self._admin_request(url=self.providers_uri, method=HTTPMethod.GET).json() + + @result_or_error(response_model=KeycloakToken) + def user_login(self, username: str, password: str) -> KeycloakToken: + """ Models the password OAuth2 flow. Exchanges username and password for an access token. + + Args: + username (str): Username used for login + password (str): Password of the user + + Returns: + KeycloakToken: If the exchange succeeds + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "username": username, + "password": password, + "grant_type": "password" + } + response = requests.post(url=self.token_uri, headers=headers, data=data) + return response + + @result_or_error(response_model=KeycloakToken) + def exchange_authorization_code(self, session_state: str, code: str) -> KeycloakToken: + """ Models the authorization code OAuth2 flow. Opening the URL provided by `login_uri` will result in a callback to the configured callback URL. + The callback will also create a session_state and code query parameter that can be exchanged for an access token. + + Args: + session_state (str): Salt to reduce the risk of successful attacks + code (str): The authorization code + + Returns: + KeycloakToken: If the exchange succeeds + + Raises: + KeycloakError: If the resulting response is not a successful HTTP-Code (>299) + """ + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "session_state": session_state, + "grant_type": "authorization_code", + "redirect_uri": self.callback_uri + } + response = requests.post(url=self.token_uri, headers=headers, data=data) + return response + + def _admin_request(self, url: str, method: HTTPMethod, data: dict = None, content_type: str = "application/json") -> Response: + """ Private method that is the basis for any requests requiring admin access to the api. Will append the necessary `Authorization` header + + Args: + url (str): The URL to be called + method (HTTPMethod): The HTTP verb to be used + data (dict): The payload of the request + content_type (str): The content type of the request + + Returns: + Response: Response of Keycloak + """ + headers = { + "Content-Type": content_type, + "Authorization": f"Bearer {self.admin_token}" + } + return requests.request(method=method.name, url=url, data=json.dumps(data), headers=headers) + + @functools.cached_property + def login_uri(self): + """ The URL for users to login on the realm. Also adds the client id and the callback. """ + return f'{self.authorization_uri}?response_type=code&client_id={self.client_id}&redirect_uri={self.callback_uri}' + + @functools.cached_property + def authorization_uri(self): + """ The authorization endpoint URL """ + return self.open_id_configuration.get('authorization_endpoint') + + @functools.cached_property + def token_uri(self): + """ The token endpoint URL """ + return self.open_id_configuration.get('token_endpoint') + + @functools.cached_property + def logout_uri(self): + """ The logout endpoint URL """ + return self.open_id_configuration.get('end_session_endpoint') + + @functools.cached_property + def realm_uri(self): + """ The realms endpoint URL """ + return f"{self.server_url}/realms/{self.realm}" + + @functools.cached_property + def users_uri(self): + """ The users endpoint URL """ + return self.admin_uri(resource="users") + + @functools.cached_property + def roles_uri(self): + """ The roles endpoint URL """ + return self.admin_uri(resource="roles") + + @functools.cached_property + def _admin_uri(self): + """ The base endpoint for any admin related action """ + return f"{self.server_url}/admin/realms/{self.realm}" + + @functools.cached_property + def _open_id(self): + """ The base endpoint for any opendid connect config info """ + return f"{self.realm_uri}/protocol/openid-connect" + + @functools.cached_property + def providers_uri(self): + """ The endpoint that returns all configured identity providers """ + return self.admin_uri(resource="identity-provider/instances") + + def admin_uri(self, resource: str): + """ Returns a admin resource URL """ + return f"{self._admin_uri}/{resource}" + + def open_id(self, resource: str): + """ Returns a openip connect resource URL """ + return f"{self._open_id}/{resource}" + + def token_is_valid(self, token: str, audience: str = None) -> bool: + """ Validates an access token, optionally also its audience + + Args: + token (str): The token to be verified + audience (str): Optional audience. Will be checked if provided + + Returns: + bool: True if the token is valid + """ + try: + self._decode_token(token=token, audience=audience) + return True + except (ExpiredSignatureError, JWTError, JWTClaimsError): + return False + + def _decode_token(self, token: str, options: dict = None, audience: str = None) -> dict: + """ Decodes a token, verifies the signature by using Keycloaks public key. Optionally verifying the audience + + Args: + token (str): + options (dict): + audience (str): Name of the audience, must match the audience given in the token + + Returns: + dict: Decoded JWT + + Raises: + ExpiredSignatureError: If the token is expired (exp > datetime.now()) + JWTError: If decoding fails or the signature is invalid + JWTClaimsError: If any claim is invalid + """ + if options is None: + options = {"verify_signature": True, "verify_aud": audience is not None, "verify_exp": True} + return jwt.decode(token=token, key=self.public_key, options=options, audience=audience) + + def __str__(self): + """ String representation """ + return f'FastAPI Keycloak Integration' + + def __repr__(self): + """ Debug representation """ + return f'{self.__str__()} ' diff --git a/fastapi_keycloak/exceptions.py b/fastapi_keycloak/exceptions.py new file mode 100644 index 0000000..60e371d --- /dev/null +++ b/fastapi_keycloak/exceptions.py @@ -0,0 +1,12 @@ +class KeycloakError(Exception): + """ Thrown if any response of keycloak does not match our expectation + + Attributes: + status_code (int): The status code of the response received + reason (str): The reason why the requests did fail + """ + + def __init__(self, status_code: int, reason: str): + self.status_code = status_code + self.reason = reason + super().__init__(reason) diff --git a/fastapi_keycloak/model.py b/fastapi_keycloak/model.py new file mode 100644 index 0000000..4a3565e --- /dev/null +++ b/fastapi_keycloak/model.py @@ -0,0 +1,181 @@ +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, SecretStr + +from fastapi_keycloak.exceptions import KeycloakError + + +class HTTPMethod(Enum): + """ Represents the basic HTTP verbs + + Values: + - GET: get + - POST: post + - DELETE: delete + - PUT: put + """ + GET = 'get' + POST = 'post' + DELETE = 'delete' + PUT = 'put' + + +class KeycloakUser(BaseModel): + """ Represents a user object of Keycloak. + + Attributes: + id (str): + createdTimestamp (int): + username (str): + enabled (bool): + totp (bool): + emailVerified (bool): + firstName (Optional[str]): + lastName (Optional[str]): + email (Optional[str]): + disableableCredentialTypes (List[str]): + requiredActions (List[str]): + notBefore (int): + access (dict): + + Notes: + Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for details. This is a mere proxy object. + """ + id: str + createdTimestamp: int + username: str + enabled: bool + totp: bool + emailVerified: bool + firstName: Optional[str] + lastName: Optional[str] + email: Optional[str] + disableableCredentialTypes: List[str] + requiredActions: List[str] + notBefore: int + access: dict + + +class UsernamePassword(BaseModel): + """ Represents a request body that contains username and password + + Attributes: + username (str): Username + password (str): Password, masked by swagger + """ + username: str + password: SecretStr + + +class OIDCUser(BaseModel): + """ Represents a user object of Keycloak, parsed from an access token + + Attributes: + sub (str): + iat (int): + exp (int): + scope (str): + email_verified (bool): + name (Optional[str]): + given_name (Optional[str]): + family_name (Optional[str]): + email (Optional[str]): + realm_access (dict): + + Notes: + Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for details. This is a mere proxy object. + """ + sub: str + iat: int + exp: int + scope: Optional[str] + email_verified: bool + name: Optional[str] + given_name: Optional[str] + family_name: Optional[str] + email: Optional[str] + realm_access: Optional[dict] + + @property + def roles(self) -> List[str]: + """ Returns the roles of the user + + Returns: + List[str]: If the realm access dict contains roles + """ + try: + return self.realm_access['roles'] + except KeyError: + raise KeycloakError(status_code=404, reason="The 'realm_access' section of the provided access token did not contain any 'roles'") + + def __str__(self) -> str: + """ String representation of an OIDCUser """ + return self.email + + +class KeycloakIdentityProvider(BaseModel): + """ Keycloak representation of an identity provider + + Attributes: + alias (str): + internalId (str): + providerId (str): + enabled (bool): + updateProfileFirstLoginMode (str): + trustEmail (bool): + storeToken (bool): + addReadTokenRoleOnCreate (bool): + authenticateByDefault (bool): + linkOnly (bool): + firstBrokerLoginFlowAlias (str): + config (dict): + + Notes: + Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for details. This is a mere proxy object. + """ + alias: str + internalId: str + providerId: str + enabled: bool + updateProfileFirstLoginMode: str + trustEmail: bool + storeToken: bool + addReadTokenRoleOnCreate: bool + authenticateByDefault: bool + linkOnly: bool + firstBrokerLoginFlowAlias: str + config: dict + + +class KeycloakRole(BaseModel): + """ Keycloak representation of a role + + Attributes: + id (str): + name (str): + composite (bool): + clientRole (bool): + containerId (str): + + Notes: + Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for details. This is a mere proxy object. + """ + id: str + name: str + composite: bool + clientRole: bool + containerId: str + + +class KeycloakToken(BaseModel): + """ Keycloak representation of a token object + + Attributes: + access_token (str): An access token + """ + access_token: str + + def __str__(self): + """ String representation of KeycloakToken """ + return f'Bearer {self.access_token}' diff --git a/fastapi_keycloak/requirements.txt b/fastapi_keycloak/requirements.txt new file mode 100644 index 0000000..6193788 --- /dev/null +++ b/fastapi_keycloak/requirements.txt @@ -0,0 +1,21 @@ +anyio==3.4.0 +asgiref==3.4.1 +certifi==2021.10.8 +charset-normalizer==2.0.9 +click==8.0.3 +ecdsa==0.17.0 +fastapi==0.70.1 +h11==0.12.0 +idna==3.3 +pyasn1==0.4.8 +pydantic==1.8.2 +python-jose==3.3.0 +requests==2.26.0 +rsa==4.8 +six==1.16.0 +sniffio==1.2.0 +starlette==0.16.0 +typing_extensions==4.0.1 +urllib3==1.26.7 +uvicorn==0.16.0 +itsdangerous==2.0.1 \ No newline at end of file diff --git a/fastapi_keycloak/setup.py b/fastapi_keycloak/setup.py new file mode 100644 index 0000000..ef7db8c --- /dev/null +++ b/fastapi_keycloak/setup.py @@ -0,0 +1,38 @@ +from typing import List + +import setuptools as setuptools +from setuptools import setup + + +def read_description() -> str: + with open("../README.md", "r") as fh: + return fh.read() + + +def read_dependencies() -> List[str]: + with open("requirements.txt", "r+") as pip_file: + requirements = pip_file.read() + requirements_list = requirements.split('\n') + return list(filter(lambda line: not (line.startswith('#') or line.startswith('-')) and line, requirements_list)) + + +def get_packages() -> List[str]: + all_packages = setuptools.find_packages() + return [package for package in all_packages] + + +setup( + name='fastapi-keycloak', + version='0.0.1a', + packages=get_packages(), + description='Keycloak integration for FastAPI', + long_description=read_description(), + long_description_content_type="text/markdown", + url='https://github.com/code-specialist/fastapi-keycloak', + install_requires=read_dependencies(), + python_requires='>=3.8', + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + ], +) diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 0000000..17fe675 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,82 @@ +site_name: FastAPI Keycloak Integration +site_url: https://fastapi-keycloak.code-specialist.com/ +site_description: "Python Package: FastAPI Keycloak Integration" +site_author: Jonas Scholl, Yannic Schröer +repo_url: https://github.com/code-specialist/fastapi-keycloak +repo_name: fastapi-keycloak + +theme: + name: material + locale: en + highlightjs: true + favicon: ./img/favicon.svg + logo: ./img/logo.png + hljs_languages: + - yaml + - python + - bash + - shell + palette: + - scheme: default + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/toggle-switch + name: Switch to light mode + font: + text: Nunito Sans + code: IBM Plex Mono + +nav: + - Introduction: ./index.md + - Installation: ./installation.md + - Quickstart: ./quick_start.md + - Keycloak Configuration: ./keycloak_configuration.md + - Full example: ./full_example.md + - API Reference: ./reference.md + - Known issues: ./apple-m1.md + +markdown_extensions: + - pymdownx.highlight + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.snippets + - toc: + permalink: + - admonition + - attr_list + - def_list + - abbr + - pymdownx.snippets + +extra_css: + - ./css/styles.css + +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + rendering: + show_source: true + setup_commands: + - import sys + - sys.path.append("fastapi_keycloak") + custom_templates: templates + +extra: + social: + - icon: fontawesome/brands/instagram + link: https://www.instagram.com/specialist_code/ + name: Code Specialist on Instagram + - icon: fontawesome/brands/youtube + link: https://www.youtube.com/channel/UCjdmChf65sGfOqWoygzBTyQ + name: Code Specialist on YouTube + - icon: fontawesome/brands/github + link: https://github.com/code-specialist + name: Code Specialist on GitHub + +copyright: Copyright © 2021 Code Specialist | Legal Notice \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d682d5b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -x -p no:warnings --cov-report=term-missing --cov-report=term --cov-report=xml:./tests/coverage.xml --no-cov-on-fail --cov=fastapi_keycloak \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ac60e8f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +anyio==3.4.0 +asgiref==3.4.1 +astunparse==1.6.3 +certifi==2021.10.8 +charset-normalizer==2.0.9 +click==8.0.3 +ecdsa==0.17.0 +fastapi==0.70.1 +ghp-import==2.0.2 +h11==0.12.0 +idna==3.3 +importlib-metadata==4.10.0 +itsdangerous==2.0.1 +Jinja2==3.0.3 +Markdown==3.3.6 +MarkupSafe==2.0.1 +mergedeep==1.3.4 +mkdocs==1.2.3 +mkdocs-autorefs==0.3.0 +mkdocs-material==7.3.6 +mkdocs-material-extensions==1.0.3 +mkdocstrings==0.16.2 +packaging==21.3 +pyasn1==0.4.8 +pydantic==1.8.2 +Pygments==2.10.0 +pymdown-extensions==9.1 +pyparsing==3.0.6 +python-dateutil==2.8.2 +python-jose==3.3.0 +pytkdocs==0.12.0 +PyYAML==6.0 +pyyaml-env-tag==0.1 +requests==2.26.0 +rsa==4.8 +six==1.16.0 +sniffio==1.2.0 +starlette==0.16.0 +typing-extensions==4.0.1 +urllib3==1.26.7 +uvicorn==0.16.0 +watchdog==2.1.6 +zipp==3.6.0 diff --git a/tests/.coverage b/tests/.coverage new file mode 100644 index 0000000..dab461a Binary files /dev/null and b/tests/.coverage differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5a809fd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,17 @@ +import pytest + +from fastapi_keycloak import FastAPIKeycloak + + +class BaseTestClass: + + @pytest.fixture + def idp(self): + return FastAPIKeycloak( + server_url="http://localhost:8085/auth", + client_id="test-client", + client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", + admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", + realm="Test", + callback_uri="http://localhost:8081/callback" + ) diff --git a/tests/app.py b/tests/app.py new file mode 100644 index 0000000..2c54247 --- /dev/null +++ b/tests/app.py @@ -0,0 +1,159 @@ +from typing import List, Optional + +import uvicorn +from fastapi import FastAPI, Depends, Query, Body +from pydantic import SecretStr + +from fastapi_keycloak import FastAPIKeycloak, OIDCUser, UsernamePassword, HTTPMethod + +app = FastAPI() +idp = FastAPIKeycloak( + server_url="http://localhost:8085/auth", + client_id="test-client", + client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", + admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", + realm="Test", + callback_uri="http://localhost:8081/callback" +) +idp.add_swagger_config(app) + + +# Admin + +@app.post("/proxy", tags=["admin-cli"]) +def proxy_admin_request(relative_path: str, method: HTTPMethod, additional_headers: dict = Body(None), payload: dict = Body(None)): + return idp.proxy( + additional_headers=additional_headers, + relative_path=relative_path, + method=method, + payload=payload + ) + + +@app.get("/identity-providers", tags=["admin-cli"]) +def get_identity_providers(): + return idp.get_identity_providers() + + +@app.get("/idp-configuration", tags=["admin-cli"]) +def get_idp_config(): + return idp.open_id_configuration + + +# User Management + +@app.get("/users", tags=["user-management"]) +def get_users(): + return idp.get_all_users() + + +@app.get("/user", tags=["user-management"]) +def get_user_by_query(query: str = None): + return idp.get_user(query=query) + + +@app.post("/users", tags=["user-management"]) +def create_user(first_name: str, last_name: str, email: str, password: SecretStr, id: str = None): + return idp.create_user(first_name=first_name, last_name=last_name, username=email, email=email, password=password.get_secret_value(), id=id) + + +@app.get("/user/{user_id}", tags=["user-management"]) +def get_user(user_id: str = None): + return idp.get_user(user_id=user_id) + + +@app.delete("/user/{user_id}", tags=["user-management"]) +def delete_user(user_id: str): + return idp.delete_user(user_id=user_id) + + +@app.put("/user/{user_id}/change-password", tags=["user-management"]) +def change_password(user_id: str, new_password: SecretStr): + return idp.change_password(user_id=user_id, new_password=new_password) + + +@app.put("/user/{user_id}/send-email-verification", tags=["user-management"]) +def send_email_verification(user_id: str): + return idp.send_email_verification(user_id=user_id) + + +# Role Management + +@app.get("/roles", tags=["role-management"]) +def get_all_roles(): + return idp.get_all_roles() + + +@app.get("/role/{role_name}", tags=["role-management"]) +def get_role(role_name: str): + return idp.get_roles([role_name]) + + +@app.post("/roles", tags=["role-management"]) +def add_role(role_name: str): + return idp.create_role(role_name=role_name) + + +@app.delete("/roles", tags=["role-management"]) +def delete_roles(role_name: str): + return idp.delete_role(role_name=role_name) + + +# User Roles + +@app.post("/users/{user_id}/roles", tags=["user-roles"]) +def add_roles_to_user(user_id: str, roles: Optional[List[str]] = Query(None)): + return idp.add_user_roles(user_id=user_id, roles=roles) + + +@app.get("/users/{user_id}/roles", tags=["user-roles"]) +def get_user_roles(user_id: str): + return idp.get_user_roles(user_id=user_id) + + +@app.delete("/users/{user_id}/roles", tags=["user-roles"]) +def delete_roles_from_user(user_id: str, roles: Optional[List[str]] = Query(None)): + return idp.remove_user_roles(user_id=user_id, roles=roles) + + +# Example User Requests + +@app.get("/protected", tags=["example-user-request"]) +def protected(user: OIDCUser = Depends(idp.get_current_user())): + return user + + +@app.get("/current_user/roles", tags=["example-user-request"]) +def get_current_users_roles(user: OIDCUser = Depends(idp.get_current_user())): + return user.roles + + +@app.get("/admin", tags=["example-user-request"]) +def company_admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): + return f'Hi admin {user}' + + +@app.get("/login", tags=["example-user-request"]) +def login(user: UsernamePassword = Depends()): + return idp.user_login(username=user.username, password=user.password.get_secret_value()) + + +# Auth Flow + +@app.get("/login-link", tags=["auth-flow"]) +def login_redirect(): + return idp.login_uri + + +@app.get("/callback", tags=["auth-flow"]) +def callback(session_state: str, code: str): + return idp.exchange_authorization_code(session_state=session_state, code=code) + + +@app.get("/logout", tags=["auth-flow"]) +def logout(): + return idp.logout_uri + + +if __name__ == '__main__': + uvicorn.run('app:app', host="127.0.0.1", port=8081) diff --git a/tests/build_keycloak_m1.sh b/tests/build_keycloak_m1.sh new file mode 100644 index 0000000..be0ad95 --- /dev/null +++ b/tests/build_keycloak_m1.sh @@ -0,0 +1,7 @@ +#!/bin/zsh + +cd /tmp +git clone git@github.com:keycloak/keycloak-containers.git +cd keycloak-containers/server +git checkout 16.1.0 +docker build -t "jboss/keycloak:16.1.0" . diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..62d676e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import subprocess +from time import sleep + + +def pytest_sessionstart(session): + # subprocess.call(['sh', './start_infra.sh']) + # print("Waiting for Keycloak to start") + # sleep(60) # Wait for startup + pass + + +def pytest_sessionfinish(session): + # subprocess.call(['sh', './stop_infra.sh']) + pass \ No newline at end of file diff --git a/tests/coverage.xml b/tests/coverage.xml new file mode 100644 index 0000000..4b272e8 --- /dev/null +++ b/tests/coverage.xml @@ -0,0 +1,340 @@ + + + + + + /Users/yannicschroer/PycharmProjects/fastapi_keycloak/fastapi_keycloak + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/keycloak_postgres.yaml b/tests/keycloak_postgres.yaml new file mode 100644 index 0000000..c0742fe --- /dev/null +++ b/tests/keycloak_postgres.yaml @@ -0,0 +1,39 @@ +version: '3' + +volumes: + postgres_data: + driver: local + +services: + postgres: + image: postgres + environment: + POSTGRES_DB: testkeycloakdb + POSTGRES_USER: testkeycloakuser + POSTGRES_PASSWORD: testkeycloakpassword + restart: + always + + keycloak: + image: docker.io/jboss/keycloak:16.0.1 # Locally built with `build_keycloak_m1.sh` as the current images do not support the architecture + volumes: + - ./realm-export.json:/opt/jboss/keycloak/imports/realm-export.json + command: + - "-b 0.0.0.0 -Dkeycloak.profile.feature.upload_scripts=enabled -Dkeycloak.import=/opt/jboss/keycloak/imports/realm-export.json" + environment: + DB_VENDOR: POSTGRES + DB_ADDR: postgres + DB_DATABASE: testkeycloakdb + DB_USER: testkeycloakuser + DB_SCHEMA: public + DB_PASSWORD: testkeycloakpassword + KEYCLOAK_USER: keycloakuser + KEYCLOAK_PASSWORD: keycloakpassword + PROXY_ADDRESS_FORWARDING: "true" + KEYCLOAK_LOGLEVEL: DEBUG + ports: + - '8085:8080' + depends_on: + - postgres + restart: + always \ No newline at end of file diff --git a/tests/realm-export.json b/tests/realm-export.json new file mode 100644 index 0000000..e92ae6d --- /dev/null +++ b/tests/realm-export.json @@ -0,0 +1,2364 @@ +{ + "id": "Test", + "realm": "Test", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": true, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "ee879222-222a-4143-84f0-99e37b591004", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "Test", + "attributes": {} + }, + { + "id": "4833bad1-0ba1-4115-a2e1-3b96a90fe268", + "name": "default-roles-test", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ] + }, + "clientRole": false, + "containerId": "Test", + "attributes": {} + }, + { + "id": "2dba50d7-351f-4ba6-9f2c-568c1d5d251b", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "Test", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "e523aa42-6470-44dd-b89a-e9213fd68696", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "c7c2401b-99a8-4565-a2f8-d74455ad2398", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "e0f6ff39-f2a7-4d7b-be24-cbac9ba9fbff", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "manage-clients", + "manage-users", + "query-realms", + "view-events", + "view-realm", + "view-clients", + "manage-events", + "create-client", + "manage-identity-providers", + "manage-authorization", + "query-users", + "view-identity-providers", + "impersonation", + "query-clients", + "view-authorization", + "manage-realm", + "view-users" + ] + } + }, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "0970d8ac-9d72-4662-bef7-461b99e76781", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "d63f1474-ae23-4478-ab13-3a1f6472f75f", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "61cba3ed-aa8a-4fe0-afe1-f2413753a3ab", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "49b17039-87b0-44a7-b5ea-85a331f363b5", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "c88a3668-ab22-43f4-8a40-c23a7a06c8bd", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "7fbc7c17-a69c-4ce6-8044-b5776588c065", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "3472bd4f-4d78-460d-94e1-23acd2cfa053", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "0578aa0e-6097-48a7-8b41-3581a24cc8de", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "76a86e8d-ef98-4903-8805-49c969095d08", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "a5d58abd-afb1-4b06-8484-6842f74d218c", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "b99129d2-89ab-4da0-bcad-d4e1a7885fb9", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "b7095428-6feb-43e7-a207-2298d2d4bd92", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "9ca60746-ced4-4bf9-86d5-8b5f1d1a6be7", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "d3adbded-a6ba-4571-9e80-0d7755eef7f2", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "9cf5c1a9-f8f6-4577-baa1-1a65bad0e219", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + }, + { + "id": "773f6881-9f3a-4aea-9cc9-bd5decea2e54", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "fb6c4935-1d0c-4e82-b262-443672d72930", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "test-client": [ + { + "id": "ef2de353-f393-4e86-82c1-2bab0a082ed2", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "9a76b2ec-b33e-40b0-9cad-e00ca7e77e40", + "attributes": {} + } + ], + "account-console": [], + "broker": [], + "account": [ + { + "id": "5ce34b80-ee04-43a5-8dc3-3a29e1bb9a69", + "name": "manage-account", + "composite": false, + "clientRole": true, + "containerId": "930e41a3-40c7-42a1-9587-2b92f31e68c5", + "attributes": {} + }, + { + "id": "81659a10-8a8a-4a70-8df8-49001d1bec42", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "930e41a3-40c7-42a1-9587-2b92f31e68c5", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "4833bad1-0ba1-4115-a2e1-3b96a90fe268", + "name": "default-roles-test", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "Test" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "33b940e2-0bdb-49a7-9356-e6e230f49619", + "createdTimestamp": 1640089861472, + "username": "service-account-admin-cli", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "admin-cli", + "disableableCredentialTypes": [], + "requiredActions": [], + "clientRoles": { + "realm-management": [ + "query-groups", + "manage-clients", + "realm-admin", + "manage-users", + "query-realms", + "view-events", + "view-realm", + "view-clients", + "manage-events", + "create-client", + "manage-identity-providers", + "manage-authorization", + "query-users", + "view-identity-providers", + "impersonation", + "query-clients", + "view-authorization", + "manage-realm", + "view-users" + ], + "account": [ + "manage-account", + "delete-account" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "83d84b8e-f053-480e-8b13-713c4fac708d", + "createdTimestamp": 1640089810342, + "username": "service-account-test-client", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "test-client", + "disableableCredentialTypes": [], + "requiredActions": [], + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "test-client": [ + { + "client": "admin-cli", + "roles": [ + "uma_protection" + ] + } + ], + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account" + ] + } + ] + }, + "clients": [ + { + "id": "930e41a3-40c7-42a1-9587-2b92f31e68c5", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Test/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/Test/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "207a4d3c-cc80-4bd2-91d4-815a1af38778", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Test/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/Test/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "70d4fa1a-79b2-489e-b9a0-47a6772819a6", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f8f4baad-a231-4a6a-b97c-5d68ac147279", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "true", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "false", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "require.pushed.authorization.requests": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "a73b0f3e-1b0c-4b14-893e-22f4985cfd60", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "030a393a-ff89-4d2e-aa30-063e95b7ce9f", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + }, + { + "id": "8e4e8915-cba7-4be3-86e8-d6991a0cd273", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Default Resource", + "type": "urn:admin-cli:resources:default", + "ownerManagedAccess": false, + "attributes": {}, + "_id": "98ea544d-9474-4cde-a7d5-f4aa8438596b", + "uris": [ + "/*" + ] + } + ], + "policies": [ + { + "id": "3747a4f9-0b6b-4ad0-aba4-181193729727", + "name": "Default Policy", + "description": "A policy that grants access only for users within this realm", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "id": "762a6303-aab7-439b-8a41-0973964640ce", + "name": "Default Permission", + "description": "A permission that applies to the default resource type", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "urn:admin-cli:resources:default", + "applyPolicies": "[\"Default Policy\"]" + } + } + ], + "scopes": [], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "1d1a4841-fbfe-4bda-9bc8-fdc73497aa5c", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "fb6c4935-1d0c-4e82-b262-443672d72930", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "97d658fa-02d4-43d5-9bba-4d0717a8466d", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/Test/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/Test/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "fb2e09ee-c7b0-49b2-870d-758173ec6be7", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "9a76b2ec-b33e-40b0-9cad-e00ca7e77e40", + "clientId": "test-client", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", + "redirectUris": [ + "http://localhost:8081/callback" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "require.pushed.authorization.requests": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "3716053c-9672-4685-9fe5-0b44307c65c1", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "4cffb7d8-1aab-4b35-8111-df1ee341c76a", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + }, + { + "id": "57540600-0bd8-42dd-8eb1-ca4177c2da57", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Default Resource", + "type": "urn:test-client:resources:default", + "ownerManagedAccess": false, + "attributes": {}, + "_id": "c4c07a91-21b2-4259-b923-4b3d6b05d93f", + "uris": [ + "/*" + ] + } + ], + "policies": [ + { + "id": "b1174446-ce63-4d3d-8829-f1b960a76b42", + "name": "Default Policy", + "description": "A policy that grants access only for users within this realm", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "id": "c595a3a7-c4d3-47b1-896d-50e5396d1eee", + "name": "Default Permission", + "description": "A permission that applies to the default resource type", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "urn:test-client:resources:default", + "applyPolicies": "[\"Default Policy\"]" + } + } + ], + "scopes": [], + "decisionStrategy": "UNANIMOUS" + } + } + ], + "clientScopes": [ + { + "id": "a894dbe0-76e7-4c22-b7b2-bd3f827e0ef5", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "762589d9-35be-4ad7-bed4-4b718d6ef6ec", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "5a5ce089-2139-4d60-8d2a-fd198c5db2ec", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "927a5908-7652-4586-9b8a-eb5920ef4150", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "bf4c9750-93e5-434e-8845-adb5d545b462", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "c3557b80-20cf-41cc-9732-9ebc2bd65e8a", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "9b1e384f-9aed-4592-a40e-734030fdcfcb", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "b19ae76e-fce0-4f6b-8d84-378f60d88f8d", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "83b45cee-daa8-4a98-af4b-b9000f36f2fd", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "8695784f-2e6b-4571-982b-26b8ba72af98", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "f36f78cb-da3f-4377-8b90-7d28078cc890", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "39baba4a-03aa-4309-8cd2-2591181f21ba", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "a2a22f05-cf5a-4206-9c7d-57fba22073c9", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "20187807-6f9e-4438-abec-164ca4e39520", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "ec0661bc-d266-4af6-aac4-a1753b1291d4", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "a9b3a239-bc80-4067-b787-a2c3ca0d2ec4", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "e3d6fefe-3579-47a1-807d-64fcf7a87dcf", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "e832567a-5345-4f8c-8b35-012f65396f67", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "fd7a31da-915a-40ae-b633-393615ce2762", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "75ffd8aa-4326-4923-bc3e-20b09bd875b0", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "f2654fbe-5521-49e4-8e50-ca04651db68b", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "3cf479a7-f66d-4274-af23-ed1c7909b6e5", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "ef85df6a-0b3d-400b-b882-a2118ad44db5", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "84451abc-bcbc-4451-9dcf-32836641765c", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "67318bb2-5f53-4f75-a587-8f3319ebe843", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "820f7a36-03eb-4503-aa11-5742efe7390e", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "233c42eb-c87d-4826-8bbc-4683c4f13a1a", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "eb34b957-ea38-41ac-9199-697e227985e7", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "50ba7956-d15f-4d8c-90aa-da136f09dcb2", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "3f7b3d46-c9f0-43c2-90e9-e1a4874bdbcf", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "01ac5e4b-4945-4667-be10-d29dc5e6ad47", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "cdb0ce02-86a3-4e76-84f7-167ede3e0ecf", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "04ef696e-7196-4c73-872d-10af8ebe4276", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "d8c56c76-ff18-4b37-b45a-237fcf8b2950", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "d21719d1-850e-488f-a58d-a4e42c76f2a5", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "891f4a61-7f6e-4523-af0f-f11c55e9113c", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "632544be-5a8c-4e7e-b3c8-4cb5faedcf66", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "bd63ffe9-c748-4d6a-85ea-4677fa6260c7", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "3743b061-854b-43fd-8fcc-b687d015e9b5", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "7051cfe2-ab43-4faa-b40d-af6446b18167", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "e0ec37dd-5965-48d3-81a6-3cb99629ccce", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "929899ea-bf1d-42b0-bd2a-9d1e432db44f", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "d4a2ebb9-a3ae-44be-8678-3e00952c4b94", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "acd1a5ea-6013-4353-beb1-4b8b00f50970", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "5b2b6b08-9d27-481a-9110-92ddba95a032", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "18f53e6d-9820-4064-ab92-4b4d59766399", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "bb03bb29-3654-40bd-89cf-b97eb025fdf6", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "4d57c525-f42f-4a6e-8763-0a220d85785c", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "543e6670-591c-4ecf-a668-7ada39c3473c", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "55b3be60-2fb9-477a-814d-2d829ec608a4", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "a3451946-0b1b-4171-b39c-f7f3b4b5ad89", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b1a42123-f661-4af3-9776-6511d01e4601", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f1205018-a7f1-4089-9861-7dfe83d8dcd9", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "31594976-52b4-400b-aff0-517e356456fa", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "6786f2ee-8b37-40ca-aa23-62d7a7a284ae", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "0eaa3e1c-b872-488c-aae6-09a943726b18", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "511b7224-cce6-4fb2-a7ed-a495b56356cb", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "714f7d8e-7f77-4824-997c-0eb833e15503", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "544b6d08-ad0f-4c99-9c1c-29aa69ae4e99", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9f9fee35-e84f-4034-a0f2-bf2116c7697e", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "ad80ffb5-e875-4568-b014-f1f98bcb786b", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "306d82a3-b850-46da-939b-a6abbe61a6c2", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "0407754a-c2d2-4abb-81bf-0aaea78eb7b7", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "776f63c5-aded-4382-8f00-cfb73c958f01", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "ef1185ce-f875-43f7-bbeb-f2ad58d4f462", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f135bb36-1add-4e15-a0a4-e6ddac723a40", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "76e5f8a0-9cfc-47ba-a7dc-31421e5095a5", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "0db01040-31a0-45e8-96c0-bf54cd5eb1e0", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "dec31c79-972a-4f58-b16e-9a4a182c60c8", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5" + }, + "keycloakVersion": "16.1.0", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/tests/start_infra.sh b/tests/start_infra.sh new file mode 100644 index 0000000..e650476 --- /dev/null +++ b/tests/start_infra.sh @@ -0,0 +1 @@ +docker-compose -f keycloak_postgres.yaml up -d diff --git a/tests/stop_infra.sh b/tests/stop_infra.sh new file mode 100644 index 0000000..2a24571 --- /dev/null +++ b/tests/stop_infra.sh @@ -0,0 +1 @@ +docker-compose -f keycloak_postgres.yaml down \ No newline at end of file diff --git a/tests/test_functional.py b/tests/test_functional.py new file mode 100644 index 0000000..0617279 --- /dev/null +++ b/tests/test_functional.py @@ -0,0 +1,176 @@ +from typing import List + +import pytest as pytest +from fastapi import HTTPException + +from fastapi_keycloak import KeycloakError +from fastapi_keycloak.model import KeycloakUser, KeycloakRole, KeycloakToken, OIDCUser +from tests import BaseTestClass + + +class TestAPIFunctional(BaseTestClass): + + def test_functional_a(self, idp): + assert idp.get_all_users() == [] # No users yet + + # Create some test users + user_alice = idp.create_user( # Create User A + first_name="test", + last_name="user", + username="testuser_alice@code-specialist.com", + email="testuser_alice@code-specialist.com", + password="test-password", + enabled=True, + send_email_verification=False + ) + assert isinstance(user_alice, KeycloakUser) + assert len(idp.get_all_users()) == 1 + + # Try to create a user with the same username + with pytest.raises(KeycloakError): # 'User exists with same username' + idp.create_user( + first_name="test", + last_name="user", + username="testuser_alice@code-specialist.com", + email="testuser_alice@code-specialist.com", + password="test-password", + enabled=True, + send_email_verification=False + ) + assert len(idp.get_all_users()) == 1 + + user_bob = idp.create_user( # Create User B + first_name="test", + last_name="user", + username="testuser_bob@code-specialist.com", + email="testuser_bob@code-specialist.com", + password="test-password", + enabled=True, + send_email_verification=False + ) + assert isinstance(user_bob, KeycloakUser) + assert len(idp.get_all_users()) == 2 + + # Check the roles + user_alice_roles = idp.get_user_roles(user_id=user_alice.id) + assert len(user_alice_roles) == 1 + for role in user_alice_roles: + assert role.name in ["default-roles-test"] + + user_bob_roles = idp.get_user_roles(user_id=user_bob.id) + assert len(user_bob_roles) == 1 + for role in user_bob_roles: + assert role.name in ["default-roles-test"] + + # Create a some roles + all_roles = idp.get_all_roles() + assert len(all_roles) == 3 + for role in all_roles: + assert role.name in ["default-roles-test", "offline_access", "uma_authorization"] + + test_role_saturn = idp.create_role("test_role_saturn") + all_roles = idp.get_all_roles() + assert len(all_roles) == 4 + for role in all_roles: + assert role.name in ["default-roles-test", "offline_access", "uma_authorization", test_role_saturn.name] + + test_role_mars = idp.create_role("test_role_mars") + all_roles = idp.get_all_roles() + assert len(all_roles) == 5 + for role in all_roles: + assert role.name in ["default-roles-test", "offline_access", "uma_authorization", test_role_saturn.name, test_role_mars.name] + + assert isinstance(test_role_saturn, KeycloakRole) + assert isinstance(test_role_mars, KeycloakRole) + + # Check the roles again + user_alice_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_alice.id) + assert len(user_alice_roles) == 1 + for role in user_alice_roles: + assert role.name in ["default-roles-test"] + + user_bob_roles = idp.get_user_roles(user_id=user_bob.id) + assert len(user_bob_roles) == 1 + for role in user_bob_roles: + assert role.name in ["default-roles-test"] + + # Assign role to Alice + idp.add_user_roles(user_id=user_alice.id, roles=[test_role_saturn.name]) + user_alice_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_alice.id) + assert len(user_alice_roles) == 2 + for role in user_alice_roles: + assert role.name in ["default-roles-test", test_role_saturn.name] + + # Assign roles to Bob + idp.add_user_roles(user_id=user_bob.id, roles=[test_role_saturn.name, test_role_mars.name]) + user_bob_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_bob.id) + assert len(user_bob_roles) == 3 + for role in user_bob_roles: + assert role.name in ["default-roles-test", test_role_saturn.name, test_role_mars.name] + + # Exchange the details for access tokens + keycloak_token_alice: KeycloakToken = idp.user_login(username=user_alice.username, password="test-password") + assert idp.token_is_valid(keycloak_token_alice.access_token) + keycloak_token_bob: KeycloakToken = idp.user_login(username=user_bob.username, password="test-password") + assert idp.token_is_valid(keycloak_token_bob.access_token) + + # Check get_current_user Alice + current_user_function = idp.get_current_user() + current_user: OIDCUser = current_user_function(token=keycloak_token_alice.access_token) + assert current_user.sub == user_alice.id + assert len(current_user.roles) == 4 # Also includes all implicit roles + for role in current_user.roles: + assert role in ["default-roles-test", "offline_access", "uma_authorization", test_role_saturn.name] + + # Check get_current_user Bob + current_user_function = idp.get_current_user() + current_user: OIDCUser = current_user_function(token=keycloak_token_bob.access_token) + assert current_user.sub == user_bob.id + assert len(current_user.roles) == 5 # Also includes all implicit roles + for role in current_user.roles: + assert role in ["default-roles-test", "offline_access", "uma_authorization", test_role_saturn.name, test_role_mars.name] + + # Check get_current_user Alice with role Saturn + current_user_function = idp.get_current_user(required_roles=[test_role_saturn.name]) + # Get Alice + current_user: OIDCUser = current_user_function(token=keycloak_token_alice.access_token) + assert current_user.sub == user_alice.id + # Get Bob + current_user: OIDCUser = current_user_function(token=keycloak_token_bob.access_token) + assert current_user.sub == user_bob.id + + # Check get_current_user Alice with role Mars + current_user_function = idp.get_current_user(required_roles=[test_role_mars.name]) + # Get Alice + with pytest.raises(HTTPException): + current_user_function(token=keycloak_token_alice.access_token) # Alice does not posses this role + # Get Bob + current_user: OIDCUser = current_user_function(token=keycloak_token_bob.access_token) + assert current_user.sub == user_bob.id + + # Remove Role Mars from Bob + idp.remove_user_roles(user_id=user_bob.id, roles=[test_role_mars.name]) + user_bob_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_bob.id) + assert len(user_bob_roles) == 2 + for role in user_bob_roles: + assert role.name in ["default-roles-test", "offline_access", "uma_authorization", test_role_saturn.name] + + # Delete Role Saturn + idp.delete_role(role_name=test_role_saturn.name) + + # Check Alice + user_alice_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_alice.id) + assert len(user_alice_roles) == 1 + for role in user_alice_roles: + assert role.name in ["default-roles-test"] + + # Check Bob + user_bob_roles = idp.get_user_roles(user_id=user_bob.id) + assert len(user_bob_roles) == 1 + for role in user_bob_roles: + assert role.name in ["default-roles-test"] + + # Clean up + idp.delete_role(role_name=test_role_mars.name) + idp.delete_user(user_id=user_alice.id) + idp.delete_user(user_id=user_bob.id) diff --git a/tests/test_unit.py b/tests/test_unit.py new file mode 100644 index 0000000..e09e58b --- /dev/null +++ b/tests/test_unit.py @@ -0,0 +1,71 @@ +from typing import List + +import pytest as pytest +from fastapi import FastAPI +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError + +from fastapi_keycloak import HTTPMethod +from fastapi_keycloak.model import KeycloakRole +from tests import BaseTestClass + + +class TestAPIUnit(BaseTestClass): + + def test_properties(self, idp): + assert idp.public_key + assert idp.admin_token + assert idp.open_id_configuration + assert idp.logout_uri + assert idp.login_uri + assert idp.roles_uri + assert idp.token_uri + assert idp.authorization_uri + assert idp.user_auth_scheme + assert idp.providers_uri + assert idp.realm_uri + assert idp.users_uri + + def test_admin_token(self, idp): + assert idp.admin_token + with pytest.raises(JWTError): # Not enough segments + idp.admin_token = "some rubbish" + + with pytest.raises(JWTError): # Invalid crypto padding + idp.admin_token = """ + eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c + """ + + def test_add_swagger_config(self, idp): + app = FastAPI() + assert app.swagger_ui_init_oauth is None + idp.add_swagger_config(app) + assert app.swagger_ui_init_oauth == { + "usePkceWithAuthorizationCodeGrant": True, + "clientId": idp.client_id, + "clientSecret": idp.client_secret + } + + def test_user_auth_scheme(self, idp): + assert isinstance(idp.user_auth_scheme, OAuth2PasswordBearer) + + def test_open_id_configuration(self, idp): + assert idp.open_id_configuration + assert type(idp.open_id_configuration) == dict + + def test_proxy(self, idp): + response = idp.proxy( + relative_path="/realms/Test", + method=HTTPMethod.GET + ) + assert type(response.json()) == dict + + def test_get_all_roles_and_get_roles(self, idp): + roles: List[KeycloakRole] = idp.get_all_roles() + assert roles + lookup = idp.get_roles(role_names=[role.name for role in roles]) + assert lookup + assert len(roles) == len(lookup) + + def test_get_identity_providers(self, idp): + assert idp.get_identity_providers() == [] diff --git a/tests/tests/coverage.xml b/tests/tests/coverage.xml new file mode 100644 index 0000000..945019b --- /dev/null +++ b/tests/tests/coverage.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +