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
+
+[](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak)
+[](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
+
+[](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak)
+[](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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+