├── .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 | [](https://github.com/code-specialist/fastapi-keycloak/actions/workflows/testing.yaml)
4 | [](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak)
5 | [](https://codecov.io/gh/code-specialist/fastapi-keycloak)
6 | 
7 | 
8 | 
9 | [](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 | [](https://www.codefactor.io/repository/github/code-specialist/fastapi-keycloak)
4 | [](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'
--------------------------------------------------------------------------------