├── .github ├── FUNDING.yml └── workflows │ ├── codeql.yml │ └── testing.yaml ├── .gitignore ├── LICENSE ├── README.md ├── codecov.yaml ├── documentation ├── docs │ ├── CNAME │ ├── apple-m1.md │ ├── css │ │ └── styles.css │ ├── examples │ │ ├── full_example │ │ │ └── full_example.py │ │ ├── introduction │ │ │ └── app.py │ │ └── quickstart │ │ │ ├── app.py │ │ │ ├── docker-compose.yaml │ │ │ └── realm-export.json │ ├── full_example.md │ ├── img │ │ ├── favicon.svg │ │ └── logo.png │ ├── index.md │ ├── keycloak_configuration.md │ ├── quick_start.md │ └── reference.md └── mkdocs.yaml ├── fastapi_keycloak ├── __init__.py ├── api.py ├── exceptions.py ├── model.py └── requirements.txt ├── pyproject.toml ├── requirements.txt ├── setup.py └── tests ├── .coveragerc ├── __init__.py ├── app.py ├── build_keycloak_m1.sh ├── conftest.py ├── coverage.xml ├── keycloak_postgres.yaml ├── pytest.ini ├── realm-export.json ├── start_infra.sh ├── stop_infra.sh ├── test_functional.py ├── test_integration.py └── wait_for_service.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '33 3 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.github/workflows/testing.yaml: -------------------------------------------------------------------------------- 1 | name: Test-Suite 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: [review_requested] 7 | push: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [ '3.8', '3.9', '3.10' ] 15 | name: Test-Suite for Python ${{ matrix.python-version }} 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Start Test Infrastructure 20 | run: | 21 | cd tests 22 | bash ./start_infra.sh 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install Pip 30 | run: | 31 | python -m pip install --upgrade pip wheel 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install -r requirements.txt 36 | 37 | - name: Wait for Infrastructure to be ready 38 | run: | 39 | chmod +x tests/wait_for_service.sh & bash tests/wait_for_service.sh http://localhost:8085 200 100 40 | 41 | - name: Test with pytest 42 | run: | 43 | pytest tests 44 | 45 | - name: Upload coverage to Codecov 46 | uses: codecov/codecov-action@v2 47 | with: 48 | fail_ci_if_error: false 49 | 50 | - name: Teardown Test Infrastructure 51 | if: always() 52 | run: | 53 | cd tests 54 | bash ./stop_infra.sh 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # IDE 7 | .idea 8 | 9 | # VSCode 10 | .vscode 11 | 12 | # Mk Docs 13 | /documentation/site 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | pip-wheel-metadata/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .cache 54 | nosetests.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | .coverage 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | documentation/docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2021] [Jonas Scholl, Yannic Schröer] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Keycloak Integration 2 | 3 | [![Test-Suite](https://github.com/code-specialist/fastapi-keycloak/actions/workflows/testing.yaml/badge.svg)](https://github.com/code-specialist/fastapi-keycloak/actions/workflows/testing.yaml) 4 | [![CodeFactor](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak/badge)](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak) 5 | [![codecov](https://codecov.io/gh/code-specialist/fastapi-keycloak/branch/master/graph/badge.svg?token=PX6NJBDUJ9)](https://codecov.io/gh/code-specialist/fastapi-keycloak) 6 | ![Py3.8](https://img.shields.io/badge/-Python%203.8-brightgreen) 7 | ![Py3.9](https://img.shields.io/badge/-Python%203.9-brightgreen) 8 | ![Py3.10](https://img.shields.io/badge/-Python%203.10-brightgreen) 9 | [![CodeQL](https://github.com/code-specialist/fastapi-keycloak/actions/workflows/codeql.yml/badge.svg)](https://github.com/code-specialist/fastapi-keycloak/actions/workflows/codeql.yml) 10 | 11 | --- 12 | 13 | ## Notice 14 | 15 | Please note that fastapi-keycloak has been moved to its own organization and is now maintained by [alexbarcelo](https://github.com/alexbarcelo). See https://github.com/fastapi-keycloak/fastapi-keycloak. The pypi package has been transfered. There are no further actions required by you. 16 | 17 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "setup.py" 3 | -------------------------------------------------------------------------------- /documentation/docs/CNAME: -------------------------------------------------------------------------------- 1 | fastapi-keycloak.code-specialist.com -------------------------------------------------------------------------------- /documentation/docs/apple-m1.md: -------------------------------------------------------------------------------- 1 | # Apple MacBook M1 issues 2 | 3 | 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 4 | ourselves by rebuilding the image locally. Doing so might look like the following: 5 | 6 | ```shell 7 | #!/bin/zsh 8 | 9 | cd /tmp 10 | git clone git@github.com:keycloak/keycloak-containers.git 11 | cd keycloak-containers/server 12 | git checkout 16.1.0 13 | docker build -t "jboss/keycloak:16.1.0" . 14 | ``` -------------------------------------------------------------------------------- /documentation/docs/css/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #3498db; 3 | --md-primary-fg-color--light: #5ea6d7; 4 | --md-primary-fg-color--dark: #0782d5; 5 | } 6 | 7 | .md-header__button.md-logo img { 8 | height: 40px; 9 | width: auto !important; 10 | } -------------------------------------------------------------------------------- /documentation/docs/examples/full_example/full_example.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import uvicorn 4 | from fastapi import FastAPI, Depends, Query, Body, Request 5 | from fastapi.responses import JSONResponse 6 | from pydantic import SecretStr 7 | 8 | from fastapi_keycloak import ( 9 | FastAPIKeycloak, 10 | OIDCUser, 11 | UsernamePassword, 12 | HTTPMethod, 13 | KeycloakUser, 14 | KeycloakGroup, 15 | KeycloakError 16 | ) 17 | 18 | app = FastAPI() 19 | idp = FastAPIKeycloak( 20 | server_url="http://localhost:8085/auth", 21 | client_id="test-client", 22 | client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", 23 | admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", 24 | realm="Test", 25 | callback_uri="http://localhost:8081/callback" 26 | ) 27 | idp.add_swagger_config(app) 28 | 29 | 30 | # Custom error handler for showing Keycloak errors on FastAPI 31 | @app.exception_handler(KeycloakError) 32 | async def keycloak_exception_handler(request: Request, exc: KeycloakError): 33 | return JSONResponse( 34 | status_code=exc.status_code, 35 | content={"message": exc.reason}, 36 | ) 37 | 38 | 39 | # Admin 40 | 41 | @app.post("/proxy", tags=["admin-cli"]) 42 | def proxy_admin_request( 43 | relative_path: str, 44 | method: HTTPMethod, 45 | additional_headers: dict = Body(None), 46 | payload: dict = Body(None), 47 | ): 48 | return idp.proxy( 49 | additional_headers=additional_headers, 50 | relative_path=relative_path, 51 | method=method, 52 | payload=payload 53 | ) 54 | 55 | 56 | @app.get("/identity-providers", tags=["admin-cli"]) 57 | def get_identity_providers(): 58 | return idp.get_identity_providers() 59 | 60 | 61 | @app.get("/idp-configuration", tags=["admin-cli"]) 62 | def get_idp_config(): 63 | return idp.open_id_configuration 64 | 65 | 66 | # User Management 67 | 68 | @app.get("/users", tags=["user-management"]) 69 | def get_users(): 70 | return idp.get_all_users() 71 | 72 | 73 | @app.get("/user", tags=["user-management"]) 74 | def get_user_by_query(query: str = None): 75 | return idp.get_user(query=query) 76 | 77 | 78 | @app.post("/users", tags=["user-management"]) 79 | def create_user( 80 | first_name: str, last_name: str, email: str, password: SecretStr, id: str = None 81 | ): 82 | return idp.create_user( 83 | first_name=first_name, 84 | last_name=last_name, 85 | username=email, 86 | email=email, 87 | password=password.get_secret_value(), 88 | id=id 89 | ) 90 | 91 | 92 | @app.get("/user/{user_id}", tags=["user-management"]) 93 | def get_user(user_id: str = None): 94 | return idp.get_user(user_id=user_id) 95 | 96 | 97 | @app.put("/user", tags=["user-management"]) 98 | def update_user(user: KeycloakUser): 99 | return idp.update_user(user=user) 100 | 101 | 102 | @app.delete("/user/{user_id}", tags=["user-management"]) 103 | def delete_user(user_id: str): 104 | return idp.delete_user(user_id=user_id) 105 | 106 | 107 | @app.put("/user/{user_id}/change-password", tags=["user-management"]) 108 | def change_password(user_id: str, new_password: SecretStr): 109 | return idp.change_password(user_id=user_id, new_password=new_password) 110 | 111 | 112 | @app.put("/user/{user_id}/send-email-verification", tags=["user-management"]) 113 | def send_email_verification(user_id: str): 114 | return idp.send_email_verification(user_id=user_id) 115 | 116 | 117 | # Role Management 118 | 119 | @app.get("/roles", tags=["role-management"]) 120 | def get_all_roles(): 121 | return idp.get_all_roles() 122 | 123 | 124 | @app.get("/role/{role_name}", tags=["role-management"]) 125 | def get_role(role_name: str): 126 | return idp.get_roles([role_name]) 127 | 128 | 129 | @app.post("/roles", tags=["role-management"]) 130 | def add_role(role_name: str): 131 | return idp.create_role(role_name=role_name) 132 | 133 | 134 | @app.delete("/roles", tags=["role-management"]) 135 | def delete_roles(role_name: str): 136 | return idp.delete_role(role_name=role_name) 137 | 138 | 139 | # Group Management 140 | 141 | @app.get("/groups", tags=["group-management"]) 142 | def get_all_groups(): 143 | return idp.get_all_groups() 144 | 145 | 146 | @app.get("/group/{group_name}", tags=["group-management"]) 147 | def get_group(group_name: str): 148 | return idp.get_groups([group_name]) 149 | 150 | 151 | @app.get("/group-by-path/{path: path}", tags=["group-management"]) 152 | def get_group_by_path(path: str): 153 | return idp.get_group_by_path(path) 154 | 155 | 156 | @app.post("/groups", tags=["group-management"]) 157 | def add_group(group_name: str, parent_id: Optional[str] = None): 158 | return idp.create_group(group_name=group_name, parent=parent_id) 159 | 160 | 161 | @app.delete("/groups", tags=["group-management"]) 162 | def delete_groups(group_id: str): 163 | return idp.delete_group(group_id=group_id) 164 | 165 | 166 | # User Roles 167 | 168 | @app.post("/users/{user_id}/roles", tags=["user-roles"]) 169 | def add_roles_to_user(user_id: str, roles: Optional[List[str]] = Query(None)): 170 | return idp.add_user_roles(user_id=user_id, roles=roles) 171 | 172 | 173 | @app.get("/users/{user_id}/roles", tags=["user-roles"]) 174 | def get_user_roles(user_id: str): 175 | return idp.get_user_roles(user_id=user_id) 176 | 177 | 178 | @app.delete("/users/{user_id}/roles", tags=["user-roles"]) 179 | def delete_roles_from_user(user_id: str, roles: Optional[List[str]] = Query(None)): 180 | return idp.remove_user_roles(user_id=user_id, roles=roles) 181 | 182 | 183 | # User Groups 184 | 185 | @app.post("/users/{user_id}/groups", tags=["user-groups"]) 186 | def add_group_to_user(user_id: str, group_id: str): 187 | return idp.add_user_group(user_id=user_id, group_id=group_id) 188 | 189 | 190 | @app.get("/users/{user_id}/groups", tags=["user-groups"]) 191 | def get_user_groups(user_id: str): 192 | return idp.get_user_groups(user_id=user_id) 193 | 194 | 195 | @app.delete("/users/{user_id}/groups", tags=["user-groups"]) 196 | def delete_groups_from_user(user_id: str, group_id: str): 197 | return idp.remove_user_group(user_id=user_id, group_id=group_id) 198 | 199 | 200 | # Example User Requests 201 | 202 | @app.get("/protected", tags=["example-user-request"]) 203 | def protected(user: OIDCUser = Depends(idp.get_current_user())): 204 | return user 205 | 206 | 207 | @app.get("/current_user/roles", tags=["example-user-request"]) 208 | def get_current_users_roles(user: OIDCUser = Depends(idp.get_current_user())): 209 | return user.roles 210 | 211 | 212 | @app.get("/admin", tags=["example-user-request"]) 213 | def company_admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): 214 | return f"Hi admin {user}" 215 | 216 | 217 | @app.post("/login", tags=["example-user-request"]) 218 | def login(user: UsernamePassword = Body(...)): 219 | return idp.user_login( 220 | username=user.username, password=user.password.get_secret_value() 221 | ) 222 | 223 | 224 | # Auth Flow 225 | 226 | @app.get("/login-link", tags=["auth-flow"]) 227 | def login_redirect(): 228 | return idp.login_uri 229 | 230 | 231 | @app.get("/callback", tags=["auth-flow"]) 232 | def callback(session_state: str, code: str): 233 | return idp.exchange_authorization_code(session_state=session_state, code=code) 234 | 235 | 236 | @app.get("/logout", tags=["auth-flow"]) 237 | def logout(): 238 | return idp.logout_uri 239 | 240 | 241 | if __name__ == "__main__": 242 | uvicorn.run("app:app", host="127.0.0.1", port=8081) 243 | -------------------------------------------------------------------------------- /documentation/docs/examples/introduction/app.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI, Depends 3 | 4 | from fastapi_keycloak import FastAPIKeycloak, OIDCUser 5 | 6 | app = FastAPI() 7 | idp = FastAPIKeycloak( 8 | server_url="https://auth.some-domain.com/auth", 9 | client_id="some-client", 10 | client_secret="some-client-secret", 11 | admin_client_secret="admin-cli-secret", 12 | realm="some-realm-name", 13 | callback_uri="http://localhost:8081/callback" 14 | ) 15 | idp.add_swagger_config(app) 16 | 17 | @app.get("/admin") 18 | def admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): 19 | return f'Hi premium user {user}' 20 | 21 | 22 | @app.get("/user/roles") 23 | def user_roles(user: OIDCUser = Depends(idp.get_current_user)): 24 | return f'{user.roles}' 25 | 26 | 27 | if __name__ == '__main__': 28 | uvicorn.run('app:app', host="127.0.0.1", port=8081) 29 | -------------------------------------------------------------------------------- /documentation/docs/examples/quickstart/app.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI, Depends 3 | from fastapi.responses import RedirectResponse 4 | from fastapi_keycloak import FastAPIKeycloak, OIDCUser 5 | 6 | app = FastAPI() 7 | idp = FastAPIKeycloak( 8 | server_url="http://localhost:8085/auth", 9 | client_id="test-client", 10 | client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", 11 | admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", 12 | realm="Test", 13 | callback_uri="http://localhost:8081/callback" 14 | ) 15 | idp.add_swagger_config(app) 16 | 17 | 18 | @app.get("/") # Unprotected 19 | def root(): 20 | return 'Hello World' 21 | 22 | 23 | @app.get("/user") # Requires logged in 24 | def current_users(user: OIDCUser = Depends(idp.get_current_user())): 25 | return user 26 | 27 | 28 | @app.get("/admin") # Requires the admin role 29 | def company_admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))): 30 | return f'Hi admin {user}' 31 | 32 | 33 | @app.get("/login") 34 | def login_redirect(): 35 | return RedirectResponse(idp.login_uri) 36 | 37 | 38 | @app.get("/callback") 39 | def callback(session_state: str, code: str): 40 | return idp.exchange_authorization_code(session_state=session_state, code=code) # This will return an access token 41 | 42 | 43 | if __name__ == '__main__': 44 | uvicorn.run('app:app', host="127.0.0.1", port=8081) 45 | -------------------------------------------------------------------------------- /documentation/docs/examples/quickstart/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | environment: 7 | POSTGRES_DB: testkeycloakdb 8 | POSTGRES_USER: testkeycloakuser 9 | POSTGRES_PASSWORD: testkeycloakpassword 10 | restart: 11 | always 12 | 13 | keycloak: 14 | image: jboss/keycloak:16.1.0 15 | volumes: 16 | - ./realm-export.json:/opt/jboss/keycloak/imports/realm-export.json 17 | command: 18 | - "-b 0.0.0.0 -Dkeycloak.profile.feature.upload_scripts=enabled -Dkeycloak.import=/opt/jboss/keycloak/imports/realm-export.json" 19 | environment: 20 | DB_VENDOR: POSTGRES 21 | DB_ADDR: postgres 22 | DB_DATABASE: testkeycloakdb 23 | DB_USER: testkeycloakuser 24 | DB_SCHEMA: public 25 | DB_PASSWORD: testkeycloakpassword 26 | KEYCLOAK_USER: keycloakuser 27 | KEYCLOAK_PASSWORD: keycloakpassword 28 | PROXY_ADDRESS_FORWARDING: "true" 29 | KEYCLOAK_LOGLEVEL: DEBUG 30 | ports: 31 | - '8085:8080' 32 | depends_on: 33 | - postgres 34 | restart: 35 | always 36 | -------------------------------------------------------------------------------- /documentation/docs/full_example.md: -------------------------------------------------------------------------------- 1 | # Example Usage 2 | 3 | ```python 4 | {!examples/full_example/full_example.py!} 5 | ``` 6 | -------------------------------------------------------------------------------- /documentation/docs/img/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /documentation/docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-specialist/fastapi-keycloak/5ab44ab062271e23a541aff3deabead7ff7dad29/documentation/docs/img/logo.png -------------------------------------------------------------------------------- /documentation/docs/index.md: -------------------------------------------------------------------------------- 1 | # FastAPI Keycloak Integration 2 | 3 | [![CodeFactor](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak/badge)](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak) 4 | [![codecov](https://codecov.io/gh/code-specialist/fastapi-keycloak/branch/master/graph/badge.svg?token=PX6NJBDUJ9)](https://codecov.io/gh/code-specialist/fastapi-keycloak) 5 | 6 | --- 7 | 8 | ## Introduction 9 | 10 | 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 11 | encouraged due to specific features. Currently, this package supports only the `password` and the `authorization_code` flow. However, the `get_current_user()` method accepts any 12 | JWT that was signed using Keycloak's private key. 13 | 14 | ## Installation 15 | 16 | ```shell 17 | pip install fastapi_keycloak 18 | ``` 19 | 20 | ## TLDR; 21 | 22 | FastAPI Keycloak enables you to do the following things without writing a single line of additional code: 23 | 24 | - Verify identities and roles of users with Keycloak 25 | - Get a list of available identity providers 26 | - Create/read/delete users 27 | - Create/read/delete groups 28 | - Create/read/delete roles 29 | - Assign/remove roles from users 30 | - Assign/remove users from groups 31 | - Implement the `password` or the `authorization_code` flow (login/callback/logout) 32 | 33 | ## Example 34 | 35 | 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` 36 | 37 | ### app.py 38 | 39 | ```python 40 | {!examples/introduction/app.py!} 41 | ``` 42 | -------------------------------------------------------------------------------- /documentation/docs/keycloak_configuration.md: -------------------------------------------------------------------------------- 1 | # Keycloak sample configuration 2 | 3 | ## Create a new realm 4 | 1. **General**: Enabled, OpenID Endpoint Configuration 5 | 2. **Login**: User registration enabled 6 | ## Create a new client 7 | 1. **Settings**: Enabled, Direct Access Granted, Service Accounts Enabled, Authorization Enabled 8 | 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 9 | client manually) 10 | 3. Valid Redirect URIs: `http://localhost:8081/callback` (Or whatever you configure in your python app) 11 | ## Modify the `admin-cli` client 12 | 1. **Settings**: Access Type confidential, Service Accounts Enabled 13 | 2. **Scope**: Full Scope Allowed 14 | 3. **Service Account Roles**: Select all Client Roles available for `account` and `realm_management` 15 | -------------------------------------------------------------------------------- /documentation/docs/quick_start.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | In order to just get started, we prepared some containers and configs for you. 4 | 5 | !!! info 6 | If you have cloned the git repo, you can run this from the examples dir `fastapi-keycloak/documentation/docs/examples/quickstart` 7 | 8 | ## 1. Configure the Containers 9 | 10 | **docker-compose.yaml** 11 | 12 | ```yaml hl_lines="16 18" 13 | {!examples/quickstart/docker-compose.yaml!} 14 | ``` 15 | 16 | This will create a Postgres and a Keycloak container ready to use. Make sure to download the [realm-export.json](./examples/quickstart/realm-export.json) and keep it in the same folder as 17 | the docker compose file to bind the configuration. 18 | 19 | !!! Caution 20 | These containers are stateless and non-persistent. Data will be lost on restart. 21 | 22 | ## 2. Start the Containers 23 | 24 | Start the containers by applying the `docker-compose.yaml`: 25 | 26 | ```shell 27 | docker-compose up -d 28 | ``` 29 | 30 | !!! info 31 | 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` 32 | 33 | ## 3. The FastAPI App 34 | 35 | You may use the code below without altering it, the imported config will match these values: 36 | 37 | ```python 38 | {!examples/quickstart/app.py!} 39 | ``` 40 | 41 | ## 4. Usage 42 | 43 | You may now use any of the [APIs exposed endpoints](reference.md) as everything is configured for testing all the features. 44 | 45 | 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 46 | log into your Keycloak instance, the username is `keycloakuser` and the password is `keycloakpassword` as described in the `docker-compose.yaml` above. 47 | 48 | To utilize this fully you need a way to store the Access-Token provided by the callback route and add it to any further requests as `Authorization` Bearer. 49 | 50 | You can test this with curl like so: 51 | 52 | ```shell 53 | # TOKEN should be changed to the value of 'access_token'. 54 | # This can be aquired once you have visited http://localhost:8081/login 55 | 56 | TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJrbF9ITTQyMHVmcVVwYmhxcHJYVFBzelNlOWZocmdkamtZZF9EbmVhb0dVIn0.eyJleHAiOjE2NjAxNDUwOTAsImlhdCI6MTY2MDE0NDc5MCwiYXV0aF90aW1lIjoxNjYwMTQ0Nzc2LCJqdGkiOiI4YTI3MmEyYS1mMDMxLTQ0ZDctOWRkNy0zMTM4MDQ2ZWQyOTciLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODUvYXV0aC9yZWFsbXMvVGVzdCIsInN1YiI6ImUxZGEwZWYzLTVhMmQtNGMyYi05NGQ4LWQwN2E2Zjc3Y2JhMyIsInR5cCI6IkJlYXJlciIsImF6cCI6InRlc3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6IjM5Mzc4ODVkLTk0Y2MtNDIyMy05YjczLWI2YmRiMGM1MzJlZiIsImFjciI6IjAiLCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiIzOTM3ODg1ZC05NGNjLTQyMjMtOWI3My1iNmJkYjBjNTMyZWYiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJNaWNoYWVsIFJvYmluc29uIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibGF4ZG9nQGdtYWlsLmNvbSIsImdpdmVuX25hbWUiOiJNaWNoYWVsIiwiZmFtaWx5X25hbWUiOiJSb2JpbnNvbiIsImVtYWlsIjoibGF4ZG9nQGdtYWlsLmNvbSJ9.FQEtefB90W53L_MHXmhm15223zemd-eb-yMDNtup-lZ9-tEyW5FhE0ro-WzEVypAllQ3b1hH0mx_vZ_wxL00wTzXG_Vi_eMT5U5HTJA6UcwR-Ogv6B1BL42l6xwXQCVLTVgrIKBf1NcJbv0k0qD0Zt-VN1S32JPKr0lURdL99idnIOzWVWrS_urG_2R2RiIn-xTcqyGyxbHkBlPbnk55p9NKl_o1lsnBH-8bJme5c35tA6YTyd8Y2tI7zPHYHZ9s8mBlxrsVLubwAZj12L3cZuG1g_H9uASBOxYbfXwX8CR6lQJ2lTaYcfRriCBOMkTzGwb8VoIG8ti9dv9gJTSgSw" 57 | 58 | curl -H 'Accept: application/json' -H "Authorization: Bearer ${TOKEN}" http://localhost:8081/user 59 | ``` 60 | -------------------------------------------------------------------------------- /documentation/docs/reference.md: -------------------------------------------------------------------------------- 1 | ## FastAPIKeycloak 2 | ::: fastapi_keycloak.FastAPIKeycloak 3 | 4 | ## KeycloakError 5 | ::: fastapi_keycloak.exceptions 6 | 7 | ## Models 8 | ::: fastapi_keycloak.model -------------------------------------------------------------------------------- /documentation/mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI Keycloak Integration 2 | site_url: https://fastapi-keycloak.code-specialist.com/ 3 | site_description: "Python Package: FastAPI Keycloak Integration" 4 | site_author: Jonas Scholl, Yannic Schröer 5 | repo_url: https://github.com/code-specialist/fastapi-keycloak 6 | repo_name: fastapi-keycloak 7 | 8 | theme: 9 | name: material 10 | locale: en 11 | highlightjs: true 12 | favicon: ./img/favicon.svg 13 | logo: ./img/logo.png 14 | hljs_languages: 15 | - yaml 16 | - python 17 | - bash 18 | - shell 19 | palette: 20 | - scheme: default 21 | toggle: 22 | icon: material/toggle-switch-off-outline 23 | name: Switch to dark mode 24 | - scheme: slate 25 | toggle: 26 | icon: material/toggle-switch 27 | name: Switch to light mode 28 | font: 29 | text: Nunito Sans 30 | code: IBM Plex Mono 31 | 32 | nav: 33 | - Introduction: ./index.md 34 | - Quickstart: ./quick_start.md 35 | - Keycloak Configuration: ./keycloak_configuration.md 36 | - Full example: ./full_example.md 37 | - API Reference: ./reference.md 38 | - Known issues: ./apple-m1.md 39 | 40 | markdown_extensions: 41 | - pymdownx.highlight 42 | - pymdownx.inlinehilite 43 | - pymdownx.superfences 44 | - pymdownx.snippets 45 | - toc: 46 | permalink: 47 | - admonition 48 | - attr_list 49 | - def_list 50 | - abbr 51 | - markdown_include.include: 52 | base_path: docs/ 53 | - pymdownx.snippets 54 | 55 | extra_css: 56 | - ./css/styles.css 57 | 58 | plugins: 59 | - search 60 | - mkdocstrings: 61 | default_handler: python 62 | handlers: 63 | python: 64 | rendering: 65 | show_source: true 66 | setup_commands: 67 | - import sys 68 | - sys.path.append("fastapi_keycloak") 69 | custom_templates: templates 70 | 71 | extra: 72 | social: 73 | - icon: fontawesome/brands/instagram 74 | link: https://www.instagram.com/specialist_code/ 75 | name: Code Specialist on Instagram 76 | - icon: fontawesome/brands/youtube 77 | link: https://www.youtube.com/channel/UCjdmChf65sGfOqWoygzBTyQ 78 | name: Code Specialist on YouTube 79 | - icon: fontawesome/brands/github 80 | link: https://github.com/code-specialist 81 | name: Code Specialist on GitHub 82 | 83 | copyright: Copyright © 2021 Code Specialist | Legal Notice 84 | -------------------------------------------------------------------------------- /fastapi_keycloak/__init__.py: -------------------------------------------------------------------------------- 1 | """Keycloak API Client for integrating authentication and authorization with FastAPI""" 2 | 3 | __version__ = "1.0.10" 4 | 5 | from fastapi_keycloak.api import FastAPIKeycloak 6 | from fastapi_keycloak.model import (HTTPMethod, KeycloakError, KeycloakGroup, 7 | KeycloakIdentityProvider, KeycloakRole, 8 | KeycloakToken, KeycloakUser, OIDCUser, 9 | UsernamePassword) 10 | 11 | __all__ = [ 12 | FastAPIKeycloak.__name__, 13 | OIDCUser.__name__, 14 | UsernamePassword.__name__, 15 | HTTPMethod.__name__, 16 | KeycloakError.__name__, 17 | KeycloakUser.__name__, 18 | KeycloakToken.__name__, 19 | KeycloakRole.__name__, 20 | KeycloakIdentityProvider.__name__, 21 | KeycloakGroup.__name__, 22 | ] 23 | -------------------------------------------------------------------------------- /fastapi_keycloak/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import json 5 | from json import JSONDecodeError 6 | from typing import Any, Callable, List, Type, Union 7 | from urllib.parse import urlencode 8 | 9 | import requests 10 | from fastapi import Depends, FastAPI, HTTPException, status 11 | from fastapi.security import OAuth2PasswordBearer 12 | from jose import ExpiredSignatureError, JWTError, jwt 13 | from jose.exceptions import JWTClaimsError 14 | from pydantic import BaseModel 15 | from requests import Response 16 | 17 | from fastapi_keycloak.exceptions import ( 18 | ConfigureTOTPException, 19 | KeycloakError, 20 | MandatoryActionException, 21 | UpdatePasswordException, 22 | UpdateProfileException, 23 | UpdateUserLocaleException, 24 | UserNotFound, 25 | VerifyEmailException, 26 | ) 27 | from fastapi_keycloak.model import ( 28 | HTTPMethod, 29 | KeycloakGroup, 30 | KeycloakIdentityProvider, 31 | KeycloakRole, 32 | KeycloakToken, 33 | KeycloakUser, 34 | OIDCUser, 35 | ) 36 | 37 | 38 | def result_or_error( 39 | response_model: Type[BaseModel] = None, is_list: bool = False 40 | ) -> List[BaseModel] or BaseModel or KeycloakError: 41 | """Decorator used to ease the handling of responses from Keycloak. 42 | 43 | Args: 44 | response_model (Type[BaseModel]): Object that should be returned based on the payload 45 | is_list (bool): True if the return value should be a list of the response model provided 46 | 47 | Returns: 48 | BaseModel or List[BaseModel]: Based on the given signature and response circumstances 49 | 50 | Raises: 51 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 52 | 53 | Notes: 54 | - Keycloak sometimes returns empty payloads but describes the error in its content (byte encoded) 55 | which is why this function checks for JSONDecode exceptions. 56 | - Keycloak often does not expose the real error for security measures. You will most likely encounter: 57 | {'error': 'unknown_error'} as a result. If so, please check the logs of your Keycloak instance to get error 58 | details, the RestAPI doesn't provide any. 59 | """ 60 | 61 | def inner(f): 62 | @functools.wraps(f) 63 | def wrapper(*args, **kwargs): 64 | def create_list(json_data: List[dict]): 65 | return [response_model.parse_obj(entry) for entry in json_data] 66 | 67 | def create_object(json_data: dict): 68 | return response_model.parse_obj(json_data) 69 | 70 | result: Response = f(*args, **kwargs) # The actual call 71 | 72 | if ( 73 | type(result) != Response 74 | ): # If the object given is not a response object, directly return it. 75 | return result 76 | 77 | if result.status_code in range(100, 299): # Successful 78 | if response_model is None: # No model given 79 | 80 | try: 81 | return result.json() 82 | except JSONDecodeError: 83 | return result.content.decode("utf-8") 84 | 85 | else: # Response model given 86 | if is_list: 87 | return create_list(result.json()) 88 | else: 89 | return create_object(result.json()) 90 | 91 | else: # Not Successful, forward status code and error 92 | try: 93 | raise KeycloakError( 94 | status_code=result.status_code, reason=result.json() 95 | ) 96 | except JSONDecodeError: 97 | raise KeycloakError( 98 | status_code=result.status_code, 99 | reason=result.content.decode("utf-8"), 100 | ) 101 | 102 | return wrapper 103 | 104 | return inner 105 | 106 | 107 | class FastAPIKeycloak: 108 | """Instance to wrap the Keycloak API with FastAPI 109 | 110 | Attributes: _admin_token (KeycloakToken): A KeycloakToken instance, containing the access token that is used for 111 | any admin related request 112 | 113 | Example: 114 | ```python 115 | app = FastAPI() 116 | idp = KeycloakFastAPI( 117 | server_url="https://auth.some-domain.com/auth", 118 | client_id="some-test-client", 119 | client_secret="some-secret", 120 | admin_client_secret="some-admin-cli-secret", 121 | realm="Test", 122 | callback_uri=f"http://localhost:8081/callback" 123 | ) 124 | idp.add_swagger_config(app) 125 | ``` 126 | """ 127 | 128 | _admin_token: str 129 | 130 | def __init__( 131 | self, 132 | server_url: str, 133 | client_id: str, 134 | client_secret: str, 135 | realm: str, 136 | admin_client_secret: str, 137 | callback_uri: str, 138 | admin_client_id: str = "admin-cli", 139 | scope: str = "openid profile email", 140 | timeout: int = 10, 141 | ): 142 | """FastAPIKeycloak constructor 143 | 144 | Args: 145 | server_url (str): The URL of the Keycloak server, with `/auth` suffix 146 | client_id (str): The id of the client used for users 147 | client_secret (str): The client secret 148 | realm (str): The realm (name) 149 | admin_client_id (str): The id for the admin client, defaults to 'admin-cli' 150 | admin_client_secret (str): Secret for the `admin-cli` client 151 | callback_uri (str): Callback URL of the instance, used for auth flows. Must match at least one 152 | `Valid Redirect URIs` of Keycloak and should point to an endpoint that utilizes the authorization_code flow. 153 | timeout (int): Timeout in seconds to wait for the server 154 | scope (str): OIDC scope 155 | """ 156 | self.server_url = server_url 157 | self.realm = realm 158 | self.client_id = client_id 159 | self.client_secret = client_secret 160 | self.admin_client_id = admin_client_id 161 | self.admin_client_secret = admin_client_secret 162 | self.callback_uri = callback_uri 163 | self.timeout = timeout 164 | self.scope = scope 165 | self._get_admin_token() # Requests an admin access token on startup 166 | 167 | @property 168 | def admin_token(self): 169 | """Holds an AccessToken for the `admin-cli` client 170 | 171 | Returns: 172 | KeycloakToken: A token, valid to perform admin actions 173 | 174 | Notes: 175 | - This might result in an infinite recursion if something unforeseen goes wrong 176 | """ 177 | if self.token_is_valid(token=self._admin_token): 178 | return self._admin_token 179 | self._get_admin_token() 180 | return self.admin_token 181 | 182 | @admin_token.setter 183 | def admin_token(self, value: str): 184 | """Setter for the admin_token 185 | 186 | Args: 187 | value (str): An access Token 188 | 189 | Returns: 190 | None: Inplace method, updates the _admin_token 191 | """ 192 | decoded_token = self._decode_token(token=value) 193 | if not decoded_token.get("resource_access").get( 194 | "realm-management" 195 | ) or not decoded_token.get("resource_access").get("account"): 196 | raise AssertionError( 197 | """The access required was not contained in the access token for the `admin-cli`. 198 | Possibly a Keycloak misconfiguration. Check if the admin-cli client has `Full Scope Allowed` 199 | and that the `Service Account Roles` contain all roles from `account` and `realm_management`""" 200 | ) 201 | self._admin_token = value 202 | 203 | def add_swagger_config(self, app: FastAPI): 204 | """Adds the client id and secret securely to the swagger ui. 205 | Enabling Swagger ui users to perform actions they usually need the client credentials, without exposing them. 206 | 207 | Args: 208 | app (FastAPI): Optional FastAPI app to add the config to swagger 209 | 210 | Returns: 211 | None: Inplace method 212 | """ 213 | app.swagger_ui_init_oauth = { 214 | "usePkceWithAuthorizationCodeGrant": True, 215 | "clientId": self.client_id, 216 | "clientSecret": self.client_secret, 217 | } 218 | 219 | @functools.cached_property 220 | def user_auth_scheme(self) -> OAuth2PasswordBearer: 221 | """Returns the auth scheme to register the endpoints with swagger 222 | 223 | Returns: 224 | OAuth2PasswordBearer: Auth scheme for swagger 225 | """ 226 | return OAuth2PasswordBearer(tokenUrl=self.token_uri) 227 | 228 | def get_current_user(self, required_roles: List[str] = None, extra_fields: List[str] = None) -> Callable[OAuth2PasswordBearer, OIDCUser]: 229 | """Returns the current user based on an access token in the HTTP-header. Optionally verifies roles are possessed 230 | by the user 231 | 232 | Args: 233 | required_roles List[str]: List of role names required for this endpoint 234 | extra_fields List[str]: The names of the additional fields you need that are encoded in JWT 235 | 236 | Returns: 237 | Callable[OAuth2PasswordBearer, OIDCUser]: Dependency method which returns the decoded JWT content 238 | 239 | Raises: 240 | ExpiredSignatureError: If the token is expired (exp > datetime.now()) 241 | JWTError: If decoding fails or the signature is invalid 242 | JWTClaimsError: If any claim is invalid 243 | HTTPException: If any role required is not contained within the roles of the users 244 | """ 245 | 246 | def current_user( 247 | token: OAuth2PasswordBearer = Depends(self.user_auth_scheme), 248 | ) -> OIDCUser: 249 | """Decodes and verifies a JWT to get the current user 250 | 251 | Args: 252 | token OAuth2PasswordBearer: Access token in `Authorization` HTTP-header 253 | 254 | Returns: 255 | OIDCUser: Decoded JWT content 256 | 257 | Raises: 258 | ExpiredSignatureError: If the token is expired (exp > datetime.now()) 259 | JWTError: If decoding fails or the signature is invalid 260 | JWTClaimsError: If any claim is invalid 261 | HTTPException: If any role required is not contained within the roles of the users 262 | """ 263 | decoded_token = self._decode_token(token=token, audience="account") 264 | user = OIDCUser.parse_obj(decoded_token) 265 | if required_roles: 266 | for role in required_roles: 267 | if role not in user.roles: 268 | raise HTTPException( 269 | status_code=status.HTTP_403_FORBIDDEN, 270 | detail=f'Role "{role}" is required to perform this action', 271 | ) 272 | 273 | if extra_fields: 274 | for field in extra_fields: 275 | user.extra_fields[field] = decoded_token.get(field, None) 276 | 277 | return user 278 | 279 | return current_user 280 | 281 | @functools.cached_property 282 | def open_id_configuration(self) -> dict: 283 | """Returns Keycloaks Open ID Connect configuration 284 | 285 | Returns: 286 | dict: Open ID Configuration 287 | """ 288 | response = requests.get( 289 | url=f"{self.realm_uri}/.well-known/openid-configuration", 290 | timeout=self.timeout, 291 | ) 292 | return response.json() 293 | 294 | def proxy( 295 | self, 296 | relative_path: str, 297 | method: HTTPMethod, 298 | additional_headers: dict = None, 299 | payload: dict = None, 300 | ) -> Response: 301 | """Proxies a request to Keycloak and automatically adds the required Authorization header. Should not be 302 | exposed under any circumstances. Grants full API admin access. 303 | 304 | Args: 305 | 306 | relative_path (str): The relative path of the request. 307 | Requests will be sent to: `[server_url]/[relative_path]` 308 | method (HTTPMethod): The HTTP-verb to be used 309 | additional_headers (dict): Optional headers besides the Authorization to add to the request 310 | payload (dict): Optional payload to send 311 | 312 | Returns: 313 | Response: Proxied response 314 | 315 | Raises: 316 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 317 | """ 318 | headers = {"Authorization": f"Bearer {self.admin_token}"} 319 | if additional_headers is not None: 320 | headers = {**headers, **additional_headers} 321 | 322 | return requests.request( 323 | method=method.name, 324 | url=f"{self.server_url}{relative_path}", 325 | data=json.dumps(payload), 326 | headers=headers, 327 | timeout=self.timeout, 328 | ) 329 | 330 | def _get_admin_token(self) -> None: 331 | """Exchanges client credentials (admin-cli) for an access token. 332 | 333 | Returns: 334 | None: Inplace method that updated the class attribute `_admin_token` 335 | 336 | Raises: 337 | KeycloakError: If fetching an admin access token fails, 338 | or the response does not contain an access_token at all 339 | 340 | Notes: 341 | - Is executed on startup and may be executed again if the token validation fails 342 | """ 343 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 344 | data = { 345 | "client_id": self.admin_client_id, 346 | "client_secret": self.admin_client_secret, 347 | "grant_type": "client_credentials", 348 | } 349 | response = requests.post(url=self.token_uri, headers=headers, data=data, timeout=self.timeout) 350 | try: 351 | self.admin_token = response.json()["access_token"] 352 | except JSONDecodeError as e: 353 | raise KeycloakError( 354 | reason=response.content.decode("utf-8"), 355 | status_code=response.status_code, 356 | ) from e 357 | 358 | except KeyError as e: 359 | raise KeycloakError( 360 | reason=f"The response did not contain an access_token: {response.json()}", 361 | status_code=403, 362 | ) from e 363 | 364 | @functools.cached_property 365 | def public_key(self) -> str: 366 | """Returns the Keycloak public key 367 | 368 | Returns: 369 | str: Public key for JWT decoding 370 | """ 371 | response = requests.get(url=self.realm_uri, timeout=self.timeout) 372 | public_key = response.json()["public_key"] 373 | return f"-----BEGIN PUBLIC KEY-----\n{public_key}\n-----END PUBLIC KEY-----" 374 | 375 | @result_or_error() 376 | def add_user_roles(self, roles: List[str], user_id: str) -> dict: 377 | """Adds roles to a specific user 378 | 379 | Args: 380 | roles List[str]: Roles to add (name) 381 | user_id str: ID of the user the roles should be added to 382 | 383 | Returns: 384 | dict: Proxied response payload 385 | 386 | Raises: 387 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 388 | """ 389 | keycloak_roles = self.get_roles(roles) 390 | return self._admin_request( 391 | url=f"{self.users_uri}/{user_id}/role-mappings/realm", 392 | data=[role.__dict__ for role in keycloak_roles], 393 | method=HTTPMethod.POST, 394 | ) 395 | 396 | @result_or_error() 397 | def remove_user_roles(self, roles: List[str], user_id: str) -> dict: 398 | """Removes roles from a specific user 399 | 400 | Args: 401 | roles List[str]: Roles to remove (name) 402 | user_id str: ID of the user the roles should be removed from 403 | 404 | Returns: 405 | dict: Proxied response payload 406 | 407 | Raises: 408 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 409 | """ 410 | keycloak_roles = self.get_roles(roles) 411 | return self._admin_request( 412 | url=f"{self.users_uri}/{user_id}/role-mappings/realm", 413 | data=[role.__dict__ for role in keycloak_roles], 414 | method=HTTPMethod.DELETE, 415 | ) 416 | 417 | @result_or_error(response_model=KeycloakRole, is_list=True) 418 | def get_roles(self, role_names: List[str]) -> List[Any] | None: 419 | """Returns full entries of Roles based on role names 420 | 421 | Args: 422 | role_names List[str]: Roles that should be looked up (names) 423 | 424 | Returns: 425 | List[KeycloakRole]: Full entries stored at Keycloak. Or None if the list of requested roles is None 426 | 427 | Notes: 428 | - The Keycloak RestAPI will only identify RoleRepresentations that 429 | use name AND id which is the only reason for existence of this function 430 | 431 | Raises: 432 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 433 | """ 434 | if role_names is None: 435 | return 436 | roles = self.get_all_roles() 437 | return list(filter(lambda role: role.name in role_names, roles)) 438 | 439 | @result_or_error(response_model=KeycloakRole, is_list=True) 440 | def get_user_roles(self, user_id: str) -> List[KeycloakRole]: 441 | """Gets all roles of a user 442 | 443 | Args: 444 | user_id (str): ID of the user of interest 445 | 446 | Returns: 447 | List[KeycloakRole]: All roles possessed by the user 448 | 449 | Raises: 450 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 451 | """ 452 | return self._admin_request( 453 | url=f"{self.users_uri}/{user_id}/role-mappings/realm", method=HTTPMethod.GET 454 | ) 455 | 456 | @result_or_error(response_model=KeycloakRole) 457 | def create_role(self, role_name: str) -> KeycloakRole: 458 | """Create a role on the realm 459 | 460 | Args: 461 | role_name (str): Name of the new role 462 | 463 | Returns: 464 | KeycloakRole: If creation succeeded, else it will return the error 465 | 466 | Raises: 467 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 468 | """ 469 | response = self._admin_request( 470 | url=self.roles_uri, data={"name": role_name}, method=HTTPMethod.POST 471 | ) 472 | if response.status_code == 201: 473 | return self.get_roles(role_names=[role_name])[0] 474 | else: 475 | return response 476 | 477 | @result_or_error(response_model=KeycloakRole, is_list=True) 478 | def get_all_roles(self) -> List[KeycloakRole]: 479 | """Get all roles of the Keycloak realm 480 | 481 | Returns: 482 | List[KeycloakRole]: All roles of the realm 483 | 484 | Raises: 485 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 486 | """ 487 | return self._admin_request(url=self.roles_uri, method=HTTPMethod.GET) 488 | 489 | @result_or_error() 490 | def delete_role(self, role_name: str) -> dict: 491 | """Deletes a role on the realm 492 | 493 | Args: 494 | role_name (str): The role (name) to delte 495 | 496 | Returns: 497 | dict: Proxied response payload 498 | 499 | Raises: 500 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 501 | """ 502 | return self._admin_request( 503 | url=f"{self.roles_uri}/{role_name}", 504 | method=HTTPMethod.DELETE, 505 | ) 506 | 507 | @result_or_error(response_model=KeycloakGroup, is_list=True) 508 | def get_all_groups(self) -> List[KeycloakGroup]: 509 | """Get all base groups of the Keycloak realm 510 | 511 | Returns: 512 | List[KeycloakGroup]: All base groups of the realm 513 | 514 | Raises: 515 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 516 | """ 517 | return self._admin_request(url=self.groups_uri, method=HTTPMethod.GET) 518 | 519 | @result_or_error(response_model=KeycloakGroup, is_list=True) 520 | def get_groups(self, group_names: List[str]) -> List[Any] | None: 521 | """Returns full entries of base Groups based on group names 522 | 523 | Args: 524 | group_names (List[str]): Groups that should be looked up (names) 525 | 526 | Returns: 527 | List[KeycloakGroup]: Full entries stored at Keycloak. Or None if the list of requested groups is None 528 | 529 | Raises: 530 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 531 | """ 532 | if group_names is None: 533 | return 534 | groups = self.get_all_groups() 535 | return list(filter(lambda group: group.name in group_names, groups)) 536 | 537 | def get_subgroups(self, group: KeycloakGroup, path: str): 538 | """Utility function to iterate through nested group structures 539 | 540 | Args: 541 | group (KeycloakGroup): Group Representation 542 | path (str): Subgroup path 543 | 544 | Returns: 545 | KeycloakGroup: Keycloak group representation or none if not exists 546 | """ 547 | for subgroup in group.subGroups: 548 | if subgroup.path == path: 549 | return subgroup 550 | elif subgroup.subGroups: 551 | for subgroup in group.subGroups: 552 | if subgroups := self.get_subgroups(subgroup, path): 553 | return subgroups 554 | # Went through the tree without hits 555 | return None 556 | 557 | @result_or_error(response_model=KeycloakGroup) 558 | def get_group_by_path( 559 | self, path: str, search_in_subgroups=True 560 | ) -> KeycloakGroup or None: 561 | """Return Group based on path 562 | 563 | Args: 564 | path (str): Path that should be looked up 565 | search_in_subgroups (bool): Whether to search in subgroups 566 | 567 | Returns: 568 | KeycloakGroup: Full entries stored at Keycloak. Or None if the path not found 569 | 570 | Raises: 571 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 572 | """ 573 | groups = self.get_all_groups() 574 | 575 | for group in groups: 576 | if group.path == path: 577 | return group 578 | elif search_in_subgroups and group.subGroups: 579 | for group in group.subGroups: 580 | if group.path == path: 581 | return group 582 | res = self.get_subgroups(group, path) 583 | if res is not None: 584 | return res 585 | 586 | @result_or_error(response_model=KeycloakGroup) 587 | def get_group(self, group_id: str) -> KeycloakGroup or None: 588 | """Return Group based on group id 589 | 590 | Args: 591 | group_id (str): Group id to be found 592 | 593 | Returns: 594 | KeycloakGroup: Keycloak object by id. Or None if the id is invalid 595 | 596 | Notes: 597 | - The Keycloak RestAPI will only identify GroupRepresentations that 598 | use name AND id which is the only reason for existence of this function 599 | 600 | Raises: 601 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 602 | """ 603 | return self._admin_request( 604 | url=f"{self.groups_uri}/{group_id}", 605 | method=HTTPMethod.GET, 606 | ) 607 | 608 | @result_or_error(response_model=KeycloakGroup) 609 | def create_group( 610 | self, group_name: str, parent: Union[KeycloakGroup, str] = None 611 | ) -> KeycloakGroup: 612 | """Create a group on the realm 613 | 614 | Args: 615 | group_name (str): Name of the new group 616 | parent (Union[KeycloakGroup, str]): Can contain an instance or object id 617 | 618 | Returns: 619 | KeycloakGroup: If creation succeeded, else it will return the error 620 | 621 | Raises: 622 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 623 | """ 624 | 625 | # If it's an objetc id get an instance of the object 626 | if isinstance(parent, str): 627 | parent = self.get_group(parent) 628 | 629 | if parent is not None: 630 | groups_uri = f"{self.groups_uri}/{parent.id}/children" 631 | path = f"{parent.path}/{group_name}" 632 | else: 633 | groups_uri = self.groups_uri 634 | path = f"/{group_name}" 635 | 636 | response = self._admin_request( 637 | url=groups_uri, data={"name": group_name}, method=HTTPMethod.POST 638 | ) 639 | if response.status_code == 201: 640 | return self.get_group_by_path(path=path, search_in_subgroups=True) 641 | else: 642 | return response 643 | 644 | @result_or_error() 645 | def delete_group(self, group_id: str) -> dict: 646 | """Deletes a group on the realm 647 | 648 | Args: 649 | group_id (str): The group (id) to delte 650 | 651 | Returns: 652 | dict: Proxied response payload 653 | 654 | Raises: 655 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 656 | """ 657 | return self._admin_request( 658 | url=f"{self.groups_uri}/{group_id}", 659 | method=HTTPMethod.DELETE, 660 | ) 661 | 662 | @result_or_error() 663 | def add_user_group(self, user_id: str, group_id: str) -> dict: 664 | """Add group to a specific user 665 | 666 | Args: 667 | user_id (str): ID of the user the group should be added to 668 | group_id (str): Group to add (id) 669 | 670 | Returns: 671 | dict: Proxied response payload 672 | 673 | Raises: 674 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 675 | """ 676 | return self._admin_request( 677 | url=f"{self.users_uri}/{user_id}/groups/{group_id}", method=HTTPMethod.PUT 678 | ) 679 | 680 | @result_or_error(response_model=KeycloakGroup, is_list=True) 681 | def get_user_groups(self, user_id: str) -> List[KeycloakGroup]: 682 | """Gets all groups of an user 683 | 684 | Args: 685 | user_id (str): ID of the user of interest 686 | 687 | Returns: 688 | List[KeycloakGroup]: All groups possessed by the user 689 | 690 | Raises: 691 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 692 | """ 693 | return self._admin_request( 694 | url=f"{self.users_uri}/{user_id}/groups", 695 | method=HTTPMethod.GET, 696 | ) 697 | 698 | @result_or_error(response_model=KeycloakUser, is_list=True) 699 | def get_group_members(self, group_id: str): 700 | """Get all members of a group. 701 | 702 | Args: 703 | group_id (str): ID of the group of interest 704 | 705 | Returns: 706 | List[KeycloakUser]: All users in the group. Note that 707 | the user objects returned are not fully populated. 708 | 709 | Raises: 710 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 711 | """ 712 | return self._admin_request( 713 | url=f"{self.groups_uri}/{group_id}/members", 714 | method=HTTPMethod.GET, 715 | ) 716 | 717 | @result_or_error() 718 | def remove_user_group(self, user_id: str, group_id: str) -> dict: 719 | """Remove group from a specific user 720 | 721 | Args: 722 | user_id str: ID of the user the groups should be removed from 723 | group_id str: Group to remove (id) 724 | 725 | Returns: 726 | dict: Proxied response payload 727 | 728 | Raises: 729 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 730 | """ 731 | return self._admin_request( 732 | url=f"{self.users_uri}/{user_id}/groups/{group_id}", 733 | method=HTTPMethod.DELETE, 734 | ) 735 | 736 | @result_or_error(response_model=KeycloakUser) 737 | def create_user( 738 | self, 739 | first_name: str, 740 | last_name: str, 741 | username: str, 742 | email: str, 743 | password: str, 744 | enabled: bool = True, 745 | initial_roles: List[str] = None, 746 | send_email_verification: bool = True, 747 | attributes: dict[str, Any] = None, 748 | ) -> KeycloakUser: 749 | """ 750 | 751 | Args: 752 | first_name (str): The first name of the new user 753 | last_name (str): The last name of the new user 754 | username (str): The username of the new user 755 | email (str): The email of the new user 756 | password (str): The password of the new user 757 | initial_roles (List[str]): The roles the user should posses. Defaults to `None` 758 | enabled (bool): True if the user should be able to be used. Defaults to `True` 759 | send_email_verification (bool): If true, the email verification will be added as an required 760 | action and the email triggered - if the user was created successfully. 761 | Defaults to `True` 762 | attributes (dict): attributes of new user 763 | 764 | Returns: 765 | KeycloakUser: If the creation succeeded 766 | 767 | Notes: 768 | - Also triggers the email verification email 769 | 770 | Raises: 771 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 772 | """ 773 | data = { 774 | "email": email, 775 | "username": username, 776 | "firstName": first_name, 777 | "lastName": last_name, 778 | "enabled": enabled, 779 | "credentials": [ 780 | {"temporary": False, "type": "password", "value": password} 781 | ], 782 | "requiredActions": ["VERIFY_EMAIL" if send_email_verification else None], 783 | "attributes": attributes, 784 | } 785 | response = self._admin_request( 786 | url=self.users_uri, data=data, method=HTTPMethod.POST 787 | ) 788 | if response.status_code != 201: 789 | return response 790 | user = self.get_user(query=f"username={username}") 791 | if send_email_verification: 792 | self.send_email_verification(user.id) 793 | if initial_roles: 794 | self.add_user_roles(initial_roles, user.id) 795 | user = self.get_user(user_id=user.id) 796 | return user 797 | 798 | @result_or_error() 799 | def change_password( 800 | self, user_id: str, new_password: str, temporary: bool = False 801 | ) -> dict: 802 | """Exchanges a users' password. 803 | 804 | Args: 805 | temporary (bool): If True, the password must be changed on the first login 806 | user_id (str): The user ID of interest 807 | new_password (str): The new password 808 | 809 | Returns: 810 | dict: Proxied response payload 811 | 812 | Notes: 813 | - Possibly should be extended by an old password check 814 | 815 | Raises: 816 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 817 | """ 818 | credentials = { 819 | "temporary": temporary, 820 | "type": "password", 821 | "value": new_password, 822 | } 823 | return self._admin_request( 824 | url=f"{self.users_uri}/{user_id}/reset-password", 825 | data=credentials, 826 | method=HTTPMethod.PUT, 827 | ) 828 | 829 | @result_or_error() 830 | def send_email_verification(self, user_id: str) -> dict: 831 | """Sends the email to verify the email address 832 | 833 | Args: 834 | user_id (str): The user ID of interest 835 | 836 | Returns: 837 | dict: Proxied response payload 838 | 839 | Raises: 840 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 841 | """ 842 | return self._admin_request( 843 | url=f"{self.users_uri}/{user_id}/send-verify-email", 844 | method=HTTPMethod.PUT, 845 | ) 846 | 847 | @result_or_error(response_model=KeycloakUser) 848 | def get_user(self, user_id: str = None, query: str = "") -> KeycloakUser: 849 | """Queries the keycloak API for a specific user either based on its ID or any **native** attribute 850 | 851 | Args: 852 | user_id (str): The user ID of interest 853 | query: Query string. e.g. `email=testuser@codespecialist.com` or `username=codespecialist` 854 | 855 | Returns: 856 | KeycloakUser: If the user was found 857 | 858 | Raises: 859 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 860 | """ 861 | if user_id is None: 862 | response = self._admin_request( 863 | url=f"{self.users_uri}?{query}", method=HTTPMethod.GET 864 | ) 865 | if not response.json(): 866 | raise UserNotFound( 867 | status_code = status.HTTP_404_NOT_FOUND, 868 | reason=f"User query with filters of [{query}] did no match any users" 869 | ) 870 | return KeycloakUser(**response.json()[0]) 871 | else: 872 | response = self._admin_request( 873 | url=f"{self.users_uri}/{user_id}", method=HTTPMethod.GET 874 | ) 875 | if response.status_code == status.HTTP_404_NOT_FOUND: 876 | raise UserNotFound( 877 | status_code = status.HTTP_404_NOT_FOUND, 878 | reason=f"User with user_id[{user_id}] was not found" 879 | ) 880 | return KeycloakUser(**response.json()) 881 | 882 | @result_or_error(response_model=KeycloakUser) 883 | def update_user(self, user: KeycloakUser): 884 | """Updates a user. Requires the whole object. 885 | 886 | Args: 887 | user (KeycloakUser): The (new) user object 888 | 889 | Returns: 890 | KeycloakUser: The updated user 891 | 892 | Raises: 893 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 894 | 895 | Notes: - You may alter any aspect of the user object, also the requiredActions for instance. There is no 896 | explicit function for updating those as it is a user update in essence 897 | """ 898 | response = self._admin_request( 899 | url=f"{self.users_uri}/{user.id}", data=user.__dict__, method=HTTPMethod.PUT 900 | ) 901 | if response.status_code == 204: # Update successful 902 | return self.get_user(user_id=user.id) 903 | return response 904 | 905 | @result_or_error() 906 | def delete_user(self, user_id: str) -> dict: 907 | """Deletes an user 908 | 909 | Args: 910 | user_id (str): The user ID of interest 911 | 912 | Returns: 913 | dict: Proxied response payload 914 | 915 | Raises: 916 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 917 | """ 918 | return self._admin_request( 919 | url=f"{self.users_uri}/{user_id}", 920 | method=HTTPMethod.DELETE 921 | ) 922 | 923 | @result_or_error(response_model=KeycloakUser, is_list=True) 924 | def get_all_users(self) -> List[KeycloakUser]: 925 | """Returns all users of the realm 926 | 927 | Returns: 928 | List[KeycloakUser]: All Keycloak users of the realm 929 | 930 | Raises: 931 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 932 | """ 933 | return self._admin_request(url=self.users_uri, method=HTTPMethod.GET) 934 | 935 | @result_or_error(response_model=KeycloakIdentityProvider, is_list=True) 936 | def get_identity_providers(self) -> List[KeycloakIdentityProvider]: 937 | """Returns all configured identity Providers 938 | 939 | Returns: 940 | List[KeycloakIdentityProvider]: All configured identity providers 941 | 942 | Raises: 943 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 944 | """ 945 | return self._admin_request(url=self.providers_uri, method=HTTPMethod.GET).json() 946 | 947 | @result_or_error(response_model=KeycloakToken) 948 | def user_login(self, username: str, password: str) -> KeycloakToken: 949 | """Models the password OAuth2 flow. Exchanges username and password for an access token. Will raise detailed 950 | errors if login fails due to requiredActions 951 | 952 | Args: 953 | username (str): Username used for login 954 | password (str): Password of the user 955 | 956 | Returns: 957 | KeycloakToken: If the exchange succeeds 958 | 959 | Raises: 960 | HTTPException: If the credentials did not match any user 961 | MandatoryActionException: If the login is not possible due to mandatory actions 962 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299, != 400, != 401) 963 | UpdateUserLocaleException: If the credentials we're correct but the has requiredActions of which the first 964 | one is to update his locale 965 | ConfigureTOTPException: If the credentials we're correct but the has requiredActions of which the first one 966 | is to configure TOTP 967 | VerifyEmailException: If the credentials we're correct but the has requiredActions of which the first one 968 | is to verify his email 969 | UpdatePasswordException: If the credentials we're correct but the has requiredActions of which the first one 970 | is to update his password 971 | UpdateProfileException: If the credentials we're correct but the has requiredActions of which the first one 972 | is to update his profile 973 | 974 | Notes: 975 | - To avoid calling this multiple times, you may want to check all requiredActions of the user if it fails 976 | due to a (sub)instance of an MandatoryActionException 977 | """ 978 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 979 | data = { 980 | "client_id": self.client_id, 981 | "client_secret": self.client_secret, 982 | "username": username, 983 | "password": password, 984 | "grant_type": "password", 985 | "scope": self.scope, 986 | } 987 | response = requests.post(url=self.token_uri, headers=headers, data=data, timeout=self.timeout) 988 | if response.status_code == 401: 989 | raise HTTPException(status_code=401, detail="Invalid user credentials") 990 | if response.status_code == 400: 991 | user: KeycloakUser = self.get_user(query=f"username={username}") 992 | if len(user.requiredActions) > 0: 993 | reason = user.requiredActions[0] 994 | exception = { 995 | "update_user_locale": UpdateUserLocaleException(), 996 | "CONFIGURE_TOTP": ConfigureTOTPException(), 997 | "VERIFY_EMAIL": VerifyEmailException(), 998 | "UPDATE_PASSWORD": UpdatePasswordException(), 999 | "UPDATE_PROFILE": UpdateProfileException(), 1000 | }.get( 1001 | reason, # Try to return the matching exception 1002 | # On custom or unknown actions return a MandatoryActionException by default 1003 | MandatoryActionException( 1004 | detail=f"This user can't login until the following action has been " 1005 | f"resolved: {reason}" 1006 | ), 1007 | ) 1008 | raise exception 1009 | return response 1010 | 1011 | @result_or_error(response_model=KeycloakToken) 1012 | def exchange_authorization_code( 1013 | self, session_state: str, code: str 1014 | ) -> KeycloakToken: 1015 | """Models the authorization code OAuth2 flow. Opening the URL provided by `login_uri` will result in a 1016 | callback to the configured callback URL. The callback will also create a session_state and code query 1017 | parameter that can be exchanged for an access token. 1018 | 1019 | Args: 1020 | session_state (str): Salt to reduce the risk of successful attacks 1021 | code (str): The authorization code 1022 | 1023 | Returns: 1024 | KeycloakToken: If the exchange succeeds 1025 | 1026 | Raises: 1027 | KeycloakError: If the resulting response is not a successful HTTP-Code (>299) 1028 | """ 1029 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 1030 | data = { 1031 | "client_id": self.client_id, 1032 | "client_secret": self.client_secret, 1033 | "code": code, 1034 | "session_state": session_state, 1035 | "grant_type": "authorization_code", 1036 | "redirect_uri": self.callback_uri, 1037 | } 1038 | return requests.post(url=self.token_uri, headers=headers, data=data, timeout=self.timeout) 1039 | 1040 | def _admin_request( 1041 | self, 1042 | url: str, 1043 | method: HTTPMethod, 1044 | data: dict = None, 1045 | content_type: str = "application/json", 1046 | ) -> Response: 1047 | """Private method that is the basis for any requests requiring admin access to the api. Will append the 1048 | necessary `Authorization` header 1049 | 1050 | Args: 1051 | url (str): The URL to be called 1052 | method (HTTPMethod): The HTTP verb to be used 1053 | data (dict): The payload of the request 1054 | content_type (str): The content type of the request 1055 | 1056 | Returns: 1057 | Response: Response of Keycloak 1058 | """ 1059 | headers = { 1060 | "Content-Type": content_type, 1061 | "Authorization": f"Bearer {self.admin_token}", 1062 | } 1063 | return requests.request( 1064 | method=method.name, url=url, data=json.dumps(data), headers=headers, timeout=self.timeout, 1065 | ) 1066 | 1067 | @functools.cached_property 1068 | def login_uri(self): 1069 | """The URL for users to login on the realm. Also adds the client id, the callback and the scope.""" 1070 | params = { 1071 | "scope": self.scope, 1072 | "response_type": "code", 1073 | "client_id": self.client_id, 1074 | "redirect_uri": self.callback_uri, 1075 | } 1076 | return f"{self.authorization_uri}?{urlencode(params)}" 1077 | 1078 | @functools.cached_property 1079 | def authorization_uri(self): 1080 | """The authorization endpoint URL""" 1081 | return self.open_id_configuration.get("authorization_endpoint") 1082 | 1083 | @functools.cached_property 1084 | def token_uri(self): 1085 | """The token endpoint URL""" 1086 | return self.open_id_configuration.get("token_endpoint") 1087 | 1088 | @functools.cached_property 1089 | def logout_uri(self): 1090 | """The logout endpoint URL""" 1091 | return self.open_id_configuration.get("end_session_endpoint") 1092 | 1093 | @functools.cached_property 1094 | def realm_uri(self): 1095 | """The realm's endpoint URL""" 1096 | return f"{self.server_url}/realms/{self.realm}" 1097 | 1098 | @functools.cached_property 1099 | def users_uri(self): 1100 | """The users endpoint URL""" 1101 | return self.admin_uri(resource="users") 1102 | 1103 | @functools.cached_property 1104 | def roles_uri(self): 1105 | """The roles endpoint URL""" 1106 | return self.admin_uri(resource="roles") 1107 | 1108 | @functools.cached_property 1109 | def groups_uri(self): 1110 | """The groups endpoint URL""" 1111 | return self.admin_uri(resource="groups") 1112 | 1113 | @functools.cached_property 1114 | def _admin_uri(self): 1115 | """The base endpoint for any admin related action""" 1116 | return f"{self.server_url}/admin/realms/{self.realm}" 1117 | 1118 | @functools.cached_property 1119 | def _open_id(self): 1120 | """The base endpoint for any opendid connect config info""" 1121 | return f"{self.realm_uri}/protocol/openid-connect" 1122 | 1123 | @functools.cached_property 1124 | def providers_uri(self): 1125 | """The endpoint that returns all configured identity providers""" 1126 | return self.admin_uri(resource="identity-provider/instances") 1127 | 1128 | def admin_uri(self, resource: str): 1129 | """Returns a admin resource URL""" 1130 | return f"{self._admin_uri}/{resource}" 1131 | 1132 | def open_id(self, resource: str): 1133 | """Returns a openip connect resource URL""" 1134 | return f"{self._open_id}/{resource}" 1135 | 1136 | def token_is_valid(self, token: str, audience: str = None) -> bool: 1137 | """Validates an access token, optionally also its audience 1138 | 1139 | Args: 1140 | token (str): The token to be verified 1141 | audience (str): Optional audience. Will be checked if provided 1142 | 1143 | Returns: 1144 | bool: True if the token is valid 1145 | """ 1146 | try: 1147 | self._decode_token(token=token, audience=audience) 1148 | return True 1149 | except (ExpiredSignatureError, JWTError, JWTClaimsError): 1150 | return False 1151 | 1152 | def _decode_token( 1153 | self, token: str, options: dict = None, audience: str = None 1154 | ) -> dict: 1155 | """Decodes a token, verifies the signature by using Keycloaks public key. Optionally verifying the audience 1156 | 1157 | Args: 1158 | token (str): 1159 | options (dict): 1160 | audience (str): Name of the audience, must match the audience given in the token 1161 | 1162 | Returns: 1163 | dict: Decoded JWT 1164 | 1165 | Raises: 1166 | ExpiredSignatureError: If the token is expired (exp > datetime.now()) 1167 | JWTError: If decoding fails or the signature is invalid 1168 | JWTClaimsError: If any claim is invalid 1169 | """ 1170 | if options is None: 1171 | options = { 1172 | "verify_signature": True, 1173 | "verify_aud": audience is not None, 1174 | "verify_exp": True, 1175 | } 1176 | return jwt.decode( 1177 | token=token, key=self.public_key, options=options, audience=audience 1178 | ) 1179 | 1180 | def __str__(self): 1181 | """String representation""" 1182 | return "FastAPI Keycloak Integration" 1183 | 1184 | def __repr__(self): 1185 | """Debug representation""" 1186 | return f"{self.__str__()} " 1187 | -------------------------------------------------------------------------------- /fastapi_keycloak/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | 3 | 4 | class KeycloakError(Exception): 5 | """Thrown if any response of keycloak does not match our expectation 6 | 7 | Attributes: 8 | status_code (int): The status code of the response received 9 | reason (str): The reason why the requests did fail 10 | """ 11 | 12 | def __init__(self, status_code: int, reason: str): 13 | self.status_code = status_code 14 | self.reason = reason 15 | super().__init__(f"HTTP {status_code}: {reason}") 16 | 17 | class UserNotFound(Exception): 18 | """Thrown when a user lookup fails. 19 | 20 | Attributes: 21 | status_code (int): The status code of the response received 22 | reason (str): The reason why the requests did fail 23 | """ 24 | def __init__(self, status_code: int, reason: str): 25 | self.status_code = status_code 26 | self.reason = reason 27 | super().__init__(f"HTTP {status_code}: {reason}") 28 | 29 | class MandatoryActionException(HTTPException): 30 | """Throw if the exchange of username and password for an access token fails""" 31 | 32 | def __init__(self, detail: str) -> None: 33 | super().__init__(status_code=400, detail=detail) 34 | 35 | 36 | class UpdateUserLocaleException(MandatoryActionException): 37 | """Throw if the exchange of username and password for an access token fails due to the update_user_locale 38 | requiredAction""" 39 | 40 | def __init__(self) -> None: 41 | super().__init__(detail="This user can't login until he updated his locale") 42 | 43 | 44 | class ConfigureTOTPException(MandatoryActionException): 45 | """Throw if the exchange of username and password for an access token fails due to the CONFIGURE_TOTP 46 | requiredAction""" 47 | 48 | def __init__(self) -> None: 49 | super().__init__(detail="This user can't login until he configured TOTP") 50 | 51 | 52 | class VerifyEmailException(MandatoryActionException): 53 | """Throw if the exchange of username and password for an access token fails due to the VERIFY_EMAIL 54 | requiredAction""" 55 | 56 | def __init__(self) -> None: 57 | super().__init__(detail="This user can't login until he verified his email") 58 | 59 | 60 | class UpdatePasswordException(MandatoryActionException): 61 | """Throw if the exchange of username and password for an access token fails due to the UPDATE_PASSWORD 62 | requiredAction""" 63 | 64 | def __init__(self) -> None: 65 | super().__init__(detail="This user can't login until he updated his password") 66 | 67 | 68 | class UpdateProfileException(MandatoryActionException): 69 | """Throw if the exchange of username and password for an access token fails due to the UPDATE_PROFILE 70 | requiredAction""" 71 | 72 | def __init__(self) -> None: 73 | super().__init__(detail="This user can't login until he updated his profile") 74 | -------------------------------------------------------------------------------- /fastapi_keycloak/model.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel, SecretStr, Field 5 | 6 | from fastapi_keycloak.exceptions import KeycloakError 7 | 8 | 9 | class HTTPMethod(Enum): 10 | """Represents the basic HTTP verbs 11 | 12 | Values: 13 | - GET: get 14 | - POST: post 15 | - DELETE: delete 16 | - PUT: put 17 | """ 18 | 19 | GET = "get" 20 | POST = "post" 21 | DELETE = "delete" 22 | PUT = "put" 23 | 24 | 25 | class KeycloakUser(BaseModel): 26 | """Represents a user object of Keycloak. 27 | 28 | Attributes: 29 | id (str): 30 | createdTimestamp (int): 31 | username (str): 32 | enabled (bool): 33 | totp (bool): 34 | emailVerified (bool): 35 | firstName (Optional[str]): 36 | lastName (Optional[str]): 37 | email (Optional[str]): 38 | disableableCredentialTypes (List[str]): 39 | requiredActions (List[str]): 40 | realmRoles (List[str]): 41 | notBefore (int): 42 | access (dict): 43 | attributes (Optional[dict]): 44 | 45 | Notes: Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for 46 | details. This is a mere proxy object. 47 | """ 48 | 49 | id: str 50 | createdTimestamp: int 51 | username: str 52 | enabled: bool 53 | totp: bool 54 | emailVerified: bool 55 | firstName: Optional[str] = None 56 | lastName: Optional[str] = None 57 | email: Optional[str] = None 58 | disableableCredentialTypes: List[str] 59 | requiredActions: List[str] 60 | realmRoles: Optional[List[str]] = None 61 | notBefore: int 62 | access: Optional[dict] = None 63 | attributes: Optional[dict] = None 64 | 65 | 66 | class UsernamePassword(BaseModel): 67 | """Represents a request body that contains username and password 68 | 69 | Attributes: 70 | username (str): Username 71 | password (str): Password, masked by swagger 72 | """ 73 | 74 | username: str 75 | password: SecretStr 76 | 77 | 78 | class OIDCUser(BaseModel): 79 | """Represents a user object of Keycloak, parsed from access token 80 | 81 | Attributes: 82 | sub (str): 83 | iat (int): 84 | exp (int): 85 | scope (str): 86 | email_verified (bool): 87 | name (Optional[str]): 88 | given_name (Optional[str]): 89 | family_name (Optional[str]): 90 | email (Optional[str]): 91 | preferred_username (Optional[str]): 92 | realm_access (dict): 93 | resource_access (dict): 94 | extra_fields (dict): 95 | 96 | Notes: Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for 97 | details. This is a mere proxy object. 98 | """ 99 | 100 | azp: Optional[str] = None 101 | sub: str 102 | iat: int 103 | exp: int 104 | scope: Optional[str] = None 105 | email_verified: bool 106 | name: Optional[str] = None 107 | given_name: Optional[str] = None 108 | family_name: Optional[str] = None 109 | email: Optional[str] = None 110 | preferred_username: Optional[str] = None 111 | realm_access: Optional[dict] = None 112 | resource_access: Optional[dict] = None 113 | extra_fields: dict = Field(default_factory=dict) 114 | 115 | @property 116 | def roles(self) -> List[str]: 117 | """Returns the roles of the user 118 | 119 | Returns: 120 | List[str]: If the realm access dict contains roles 121 | """ 122 | if not self.realm_access and not self.resource_access: 123 | raise KeycloakError( 124 | status_code=404, 125 | reason="The 'realm_access' and 'resource_access' sections of the provided access token are missing.", 126 | ) 127 | roles = [] 128 | if self.realm_access: 129 | if "roles" in self.realm_access: 130 | roles += self.realm_access["roles"] 131 | if self.azp and self.resource_access: 132 | if self.azp in self.resource_access: 133 | if "roles" in self.resource_access[self.azp]: 134 | roles += self.resource_access[self.azp]["roles"] 135 | if not roles: 136 | raise KeycloakError( 137 | status_code=404, 138 | reason="The 'realm_access' and 'resource_access' sections of the provided access token did not " 139 | "contain any 'roles'", 140 | ) 141 | return roles 142 | 143 | def __str__(self) -> str: 144 | """String representation of an OIDCUser""" 145 | return self.preferred_username 146 | 147 | 148 | class KeycloakIdentityProvider(BaseModel): 149 | """Keycloak representation of an identity provider 150 | 151 | Attributes: 152 | alias (str): 153 | internalId (str): 154 | providerId (str): 155 | enabled (bool): 156 | updateProfileFirstLoginMode (str): 157 | trustEmail (bool): 158 | storeToken (bool): 159 | addReadTokenRoleOnCreate (bool): 160 | authenticateByDefault (bool): 161 | linkOnly (bool): 162 | firstBrokerLoginFlowAlias (str): 163 | config (dict): 164 | 165 | Notes: Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for 166 | details. This is a mere proxy object. 167 | """ 168 | 169 | alias: str 170 | internalId: str 171 | providerId: str 172 | enabled: bool 173 | updateProfileFirstLoginMode: str 174 | trustEmail: bool 175 | storeToken: bool 176 | addReadTokenRoleOnCreate: bool 177 | authenticateByDefault: bool 178 | linkOnly: bool 179 | firstBrokerLoginFlowAlias: str 180 | config: dict 181 | 182 | 183 | class KeycloakRole(BaseModel): 184 | """Keycloak representation of a role 185 | 186 | Attributes: 187 | id (str): 188 | name (str): 189 | composite (bool): 190 | clientRole (bool): 191 | containerId (str): 192 | 193 | Notes: Check the Keycloak documentation at https://www.keycloak.org/docs-api/15.0/rest-api/index.html for 194 | details. This is a mere proxy object. 195 | """ 196 | 197 | id: str 198 | name: str 199 | composite: bool 200 | clientRole: bool 201 | containerId: str 202 | 203 | 204 | class KeycloakToken(BaseModel): 205 | """Keycloak representation of a token object 206 | 207 | Attributes: 208 | access_token (str): An access token 209 | refresh_token (str): An a refresh token, default None 210 | id_token (str): An issued by the Authorization Server token id, default None 211 | """ 212 | 213 | access_token: str 214 | refresh_token: Optional[str] = None 215 | id_token: Optional[str] = None 216 | 217 | def __str__(self): 218 | """String representation of KeycloakToken""" 219 | return f"Bearer {self.access_token}" 220 | 221 | 222 | class KeycloakGroup(BaseModel): 223 | """Keycloak representation of a group 224 | 225 | Attributes: 226 | id (str): 227 | name (str): 228 | path (Optional[str]): 229 | realmRoles (Optional[str]): 230 | """ 231 | 232 | id: str 233 | name: str 234 | path: Optional[str] = None 235 | realmRoles: Optional[List[str]] = None 236 | subGroups: Optional[List["KeycloakGroup"]] = None 237 | 238 | 239 | KeycloakGroup.update_forward_refs() 240 | -------------------------------------------------------------------------------- /fastapi_keycloak/requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.4.0 2 | asgiref==3.4.1 3 | certifi==2022.12.7 4 | charset-normalizer==2.0.9 5 | click==8.0.3 6 | ecdsa==0.17.0 7 | fastapi==0.79.0 8 | h11==0.12.0 9 | idna==3.3 10 | pyasn1==0.4.8 11 | pydantic==1.8.2 12 | python-jose==3.3.0 13 | requests==2.26.0 14 | rsa==4.8 15 | six==1.16.0 16 | sniffio==1.2.0 17 | starlette==0.19.1 18 | typing_extensions==4.0.1 19 | urllib3==1.26.7 20 | uvicorn==0.16.0 21 | watchdog==2.1.6 22 | zipp==3.6.0 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi_keycloak" 3 | authors = [ 4 | { name = "Jonas Scholl", email = "jonas@code-specialist.com" }, 5 | { name = "Yannic Schröer", email = "yannic@code-specialist.com" } 6 | ] 7 | maintainers = [ 8 | { name = "Jonas Scholl", email = "jonas@code-specialist.com" }, 9 | { name = "Yannic Schröer", email = "yannic@code-specialist.com" } 10 | ] 11 | readme = "README.md" 12 | keywords = ['Keycloak', 'FastAPI', 'Authentication', 'Authorization'] 13 | classifiers = [ 14 | 'Development Status :: 3 - Alpha', 15 | 'Intended Audience :: Developers', 16 | 'Topic :: Internet :: WWW/HTTP :: Session', 17 | 'Topic :: Internet :: WWW/HTTP :: WSGI', 18 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 19 | 'Topic :: Software Development :: Libraries :: Python Modules', 20 | 'Framework :: FastAPI', 21 | 'License :: OSI Approved :: Apache Software License', 22 | 'Programming Language :: Python :: 3.8', 23 | ] 24 | requires-python = ">=3.8" 25 | dynamic = ["version", "description"] 26 | dependencies = [ 27 | "anyio>=3.4.0", 28 | "asgiref>=3.4.1", 29 | "certifi>=2021.10.8", 30 | "charset-normalizer>=2.0.9", 31 | "click>=8.0.3", 32 | "ecdsa>=0.17.0", 33 | "fastapi>=0.70.1", 34 | "h11>=0.12.0", 35 | "idna>=3.3", 36 | "pyasn1>=0.4.8", 37 | "pydantic>=1.5a1", 38 | "python-jose>=3.3.0", 39 | "requests>=2.26.0", 40 | "rsa>=4.8", 41 | "six>=1.16.0", 42 | "sniffio>=1.2.0", 43 | "starlette>=0.16.0", 44 | "typing_extensions>=4.0.1", 45 | "urllib3>=1.26.7", 46 | "uvicorn>=0.16.0", 47 | "itsdangerous>=2.0.1", 48 | ] 49 | 50 | [project.urls] 51 | Documentation = "https://fastapi-keycloak.code-specialist.com/" 52 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.4.0 2 | asgiref==3.4.1 3 | astunparse==1.6.3 4 | attrs==21.4.0 5 | certifi==2022.12.7 6 | charset-normalizer==2.0.9 7 | click==8.0.3 8 | coverage==6.4.2 9 | ecdsa==0.17.0 10 | fastapi==0.79.0 11 | ghp-import==2.0.2 12 | h11==0.12.0 13 | httpretty==1.1.4 14 | idna==3.3 15 | importlib-metadata==4.10.0 16 | iniconfig==1.1.1 17 | itsdangerous==2.0.1 18 | Jinja2==3.0.3 19 | Markdown==3.3.6 20 | MarkupSafe==2.0.1 21 | mergedeep==1.3.4 22 | mkdocs==1.2.3 23 | mkdocs-autorefs==0.3.0 24 | mkdocs-material==7.3.6 25 | mkdocs-material-extensions==1.0.3 26 | mkdocstrings==0.16.2 27 | packaging==21.3 28 | pluggy==1.0.0 29 | py==1.11.0 30 | pyasn1==0.4.8 31 | pydantic==1.8.2 32 | Pygments==2.10.0 33 | pymdown-extensions==9.1 34 | pyparsing==3.0.6 35 | pytest==7.1.2 36 | pytest-cov==3.0.0 37 | python-dateutil==2.8.2 38 | python-jose==3.3.0 39 | pytkdocs==0.12.0 40 | PyYAML==6.0 41 | pyyaml_env_tag==0.1 42 | requests==2.26.0 43 | rsa==4.8 44 | six==1.16.0 45 | sniffio==1.2.0 46 | starlette==0.19.1 47 | tomli==2.0.1 48 | typing_extensions==4.0.1 49 | urllib3==1.26.7 50 | uvicorn==0.16.0 51 | watchdog==2.1.6 52 | zipp==3.6.0 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This has only been added to allow editable dev installs, pyproject.toml replaces setup.py 4 | # e.g. pip install -e . 5 | 6 | import setuptools 7 | 8 | if __name__ == "__main__": 9 | setuptools.setup() 10 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | */setup* 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_keycloak import FastAPIKeycloak 4 | 5 | 6 | class BaseTestClass: 7 | @pytest.fixture 8 | def idp(self): 9 | return FastAPIKeycloak( 10 | server_url="http://localhost:8085/auth", 11 | client_id="test-client", 12 | client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", 13 | admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", 14 | realm="Test", 15 | callback_uri="http://localhost:8081/callback", 16 | ) 17 | -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import uvicorn 4 | from fastapi import FastAPI, Depends, Query, Body, Request 5 | from fastapi.responses import JSONResponse 6 | from pydantic import SecretStr 7 | 8 | from fastapi_keycloak import ( 9 | FastAPIKeycloak, 10 | HTTPMethod, 11 | KeycloakUser, 12 | OIDCUser, 13 | UsernamePassword, 14 | KeycloakError 15 | ) 16 | 17 | app = FastAPI() 18 | idp = FastAPIKeycloak( 19 | server_url="http://localhost:8085/auth", 20 | client_id="test-client", 21 | client_secret="GzgACcJzhzQ4j8kWhmhazt7WSdxDVUyE", 22 | admin_client_id="admin-cli", 23 | admin_client_secret="BIcczGsZ6I8W5zf0rZg5qSexlloQLPKB", 24 | realm="Test", 25 | callback_uri="http://localhost:8081/callback", 26 | scope="openid profile email", 27 | ) 28 | idp.add_swagger_config(app) 29 | 30 | 31 | # Custom error handler for showing Keycloak errors on FastAPI 32 | @app.exception_handler(KeycloakError) 33 | async def keycloak_exception_handler(request: Request, exc: KeycloakError): 34 | return JSONResponse( 35 | status_code=exc.status_code, 36 | content={"message": exc.reason}, 37 | ) 38 | 39 | 40 | # Admin 41 | 42 | 43 | @app.post("/proxy", tags=["admin-cli"]) 44 | def proxy_admin_request( 45 | relative_path: str, 46 | method: HTTPMethod, 47 | additional_headers: dict = Body(None), 48 | payload: dict = Body(None), 49 | ): 50 | return idp.proxy( 51 | additional_headers=additional_headers, 52 | relative_path=relative_path, 53 | method=method, 54 | payload=payload, 55 | ) 56 | 57 | 58 | @app.get("/identity-providers", tags=["admin-cli"]) 59 | def get_identity_providers(): 60 | return idp.get_identity_providers() 61 | 62 | 63 | @app.get("/idp-configuration", tags=["admin-cli"]) 64 | def get_idp_config(): 65 | return idp.open_id_configuration 66 | 67 | 68 | # User Management 69 | 70 | 71 | @app.get("/users", tags=["user-management"]) 72 | def get_users(): 73 | return idp.get_all_users() 74 | 75 | 76 | @app.get("/user", tags=["user-management"]) 77 | def get_user_by_query(query: str = None): 78 | return idp.get_user(query=query) 79 | 80 | 81 | @app.post("/users", tags=["user-management"]) 82 | def create_user( 83 | first_name: str, last_name: str, email: str, password: SecretStr, id: str = None 84 | ): 85 | return idp.create_user( 86 | first_name=first_name, 87 | last_name=last_name, 88 | username=email, 89 | email=email, 90 | password=password.get_secret_value(), 91 | id=id, 92 | ) 93 | 94 | 95 | @app.get("/user/{user_id}", tags=["user-management"]) 96 | def get_user(user_id: str = None): 97 | return idp.get_user(user_id=user_id) 98 | 99 | 100 | @app.put("/user", tags=["user-management"]) 101 | def update_user(user: KeycloakUser): 102 | return idp.update_user(user=user) 103 | 104 | 105 | @app.delete("/user/{user_id}", tags=["user-management"]) 106 | def delete_user(user_id: str): 107 | return idp.delete_user(user_id=user_id) 108 | 109 | 110 | @app.put("/user/{user_id}/change-password", tags=["user-management"]) 111 | def change_password(user_id: str, new_password: SecretStr): 112 | return idp.change_password(user_id=user_id, new_password=new_password) 113 | 114 | 115 | @app.put("/user/{user_id}/send-email-verification", tags=["user-management"]) 116 | def send_email_verification(user_id: str): 117 | return idp.send_email_verification(user_id=user_id) 118 | 119 | 120 | # Role Management 121 | 122 | 123 | @app.get("/roles", tags=["role-management"]) 124 | def get_all_roles(): 125 | return idp.get_all_roles() 126 | 127 | 128 | @app.get("/role/{role_name}", tags=["role-management"]) 129 | def get_role(role_name: str): 130 | return idp.get_roles([role_name]) 131 | 132 | 133 | @app.post("/roles", tags=["role-management"]) 134 | def add_role(role_name: str): 135 | return idp.create_role(role_name=role_name) 136 | 137 | 138 | @app.delete("/roles", tags=["role-management"]) 139 | def delete_roles(role_name: str): 140 | return idp.delete_role(role_name=role_name) 141 | 142 | 143 | # Group Management 144 | 145 | 146 | @app.get("/groups", tags=["group-management"]) 147 | def get_all_groups(): 148 | return idp.get_all_groups() 149 | 150 | 151 | @app.get("/group/{group_name}", tags=["group-management"]) 152 | def get_group(group_name: str): 153 | return idp.get_groups([group_name]) 154 | 155 | 156 | @app.get("/group-by-path/{path: path}", tags=["group-management"]) 157 | def get_group_by_path(path: str): 158 | return idp.get_group_by_path(path) 159 | 160 | 161 | @app.post("/groups", tags=["group-management"]) 162 | def add_group(group_name: str, parent_id: Optional[str] = None): 163 | return idp.create_group(group_name=group_name, parent=parent_id) 164 | 165 | 166 | @app.delete("/groups", tags=["group-management"]) 167 | def delete_groups(group_id: str): 168 | return idp.delete_group(group_id=group_id) 169 | 170 | 171 | # User Roles 172 | 173 | 174 | @app.post("/users/{user_id}/roles", tags=["user-roles"]) 175 | def add_roles_to_user(user_id: str, roles: Optional[List[str]] = Query(None)): 176 | return idp.add_user_roles(user_id=user_id, roles=roles) 177 | 178 | 179 | @app.get("/users/{user_id}/roles", tags=["user-roles"]) 180 | def get_user_roles(user_id: str): 181 | return idp.get_user_roles(user_id=user_id) 182 | 183 | 184 | @app.delete("/users/{user_id}/roles", tags=["user-roles"]) 185 | def delete_roles_from_user(user_id: str, roles: Optional[List[str]] = Query(None)): 186 | return idp.remove_user_roles(user_id=user_id, roles=roles) 187 | 188 | 189 | # User Groups 190 | 191 | 192 | @app.post("/users/{user_id}/groups", tags=["user-groups"]) 193 | def add_group_to_user(user_id: str, group_id: str): 194 | return idp.add_user_group(user_id=user_id, group_id=group_id) 195 | 196 | 197 | @app.get("/users/{user_id}/groups", tags=["user-groups"]) 198 | def get_user_groups(user_id: str): 199 | return idp.get_user_groups(user_id=user_id) 200 | 201 | 202 | @app.delete("/users/{user_id}/groups", tags=["user-groups"]) 203 | def delete_groups_from_user(user_id: str, group_id: str): 204 | return idp.remove_user_group(user_id=user_id, group_id=group_id) 205 | 206 | 207 | # Example User Requests 208 | 209 | 210 | @app.get("/protected", tags=["example-user-request"]) 211 | def protected(user: OIDCUser = Depends(idp.get_current_user())): 212 | return user 213 | 214 | 215 | @app.get("/current_user/roles", tags=["example-user-request"]) 216 | def get_current_users_roles(user: OIDCUser = Depends(idp.get_current_user())): 217 | return user.roles 218 | 219 | 220 | @app.get("/admin", tags=["example-user-request"]) 221 | def company_admin( 222 | user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"])), 223 | ): 224 | return f"Hi admin {user}" 225 | 226 | 227 | @app.post("/login", tags=["example-user-request"]) 228 | def login(user: UsernamePassword = Body(...)): 229 | return idp.user_login( 230 | username=user.username, password=user.password.get_secret_value() 231 | ) 232 | 233 | 234 | # Auth Flow 235 | 236 | 237 | @app.get("/login-link", tags=["auth-flow"]) 238 | def login_redirect(): 239 | return idp.login_uri 240 | 241 | 242 | @app.get("/callback", tags=["auth-flow"]) 243 | def callback(session_state: str, code: str): 244 | return idp.exchange_authorization_code(session_state=session_state, code=code) 245 | 246 | 247 | @app.get("/logout", tags=["auth-flow"]) 248 | def logout(): 249 | return idp.logout_uri 250 | 251 | 252 | if __name__ == "__main__": 253 | uvicorn.run("app:app", host="127.0.0.1", port=8081) 254 | -------------------------------------------------------------------------------- /tests/build_keycloak_m1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | cd /tmp 4 | git clone git@github.com:keycloak/keycloak-containers.git 5 | cd keycloak-containers/server 6 | git checkout 16.1.0 7 | docker build -t "jboss/keycloak:16.1.0" . 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_sessionstart(session): 2 | # subprocess.call(['sh', './start_infra.sh']) 3 | # print("Waiting for Keycloak to start") 4 | # sleep(60) # Wait for startup 5 | pass 6 | 7 | 8 | def pytest_sessionfinish(session): 9 | # subprocess.call(['sh', './stop_infra.sh']) 10 | pass 11 | -------------------------------------------------------------------------------- /tests/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | -------------------------------------------------------------------------------- /tests/keycloak_postgres.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | postgres_data: 5 | driver: local 6 | 7 | services: 8 | postgres: 9 | image: postgres 10 | environment: 11 | POSTGRES_DB: testkeycloakdb 12 | POSTGRES_USER: testkeycloakuser 13 | POSTGRES_PASSWORD: testkeycloakpassword 14 | restart: 15 | always 16 | 17 | keycloak: 18 | image: docker.io/jboss/keycloak:16.1.0 # Locally built with `build_keycloak_m1.sh` as the current images do not support the architecture 19 | volumes: 20 | - ./realm-export.json:/opt/jboss/keycloak/imports/realm-export.json 21 | command: 22 | - "-b 0.0.0.0 -Dkeycloak.profile.feature.upload_scripts=enabled -Dkeycloak.import=/opt/jboss/keycloak/imports/realm-export.json" 23 | environment: 24 | DB_VENDOR: POSTGRES 25 | DB_ADDR: postgres 26 | DB_DATABASE: testkeycloakdb 27 | DB_USER: testkeycloakuser 28 | DB_SCHEMA: public 29 | DB_PASSWORD: testkeycloakpassword 30 | KEYCLOAK_USER: keycloakuser 31 | KEYCLOAK_PASSWORD: keycloakpassword 32 | PROXY_ADDRESS_FORWARDING: "true" 33 | KEYCLOAK_LOGLEVEL: DEBUG 34 | ports: 35 | - '8085:8080' 36 | depends_on: 37 | - postgres 38 | restart: 39 | always 40 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -x -p no:warnings --cov-report=term-missing --cov-report=term --cov-report=xml:./coverage.xml --no-cov-on-fail --cov=fastapi_keycloak 3 | -------------------------------------------------------------------------------- /tests/start_infra.sh: -------------------------------------------------------------------------------- 1 | docker-compose -f keycloak_postgres.yaml up -d 2 | -------------------------------------------------------------------------------- /tests/stop_infra.sh: -------------------------------------------------------------------------------- 1 | docker-compose -f keycloak_postgres.yaml down 2 | -------------------------------------------------------------------------------- /tests/test_functional.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest as pytest 4 | from fastapi import HTTPException 5 | 6 | from fastapi_keycloak import KeycloakError 7 | from fastapi_keycloak.exceptions import ( 8 | ConfigureTOTPException, 9 | UpdatePasswordException, 10 | UpdateProfileException, 11 | UpdateUserLocaleException, 12 | UserNotFound, 13 | VerifyEmailException, 14 | ) 15 | from fastapi_keycloak.model import ( 16 | KeycloakGroup, 17 | KeycloakRole, 18 | KeycloakToken, 19 | KeycloakUser, 20 | OIDCUser, 21 | ) 22 | from tests import BaseTestClass 23 | 24 | TEST_PASSWORD = "test-password" 25 | 26 | 27 | class TestAPIFunctional(BaseTestClass): 28 | @pytest.fixture 29 | def user(self, idp): 30 | return idp.create_user( 31 | first_name="test", 32 | last_name="user", 33 | username="user@code-specialist.com", 34 | email="user@code-specialist.com", 35 | password=TEST_PASSWORD, 36 | enabled=True, 37 | send_email_verification=False, 38 | ) 39 | 40 | @pytest.fixture() 41 | def users(self, idp): 42 | assert idp.get_all_users() == [] # No users yet 43 | 44 | # Create some test users 45 | user_alice = idp.create_user( # Create User A 46 | first_name="test", 47 | last_name="user", 48 | username="testuser_alice@code-specialist.com", 49 | email="testuser_alice@code-specialist.com", 50 | password=TEST_PASSWORD, 51 | enabled=True, 52 | send_email_verification=False, 53 | ) 54 | assert isinstance(user_alice, KeycloakUser) 55 | assert len(idp.get_all_users()) == 1 56 | 57 | # Try to create a user with the same username 58 | with pytest.raises(KeycloakError): # 'User exists with same username' 59 | idp.create_user( 60 | first_name="test", 61 | last_name="user", 62 | username="testuser_alice@code-specialist.com", 63 | email="testuser_alice@code-specialist.com", 64 | password=TEST_PASSWORD, 65 | enabled=True, 66 | send_email_verification=False, 67 | ) 68 | assert len(idp.get_all_users()) == 1 69 | 70 | user_bob = idp.create_user( # Create User B 71 | first_name="test", 72 | last_name="user", 73 | username="testuser_bob@code-specialist.com", 74 | email="testuser_bob@code-specialist.com", 75 | password=TEST_PASSWORD, 76 | enabled=True, 77 | send_email_verification=False, 78 | ) 79 | assert isinstance(user_bob, KeycloakUser) 80 | assert len(idp.get_all_users()) == 2 81 | return user_alice, user_bob 82 | 83 | def test_roles(self, idp, users): 84 | user_alice, user_bob = users 85 | 86 | # Check the roles 87 | user_alice_roles = idp.get_user_roles(user_id=user_alice.id) 88 | assert len(user_alice_roles) == 1 89 | for role in user_alice_roles: 90 | assert role.name in ["default-roles-test"] 91 | 92 | user_bob_roles = idp.get_user_roles(user_id=user_bob.id) 93 | assert len(user_bob_roles) == 1 94 | for role in user_bob_roles: 95 | assert role.name in ["default-roles-test"] 96 | 97 | # Create a some roles 98 | all_roles = idp.get_all_roles() 99 | assert len(all_roles) == 3 100 | for role in all_roles: 101 | assert role.name in [ 102 | "default-roles-test", 103 | "offline_access", 104 | "uma_authorization", 105 | ] 106 | 107 | test_role_saturn = idp.create_role("test_role_saturn") 108 | all_roles = idp.get_all_roles() 109 | assert len(all_roles) == 4 110 | for role in all_roles: 111 | assert role.name in [ 112 | "default-roles-test", 113 | "offline_access", 114 | "uma_authorization", 115 | test_role_saturn.name, 116 | ] 117 | 118 | test_role_mars = idp.create_role("test_role_mars") 119 | all_roles = idp.get_all_roles() 120 | assert len(all_roles) == 5 121 | for role in all_roles: 122 | assert role.name in [ 123 | "default-roles-test", 124 | "offline_access", 125 | "uma_authorization", 126 | test_role_saturn.name, 127 | test_role_mars.name, 128 | ] 129 | 130 | assert isinstance(test_role_saturn, KeycloakRole) 131 | assert isinstance(test_role_mars, KeycloakRole) 132 | 133 | # Check the roles again 134 | user_alice_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_alice.id) 135 | assert len(user_alice_roles) == 1 136 | for role in user_alice_roles: 137 | assert role.name in ["default-roles-test"] 138 | 139 | user_bob_roles = idp.get_user_roles(user_id=user_bob.id) 140 | assert len(user_bob_roles) == 1 141 | for role in user_bob_roles: 142 | assert role.name in ["default-roles-test"] 143 | 144 | # Assign role to Alice 145 | idp.add_user_roles(user_id=user_alice.id, roles=[test_role_saturn.name]) 146 | user_alice_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_alice.id) 147 | assert len(user_alice_roles) == 2 148 | for role in user_alice_roles: 149 | assert role.name in ["default-roles-test", test_role_saturn.name] 150 | 151 | # Assign roles to Bob 152 | idp.add_user_roles( 153 | user_id=user_bob.id, roles=[test_role_saturn.name, test_role_mars.name] 154 | ) 155 | user_bob_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_bob.id) 156 | assert len(user_bob_roles) == 3 157 | for role in user_bob_roles: 158 | assert role.name in [ 159 | "default-roles-test", 160 | test_role_saturn.name, 161 | test_role_mars.name, 162 | ] 163 | 164 | # Exchange the details for access tokens 165 | keycloak_token_alice: KeycloakToken = idp.user_login( 166 | username=user_alice.username, password=TEST_PASSWORD 167 | ) 168 | assert idp.token_is_valid(keycloak_token_alice.access_token) 169 | keycloak_token_bob: KeycloakToken = idp.user_login( 170 | username=user_bob.username, password=TEST_PASSWORD 171 | ) 172 | assert idp.token_is_valid(keycloak_token_bob.access_token) 173 | 174 | # Check get_current_user Alice 175 | current_user_function = idp.get_current_user() 176 | current_user: OIDCUser = current_user_function( 177 | token=keycloak_token_alice.access_token 178 | ) 179 | assert current_user.sub == user_alice.id 180 | assert len(current_user.roles) == 4 # Also includes all implicit roles 181 | for role in current_user.roles: 182 | assert role in [ 183 | "default-roles-test", 184 | "offline_access", 185 | "uma_authorization", 186 | test_role_saturn.name, 187 | ] 188 | 189 | # Check get_current_user Bob 190 | current_user_function = idp.get_current_user() 191 | current_user: OIDCUser = current_user_function( 192 | token=keycloak_token_bob.access_token 193 | ) 194 | assert current_user.sub == user_bob.id 195 | assert len(current_user.roles) == 5 # Also includes all implicit roles 196 | for role in current_user.roles: 197 | assert role in [ 198 | "default-roles-test", 199 | "offline_access", 200 | "uma_authorization", 201 | test_role_saturn.name, 202 | test_role_mars.name, 203 | ] 204 | 205 | # Check get_current_user Alice with role Saturn 206 | current_user_function = idp.get_current_user( 207 | required_roles=[test_role_saturn.name] 208 | ) 209 | # Get Alice 210 | current_user: OIDCUser = current_user_function( 211 | token=keycloak_token_alice.access_token 212 | ) 213 | assert current_user.sub == user_alice.id 214 | # Get Bob 215 | current_user: OIDCUser = current_user_function( 216 | token=keycloak_token_bob.access_token 217 | ) 218 | assert current_user.sub == user_bob.id 219 | 220 | # Check get_current_user Alice with role Mars 221 | current_user_function = idp.get_current_user( 222 | required_roles=[test_role_mars.name] 223 | ) 224 | # Get Alice 225 | with pytest.raises(HTTPException): 226 | current_user_function( 227 | token=keycloak_token_alice.access_token 228 | ) # Alice does not posses this role 229 | # Get Bob 230 | current_user: OIDCUser = current_user_function( 231 | token=keycloak_token_bob.access_token 232 | ) 233 | assert current_user.sub == user_bob.id 234 | 235 | # Remove Role Mars from Bob 236 | idp.remove_user_roles(user_id=user_bob.id, roles=[test_role_mars.name]) 237 | user_bob_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_bob.id) 238 | assert len(user_bob_roles) == 2 239 | for role in user_bob_roles: 240 | assert role.name in [ 241 | "default-roles-test", 242 | "offline_access", 243 | "uma_authorization", 244 | test_role_saturn.name, 245 | ] 246 | 247 | # Delete Role Saturn 248 | idp.delete_role(role_name=test_role_saturn.name) 249 | 250 | # Check Alice 251 | user_alice_roles: List[KeycloakRole] = idp.get_user_roles(user_id=user_alice.id) 252 | assert len(user_alice_roles) == 1 253 | for role in user_alice_roles: 254 | assert role.name in ["default-roles-test"] 255 | 256 | # Check Bob 257 | user_bob_roles = idp.get_user_roles(user_id=user_bob.id) 258 | assert len(user_bob_roles) == 1 259 | for role in user_bob_roles: 260 | assert role.name in ["default-roles-test"] 261 | 262 | # Clean up 263 | idp.delete_role(role_name=test_role_mars.name) 264 | idp.delete_user(user_id=user_alice.id) 265 | idp.delete_user(user_id=user_bob.id) 266 | 267 | def test_user_with_initial_roles(self, idp): 268 | idp.create_role("role_a") 269 | idp.create_role("role_b") 270 | 271 | user: KeycloakUser = idp.create_user( 272 | first_name="test", 273 | last_name="user", 274 | username="user@code-specialist.com", 275 | email="user@code-specialist.com", 276 | initial_roles=["role_a", "role_b"], 277 | password=TEST_PASSWORD, 278 | enabled=True, 279 | send_email_verification=False, 280 | ) 281 | assert user 282 | 283 | user_token: KeycloakToken = idp.user_login( 284 | username=user.username, password=TEST_PASSWORD 285 | ) 286 | decoded_token = idp._decode_token( 287 | token=user_token.access_token, audience="account" 288 | ) 289 | oidc_user: OIDCUser = OIDCUser.parse_obj(decoded_token) 290 | for role in ["role_a", "role_b"]: 291 | assert role in oidc_user.roles 292 | 293 | idp.delete_role("role_a") 294 | idp.delete_role("role_b") 295 | idp.delete_user(user.id) 296 | 297 | def test_groups(self, idp): 298 | 299 | # None of empty list groups 300 | none_return = idp.get_groups([]) 301 | assert not none_return 302 | 303 | # None of none param 304 | none_return = idp.get_groups(None) 305 | assert none_return is None 306 | 307 | # Error create group 308 | with pytest.raises(KeycloakError): 309 | idp.create_group(group_name=None) 310 | 311 | # Error get group 312 | with pytest.raises(KeycloakError): 313 | idp.get_group(group_id=None) 314 | 315 | # Create the first group 316 | foo_group: KeycloakGroup = idp.create_group(group_name="Foo Group") 317 | assert foo_group is not None 318 | assert foo_group.name == "Foo Group" 319 | 320 | # Get Empty Subgroups for group 321 | empty_subgroups = idp.get_subgroups(foo_group, "/nonexistent") 322 | assert empty_subgroups is None 323 | 324 | # Find Group by invalid Path 325 | invalid_group = idp.get_group_by_path("/nonexistent") 326 | assert invalid_group is None 327 | 328 | # Create the second group 329 | bar_group: KeycloakGroup = idp.create_group(group_name="Bar Group") 330 | assert bar_group is not None 331 | assert bar_group.name == "Bar Group" 332 | 333 | # Check if groups are registered 334 | all_groups: List[KeycloakGroup] = idp.get_all_groups() 335 | assert len(all_groups) == 2 336 | 337 | # Check get_groups 338 | groups: List[KeycloakGroup] = idp.get_groups(group_names=[foo_group.name]) 339 | assert len(groups) == 1 340 | assert groups[0].name == foo_group.name 341 | 342 | # Create Subgroup 1 by parent object 343 | subgroup1: KeycloakGroup = idp.create_group( 344 | group_name="Subgroup 01", parent=foo_group 345 | ) 346 | assert subgroup1 is not None 347 | assert subgroup1.name == "Subgroup 01" 348 | assert subgroup1.path == f"{foo_group.path}/Subgroup 01" 349 | 350 | # Create Subgroup 2 by parent id 351 | subgroup2: KeycloakGroup = idp.create_group( 352 | group_name="Subgroup 02", parent=foo_group.id 353 | ) 354 | assert subgroup2 is not None 355 | assert subgroup2.name == "Subgroup 02" 356 | assert subgroup2.path == f"{foo_group.path}/Subgroup 02" 357 | 358 | # Create Subgroup Level 3 359 | subgroup_l3: KeycloakGroup = idp.create_group( 360 | group_name="Subgroup l3", parent=subgroup2 361 | ) 362 | assert subgroup_l3 is not None 363 | assert subgroup_l3.name == "Subgroup l3" 364 | assert subgroup_l3.path == f"{subgroup2.path}/Subgroup l3" 365 | 366 | # Create Subgroup Level 4 367 | subgroup_l4: KeycloakGroup = idp.create_group( 368 | group_name="Subgroup l4", parent=subgroup_l3 369 | ) 370 | assert subgroup_l4 is not None 371 | assert subgroup_l4.name == "Subgroup l4" 372 | assert subgroup_l4.path == f"{subgroup_l3.path}/Subgroup l4" 373 | 374 | # Find Group by Path 375 | foo_group = idp.get_group_by_path(foo_group.path) 376 | assert foo_group is not None 377 | assert len(foo_group.subGroups) == 2 378 | 379 | # Find Subgroup by Path 380 | subgroup_by_path = idp.get_group_by_path(subgroup2.path) 381 | assert subgroup_by_path is not None 382 | assert subgroup_by_path.id == subgroup2.id 383 | 384 | # Find subgroup that does not exist 385 | subgroup_by_path = idp.get_group_by_path("/The Subgroup/Not Exists") 386 | assert subgroup_by_path is None 387 | 388 | # Clean up 389 | idp.delete_group(group_id=bar_group.id) 390 | idp.delete_group(group_id=foo_group.id) 391 | 392 | def test_user_groups(self, idp, user): 393 | 394 | # Check initial user groups 395 | user_groups = idp.get_user_groups(user.id) 396 | assert len(user_groups) == 0 397 | 398 | # Create the first group and add to user 399 | foo_group: KeycloakGroup = idp.create_group(group_name="Foo") 400 | idp.add_user_group(user_id=user.id, group_id=foo_group.id) 401 | 402 | # Check if the user is in the group 403 | user_groups = idp.get_user_groups(user.id) 404 | assert len(user_groups) == 1 405 | assert user_groups[0].id == foo_group.id 406 | 407 | # Remove User of the group 408 | idp.remove_user_group(user.id, foo_group.id) 409 | 410 | # Check if the user has no group 411 | user_groups = idp.get_user_groups(user.id) 412 | assert len(user_groups) == 0 413 | 414 | idp.delete_group(group_id=foo_group.id) 415 | idp.delete_user(user_id=user.id) 416 | 417 | @pytest.mark.parametrize( 418 | "action, exception", 419 | [ 420 | ("update_user_locale", UpdateUserLocaleException), 421 | ("CONFIGURE_TOTP", ConfigureTOTPException), 422 | ("VERIFY_EMAIL", VerifyEmailException), 423 | ("UPDATE_PASSWORD", UpdatePasswordException), 424 | ("UPDATE_PROFILE", UpdateProfileException), 425 | ], 426 | ) 427 | def test_login_exceptions(self, idp, action, exception, user): 428 | 429 | # Get access, refresh and id token for the users 430 | tokens = idp.user_login(username=user.username, password=TEST_PASSWORD) 431 | assert tokens.access_token 432 | assert tokens.refresh_token 433 | assert tokens.id_token 434 | 435 | user.requiredActions.append(action) # Add an action 436 | user: KeycloakUser = idp.update_user(user=user) # Save the change 437 | 438 | with pytest.raises( 439 | exception 440 | ): # Expect the login to fail due to the verify email action 441 | idp.user_login(username=user.username, password=TEST_PASSWORD) 442 | 443 | user.requiredActions.remove(action) # Remove the action 444 | user: KeycloakUser = idp.update_user(user=user) # Save the change 445 | assert idp.user_login( 446 | username=user.username, password=TEST_PASSWORD 447 | ) # Login possible again 448 | 449 | # Clean up 450 | idp.delete_user(user_id=user.id) 451 | 452 | def test_user_not_found_exception(self, idp): 453 | with pytest.raises(UserNotFound): # Expect the get to fail due to a non existent user 454 | idp.get_user(user_id='abc') 455 | 456 | with pytest.raises(UserNotFound): # Expect the get to fail due to a failed query search 457 | idp.get_user(query='username="some_non_existant_username"') 458 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from typing import List 3 | 4 | import httpretty as httpretty 5 | import pytest as pytest 6 | from fastapi import FastAPI 7 | from fastapi.security import OAuth2PasswordBearer 8 | from jose import JWTError 9 | from requests import ReadTimeout 10 | 11 | from fastapi_keycloak import HTTPMethod 12 | from fastapi_keycloak.model import KeycloakRole 13 | from tests import BaseTestClass 14 | 15 | 16 | class TestAPIIntegration(BaseTestClass): 17 | def test_properties(self, idp): 18 | assert idp.public_key 19 | assert idp.admin_token 20 | assert idp.open_id_configuration 21 | assert idp.logout_uri 22 | assert idp.login_uri 23 | assert idp.roles_uri 24 | assert idp.token_uri 25 | assert idp.authorization_uri 26 | assert idp.user_auth_scheme 27 | assert idp.providers_uri 28 | assert idp.realm_uri 29 | assert idp.users_uri 30 | 31 | def test_admin_token(self, idp): 32 | assert idp.admin_token 33 | with pytest.raises(JWTError): # Not enough segments 34 | idp.admin_token = "some rubbish" 35 | 36 | with pytest.raises(JWTError): # Invalid crypto padding 37 | idp.admin_token = """ 38 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 39 | """ 40 | 41 | def test_add_swagger_config(self, idp): 42 | app = FastAPI() 43 | assert app.swagger_ui_init_oauth is None 44 | idp.add_swagger_config(app) 45 | assert app.swagger_ui_init_oauth == { 46 | "usePkceWithAuthorizationCodeGrant": True, 47 | "clientId": idp.client_id, 48 | "clientSecret": idp.client_secret, 49 | } 50 | 51 | def test_user_auth_scheme(self, idp): 52 | assert isinstance(idp.user_auth_scheme, OAuth2PasswordBearer) 53 | 54 | def test_open_id_configuration(self, idp): 55 | assert idp.open_id_configuration 56 | assert type(idp.open_id_configuration) == dict 57 | 58 | def test_proxy(self, idp): 59 | response = idp.proxy(relative_path="/realms/Test", method=HTTPMethod.GET) 60 | assert type(response.json()) == dict 61 | 62 | @httpretty.activate(allow_net_connect=False) 63 | def test_timeout(self, idp): 64 | def request_callback(request, url, headers): 65 | sleep(1) 66 | return 200, headers, 'OK' 67 | 68 | httpretty.register_uri(httpretty.GET, f"{idp.server_url}/timeout", body=request_callback) 69 | idp.timeout = 0.5 70 | 71 | with pytest.raises(ReadTimeout): 72 | idp.proxy(relative_path="/timeout", method=HTTPMethod.GET) 73 | 74 | def test_get_all_roles_and_get_roles(self, idp): 75 | roles: List[KeycloakRole] = idp.get_all_roles() 76 | assert roles 77 | lookup = idp.get_roles(role_names=[role.name for role in roles]) 78 | assert lookup 79 | assert len(roles) == len(lookup) 80 | 81 | def test_get_identity_providers(self, idp): 82 | assert idp.get_identity_providers() == [] 83 | -------------------------------------------------------------------------------- /tests/wait_for_service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | declare URL=$1 6 | declare STATUS=$2 7 | declare TIMEOUT=$3 8 | 9 | URL=$URL STATUS=$STATUS timeout --foreground -s TERM $TIMEOUT bash -c \ 10 | 'while [[ ${STATUS_RECEIVED} != ${STATUS} ]];\ 11 | do STATUS_RECEIVED=$(curl -s -o /dev/null -L -w ''%{http_code}'' ${URL}) && \ 12 | echo "received status: $STATUS_RECEIVED" && \ 13 | sleep 1;\ 14 | done; 15 | echo success with status: $STATUS_RECEIVED' --------------------------------------------------------------------------------