├── .codecov.yml ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report-question.md │ ├── feature_request.md │ └── security.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yaml │ ├── coverage.yaml │ ├── publish_docs.yaml │ ├── publish_to_pypi.yaml │ └── testing.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── demo_project ├── .env.example ├── __init__.py ├── api │ ├── __init__.py │ ├── api_v1 │ │ ├── __init__.py │ │ ├── api.py │ │ └── endpoints │ │ │ ├── __init__.py │ │ │ ├── graph.py │ │ │ ├── hello_world.py │ │ │ └── hello_world_multi_auth.py │ └── dependencies.py ├── core │ ├── __init__.py │ └── config.py ├── main.py └── schemas │ ├── __init__.py │ └── hello_world.py ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── b2c │ │ ├── _category_.json │ │ ├── azure_setup.mdx │ │ └── fastapi_configuration.mdx │ ├── installation.mdx │ ├── introduction.mdx │ ├── multi-tenant │ │ ├── _category_.json │ │ ├── accept_specific_tenants_only.mdx │ │ ├── azure_setup.mdx │ │ └── fastapi_configuration.mdx │ ├── settings │ │ ├── _category_.json │ │ ├── b2c.mdx │ │ ├── multi_tenant.mdx │ │ └── single_tenant.mdx │ ├── single-tenant │ │ ├── _category_.json │ │ ├── azure_setup.mdx │ │ └── fastapi_configuration.mdx │ └── usage-and-faq │ │ ├── _category_.json │ │ ├── accessing_the_user.mdx │ │ ├── admin_consent_when_logging_in.mdx │ │ ├── calling_your_apis_from_python.mdx │ │ ├── graph_usage.mdx │ │ ├── guest_users.mdx │ │ ├── locking_down_on_roles.mdx │ │ ├── testing.mdx │ │ └── troubleshooting.mdx ├── docusaurus.config.ts ├── package.json ├── sidebars.js ├── src │ └── css │ │ └── custom.css └── static │ ├── .nojekyll │ └── img │ ├── b2c │ ├── 10_add_user_flow.png │ ├── 11_add_user_flow_props_name_provider.png │ ├── 12_add_user_flow_props_attributes_claims.png │ ├── 1_application_registration.png │ ├── 2_manifest.png │ ├── 3_overview.png │ ├── 4_add_scope.png │ ├── 5_add_scope_props.png │ ├── 6_application_registration_openapi.png │ ├── 7_overview_openapi.png │ ├── 8_api_permissions.png │ └── 9_api_permissions_finish.png │ ├── global │ ├── fastad.png │ ├── fastadmultitenant.png │ ├── favicon.ico │ └── intility.png │ ├── multi-tenant │ ├── 1_application_registration.png │ ├── 2_manifest.png │ ├── 3_overview.png │ ├── 4_add_scope.png │ ├── 5_add_scope_props.png │ ├── 6_application_registration_openapi.png │ ├── 7_overview_openapi.png │ ├── 8_api_permissions.png │ └── 9_api_permissions_finish.png │ ├── single-and-multi-tenant │ ├── fastapi_1_authorize_button.png │ ├── fastapi_2_not_authenticated.png │ ├── fastapi_3_authenticate.png │ ├── fastapi_4_consent.png │ └── fastapi_5_success.png │ ├── single-tenant │ ├── 1_application_registration.png │ ├── 2_manifest.png │ ├── 3_overview.png │ ├── 4_add_scope.png │ ├── 5_add_scope_props.png │ ├── 6_application_registration_openapi.png │ ├── 7_overview_openapi.png │ ├── 8_api_permissions.png │ ├── 9_api_permissions_finish.png │ └── guest_1_link_from_appreg.png │ └── usage-and-faq │ ├── approval_required.png │ ├── copy_secret.png │ ├── graph_secret.png │ ├── manifest.png │ ├── openapi_scopes.png │ ├── role_1.png │ ├── role_2.png │ ├── role_3.png │ ├── role_4.png │ ├── role_5.png │ ├── role_6.png │ ├── secret_picture.png │ └── user_read.png ├── fastapi_azure_auth ├── __init__.py ├── auth.py ├── exceptions.py ├── openid_config.py ├── py.typed ├── user.py └── utils.py ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── pytest.ini └── tests ├── .env.test ├── __init__.py ├── conftest.py ├── multi_tenant ├── __init__.py ├── conftest.py ├── multi_auth │ ├── README.md │ ├── __init__.py │ └── test_auto_error.py ├── test_multi_tenant.py ├── test_settings.py └── test_websocket.py ├── multi_tenant_b2c ├── __init__.py ├── conftest.py ├── multi_auth │ ├── README.md │ ├── __init__.py │ └── test_auto_error.py ├── test_multi_tenant.py └── test_settings.py ├── single_tenant ├── __init__.py ├── conftest.py └── test_single_tenant.py ├── test_exception_compat.py ├── test_openapi_scheme.py ├── test_provider_config.py ├── test_user.py └── utils.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://docs.codecov.io/docs/codecovyml-reference 2 | 3 | codecov: 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 1 8 | round: down 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | patch: no 14 | changes: no 15 | 16 | comment: 17 | layout: "diff,files" 18 | require_changes: yes 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | 4 | ignore= 5 | # E501: Line length 6 | E501 7 | # Function calls in argument defaults 8 | B008 9 | # Docstring at the top of a public module 10 | D100 11 | # Docstring at the top of a public class (method is enough) 12 | D101 13 | # Make docstrings one line if it can fit. 14 | D200 15 | # Imperative docstring declarations 16 | D401 17 | # Type annotation for `self` 18 | TYP101 19 | # for cls 20 | TYP102 21 | # Missing docstring in __init__ 22 | D107 23 | # Missing docstring in public package 24 | D104 25 | # Missing type annotations for `**kwargs` 26 | TYP003 27 | # Whitespace before ':'. Black formats code this way. 28 | E203 29 | # 1 blank line required between summary line and description 30 | D205 31 | # First line should end with a period - here we have a few cases where the first line is too long, and 32 | # this issue can't be fixed without using noqa notation 33 | D400 34 | # Missing type annotations for self 35 | ANN101 36 | # Missing type annotation for cls in classmethod 37 | ANN102 38 | # Missing type annotations for **args 39 | ANN002 40 | # Missing type annotations for **kwargs 41 | ANN003 42 | # W503 line break before binary operator - conflicts with black 43 | W503 44 | 45 | exclude = 46 | .git, 47 | .idea, 48 | __pycache__, 49 | tests/*, 50 | venv 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report/question 3 | about: Create a report to help us improve 4 | title: "[BUG/Question]" 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 24 | 25 | **Stack trace** 26 | 27 | 28 | 29 | **Your configuration** 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature request]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 14 | 15 | **Describe the feature you'd like** 16 | 17 | 18 | **Additional context** 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Security 3 | about: Template used to report potential security vulnerabilities 4 | title: '' 5 | labels: '' 6 | assignees: JonasKs 7 | 8 | --- 9 | 10 | 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | time: "09:00" 10 | timezone: "Europe/Oslo" 11 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yaml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | schedule: 7 | - cron: '0 07 * * 1' 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | security-events: write 15 | strategy: 16 | fail-fast: false 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | persist-credentials: false 22 | 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.11" 26 | 27 | - name: Install poetry 28 | uses: snok/install-poetry@v1 29 | with: 30 | virtualenvs-create: true 31 | virtualenvs-in-project: true 32 | 33 | - name: Load cached venv 34 | uses: actions/cache@v4.2.3 35 | id: cache-venv 36 | with: 37 | path: .venv 38 | key: ${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}-0 39 | 40 | - name: Install dependencies 41 | run: poetry install --no-interaction --no-root 42 | if: steps.cache-venv.outputs.cache-hit != 'true' 43 | 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | setup-python-dependencies: false 48 | languages: python 49 | queries: +security-extended 50 | 51 | - name: Perform CodeQL Analysis 52 | uses: github/codeql-action/analyze@v2 53 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | codecov: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.11.0" 17 | - name: Install poetry 18 | uses: snok/install-poetry@v1 19 | with: 20 | virtualenvs-create: true 21 | virtualenvs-in-project: true 22 | - name: Load cached venv 23 | id: cached-poetry-dependencies 24 | uses: actions/cache@v4.2.3 25 | with: 26 | path: .venv 27 | key: venv-${{ runner.os }}-3.11.0-${{ hashFiles('**/poetry.lock') }}-0 28 | - name: Install dependencies 29 | run: poetry install 30 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 31 | - name: Test with pytest 32 | run: poetry run pytest --cov=fastapi_azure_auth tests/ --verbose --assert=plain --cov-report=xml 33 | - name: Upload coverage 34 | uses: codecov/codecov-action@v5 35 | with: 36 | file: ./coverage.xml 37 | fail_ci_if_error: true 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/publish_docs.yaml: -------------------------------------------------------------------------------- 1 | name: Publish docs 📄 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'docs/**' 9 | - '.github/workflows/publish_docs.yaml' 10 | 11 | jobs: 12 | gh-release: 13 | permissions: 14 | contents: write 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: '22.2' 21 | - name: Build 22 | run: | 23 | cd docs 24 | npm install 25 | npm run build 26 | - name: Deploy 27 | uses: peaceiris/actions-gh-pages@v4 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | publish_dir: ./docs/build 31 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.11 16 | - uses: snok/install-poetry@v1 17 | - name: Publish to pypi 18 | run: | 19 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 20 | poetry publish --build --no-interaction 21 | -------------------------------------------------------------------------------- /.github/workflows/testing.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | linting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-python@v5 11 | with: 12 | python-version: 3.12 13 | - run: python -m pip install pre-commit 14 | - run: pre-commit run --all-files 15 | test: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: [ "3.9", "3.10", "3.11", "3.12" ] 21 | fastapi-version: [ "0.103.2", "0.111.1"] 22 | steps: 23 | - name: Check out repository 24 | uses: actions/checkout@v4 25 | - name: Set up python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install poetry 30 | uses: snok/install-poetry@v1 31 | with: 32 | virtualenvs-create: true 33 | virtualenvs-in-project: true 34 | - name: Install dependencies 35 | run: poetry install --no-interaction --no-root 36 | - name: Install package 37 | run: poetry install --no-interaction 38 | - name: Install FastAPI ${{ matrix.fastapi-version }} 39 | run: | 40 | source .venv/bin/activate 41 | poetry add "fastapi==${{ matrix.fastapi-version }}" 42 | - name: Run tests 43 | run: | 44 | source .venv/bin/activate 45 | poetry run pytest --cov=fastapi_azure_auth --verbose --assert=plain 46 | poetry run coverage report 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea/* 3 | .vscode 4 | env/ 5 | demo_project/.env 6 | .DS_Store 7 | venv/ 8 | .venv/ 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | notes 13 | .pytest_cache 14 | .coverage 15 | htmlcov/ 16 | coverage.xml 17 | 18 | 19 | 20 | # Generated files 21 | .docusaurus 22 | .cache-loader 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log*2 26 | docs/package-lock.json 27 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: README.md 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: '23.9.1' 5 | hooks: 6 | - id: black 7 | args: ['--quiet'] 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.4.0 10 | hooks: 11 | - id: check-case-conflict 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - id: check-ast 15 | - id: check-json 16 | - id: check-merge-conflict 17 | - id: detect-private-key 18 | - repo: https://github.com/pycqa/flake8 19 | rev: 6.1.0 20 | hooks: 21 | - id: flake8 22 | additional_dependencies: [ 23 | 'flake8-bugbear==23.9.16', # Looks for likely bugs and design problems 24 | 'flake8-comprehensions==3.14.0', # Looks for unnecessary generator functions that can be converted to list comprehensions 25 | 'flake8-deprecated==2.1', # Looks for method deprecations 26 | 'flake8-use-fstring==1.4', # Enforces use of f-strings over .format and %s 27 | 'flake8-print==5.0.0', # Checks for print statements 28 | 'flake8-docstrings==1.7.0', # Verifies that all functions/methods have docstrings 29 | 'flake8-annotations==3.0.1', # Enforces type annotation 30 | ] 31 | args: ['--enable-extensions=G'] 32 | - repo: https://github.com/asottile/pyupgrade 33 | rev: v3.14.0 34 | hooks: 35 | - id: pyupgrade 36 | args: ["--py36-plus"] 37 | - repo: https://github.com/pycqa/isort 38 | rev: 5.12.0 39 | hooks: 40 | - id: isort 41 | - repo: https://github.com/pre-commit/mirrors-mypy 42 | rev: "v1.15.0" 43 | hooks: 44 | - id: mypy 45 | exclude: "test_*" 46 | additional_dependencies: 47 | [ 48 | fastapi, 49 | pydantic, 50 | pydantic-settings, 51 | starlette, 52 | httpx 53 | ] 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This package is open to contributions 👏 4 | 5 | To contribute, please follow these steps: 6 | 7 | 1. Create an issue explaining what you'd like to fix or add. This way, we can approve and discuss the 8 | solution before any time is spent on developing it. 9 | 2. Fork the upstream repository into a personal account. 10 | 3. Install [poetry](https://python-poetry.org/), and install all dependencies using ``poetry install --with dev`` 11 | 4. Activate the environment by running ``poetry shell`` 12 | 5. Install [pre-commit](https://pre-commit.com/) (for project linting) by running ``pre-commit install`` 13 | 6. Create a new branch for your changes. 14 | 7. Create and run tests with full coverage by running `poetry run pytest --cov fastapi_azure_auth --cov-report=term-missing` 15 | 8. Push the topic branch to your personal fork. 16 | 9. Run `pre-commit run --all-files` locally to ensure proper linting. 17 | 10. Create a pull request to the intility repository with a detailed summary of your changes and what motivated the change. 18 | 19 | If you need a more detailed walk through, please see this 20 | [issue comment](https://github.com/Intility/fastapi-azure-auth/issues/49#issuecomment-1056962282). 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Intility AS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | FastAPI-Azure-Auth 5 |

6 | 7 |

8 | Azure Entra ID Authentication for FastAPI apps made easy. 9 |

10 |

11 | 12 | 13 | Python version 14 | 15 | 16 | FastAPI Version 17 | 18 | 19 | Package version 20 | 21 | 22 |
23 | 24 | Codecov 25 | 26 | 27 | Pre-commit 28 | 29 | 30 | Black 31 | 32 | 33 | mypy 34 | 35 | 36 | isort 37 | 38 | 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |

50 | 51 | 52 | ## 🚀 Description 53 | 54 | > FastAPI is a modern, fast (high-performance), web framework for building APIs with Python, based on standard Python type hints. 55 | 56 | At Intility we use FastAPI for both internal (single-tenant) and customer-facing (multi-tenant) APIs. This package enables our developers (and you 😊) to create features without worrying about authentication and authorization. 57 | 58 | Also, [we're hiring!](https://intility.no/en/career/) 59 | 60 | ## 📚 Resources 61 | 62 | The [documentation](https://intility.github.io/fastapi-azure-auth/) contains a full tutorial on how to configure Azure Entra ID 63 | and FastAPI for single- and multi-tenant applications as well as B2C apps. It includes examples on how to lock down 64 | your APIs to certain scopes, tenants, roles etc. For first time users it's strongly advised to set up your 65 | application exactly how it's described there, and then alter it to your needs later. 66 | 67 | [**MIT License**](https://github.com/Intility/fastapi-azure-auth/blob/main/LICENSE) 68 | | [**Documentation**](https://intility.github.io/fastapi-azure-auth/) 69 | | [**GitHub**](https://github.com/snok/django-guid) 70 | 71 | 72 | ## ⚡ Setup 73 | 74 | This is a tl;dr intended to give you an idea of what this package does and how to use it. 75 | For a more in-depth tutorial and settings reference you should read the 76 | [documentation](https://intility.github.io/fastapi-azure-auth/). 77 | 78 | 79 | #### 1. Install this library: 80 | ```bash 81 | pip install fastapi-azure-auth 82 | # or 83 | poetry add fastapi-azure-auth 84 | ``` 85 | 86 | #### 2. Configure your FastAPI app 87 | Include `swagger_ui_oauth2_redirect_url` and `swagger_ui_init_oauth` in your FastAPI app initialization: 88 | 89 | ```python 90 | # file: main.py 91 | app = FastAPI( 92 | ... 93 | swagger_ui_oauth2_redirect_url='/oauth2-redirect', 94 | swagger_ui_init_oauth={ 95 | 'usePkceWithAuthorizationCodeGrant': True, 96 | 'clientId': settings.OPENAPI_CLIENT_ID, 97 | }, 98 | ) 99 | ``` 100 | 101 | #### 3. Setup CORS 102 | Ensure you have CORS enabled for your local environment, such as `http://localhost:8000`. 103 | 104 | #### 4. Configure FastAPI-Azure-Auth 105 | Configure either [`SingleTenantAzureAuthorizationCodeBearer`](https://intility.github.io/fastapi-azure-auth/settings/single_tenant), [`MultiTenantAzureAuthorizationCodeBearer`](https://intility.github.io/fastapi-azure-auth/settings/multi_tenant) or [`B2CMultiTenantAuthorizationCodeBearer`](https://intility.github.io/fastapi-azure-auth/settings/b2c) 106 | 107 | 108 | ```python 109 | # file: demoproj/api/dependencies.py 110 | from fastapi_azure_auth.auth import SingleTenantAzureAuthorizationCodeBearer 111 | 112 | azure_scheme = SingleTenantAzureAuthorizationCodeBearer( 113 | app_client_id=settings.APP_CLIENT_ID, 114 | tenant_id=settings.TENANT_ID, 115 | scopes={ 116 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation', 117 | } 118 | ) 119 | ``` 120 | or for multi-tenant applications: 121 | ```python 122 | # file: demoproj/api/dependencies.py 123 | from fastapi_azure_auth.auth import MultiTenantAzureAuthorizationCodeBearer 124 | 125 | azure_scheme = MultiTenantAzureAuthorizationCodeBearer( 126 | app_client_id=settings.APP_CLIENT_ID, 127 | scopes={ 128 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation', 129 | }, 130 | validate_iss=False 131 | ) 132 | ``` 133 | To validate the `iss`, configure an 134 | [`iss_callable`](https://intility.github.io/fastapi-azure-auth/multi-tenant/accept_specific_tenants_only). 135 | 136 | #### 5. Configure dependencies 137 | 138 | Add `azure_scheme` as a dependency for your views/routers, using either `Security()` or `Depends()`. 139 | ```python 140 | # file: main.py 141 | from demoproj.api.dependencies import azure_scheme 142 | 143 | app.include_router(api_router, prefix=settings.API_V1_STR, dependencies=[Security(azure_scheme, scopes=['user_impersonation'])]) 144 | ``` 145 | 146 | #### 6. Load config on startup 147 | 148 | Optional but recommended. 149 | 150 | ```python 151 | # file: main.py 152 | @app.on_event('startup') 153 | async def load_config() -> None: 154 | """ 155 | Load OpenID config on startup. 156 | """ 157 | await azure_scheme.openid_config.load_config() 158 | ``` 159 | 160 | 161 | ## 📄 Example OpenAPI documentation 162 | Your OpenAPI documentation will get an `Authorize` button, which can be used to authenticate. 163 | ![authorize](docs/static/img/single-and-multi-tenant/fastapi_1_authorize_button.png) 164 | 165 | The user can select which scopes to authenticate with, based on your configuration. 166 | ![scopes](docs/static/img/single-and-multi-tenant/fastapi_3_authenticate.png) 167 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 🔒 2 | 3 | By having 100% code coverage, testing edge cases and making sure we're compliant with RFCs, we're committed to 4 | providing a secure library. However, if you do find a vulnerability (even if you're not sure), 5 | please disclose it with us privately. You can do so by sending an email to `jonas.svensson@intility.no`. 6 | When sending an email, please try to be explicit by giving reproduce steps. 7 | 8 | ## Versions 9 | 10 | The latest versions of FastAPI-Azure-Auth are supported, which means you should always be on the latest release. 11 | You're strongly encouraged to write tests for your application, both for security reasons, but also to ensure that 12 | you can upgrade versions easily. 13 | -------------------------------------------------------------------------------- /demo_project/.env.example: -------------------------------------------------------------------------------- 1 | # This file is overwritten in tests. See the test folder 2 | # How ever, it can be nice to have this project to run with real credentials, if you want to do manual testing 3 | # with Azure AD. 4 | # Create a new file with these values, and the project should run. (Single tenant, v2 tokens) 5 | SECRET_KEY=MyBigSecret 6 | APP_CLIENT_ID=ThisIsTheBEClientId 7 | OPENAPI_CLIENT_ID=ThisIsFElientId 8 | TENANT_ID=TheTenantId 9 | GRAPH_SECRET=MyBigSecret 10 | #AUTH_URL=https://{project name from Azure}.b2clogin.com/{project name from Azure}.onmicrosoft.com/{Tenant}/ 11 | #oauth2/v2.0/authorize 12 | AUTH_URL=TheAuthUrl 13 | #https://{project name from Azure}.b2clogin.com/{project name from Azure}.onmicrosoft.com/{Tenant}/ 14 | #v2.0/.well-known/openid-configuration 15 | CONFIG_URL=ConfigUrl 16 | #https://{project name from Azure}.b2clogin.com/{project name from Azure}.onmicrosoft.com/{Tenant}/oauth2/v2.0/token 17 | TOKEN_URL=TokenUrl 18 | -------------------------------------------------------------------------------- /demo_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/demo_project/__init__.py -------------------------------------------------------------------------------- /demo_project/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/demo_project/api/__init__.py -------------------------------------------------------------------------------- /demo_project/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/demo_project/api/api_v1/__init__.py -------------------------------------------------------------------------------- /demo_project/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from demo_project.api.api_v1.endpoints import graph, hello_world, hello_world_multi_auth 2 | from fastapi import APIRouter 3 | 4 | api_router_azure_auth = APIRouter(tags=['hello']) 5 | api_router_azure_auth.include_router(hello_world.router) 6 | api_router_multi_auth = APIRouter(tags=['hello']) 7 | api_router_multi_auth.include_router(hello_world_multi_auth.router) 8 | api_router_graph = APIRouter(tags=['graph']) 9 | api_router_graph.include_router(graph.router) 10 | -------------------------------------------------------------------------------- /demo_project/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/demo_project/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /demo_project/api/api_v1/endpoints/graph.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import httpx 4 | import jwt 5 | from demo_project.api.dependencies import azure_scheme 6 | from demo_project.core.config import settings 7 | from fastapi import APIRouter, Depends, Request 8 | from httpx import AsyncClient 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.get( 14 | '/hello-graph', 15 | summary='Fetch graph API using OBO', 16 | name='graph', 17 | operation_id='helloGraph', 18 | dependencies=[Depends(azure_scheme)], 19 | ) 20 | async def graph_world(request: Request) -> Any: # noqa: ANN401 21 | """ 22 | An example on how to use "on behalf of"-flow to fetch a graph token and then fetch data from graph. 23 | """ 24 | async with AsyncClient() as client: 25 | # Use the users access token and fetch a new access token for the Graph API 26 | obo_response: httpx.Response = await client.post( 27 | f'https://login.microsoftonline.com/{settings.TENANT_ID}/oauth2/v2.0/token', 28 | data={ 29 | 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', 30 | 'client_id': settings.APP_CLIENT_ID, 31 | 'client_secret': settings.GRAPH_SECRET, 32 | 'assertion': request.state.user.access_token, 33 | 'scope': 'https://graph.microsoft.com/user.read', 34 | 'requested_token_use': 'on_behalf_of', 35 | }, 36 | ) 37 | 38 | if obo_response.is_success: 39 | # Call the graph `/me` endpoint to fetch more information about the current user, using the new token 40 | graph_response: httpx.Response = await client.get( 41 | 'https://graph.microsoft.com/v1.0/me', 42 | headers={'Authorization': f'Bearer {obo_response.json()["access_token"]}'}, 43 | ) 44 | graph = graph_response.json() 45 | else: 46 | graph = 'skipped' 47 | 48 | # Return all the information to the end user 49 | return ( 50 | {'claims': jwt.decode(request.state.user.access_token, options={'verify_signature': False})} 51 | | {'obo_response': obo_response.json()} 52 | | {'graph_response': graph} 53 | ) 54 | -------------------------------------------------------------------------------- /demo_project/api/api_v1/endpoints/hello_world.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union 2 | 3 | from demo_project.api.dependencies import validate_is_admin_user 4 | from demo_project.schemas.hello_world import HelloWorldResponse 5 | from fastapi import APIRouter, Depends, Request 6 | 7 | from fastapi_azure_auth.user import User 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get( 13 | '/hello', 14 | response_model=HelloWorldResponse, 15 | summary='Say hello', 16 | name='hello_world', 17 | operation_id='helloWorld', 18 | dependencies=[Depends(validate_is_admin_user)], 19 | ) 20 | async def world(request: Request) -> Dict[str, Union[str, User]]: 21 | """ 22 | Wonder who we say hello to? 23 | """ 24 | user: User = request.state.user 25 | return {'hello': 'world', 'user': user} 26 | -------------------------------------------------------------------------------- /demo_project/api/api_v1/endpoints/hello_world_multi_auth.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union 2 | 3 | from demo_project.api.dependencies import multi_auth, multi_auth_b2c 4 | from demo_project.schemas.hello_world import TokenType 5 | from fastapi import APIRouter, Depends, Request 6 | 7 | from fastapi_azure_auth.user import User 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get( 13 | '/hello-multi-auth', 14 | response_model=TokenType, 15 | summary='Say hello with an API key', 16 | name='hello_world_api_key', 17 | operation_id='helloWorldApiKeyMultiAuth', 18 | ) 19 | async def world(request: Request, auth: Union[str, User] = Depends(multi_auth)) -> Dict[str, bool]: 20 | """ 21 | Wonder how this auth is done? 22 | """ 23 | if isinstance(auth, str): 24 | # An API key was used 25 | return {'api_key': True, 'azure_auth': False} 26 | return {'api_key': False, 'azure_auth': True} 27 | 28 | 29 | @router.get( 30 | '/hello-multi-auth-b2c', 31 | response_model=TokenType, 32 | summary='Say hello with an API key', 33 | name='hello_world_api_key', 34 | operation_id='helloWorldApiKeyMultiAuthB2C', 35 | ) 36 | async def world_b2c(request: Request, auth: Union[str, User] = Depends(multi_auth_b2c)) -> Dict[str, bool]: 37 | """ 38 | Wonder how this auth is done? 39 | """ 40 | if isinstance(auth, str): 41 | # An API key was used 42 | return {'api_key': True, 'azure_auth': False} 43 | return {'api_key': False, 'azure_auth': True} 44 | -------------------------------------------------------------------------------- /demo_project/api/dependencies.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | from typing import Optional, Union 4 | 5 | from demo_project.core.config import settings 6 | from fastapi import Depends 7 | from fastapi.security.api_key import APIKeyHeader 8 | 9 | from fastapi_azure_auth import ( 10 | B2CMultiTenantAuthorizationCodeBearer, 11 | MultiTenantAzureAuthorizationCodeBearer, 12 | SingleTenantAzureAuthorizationCodeBearer, 13 | ) 14 | from fastapi_azure_auth.exceptions import ForbiddenHttp, UnauthorizedHttp 15 | from fastapi_azure_auth.user import User 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | azure_scheme = SingleTenantAzureAuthorizationCodeBearer( 21 | app_client_id=settings.APP_CLIENT_ID, 22 | scopes={f'api://{settings.APP_CLIENT_ID}/user_impersonation': '**No client secret needed, leave blank**'}, 23 | tenant_id=settings.TENANT_ID, 24 | ) 25 | 26 | 27 | async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None: 28 | """ 29 | Validate that a user is in the `AdminUser` role in order to access the API. 30 | Raises a 401 authentication error if not. 31 | """ 32 | if 'AdminUser' not in user.roles: 33 | raise ForbiddenHttp('User is not an AdminUser') 34 | 35 | 36 | class IssuerFetcher: 37 | def __init__(self) -> None: 38 | """ 39 | Example class for multi tenant apps, that caches issuers for an hour 40 | """ 41 | self.tid_to_iss: dict[str, str] = {} 42 | self._config_timestamp: Optional[datetime] = None 43 | 44 | async def __call__(self, tid: str) -> str: 45 | """ 46 | Check if memory cache needs to be updated or not, and then returns an issuer for a given tenant 47 | :raises Unauthorized when it's not a valid tenant 48 | """ 49 | refresh_time = datetime.now() - timedelta(hours=1) 50 | if not self._config_timestamp or self._config_timestamp < refresh_time: 51 | self._config_timestamp = datetime.now() 52 | # logic to find your allowed tenants and it's issuers here 53 | # (This example cache in memory for 1 hour) 54 | self.tid_to_iss = { 55 | 'intility_tenant_id': 'https://login.microsoftonline.com/intility_tenant/v2.0', 56 | } 57 | try: 58 | return self.tid_to_iss[tid] 59 | except Exception as error: 60 | log.exception('`iss` not found for `tid` %s. Error %s', tid, error) 61 | raise UnauthorizedHttp('You must be an Intility customer to access this resource') 62 | 63 | 64 | issuer_fetcher = IssuerFetcher() 65 | 66 | azure_scheme_auto_error_false = MultiTenantAzureAuthorizationCodeBearer( 67 | app_client_id=settings.APP_CLIENT_ID, 68 | scopes={ 69 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 70 | }, 71 | validate_iss=True, 72 | iss_callable=issuer_fetcher, 73 | auto_error=False, 74 | ) 75 | 76 | azure_scheme_auto_error_false_b2c = B2CMultiTenantAuthorizationCodeBearer( 77 | app_client_id=settings.APP_CLIENT_ID, 78 | openapi_authorization_url=str(settings.AUTH_URL), 79 | openapi_token_url=str(settings.TOKEN_URL), 80 | openid_config_url=str(settings.CONFIG_URL), 81 | scopes={ 82 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 83 | }, 84 | validate_iss=True, 85 | iss_callable=issuer_fetcher, 86 | auto_error=False, 87 | ) 88 | 89 | 90 | api_key_auth_auto_error_false = APIKeyHeader(name='TEST-API-KEY', auto_error=False) 91 | 92 | 93 | async def multi_auth( 94 | azure_auth: Optional[User] = Depends(azure_scheme_auto_error_false), 95 | api_key: Optional[str] = Depends(api_key_auth_auto_error_false), 96 | ) -> Union[User, str]: 97 | """ 98 | Example implementation. 99 | """ 100 | if azure_auth: 101 | return azure_auth 102 | if api_key == 'JonasIsCool': 103 | return api_key 104 | raise UnauthorizedHttp('You must either provide a valid bearer token or API key') 105 | 106 | 107 | async def multi_auth_b2c( 108 | azure_auth: Optional[User] = Depends(azure_scheme_auto_error_false_b2c), 109 | api_key: Optional[str] = Depends(api_key_auth_auto_error_false), 110 | ) -> Union[User, str]: 111 | """ 112 | Example implementation. 113 | """ 114 | if azure_auth: 115 | return azure_auth 116 | if api_key == 'JonasIsCool': 117 | return api_key 118 | raise UnauthorizedHttp('You must either provide a valid bearer token or API key') 119 | -------------------------------------------------------------------------------- /demo_project/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/demo_project/core/__init__.py -------------------------------------------------------------------------------- /demo_project/core/config.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | import pydantic 4 | from pydantic import AnyHttpUrl, Field, HttpUrl 5 | 6 | if pydantic.VERSION.startswith('1.'): 7 | from pydantic import BaseSettings 8 | else: 9 | from pydantic_settings import BaseSettings, SettingsConfigDict 10 | 11 | 12 | class AzureActiveDirectory(BaseSettings): # type: ignore[misc, valid-type] 13 | OPENAPI_CLIENT_ID: str = Field(default='') 14 | TENANT_ID: str = Field(default='') 15 | APP_CLIENT_ID: str = Field(default='') 16 | AUTH_URL: AnyHttpUrl = Field(default=AnyHttpUrl('https://dummy.com/')) 17 | CONFIG_URL: AnyHttpUrl = Field(default=AnyHttpUrl('https://dummy.com/')) 18 | TOKEN_URL: AnyHttpUrl = Field(default=AnyHttpUrl('https://dummy.com/')) 19 | GRAPH_SECRET: str = Field(default='') 20 | CLIENT_SECRET: str = Field(default='') 21 | 22 | 23 | class Settings(AzureActiveDirectory): 24 | API_V1_STR: str = '/api/v1' 25 | 26 | # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins 27 | # e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \ 28 | # "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]' 29 | BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = ['http://localhost:8000'] 30 | 31 | PROJECT_NAME: str = 'My Project' 32 | SENTRY_DSN: Optional[HttpUrl] = None 33 | 34 | model_config = SettingsConfigDict( 35 | env_file='demo_project/.env', env_file_encoding='utf-8', extra='ignore', case_sensitive=True 36 | ) 37 | 38 | 39 | settings = Settings() 40 | -------------------------------------------------------------------------------- /demo_project/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from argparse import ArgumentParser 3 | from contextlib import asynccontextmanager 4 | from typing import AsyncGenerator 5 | 6 | import uvicorn 7 | from demo_project.api.api_v1.api import api_router_azure_auth, api_router_graph, api_router_multi_auth 8 | from demo_project.api.dependencies import azure_scheme 9 | from demo_project.core.config import settings 10 | from fastapi import FastAPI, Security 11 | from fastapi.middleware.cors import CORSMiddleware 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | @asynccontextmanager 17 | async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 18 | """ 19 | Load OpenID config on startup. 20 | """ 21 | await azure_scheme.openid_config.load_config() 22 | yield 23 | 24 | 25 | app = FastAPI( 26 | openapi_url=f'{settings.API_V1_STR}/openapi.json', 27 | swagger_ui_oauth2_redirect_url='/oauth2-redirect', 28 | swagger_ui_init_oauth={ 29 | 'usePkceWithAuthorizationCodeGrant': True, 30 | 'clientId': settings.OPENAPI_CLIENT_ID, 31 | 'additionalQueryStringParams': {'prompt': 'consent'}, 32 | }, 33 | version='1.0.0', 34 | description='## Welcome to my API! \n This is my description, written in `markdown`', 35 | title=settings.PROJECT_NAME, 36 | lifespan=lifespan, 37 | ) 38 | 39 | 40 | # Set all CORS enabled origins 41 | if settings.BACKEND_CORS_ORIGINS: # pragma: no cover 42 | app.add_middleware( 43 | CORSMiddleware, 44 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 45 | allow_credentials=True, 46 | allow_methods=['*'], 47 | allow_headers=['*'], 48 | ) 49 | 50 | 51 | app.include_router( 52 | api_router_azure_auth, 53 | prefix=settings.API_V1_STR, 54 | dependencies=[Security(azure_scheme, scopes=['user_impersonation'])], 55 | ) 56 | app.include_router( 57 | api_router_multi_auth, 58 | prefix=settings.API_V1_STR, 59 | # Dependencies specified on the API itself 60 | ) 61 | app.include_router( 62 | api_router_graph, 63 | prefix=settings.API_V1_STR, 64 | # Dependencies specified on the API itself 65 | ) 66 | 67 | 68 | if __name__ == '__main__': 69 | parser = ArgumentParser() 70 | parser.add_argument('--api', action='store_true') 71 | parser.add_argument('--reload', action='store_true') 72 | args = parser.parse_args() 73 | if args.api: 74 | uvicorn.run('main:app', reload=args.reload) 75 | else: 76 | raise ValueError('No valid combination of arguments provided.') 77 | -------------------------------------------------------------------------------- /demo_project/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/demo_project/schemas/__init__.py -------------------------------------------------------------------------------- /demo_project/schemas/hello_world.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from fastapi_azure_auth.user import User 4 | 5 | 6 | class HelloWorldResponse(BaseModel): 7 | hello: str = Field(..., description='What we\'re saying hello to') 8 | user: User = Field(..., description='The user object') 9 | 10 | 11 | class TokenType(BaseModel): 12 | api_key: bool = Field(..., description='API key was used') 13 | azure_auth: bool = Field(..., description='Azure auth was used') 14 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/). 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | or 12 | 13 | ``` 14 | npm install 15 | ``` 16 | 17 | ### Local Development 18 | 19 | ``` 20 | $ yarn start 21 | ``` 22 | 23 | or 24 | 25 | ``` 26 | npm start 27 | ``` 28 | 29 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 30 | 31 | ### Build 32 | 33 | ``` 34 | $ yarn build 35 | ``` 36 | 37 | or 38 | 39 | ``` 40 | npm run build 41 | ``` 42 | 43 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 44 | 45 | It's important that you build the documentation using `yarn build` **before pushing to `main`**. After building, 46 | check that everything works, such as syntax highlighting etc. 47 | 48 | If there are issues, please try 49 | * to run `npm run clear` or `yarn clear` 50 | * delete `package-lock.json` and re-.install packages 51 | 52 | 53 | ### Deployment 54 | 55 | GitHub actions takes care of deployment. Any changes to the `docs` folder on the `main` branch will trigger 56 | the pipeline. You can see the documentation live at https://intility.github.io/fastapi-azure-auth/, and browse 57 | the static files in the `gh-pages` branch. 58 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/b2c/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "B2C setup", 3 | "position": 4, 4 | "collapsible": true 5 | } 6 | -------------------------------------------------------------------------------- /docs/docs/b2c/azure_setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Azure configuration 3 | sidebar_position: 1 4 | --- 5 | 6 | We'll need to create two application registrations for Azure Entra ID B2C authentication to cover both direct API 7 | use and usage from the OpenAPI (swagger) documentation. 8 | 9 | :::info 10 | This guide assumes that an Azure B2C tenant was already created and linked to an Azure subscription. 11 | ::: 12 | 13 | 14 | ## Backend API 15 | 16 | ### Step 1 - Create app registration 17 | Head over to 18 | [Azure -> Azure Entra ID B2C -> App registrations](https://portal.azure.com/#view/Microsoft_AAD_B2CAdmin/TenantManagementMenuBlade/~/registeredApps), 19 | and create a new registration. 20 | 21 | Select a fitting name for your project; Azure will present the name to the user during consent. 22 | 23 | * `Supported account types`: `Accounts in any identity provider or organizational directory (for authenticating users with user flows)` 24 | * `Redirect URI`: Choose `Web` and `http://localhost:8000/signin-oidc` as a value 25 | * `Grant admin consent to openid and offline_access permissions`: `Yes` 26 | 27 | Press **Register** 28 | 29 | ![1_application_registration](../../static/img/b2c/1_application_registration.png) 30 | 31 | ### Step 2 - Verify token version is `v2` 32 | 33 | First we'll verify that the token version is version 2. In the left menu bar, click `Manifest` and find the line 34 | that says `requestedAccessTokenVersion`. Verify that the value is `2`. 35 | 36 | Press **Save** 37 | 38 | (A change can take some time to happen, which is why we do this first.) 39 | 40 | ![2_manifest](../../static/img/b2c/2_manifest.png) 41 | 42 | 43 | ### Step 3 - Note down your tenant name and application ID 44 | 45 | Go back to the App Registration `Overview`, found in the left menu. 46 | 47 | ![3_overview](../../static/img/b2c/3_overview.png) 48 | 49 | Copy the `Application (Client) ID`, we'll need it for later. I like to use `.env` files to 50 | store variables like these: 51 | 52 | ```bash title=".env" {2} 53 | TENANT_NAME= 54 | APP_CLIENT_ID= 55 | OPENAPI_CLIENT_ID= 56 | AUTH_POLICY_NAME= 57 | ``` 58 | 59 | Also, in the Azure Entra ID B2C overview get the tenant name from the domain name (without the `.onmicrosoft.com` part) 60 | and add it to the `.env` file as well: 61 | 62 | ```bash title=".env" {1} 63 | TENANT_NAME= 64 | APP_CLIENT_ID= 65 | OPENAPI_CLIENT_ID= 66 | AUTH_POLICY_NAME= 67 | ``` 68 | 69 | 70 | 71 | 72 | ### Step 4 - Add an application scope 73 | 74 | 1. Go to **Expose an API** in the left menu bar under your app registration. 75 | 2. Press **+ Add a scope** 76 | 3. You'll be prompted to set an Application ID URI, leave the suggested one and press **Save and continue** 77 | ![4_add_scope](../../static/img/b2c/4_add_scope.png) 78 | 79 | 4. You'll be prompted to set a scope name, display name and description. Set the scope name to `user_impersonation`, 80 | display name to `Access API as user` and description to `Allows the app to access the API as the user.` 81 | 5. Make sure the State is `Enabled` 82 | 6. Press **Add scope** 83 | 84 | 85 | ![5_add_scope_props](../../static/img/b2c/5_add_scope_props.png) 86 | 87 | 88 | ## OpenAPI Documentation 89 | Our OpenAPI documentation will use the `Authorization Code Grant Flow, with Proof Key for Code Exchange` flow. 90 | It's a flow that enables a user of a Single-Page Application to safely log in, consent to permissions and fetch an `access_token` 91 | in the `JWT` format. When the user clicks `Try out` on the APIs, the `access_token` is attached to the header as a `Bearer ` token. 92 | This is the token the backend will validate. 93 | 94 | So, let's set it up! 95 | 96 | ### Step 1 - Create app registration 97 | Just like in the previous chapter, we have to create an application registration for our OpenAPI. 98 | 99 | Head over to 100 | [Azure -> Azure Entra ID B2C -> App registrations](https://portal.azure.com/#view/Microsoft_AAD_B2CAdmin/TenantManagementMenuBlade/~/registeredApps), 101 | and create a new registration. 102 | 103 | Use the same name, but with `- OpenAPI` appended to it. 104 | 105 | * `Supported account types`: `Accounts in any identity provider or organizational directory (for authenticating users with user flows)` 106 | * `Redirect URI`: Choose `Single-Page Application (SPA)` and `http://localhost:8000/oauth2-redirect` as a value 107 | * `Grant admin consent to openid and offline_access permissions`: `Yes` 108 | 109 | Press **Register** 110 | 111 | ![6_application_registration_openapi](../../static/img/b2c/6_application_registration_openapi.png) 112 | 113 | 114 | ### Step 2 - Change token version to `v2` 115 | 116 | Like last time, we'll verify the token version is set to version 2. In the left menu bar, click `Manifest` and find the line 117 | that says `requestedAccessTokenVersion`. Verify the value is `2`. 118 | 119 | Press **Save** 120 | 121 | ![3_manifest](../../static/img/b2c/2_manifest.png) 122 | 123 | 124 | ### Step 3 - Note down your application IDs 125 | Go back to the `Overview`, found in the left menu. 126 | 127 | Copy the `Application (Client) ID` and save it as your `OPENAPI_CLIENT_ID`: 128 | 129 | ```bash title=".env" {3} 130 | TENANT_NAME= 131 | APP_CLIENT_ID= 132 | OPENAPI_CLIENT_ID= 133 | AUTH_POLICY_NAME= 134 | ```` 135 | 136 | ![7_overview_openapi](../../static/img/b2c/7_overview_openapi.png) 137 | 138 | 139 | ### Step 4 - Allow OpenAPI to talk to the backend 140 | 141 | To allow OpenAPI to talk to the backend API, you must add API permissions to the OpenAPI app registration. 142 | In the left menu, go to **API Permissions** and **Add a permission**. 143 | 144 | ![8_api_permissions](../../static/img/b2c/8_api_permissions.png) 145 | 146 | Select the `user_impersonation` scope, and press **Add a permission**. 147 | 148 | Your view should now look something like this: 149 | 150 | ![9_api_permissions_finish](../../static/img/b2c/9_api_permissions_finish.png) 151 | 152 | That's it! Next step is to configure the FastAPI application. 153 | 154 | 155 | ## User flows 156 | 157 | ### Step 1 - Create a user flow 158 | 159 | Head over to 160 | [Azure -> Azure Entra ID B2C -> Users flows](https://portal.azure.com/#view/Microsoft_AAD_B2CAdmin/TenantManagementMenuBlade/~/userJourneys), 161 | and create a new user flow. 162 | 163 | Select a user flow type of `Sign up and sign in` with the Version `Recommended`, then press **Create**. 164 | 165 | ![10_create_user_flow](../../static/img/b2c/10_add_user_flow.png) 166 | 167 | You are prompted to fill out details of the new user flow. 168 | 169 | Give it a name of `sign_up_sign_in` (note that `B2C_1_` is already prefixed), and choose `Email signup` as the identity provider. 170 | 171 | ![11_add_user_flow_props_name_provider](../../static/img/b2c/11_add_user_flow_props_name_provider.png) 172 | 173 | 174 | Keep all defaults for now and choose user attributes and token claims as required, and press **Create** 175 | 176 | ![12_add_user_flow_props_attributes_claims](../../static/img/b2c/12_add_user_flow_props_attributes_claims.png) 177 | 178 | 179 | ### Step 2 - Note down your User flow name 180 | 181 | Copy the User Flow name just created (including the `B2C_1_` prefix, e.g. `B2C_1_sign_up_sign_in`) 182 | and save it in the .env file: 183 | 184 | ```bash title=".env" {4} 185 | TENANT_NAME= 186 | APP_CLIENT_ID= 187 | OPENAPI_CLIENT_ID= 188 | AUTH_POLICY_NAME=B2C_1_sign_up_sign_in 189 | ```` 190 | 191 | That's it! Next step is to configure the FastAPI application. 192 | -------------------------------------------------------------------------------- /docs/docs/installation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | sidebar_position: 2 4 | --- 5 | 6 | You can install FastAPI-Azure-Auth like any other package on PyPI: 7 | 8 | ```shell 9 | pip install fastapi-azure-auth 10 | # OR 11 | poetry add fastapi-azure-auth 12 | ``` 13 | 14 | :::info 15 | Only Python 3.8 and above is currently supported. If you can't install the package, check your Python version. 16 | ::: 17 | 18 | Now that it's installed, jump on over to the relevant section: 19 | 20 | * [Single-tenant](single-tenant/azure_setup.mdx) 21 | * [Multi-tenant](multi-tenant/azure_setup.mdx) 22 | * [B2C](b2c/azure_setup.mdx) 23 | -------------------------------------------------------------------------------- /docs/docs/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title:   3 | sidebar_label: Introduction 4 | slug: / 5 | sidebar_position: 1 6 | --- 7 | import GitHubButton from 'react-github-btn'; 8 | 9 |
10 | FastAPI-Azure-Auth logo 11 | Intility logo 12 |

13 |

14 |

FastAPI-Azure-Auth

15 |

16 |

17 | Azure Entra ID Authentication for FastAPI apps made easy. 18 |

19 |
20 | 21 | 22 | 23 | **FastAPI-Azure-Auth** implements Azure Entra ID and Azure Entra ID B2C authentication and authorization 24 | for your FastAPI APIs and OpenAPI documentation. 25 | 26 | In the sidebar to the left you'll be able to find information on how to configure both Azure and your FastAPI application. 27 | If you need an example project, one can be found on GitHub [here](https://github.com/Intility/fastapi-azure-auth/tree/main/demo_project). 28 | 29 | The first step is to decide whether your application should be single- or multi-tenant or using B2C. 30 | You can always change this later, so if you're unsure, you should choose **single-tenant**. 31 | 32 | Even though FastAPI-Azure-Auth supports both `v1` and `v2` tokens, 33 | if you're creating a new project, you should use `v2` tokens. We'll walk you through all the steps in this tutorial. 34 | If you have a project up and running already, and want to change from `v1` to `v2`, you can do so in the [manifest](https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens#v10-and-v20). To read about the difference between `v1` and `v2` tokens, check out 35 | [this article](https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens). 36 | 37 |
38 |

If you like this project, please leave us a star ❤ ️️

39 | 40 | 45 | Star 46 | 47 |
48 | -------------------------------------------------------------------------------- /docs/docs/multi-tenant/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Multi-tenant setup", 3 | "position": 3, 4 | "collapsible": true 5 | } 6 | -------------------------------------------------------------------------------- /docs/docs/multi-tenant/accept_specific_tenants_only.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Accept specific tenants only 3 | sidebar_position: 3 4 | --- 5 | 6 | If your multi-tenant application only should accept a few tenants, you'll have to verify `issuers`, or the `iss` field in the JWT. 7 | 8 | We'll take the last code snippet from [FastAPI configuration](fastapi_configuration) and change a few lines of code to make 9 | this happen: 10 | 11 | ```python {7,42-49,56-57} 12 | import uvicorn 13 | from fastapi import FastAPI, Security 14 | from fastapi.middleware.cors import CORSMiddleware 15 | from pydantic import AnyHttpUrl 16 | from pydantic_settings import BaseSettings, SettingsConfigDict 17 | from fastapi_azure_auth import MultiTenantAzureAuthorizationCodeBearer 18 | from fastapi_azure_auth.exceptions import Unauthorized 19 | 20 | 21 | class Settings(BaseSettings): 22 | BACKEND_CORS_ORIGINS: list[str | AnyHttpUrl] = ['http://localhost:8000'] 23 | OPENAPI_CLIENT_ID: str = "" 24 | APP_CLIENT_ID: str = "" 25 | 26 | model_config = SettingsConfigDict( 27 | env_file='.env', 28 | env_file_encoding='utf-8', 29 | case_sensitive=True 30 | ) 31 | 32 | settings = Settings() 33 | 34 | app = FastAPI( 35 | swagger_ui_oauth2_redirect_url='/oauth2-redirect', 36 | swagger_ui_init_oauth={ 37 | 'usePkceWithAuthorizationCodeGrant': True, 38 | 'clientId': settings.OPENAPI_CLIENT_ID, 39 | }, 40 | ) 41 | 42 | if settings.BACKEND_CORS_ORIGINS: 43 | app.add_middleware( 44 | CORSMiddleware, 45 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 46 | allow_credentials=True, 47 | allow_methods=['*'], 48 | allow_headers=['*'], 49 | ) 50 | 51 | 52 | async def check_if_valid_tenant(tid: str) -> str: 53 | tid_to_iss_mapping = { 54 | '9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6': 'https://login.microsoftonline.com/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/v2.0' 55 | } 56 | try: 57 | return tid_to_iss_mapping[tid] 58 | except KeyError: 59 | raise Unauthorized('Tenant not allowed') 60 | 61 | azure_scheme = MultiTenantAzureAuthorizationCodeBearer( 62 | app_client_id=settings.APP_CLIENT_ID, 63 | scopes={ 64 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation', 65 | }, 66 | validate_iss=True, 67 | iss_callable=check_if_valid_tenant 68 | ) 69 | 70 | 71 | @app.on_event('startup') 72 | async def load_config() -> None: 73 | """ 74 | Load OpenID config on startup. 75 | """ 76 | await azure_scheme.openid_config.load_config() 77 | 78 | 79 | @app.get("/", dependencies=[Security(azure_scheme)]) 80 | async def root(): 81 | return {"message": "Hello World"} 82 | 83 | 84 | if __name__ == '__main__': 85 | uvicorn.run('main:app', host='localhost', port=8000, reload=True) 86 | ``` 87 | 88 | We're first creating an `async function`, which takes a `tid` as an argument, and returns the tenant ID's `iss` if it's a valid tenant. 89 | If it's not a valid tenant, it has to raise an `Unauthorized()` exception. 90 | 91 | ## More sophisticated callable 92 | If you want to cache these results in memory, you can do so by creating a more sophisticated callable: 93 | 94 | ```python 95 | class IssuerFetcher: 96 | def __init__(self) -> None: 97 | """ 98 | Example class for multi tenant apps, that caches issuers for an hour 99 | """ 100 | self.tid_to_iss: dict[str, str] = {} 101 | self._config_timestamp: Optional[datetime] = None 102 | 103 | async def __call__(self, tid: str) -> str: 104 | """ 105 | Check if memory cache needs to be updated or not, and then returns an issuer for a given tenant 106 | :raises Unauthorized when it's not a valid tenant 107 | """ 108 | refresh_time = datetime.now() - timedelta(hours=1) 109 | if not self._config_timestamp or self._config_timestamp < refresh_time: 110 | self._config_timestamp = datetime.now() 111 | # logic to find your allowed tenants and it's issuers here 112 | # (This example cache in memory for 1 hour) 113 | self.tid_to_iss = { 114 | 'intility_tenant': 'intility_tenant', 115 | } 116 | try: 117 | return self.tid_to_iss[tid] 118 | except Exception as error: 119 | log.exception('`iss` not found for `tid` %s. Error %s', tid, error) 120 | raise Unauthorized('You must be an Intility customer to access this resource') 121 | 122 | 123 | issuer_fetcher = IssuerFetcher() 124 | 125 | azure_scheme = MultiTenantAzureAuthorizationCodeBearer( 126 | ... 127 | validate_iss=True, 128 | iss_callable=issuer_fetcher 129 | ) 130 | ``` 131 | -------------------------------------------------------------------------------- /docs/docs/multi-tenant/azure_setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Azure configuration 3 | sidebar_position: 1 4 | --- 5 | 6 | We'll need to create two application registrations for Azure Entra ID authentication to cover both direct API 7 | use and usage from the OpenAPI (swagger) documentation. 8 | 9 | We'll start with the API. 10 | 11 | ## Backend API 12 | 13 | ### Step 1 - Create app registration 14 | Head over to 15 | [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps), 16 | and create a new registration. 17 | 18 | Select a fitting name for your project; Azure will present the name to the user during consent. 19 | 20 | * `Supported account types`: `Multitenant` - If you want to create a multi-tenant application, you 21 | should head over to the [multi-tenant documentation](../multi-tenant/azure_setup.mdx) 22 | 23 | ![1_application_registration](../../static/img/multi-tenant/1_application_registration.png) 24 | 25 | Press **Register** 26 | 27 | ### Step 2 - Change token version to `v2` 28 | 29 | First we'll change the token version to version 2. In the left menu bar, click `Manifest` and find the line 30 | that says `requestedAccessTokenVersion`. Change its value from `null` to `2`. 31 | 32 | ![2_manifest](../../static/img/single-tenant/2_manifest.png) 33 | 34 | Press **Save** 35 | 36 | (This change can take some time to happen, which is why we do this first.) 37 | 38 | 39 | ### Step 3 - Note down your application IDs 40 | Go back to the `Overview`, found in the left menu. 41 | 42 | ![3_overview](../../static/img/multi-tenant/3_overview.png) 43 | 44 | 45 | Copy the `Application (Client) ID`, we'll need that for later. I like to use `.env` files to 46 | store variables like these: 47 | 48 | ```bash title=".env" 49 | APP_CLIENT_ID= 50 | ```` 51 | 52 | ### Step 4 - Add an application scope 53 | 54 | 1. Go to **Expose an API** in the left menu bar under your app registration. 55 | 2. Press **+ Add a scope** 56 | 3. Press **Save and continue** 57 | 58 | ![4_add_scope](../../static/img/multi-tenant/4_add_scope.png) 59 | 60 | Add a scope named `user_impersonation` that can be consented by `Admins and users`. 61 | ![5_add_scope_props](../../static/img/multi-tenant/5_add_scope_props.png) 62 | 63 | You can use the following descriptions: 64 | 65 | ```text 66 | Access API as user 67 | Allows the app to access the API as the user. 68 | 69 | Access API as you 70 | Allows the app to access the API as you. 71 | ``` 72 | 73 | ## OpenAPI Documentation 74 | Our OpenAPI documentation will use the `Authorization Code Grant Flow, with Proof Key for Code Exchange` flow. 75 | It's a flow that enables a user of a Single-Page Application to safely log in, consent to permissions and fetch an `access_token` 76 | in the `JWT` format. When the user clicks `Try out` on the APIs, the `access_token` is attached to the header as a `Bearer ` token. 77 | This is the token the backend will validate. 78 | 79 | So, let's set it up! 80 | 81 | ### Step 1 - Create app registration 82 | Just like in the previous chapter, we have to create an application registration for our OpenAPI. 83 | 84 | Head over to 85 | [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps), 86 | and create a new registration. 87 | 88 | Use the same name, but with `- OpenAPI` appended to it. 89 | 90 | * `Supported account types`: `Multitenant` 91 | * `Redirect URI`: Choose `Single-Page Application (SPA)` and `http://localhost:8000/oauth2-redirect` as a value 92 | 93 | ![6_application_registration_openapi](../../static/img/multi-tenant/6_application_registration_openapi.png) 94 | 95 | Press **Register** 96 | 97 | ### Step 2 - Change token version to `v2` 98 | 99 | Like last time, we'll change the token version to version 2. In the left menu bar, click `Manifest` and find the line 100 | that says `requestedAccessTokenVersion`. Change its value from `null` to `2`. 101 | 102 | ![3_manifest](../../static/img/single-tenant/2_manifest.png) 103 | 104 | Press **Save** 105 | 106 | ### Step 3 - Note down your application IDs 107 | You should now be redirected to the `Overview`. 108 | 109 | ![7_overview_openapi](../../static/img/multi-tenant/7_overview_openapi.png) 110 | 111 | 112 | Copy the `Application (Client) ID` and save it as your `OPENAPI_CLIENT_ID`: 113 | 114 | ```bash title=".env" {2} 115 | APP_CLIENT_ID= 116 | OPENAPI_CLIENT_ID= 117 | ```` 118 | 119 | ### Step 4 - Allow OpenAPI to talk to the backend 120 | 121 | To allow OpenAPI to talk to the backend API, you must add API permissions to the OpenAPI app registration. 122 | In the left menu, go to **API Permissions** and **Add a permission**. 123 | 124 | ![8_api_permissions](../../static/img/multi-tenant/8_api_permissions.png) 125 | 126 | Select the `user_impersonation` scope, and press **Add a permission**. 127 | 128 | Your view should now look something like this: 129 | 130 | ![9_api_permissions_finish](../../static/img/multi-tenant/9_api_permissions_finish.png) 131 | 132 | That's it! Next step is to configure the FastAPI application. 133 | -------------------------------------------------------------------------------- /docs/docs/settings/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Settings", 3 | "position": 6, 4 | "collapsible": true 5 | } 6 | -------------------------------------------------------------------------------- /docs/docs/settings/b2c.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: B2C settings 3 | sidebar_position: 3 4 | --- 5 | 6 | ### app_client_id: `str` 7 | **Default**: `None` 8 | 9 | Your applications client ID. This will be the `Web app` in Azure Entra ID 10 | 11 | ----------------- 12 | 13 | ### openid_config_url: `str` 14 | **Default**: `None` 15 | 16 | Override OpenID config URL (used for B2C tenants) 17 | 18 | ----------------- 19 | 20 | ### scopes: `Optional[dict[str, str]]` 21 | **Default:** `None` 22 | 23 | Scopes, these are the ones you've configured in Azure Entra ID B2C. Key is scope, value is a description. 24 | 25 | ```python 26 | { 27 | f'https://{settings.TENANT_NAME}.onmicrosoft.com/{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation' 28 | } 29 | ``` 30 | 31 | ----------------- 32 | 33 | ### leeway: int 34 | **Default:** `0` 35 | 36 | By adding leeway, you define a tolerance window in terms of seconds, allowing the token to be 37 | considered valid even if it falls within the leeway time before or after the "exp" or "nbf" times. 38 | 39 | ----------------- 40 | 41 | ### validate_iss: bool 42 | **Default:** `True` 43 | 44 | Whether to validate the token issuer or not. This can be skipped to allow anyone to log in. 45 | 46 | ----------------- 47 | 48 | ### iss_callable: Callable 49 | **Default:** `None` 50 | 51 | Async function that has to accept a `tid` and return a `iss` / raise an InvalidIssuer exception 52 | This is required when validate_iss is set to `True`. For examples, see 53 | [Accept specific tenants only](../multi-tenant/accept_specific_tenants_only) 54 | 55 | ----------------- 56 | 57 | ### openid_config_use_app_id: `bool` 58 | **Default:** `False` 59 | 60 | Set this to True if you're using claims-mapping. If you're unsure, leave at False. Read more in the 61 | [Azure docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#sample-response). 62 | 63 | ----------------- 64 | 65 | 66 | ### openapi_authorization_url: `Optional[str]` 67 | **Default:** `None` 68 | 69 | Override OpenAPI authorization URL 70 | 71 | ----------------- 72 | 73 | ### openapi_token_url: `Optional[str]` 74 | **Default:** `None` 75 | 76 | Override OpenAPI token URL 77 | 78 | ----------------- 79 | 80 | ### openapi_description: `Optional[str]` 81 | **Default:** `None` 82 | 83 | Override OpenAPI description 84 | 85 | ----------------- 86 | 87 | ### auto_error: `bool` 88 | **Default:** `True` 89 | 90 | Set this to False if you are using multiple authentication libraries. This will return rather than 91 | throwing authentication exceptions. 92 | -------------------------------------------------------------------------------- /docs/docs/settings/multi_tenant.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Multi-tenant settings 3 | sidebar_position: 2 4 | --- 5 | 6 | ### app_client_id: `str` 7 | **Default**: `None` 8 | 9 | Your applications client ID. This will be the `Web app` in Azure Entra ID 10 | 11 | ----------------- 12 | 13 | ### allow_guest_users: `bool` 14 | **Default**: `False` 15 | 16 | Whether to allow guest users in the tenant. Defaults to ``False``. See the 17 | [guest user documentation](../usage-and-faq/guest_users.mdx) 18 | for more details 19 | 20 | ----------------- 21 | 22 | ### scopes: `Optional[dict[str, str]]` 23 | **Default:** `None` 24 | 25 | Scopes, these are the ones you've configured in Azure Entra ID. Key is scope, value is a description. 26 | 27 | ```python 28 | { 29 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation', 30 | } 31 | ``` 32 | 33 | ----------------- 34 | 35 | ### leeway: int 36 | **Default:** `0` 37 | 38 | By adding leeway, you define a tolerance window in terms of seconds, allowing the token to be 39 | considered valid even if it falls within the leeway time before or after the "exp" or "nbf" times. 40 | 41 | ----------------- 42 | 43 | ### validate_iss: bool 44 | **Default:** `True` 45 | 46 | Whether to validate the token issuer or not. This can be skipped to allow anyone to log in. 47 | 48 | ----------------- 49 | 50 | ### iss_callable: Callable 51 | **Default:** `None` 52 | 53 | Async function that has to accept a `tid` and return a `iss` / raise an InvalidIssuer exception 54 | This is required when validate_iss is set to `True`. For examples, see 55 | [Accept specific tenants only](../multi-tenant/accept_specific_tenants_only) 56 | 57 | ----------------- 58 | 59 | ### openid_config_use_app_id: `bool` 60 | **Default:** `False` 61 | 62 | Set this to True if you're using claims-mapping. If you're unsure, leave at False. Read more in the 63 | [Azure docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#sample-response). 64 | 65 | ----------------- 66 | 67 | ### openapi_authorization_url: `Optional[str]` 68 | **Default:** `None` 69 | 70 | Override OpenAPI authorization URL 71 | 72 | ----------------- 73 | 74 | ### openapi_token_url: `Optional[str]` 75 | **Default:** `None` 76 | 77 | Override OpenAPI token URL 78 | 79 | ----------------- 80 | 81 | ### openapi_description: `Optional[str]` 82 | **Default:** `None` 83 | 84 | Override OpenAPI description 85 | 86 | ----------------- 87 | 88 | ### auto_error: `bool` 89 | **Default:** `True` 90 | 91 | Set this to False if you are using multiple authentication libraries. This will return rather than 92 | throwing authentication exceptions. 93 | -------------------------------------------------------------------------------- /docs/docs/settings/single_tenant.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Single-tenant settings 3 | sidebar_position: 1 4 | --- 5 | 6 | ### app_client_id: `str` 7 | **Default**: `None` 8 | 9 | Your applications client ID. This will be the `Web app` in Azure Entra ID 10 | 11 | ----------------- 12 | 13 | ### tenant_id: `str` 14 | **Default:** `None` 15 | 16 | The Azure Tenant ID 17 | 18 | ----------------- 19 | 20 | ### allow_guest_users: `bool` 21 | **Default**: `False` 22 | 23 | Whether to allow guest users in the tenant. Defaults to ``False``. See the 24 | [guest user documentation](../usage-and-faq/guest_users.mdx) 25 | for more details 26 | 27 | ----------------- 28 | 29 | ### scopes: `Optional[dict[str, str]]` 30 | **Default:** `None` 31 | 32 | Scopes, these are the ones you've configured in Azure Entra ID. Key is scope, value is a description. 33 | 34 | ```python 35 | { 36 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation', 37 | } 38 | ``` 39 | 40 | ----------------- 41 | 42 | ### leeway: int 43 | **Default:** `0` 44 | 45 | By adding leeway, you define a tolerance window in terms of seconds, allowing the token to be 46 | considered valid even if it falls within the leeway time before or after the "exp" or "nbf" times. 47 | 48 | ----------------- 49 | 50 | ### openid_config_use_app_id: `bool` 51 | **Default:** `False` 52 | 53 | Set this to True if you're using claims-mapping. If you're unsure, leave at False. Read more in the 54 | [Azure docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#sample-response). 55 | 56 | ----------------- 57 | 58 | ### openapi_authorization_url: `Optional[str]` 59 | **Default:** `None` 60 | 61 | Override OpenAPI authorization URL 62 | 63 | ----------------- 64 | 65 | ### openapi_token_url: `Optional[str]` 66 | **Default:** `None` 67 | 68 | Override OpenAPI token URL 69 | 70 | ----------------- 71 | 72 | ### openapi_description: `Optional[str]` 73 | **Default:** `None` 74 | 75 | Override OpenAPI description 76 | 77 | ----------------- 78 | 79 | ### auto_error: `bool` 80 | **Default:** `True` 81 | 82 | Set this to False if you are using multiple authentication libraries. This will return rather than 83 | throwing authentication exceptions. 84 | -------------------------------------------------------------------------------- /docs/docs/single-tenant/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Single-tenant setup", 3 | "position": 2, 4 | "collapsible": true 5 | } 6 | -------------------------------------------------------------------------------- /docs/docs/single-tenant/azure_setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Azure configuration 3 | sidebar_position: 1 4 | --- 5 | 6 | We'll need to create two application registrations for Azure Entra ID authentication to cover both direct API 7 | use and usage from the OpenAPI (swagger) documentation. 8 | 9 | We'll start with the API. 10 | 11 | ## Backend API 12 | 13 | ### Step 1 - Create app registration 14 | Head over to 15 | [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps), 16 | and create a new registration. 17 | 18 | Select a fitting name for your project; Azure will present the name to the user during consent. 19 | 20 | * `Supported account types`: `Single tenant` - If you want to create a multi-tenant application, you 21 | should head over to the [multi-tenant documentation](../multi-tenant/azure_setup.mdx) 22 | 23 | Press **Register** 24 | 25 | ![1_application_registration](../../static/img/single-tenant/1_application_registration.png) 26 | 27 | 28 | 29 | ### Step 2 - Change token version to `v2` 30 | 31 | First we'll change the token version to version 2. In the left menu bar, click `Manifest` and find the line 32 | that says `requestedAccessTokenVersion`. Change its value from `null` to `2`. 33 | 34 | Press **Save** 35 | 36 | (This change can take some time to happen, which is why we do this first.) 37 | 38 | ![2_manifest](../../static/img/single-tenant/2_manifest.png) 39 | 40 | 41 | ### Step 3 - Note down your application IDs 42 | Go back to the `Overview`, found in the left menu. 43 | 44 | Copy the `Application (Client) ID` and `Directory (tenant) ID`, we'll need these for later. I like to use `.env` files to 45 | store variables like these: 46 | 47 | ```bash title=".env" {1,2} 48 | TENANT_ID= 49 | APP_CLIENT_ID= 50 | OPENAPI_CLIENT_ID= 51 | ```` 52 | 53 | ![3_overview](../../static/img/single-tenant/3_overview.png) 54 | 55 | 56 | ### Step 4 - Add an application scope 57 | 58 | 1. Go to **Expose an API** in the left menu bar under your app registration. 59 | 2. Press **+ Add a scope** 60 | 3. You'll be prompted to set an Application ID URI, leave the suggested one and press **Save and continue** 61 | 62 | ![4_add_scope](../../static/img/single-tenant/4_add_scope.png) 63 | 64 | Add a scope named `user_impersonation` that can be consented by `Admins and users`. 65 | 66 | You can use the following descriptions: 67 | 68 | ```text 69 | Access API as user 70 | Allows the app to access the API as the user. 71 | 72 | Access API as you 73 | Allows the app to access the API as you. 74 | ``` 75 | 76 | 77 | ![5_add_scope_props](../../static/img/single-tenant/5_add_scope_props.png) 78 | 79 | 80 | ## OpenAPI Documentation 81 | Our OpenAPI documentation will use the `Authorization Code Grant Flow, with Proof Key for Code Exchange` flow. 82 | It's a flow that enables a user of a Single-Page Application to safely log in, consent to permissions and fetch an `access_token` 83 | in the `JWT` format. When the user clicks `Try out` on the APIs, the `access_token` is attached to the header as a `Bearer ` token. 84 | This is the token the backend will validate. 85 | 86 | So, let's set it up! 87 | 88 | ### Step 1 - Create app registration 89 | Just like in the previous chapter, we have to create an application registration for our OpenAPI. 90 | 91 | Head over to 92 | [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps), 93 | and create a new registration. 94 | 95 | Use the same name, but with `- OpenAPI` appended to it. 96 | 97 | * `Supported account types`: `Single tenant` 98 | * `Redirect URI`: Choose `Single-Page Application (SPA)` and `http://localhost:8000/oauth2-redirect` as a value 99 | 100 | Press **Register** 101 | 102 | ![6_application_registration_openapi](../../static/img/single-tenant/6_application_registration_openapi.png) 103 | 104 | 105 | ### Step 2 - Change token version to `v2` 106 | 107 | Like last time, we'll change the token version to version 2. In the left menu bar, click `Manifest` and find the line 108 | that says `requestedAccessTokenVersion`. Change its value from `null` to `2`. 109 | 110 | Press **Save** 111 | 112 | ![3_manifest](../../static/img/single-tenant/2_manifest.png) 113 | 114 | 115 | ### Step 3 - Note down your application IDs 116 | Go back to the `Overview`, found in the left menu. 117 | 118 | Copy the `Application (Client) ID` and save it as your `OPENAPI_CLIENT_ID`: 119 | 120 | ```bash title=".env" {3} 121 | TENANT_ID= 122 | APP_CLIENT_ID= 123 | OPENAPI_CLIENT_ID= 124 | ```` 125 | 126 | ![7_overview_openapi](../../static/img/single-tenant/7_overview_openapi.png) 127 | 128 | 129 | ### Step 4 - Allow OpenAPI to talk to the backend 130 | 131 | To allow OpenAPI to talk to the backend API, you must add API permissions to the OpenAPI app registration. 132 | In the left menu, go to **API Permissions** and **Add a permission**. 133 | 134 | ![8_api_permissions](../../static/img/single-tenant/8_api_permissions.png) 135 | 136 | Select the `user_impersonation` scope, and press **Add a permission**. 137 | 138 | Your view should now look something like this: 139 | 140 | ![9_api_permissions_finish](../../static/img/single-tenant/9_api_permissions_finish.png) 141 | 142 | That's it! Next step is to configure the FastAPI application. 143 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Usage and FAQ", 3 | "position": 5, 4 | "collapsible": true 5 | } 6 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/accessing_the_user.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Accessing the user object 3 | sidebar_position: 1 4 | --- 5 | 6 | import Tabs from '@theme/Tabs'; 7 | import TabItem from '@theme/TabItem'; 8 | 9 | You can access your user object in two ways, either with `Depends()` or with `request.state.user`. 10 | 11 | ### `Depends()` 12 | 13 | 14 | 15 | 16 | ```python title="depends_api_example.py" 17 | from fastapi import APIRouter, Depends 18 | 19 | from demo_project.api.dependencies import azure_scheme 20 | from fastapi_azure_auth.user import User 21 | 22 | router = APIRouter() 23 | 24 | 25 | @router.get( 26 | '/hello-user', 27 | response_model=User, 28 | operation_id='helloWorldApiKey', 29 | ) 30 | async def hello_user(user: User = Depends(azure_scheme)) -> dict[str, bool]: 31 | """ 32 | Wonder how this auth is done? 33 | """ 34 | return user.dict() 35 | ``` 36 | 37 | 38 | 39 | 40 | ```python title="depends_api_example.py" 41 | from fastapi import APIRouter, Depends 42 | from typing import Dict 43 | 44 | from demo_project.api.dependencies import azure_scheme 45 | from fastapi_azure_auth.user import User 46 | 47 | router = APIRouter() 48 | 49 | 50 | @router.get( 51 | '/hello-user', 52 | response_model=User, 53 | operation_id='helloWorldApiKey', 54 | ) 55 | async def hello_user(user: User = Depends(azure_scheme)) -> Dict[str, bool]: 56 | """ 57 | Wonder how this auth is done? 58 | """ 59 | return user.dict() 60 | ``` 61 | 62 | 63 | 64 | 65 | 66 | ### `request.state.user` 67 | 68 | 69 | 70 | 71 | ```python title="request_state_user_api_example.py" 72 | from fastapi import APIRouter, Depends, Request 73 | 74 | from demo_project.api.dependencies import azure_scheme 75 | from fastapi_azure_auth.user import User 76 | 77 | router = APIRouter() 78 | 79 | 80 | @router.get( 81 | '/hello-user', 82 | response_model=User, 83 | operation_id='helloWorldApiKey', 84 | dependencies=[Depends(azure_scheme)] 85 | ) 86 | async def hello_user(request: Request) -> dict[str, bool]: 87 | """ 88 | Wonder how this auth is done? 89 | """ 90 | return request.state.user.dict() 91 | ``` 92 | 93 | 94 | 95 | 96 | 97 | ```python title="request_state_user_api_example.py" 98 | from fastapi import APIRouter, Depends, Request 99 | from typing import Dict 100 | 101 | from demo_project.api.dependencies import azure_scheme 102 | from fastapi_azure_auth.user import User 103 | 104 | router = APIRouter() 105 | 106 | 107 | @router.get( 108 | '/hello-user', 109 | response_model=User, 110 | operation_id='helloWorldApiKey', 111 | dependencies=[Depends(azure_scheme)] 112 | ) 113 | async def hello_user(request: Request) -> Dict[str, bool]: 114 | """ 115 | Wonder how this auth is done? 116 | """ 117 | return request.state.user.dict() 118 | ``` 119 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/admin_consent_when_logging_in.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Approval required on login 3 | sidebar_position: 6 4 | --- 5 | 6 | If you're met by this screen when attempting to log in: 7 | 8 | ![approval_required](../../static/img/usage-and-faq/approval_required.png) 9 | 10 | Then please follow the steps provided in [issue 45](https://github.com/Intility/fastapi-azure-auth/issues/45): 11 | 12 | 1. Navigate to [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) 13 | and find your backend application registration 14 | 2. Go to `Expose an API` 15 | 3. Under `Authorized client applications` click `Add a client application` 16 | 4. Add the client ID of your OpenAPI application registration (saved as `OPENAPI_CLIENT_ID` in your `.env` file) 17 | 5. Select the `api:///user_impersonation` checkbox 18 | 6. Click `Add Application` 19 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/calling_your_apis_from_python.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Calling your APIs from Python 3 | sidebar_position: 4 4 | --- 5 | 6 | ## Azure setup 7 | 8 | In order to call your APIs from Python (or any other backend), you should use the [Client Credential Flow](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow). 9 | 10 | 1. Navigate to [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) 11 | and find your **OpenAPI application registration*** 12 | 2. Navigate over to `Certificate & secrets` 13 | 3. Click `New client secret` 14 | 4. Give it a name and an expiry time 15 | 5. Click `Add` 16 | 17 | :::info 18 | In this example, we used the already created OpenAPI app registration in order to keep it short, 19 | but in reality you should **create a new app registration** for _every_ application talking to your backend. 20 | In other words, if someone wants to use your API, they should create their own app registration and their own secret. 21 | ::: 22 | 23 | ![secret_picture](../../static/img/usage-and-faq/secret_picture.png) 24 | 25 | 26 | :::info 27 | You can use client certificates too, but we won't cover this here. 28 | ::: 29 | 30 | 6. Copy the secret and save it for later. 31 | 32 | ![copy_secret](../../static/img/usage-and-faq/copy_secret.png) 33 | 34 | ## FastAPI setup 35 | 36 | The basic process is to first fetch the access token from Azure, and then call your own API endpoint. 37 | 38 | 39 | ### Single- and multi-tenant 40 | 41 | ```python title="my_script.py" 42 | import asyncio 43 | from httpx import AsyncClient 44 | from demo_project.core.config import settings 45 | 46 | async def main(): 47 | async with AsyncClient() as client: 48 | azure_response = await client.post( 49 | url=f'https://login.microsoftonline.com/{settings.TENANT_ID}/oauth2/v2.0/token', 50 | data={ 51 | 'grant_type': 'client_credentials', 52 | 'client_id': settings.OPENAPI_CLIENT_ID, # the ID of the app reg you created the secret for 53 | 'client_secret': settings.CLIENT_SECRET, # the secret you created 54 | 'scope': f'api://{settings.APP_CLIENT_ID}/.default', # note: NOT .user_impersonation 55 | } 56 | ) 57 | token = azure_response.json()['access_token'] 58 | 59 | my_api_response = await client.get( 60 | 'http://localhost:8000/api/v1/hello-graph', 61 | headers={'Authorization': f'Bearer {token}'}, 62 | ) 63 | print(my_api_response.json()) 64 | 65 | if __name__ == '__main__': 66 | asyncio.run(main()) 67 | ``` 68 | 69 | ### B2C 70 | 71 | Compared to the above, the only differences are the `scope` and `url` parameters: 72 | 73 | ```python title="my_script.py" 74 | import asyncio 75 | from httpx import AsyncClient 76 | from demo_project.core.config import settings 77 | 78 | async def main(): 79 | async with AsyncClient() as client: 80 | azure_response = await client.post( 81 | url=f'https://{settings.TENANT_NAME}.b2clogin.com/{settings.TENANT_NAME}.onmicrosoft.com/{settings.AUTH_POLICY_NAME}/oauth2/v2.0/token', 82 | data={ 83 | 'grant_type': 'client_credentials', 84 | 'client_id': settings.OPENAPI_CLIENT_ID, # the ID of the app reg you created the secret for 85 | 'client_secret': settings.CLIENT_SECRET, # the secret you created 86 | 'scope': f'https://{settings.TENANT_NAME}.onmicrosoft.com/{settings.APP_CLIENT_ID}/.default', 87 | } 88 | ) 89 | token = azure_response.json()['access_token'] 90 | 91 | my_api_response = await client.get( 92 | 'http://localhost:8000/api/v1/hello-graph', 93 | headers={'Authorization': f'Bearer {token}'}, 94 | ) 95 | print(my_api_response.json()) 96 | 97 | if __name__ == '__main__': 98 | asyncio.run(main()) 99 | 100 | ``` 101 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/graph_usage.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Using Microsoft Graph 3 | sidebar_position: 5 4 | --- 5 | 6 | [Microsoft Graph](https://docs.microsoft.com/en-us/graph/overview) can be used together with the 7 | [On Behalf Flow (OBO)](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow), 8 | but in order to make this work you must alter your app registration configuration a bit. 9 | 10 | :::info 11 | This documentation is based off [issue #40](https://github.com/Intility/fastapi-azure-auth/issues/40) 12 | ::: 13 | 14 | 15 | ### Backend API App Registration 16 | 1. Head over to [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps), 17 | and select your **Backend API** Application Registration 18 | 2. Navigate to the `Manifest` in the menu on the left 19 | 3. Add your OpenAPI/Swagger ClientID to the `knownClientApplications` (saved as `OPENAPI_CLIENT_ID` in your `.env`) 20 | 21 | ![manifest](../../static/img/usage-and-faq/manifest.png) 22 | 23 | 24 | 4. Select `API permissions` and ensure `User.Read` is there. If not, follow the steps in the picture below: 25 | 1. `Add a permission` 26 | 2. Select `Microsoft Graph` under `Microsoft APIs` 27 | 3. Select `Delegated permissions` 28 | 4. Search for and select `User.Read` 29 | 5. Click add permission 30 | 31 | ![user_read](../../static/img/usage-and-faq/user_read.png) 32 | 33 | 34 | 5. Select `Certificates & Secrets` and create a secret for your backend to use in order to fetch a Graph token 35 | 1. `New client secret` 36 | 2. Give it a name 37 | 3. Add 38 | 39 | ![graph_secret](../../static/img/usage-and-faq/graph_secret.png) 40 | 41 | 42 | 43 | ### OpenAPI App Registration 44 | 45 | 1. Head back to [Azure -> Azure Active Directory -> App registrations](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps), 46 | and select your **OpenAPI/Swagger** Application Registration 47 | 2. Select `API permissions` in the menu on the left 48 | 3. Add `email`, `offline_access`, `openid`, `profile` scopes 49 | 1. `Add a permission` 50 | 2. Select `Microsoft Graph` under `Microsoft APIs` 51 | 3. Select `Delegated permissions` 52 | 4. Select the permissions 53 | 5. Click add permission 54 | 55 | ![user_read](../../static/img/usage-and-faq/openapi_scopes.png) 56 | 57 | 58 | ### Code 59 | You can now fetch a graph token using the 60 | [OBO flow](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow). 61 | A full code example of an API using Graph can be found in the 62 | [demo project](https://github.com/Intility/fastapi-azure-auth/blob/main/demo_project/api/api_v1/endpoints/graph.py). 63 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/guest_users.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Guest Users 3 | sidebar_position: 2 4 | --- 5 | 6 | In ``FastAPI-Azure-Auth`` version 4 and above, guest users in single- and multi-tenant applications (not B2C) will no longer 7 | be able to access your APIs by default. Most developers do not intend to give access to guest users which appear 8 | in the tenant because of other applications (such as users invited into Teams channels), and this can lead to a security 9 | concerns if not handled. 10 | 11 | In single-tenant applications, it is recommended to deny the user on Azure login, instead of after they're calling the APIs, 12 | please check out the [tutorial below](#user-assignment-required). 13 | In multi-tenant applications, this can be hard to manage, as you'd have to fix this in every tenant. 14 | 15 | If you want to allow guest users into your tenant, you can change the ``allow_guest_users`` setting to ``True``. 16 | If you want to lock down a specific endpoint from guest users, you can do so by creating a 17 | [dependency](#creating-a-dependency-in-code). 18 | 19 | ### User assignment required 20 | 21 | Go to **all** your [Enterprise Applications](https://portal.azure.com/#blade/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/AllApps/menuId/) 22 | and do the following steps. You can find your Enterprise Application either by searching on the Client ID in the 23 | [Enterprise Applications](https://portal.azure.com/#blade/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/AllApps/menuId/) menu, 24 | or by first navigating to your [App registration](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) and 25 | clicking the `Managed application in local directory` link: 26 | 27 | ![guest_1_link_from_appreg](../../static/img/single-tenant/guest_1_link_from_appreg.png) 28 | 29 | Under **Properties**, enable `User assignment required?` and **Save**. 30 | 31 | Then, go to **Users and groups**, and **add user/group**. Find users or a fitting group and assign it to the role Default Access. 32 | 33 | 34 | ### Creating a dependency in code 35 | 36 | Sometimes, especially for multi-tenant applications, doing in-code checks are beneficial. Please note that guest 37 | users are denied by default, so you only need this if you configure ``allow_guest_users`` setting to ``True``, but 38 | would like to lock down specific endpoints. 39 | 40 | ```python title="security.py" 41 | from fastapi import Depends 42 | from fastapi_azure_auth.exceptions import Unauthorized 43 | from fastapi_azure_auth.user import User 44 | 45 | async def deny_guest_users(user: User = Depends(azure_scheme)) -> None: 46 | """ 47 | Deny guest users 48 | """ 49 | if user.is_guest: 50 | raise Unauthorized('Guest user not allowed') 51 | ``` 52 | 53 | 54 | Alternatively, after [FastAPI 0.95.0](https://github.com/tiangolo/fastapi/releases/tag/0.95.0) you can create an 55 | ``Annotated`` dependency. 56 | 57 | ```python title="security.py" 58 | from typing import Annotated 59 | from fastapi import Depends 60 | from fastapi_azure_auth.exceptions import Unauthorized 61 | from fastapi_azure_auth.user import User 62 | 63 | async def deny_guest_users(user: User = Depends(azure_scheme)) -> None: 64 | """ 65 | Deny guest users 66 | """ 67 | if user.is_guest: 68 | raise Unauthorized('Guest user not allowed') 69 | 70 | NonGuestUser = Annotated[User, Depends(deny_guest_users)] 71 | ``` 72 | and in your view: 73 | 74 | ```python title="my_view.py" 75 | @app.get("/items/") 76 | def read_items(user: NonGuestUser): 77 | ... 78 | ``` 79 | 80 | :::note 81 | You can configure the `acct` claim in AzureAD if you'd like a specific claim to indicate if the user 82 | is a guest or tenant member 83 | ::: 84 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/locking_down_on_roles.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Locking down on roles 3 | sidebar_position: 3 4 | --- 5 | 6 | ## Azure setup 7 | Go to your application registration, click `Groups`: 8 | ![manifest](../../static/img/usage-and-faq/role_1.png) 9 | 10 | Create a role: 11 | ![manifest](../../static/img/usage-and-faq/role_2.png) 12 | 13 | Go to Overview and click the Enterprise application link: 14 | ![manifest](../../static/img/usage-and-faq/role_3.png) 15 | 16 | Go to `Users and groups` and add a user/group: 17 | ![manifest](../../static/img/usage-and-faq/role_4.png) 18 | 19 | Add the user/group that you want to have this role (if you use a group, the users must be a direct member of that group. Nested membership does not work): 20 | ![manifest](../../static/img/usage-and-faq/role_5.png) 21 | 22 | Click `select role` and assign the user/role to your application role: 23 | ![manifest](../../static/img/usage-and-faq/role_6.png) 24 | 25 | Finish by clicking `Assign`. The user will now have that group in their token. 26 | 27 | ## App code 28 | 29 | You can lock down on roles by creating your own wrapper dependency: 30 | 31 | ```python title="dependencies.py" 32 | from fastapi import Depends 33 | from fastapi_azure_auth.exceptions import Unauthorized 34 | from fastapi_azure_auth.user import User 35 | 36 | async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None: 37 | """ 38 | Validate that a user is in the `AdminUser` role in order to access the API. 39 | Raises a 401 authentication error if not. 40 | """ 41 | if 'AdminUser' not in user.roles: 42 | raise Unauthorized('User is not an AdminUser') 43 | ``` 44 | 45 | and then use this dependency over `azure_scheme`. 46 | 47 | 48 | Alternatively, after [FastAPI 0.95.0](https://github.com/tiangolo/fastapi/releases/tag/0.95.0) you can create an 49 | ``Annotated`` dependency. 50 | 51 | ```python title="security.py" 52 | from typing import Annotated 53 | from fastapi import Depends 54 | from fastapi_azure_auth.exceptions import Unauthorized 55 | from fastapi_azure_auth.user import User 56 | 57 | async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None: 58 | """ 59 | Validate that a user is in the `AdminUser` role in order to access the API. 60 | Raises a 401 authentication error if not. 61 | """ 62 | if 'AdminUser' not in user.roles: 63 | raise Unauthorized('User is not an AdminUser') 64 | 65 | AdminUser = Annotated[User, Depends(validate_is_admin_user)] 66 | ``` 67 | and in your view: 68 | 69 | ```python title="my_view.py" 70 | @app.get("/items/") 71 | def read_items(user: AdminUser): 72 | ... 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/testing.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing 3 | sidebar_position: 7 4 | --- 5 | 6 | See issue [81](https://github.com/Intility/fastapi-azure-auth/issues/81) for more examples. 7 | 8 | ```python title="test_example.py" 9 | import pytest 10 | from httpx import AsyncClient 11 | from demo_project.api.dependencies import azure_scheme 12 | from fastapi import Request 13 | from fastapi_azure_auth.user import User 14 | from demo_project.main import app as fastapi_app 15 | 16 | @pytest.fixture 17 | async def normal_user_client(): 18 | async def mock_normal_user(request: Request): 19 | user = User( 20 | claims={}, 21 | preferred_username="NormalUser", 22 | roles=["role1"], 23 | aud="aud", 24 | tid="tid", 25 | access_token="123", 26 | is_guest=False, 27 | iat=1537231048, 28 | nbf=1537231048, 29 | exp=1537234948, 30 | iss="iss", 31 | aio="aio", 32 | sub="sub", 33 | oid="oid", 34 | uti="uti", 35 | rh="rh", 36 | ver="2.0", 37 | ) 38 | request.state.user = user 39 | return user 40 | 41 | fastapi_app.dependency_overrides[azure_scheme] = mock_normal_user 42 | async with AsyncClient(app=fastapi_app, base_url='http://test') as ac: 43 | yield ac 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/docs/usage-and-faq/troubleshooting.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Troubleshooting 3 | sidebar_position: 8 4 | --- 5 | 6 | ## Logging 7 | 8 | If you run into any problems, set the logging level to `DEBUG` and check the logs for more information. 9 | 10 | Below an example: 11 | 12 | ```python title="logging_config.py" 13 | from logging.config import dictConfig 14 | 15 | LOGGING = { 16 | 'version': 1, 17 | 'disable_existing_loggers': False, 18 | 'formatters': { 19 | 'verbose': { 20 | 'format': '%(levelname)s %(asctime)s %(name)s %(message)s' 21 | }, 22 | }, 23 | 'handlers': { 24 | 'console': { 25 | 'class': 'logging.StreamHandler', 26 | 'formatter': 'verbose', 27 | }, 28 | }, 29 | 'loggers': { 30 | 'fastapi_azure_auth': {'handlers': ['console'], 'level': 'DEBUG', 'propagate': False}, 31 | }, 32 | } 33 | 34 | dictConfig(LOGGING) 35 | ``` 36 | 37 | :::info 38 | It is recommended to call the `dictConfig()` function before the FastAPI (and `fastapi-azure-auth`) initialization. 39 | ::: 40 | -------------------------------------------------------------------------------- /docs/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import type {Config} from '@docusaurus/types'; 2 | import type * as Preset from '@docusaurus/preset-classic'; 3 | 4 | const config: Config = { 5 | future: { 6 | experimental_faster: true, 7 | }, 8 | title: 'FastAPI-Azure-Auth', 9 | tagline: 'Easy and secure implementation of Azure Entra ID for your FastAPI APIs 🔒', 10 | url: 'https://your-docusaurus-test-site.com', 11 | baseUrl: '/fastapi-azure-auth/', 12 | onBrokenLinks: 'throw', 13 | onBrokenMarkdownLinks: 'warn', 14 | favicon: 'img/global/favicon.ico', 15 | organizationName: 'Intility', // Usually your GitHub org/user name. 16 | projectName: 'FastAPI-Azure-Auth', // Usually your repo name. 17 | themeConfig: { 18 | navbar: { 19 | title: 'FastAPI-Azure-Auth', 20 | logo: { 21 | alt: 'FastAPI-Azure-Auth logo', 22 | src: 'img/global/fastad.png', 23 | }, 24 | items: [ 25 | { 26 | href: 'https://github.com/Intility/FastAPI-Azure-Auth', 27 | label: 'GitHub', 28 | position: 'right', 29 | }, 30 | ], 31 | }, 32 | footer: { 33 | style: 'dark', 34 | links: [ 35 | { 36 | title: 'Quick links', 37 | items: [ 38 | { 39 | label: 'SECURITY.md', 40 | href: 'https://github.com/Intility/FastAPI-Azure-Auth/blob/main/SECURITY.md', 41 | }, 42 | { 43 | label: 'jonas.svensson@intility.no', 44 | href: 'mailto:jonas.svensson@intility.no', 45 | }, 46 | { 47 | label: 'Intility.com', 48 | href: 'https://intility.com', 49 | }, 50 | { 51 | label: 'Azure', 52 | href: 'https://portal.azure.com', 53 | }, 54 | ], 55 | }, 56 | ], 57 | copyright: `Copyright © ${new Date().getFullYear()} Intility AS. Built with Docusaurus.`, 58 | }, 59 | } satisfies Preset.ThemeConfig, 60 | presets: [ 61 | [ 62 | '@docusaurus/preset-classic', 63 | { 64 | docs: { 65 | id: 'docs', 66 | routeBasePath: '/', 67 | sidebarPath: require.resolve('./sidebars.js'), 68 | editUrl: 'https://github.com/Intility/FastAPI-Azure-Auth/edit/main/docs/', 69 | sidebarCollapsible: false, 70 | }, 71 | theme: { 72 | customCss: require.resolve('./src/css/custom.css'), 73 | }, 74 | } satisfies Preset.Options, 75 | ], 76 | ], 77 | plugins: [ 78 | require.resolve('docusaurus-lunr-search'), 79 | // [ 80 | // '@docusaurus/plugin-content-docs', 81 | // { 82 | // id: 'docs', 83 | // routeBasePath: '/', 84 | // sidebarPath: require.resolve('./sidebars.js'), 85 | // editUrl: 'https://github.com/Intility/FastAPI-Azure-Auth/edit/main/docs/', 86 | // sidebarCollapsible: false, 87 | // }, 88 | // ], 89 | ] 90 | } 91 | 92 | export default config; -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start --host 0.0.0.0", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "3.7.0", 18 | "@docusaurus/preset-classic": "3.7.0", 19 | "@docusaurus/faster": "3.7.0", 20 | "@mdx-js/react": "3.1.0", 21 | "@svgr/webpack": "8.1.0", 22 | "clsx": "2.1.1", 23 | "docusaurus-lunr-search": "3.6.0", 24 | "file-loader": "6.2.0", 25 | "prism-react-renderer": "2.4.1", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "react-github-btn": "1.4.0", 29 | "url-loader": "4.1.1" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.5%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | module.exports = { 13 | // By default, Docusaurus generates a sidebar from the docs folder structure 14 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 15 | 16 | // But you can create a sidebar manually 17 | /* 18 | tutorialSidebar: [ 19 | { 20 | type: 'category', 21 | label: 'Tutorial', 22 | items: ['hello'], 23 | }, 24 | ], 25 | */ 26 | }; 27 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: hsl(238, 72%, 50%); 11 | --ifm-color-primary-dark: rgb(33, 175, 144); 12 | --ifm-color-primary-darker: rgb(31, 165, 136); 13 | --ifm-color-primary-darkest: rgb(26, 136, 112); 14 | --ifm-color-primary-light: rgb(70, 203, 174); 15 | --ifm-color-primary-lighter: rgb(102, 212, 189); 16 | --ifm-color-primary-lightest: rgb(146, 224, 208); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | html[data-theme='dark'] { 21 | --ifm-color-primary: hsl(173, 88%, 50%); 22 | --ifm-color-primary-dark: rgb(33, 175, 144); 23 | --ifm-color-primary-darker: rgb(31, 165, 136); 24 | --ifm-color-primary-darkest: rgb(26, 136, 112); 25 | --ifm-color-primary-light: rgb(70, 203, 174); 26 | --ifm-color-primary-lighter: rgb(102, 212, 189); 27 | --ifm-color-primary-lightest: rgb(146, 224, 208); 28 | } 29 | 30 | html[data-theme='dark'] .footer--dark { 31 | --ifm-footer-background-color: hsl(0, 0%, 15%); 32 | } 33 | 34 | .docusaurus-highlight-code-line { 35 | background-color: rgb(72, 77, 91); 36 | display: block; 37 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 38 | padding: 0 var(--ifm-pre-padding); 39 | } 40 | 41 | code { 42 | white-space: pre; 43 | } -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/b2c/10_add_user_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/10_add_user_flow.png -------------------------------------------------------------------------------- /docs/static/img/b2c/11_add_user_flow_props_name_provider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/11_add_user_flow_props_name_provider.png -------------------------------------------------------------------------------- /docs/static/img/b2c/12_add_user_flow_props_attributes_claims.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/12_add_user_flow_props_attributes_claims.png -------------------------------------------------------------------------------- /docs/static/img/b2c/1_application_registration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/1_application_registration.png -------------------------------------------------------------------------------- /docs/static/img/b2c/2_manifest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/2_manifest.png -------------------------------------------------------------------------------- /docs/static/img/b2c/3_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/3_overview.png -------------------------------------------------------------------------------- /docs/static/img/b2c/4_add_scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/4_add_scope.png -------------------------------------------------------------------------------- /docs/static/img/b2c/5_add_scope_props.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/5_add_scope_props.png -------------------------------------------------------------------------------- /docs/static/img/b2c/6_application_registration_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/6_application_registration_openapi.png -------------------------------------------------------------------------------- /docs/static/img/b2c/7_overview_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/7_overview_openapi.png -------------------------------------------------------------------------------- /docs/static/img/b2c/8_api_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/8_api_permissions.png -------------------------------------------------------------------------------- /docs/static/img/b2c/9_api_permissions_finish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/b2c/9_api_permissions_finish.png -------------------------------------------------------------------------------- /docs/static/img/global/fastad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/global/fastad.png -------------------------------------------------------------------------------- /docs/static/img/global/fastadmultitenant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/global/fastadmultitenant.png -------------------------------------------------------------------------------- /docs/static/img/global/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/global/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/global/intility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/global/intility.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/1_application_registration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/1_application_registration.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/2_manifest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/2_manifest.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/3_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/3_overview.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/4_add_scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/4_add_scope.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/5_add_scope_props.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/5_add_scope_props.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/6_application_registration_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/6_application_registration_openapi.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/7_overview_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/7_overview_openapi.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/8_api_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/8_api_permissions.png -------------------------------------------------------------------------------- /docs/static/img/multi-tenant/9_api_permissions_finish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/multi-tenant/9_api_permissions_finish.png -------------------------------------------------------------------------------- /docs/static/img/single-and-multi-tenant/fastapi_1_authorize_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-and-multi-tenant/fastapi_1_authorize_button.png -------------------------------------------------------------------------------- /docs/static/img/single-and-multi-tenant/fastapi_2_not_authenticated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-and-multi-tenant/fastapi_2_not_authenticated.png -------------------------------------------------------------------------------- /docs/static/img/single-and-multi-tenant/fastapi_3_authenticate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-and-multi-tenant/fastapi_3_authenticate.png -------------------------------------------------------------------------------- /docs/static/img/single-and-multi-tenant/fastapi_4_consent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-and-multi-tenant/fastapi_4_consent.png -------------------------------------------------------------------------------- /docs/static/img/single-and-multi-tenant/fastapi_5_success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-and-multi-tenant/fastapi_5_success.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/1_application_registration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/1_application_registration.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/2_manifest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/2_manifest.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/3_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/3_overview.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/4_add_scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/4_add_scope.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/5_add_scope_props.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/5_add_scope_props.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/6_application_registration_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/6_application_registration_openapi.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/7_overview_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/7_overview_openapi.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/8_api_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/8_api_permissions.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/9_api_permissions_finish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/9_api_permissions_finish.png -------------------------------------------------------------------------------- /docs/static/img/single-tenant/guest_1_link_from_appreg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/single-tenant/guest_1_link_from_appreg.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/approval_required.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/approval_required.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/copy_secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/copy_secret.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/graph_secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/graph_secret.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/manifest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/manifest.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/openapi_scopes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/openapi_scopes.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/role_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/role_1.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/role_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/role_2.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/role_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/role_3.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/role_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/role_4.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/role_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/role_5.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/role_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/role_6.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/secret_picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/secret_picture.png -------------------------------------------------------------------------------- /docs/static/img/usage-and-faq/user_read.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/docs/static/img/usage-and-faq/user_read.png -------------------------------------------------------------------------------- /fastapi_azure_auth/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_azure_auth.auth import ( # noqa: F401 2 | B2CMultiTenantAuthorizationCodeBearer as B2CMultiTenantAuthorizationCodeBearer, 3 | MultiTenantAzureAuthorizationCodeBearer as MultiTenantAzureAuthorizationCodeBearer, 4 | SingleTenantAzureAuthorizationCodeBearer as SingleTenantAzureAuthorizationCodeBearer, 5 | ) 6 | 7 | __version__ = '5.1.1' 8 | -------------------------------------------------------------------------------- /fastapi_azure_auth/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fastapi import HTTPException, WebSocketException, status 4 | from starlette.requests import HTTPConnection 5 | 6 | 7 | class InvalidRequestHttp(HTTPException): 8 | """HTTP exception for malformed/invalid requests""" 9 | 10 | def __init__(self, detail: str) -> None: 11 | super().__init__( 12 | status_code=status.HTTP_400_BAD_REQUEST, detail={"error": "invalid_request", "message": detail} 13 | ) 14 | 15 | 16 | class InvalidRequestWebSocket(WebSocketException): 17 | """WebSocket exception for malformed/invalid requests""" 18 | 19 | def __init__(self, detail: str) -> None: 20 | super().__init__( 21 | code=status.WS_1008_POLICY_VIOLATION, reason=str({"error": "invalid_request", "message": detail}) 22 | ) 23 | 24 | 25 | class UnauthorizedHttp(HTTPException): 26 | """HTTP exception for authentication failures""" 27 | 28 | def __init__(self, detail: str, authorization_url: str | None = None, client_id: str | None = None) -> None: 29 | header_value = 'Bearer' 30 | if authorization_url: 31 | header_value += f', authorization_uri="{authorization_url}"' 32 | if client_id: 33 | header_value += f', client_id="{client_id}"' 34 | super().__init__( 35 | status_code=status.HTTP_401_UNAUTHORIZED, 36 | detail={"error": "invalid_token", "message": detail}, 37 | headers={"WWW-Authenticate": header_value}, 38 | ) 39 | 40 | 41 | class UnauthorizedWebSocket(WebSocketException): 42 | """WebSocket exception for authentication failures""" 43 | 44 | def __init__(self, detail: str) -> None: 45 | super().__init__( 46 | code=status.WS_1008_POLICY_VIOLATION, reason=str({"error": "invalid_token", "message": detail}) 47 | ) 48 | 49 | 50 | class ForbiddenHttp(HTTPException): 51 | """HTTP exception for insufficient permissions""" 52 | 53 | def __init__(self, detail: str) -> None: 54 | super().__init__( 55 | status_code=status.HTTP_403_FORBIDDEN, 56 | detail={"error": "insufficient_scope", "message": detail}, 57 | headers={"WWW-Authenticate": "Bearer"}, 58 | ) 59 | 60 | 61 | class ForbiddenWebSocket(WebSocketException): 62 | """WebSocket exception for insufficient permissions""" 63 | 64 | def __init__(self, detail: str) -> None: 65 | super().__init__( 66 | code=status.WS_1008_POLICY_VIOLATION, reason=str({"error": "insufficient_scope", "message": detail}) 67 | ) 68 | 69 | 70 | # --- start backwards-compatible code --- 71 | def InvalidAuth(detail: str, request: HTTPConnection) -> UnauthorizedHttp | UnauthorizedWebSocket: 72 | """ 73 | Legacy factory function that maps to Unauthorized for backwards compatibility. 74 | Returns the correct exception based on the connection type. 75 | TODO: Remove in v6.0.0 76 | """ 77 | if request.scope['type'] == 'http': 78 | # Convert the legacy format to new format 79 | return UnauthorizedHttp(detail) 80 | return UnauthorizedWebSocket(detail) 81 | 82 | 83 | class InvalidAuthHttp(UnauthorizedHttp): 84 | """Legacy HTTP exception class that maps to UnauthorizedHttp 85 | TODO: Remove in v6.0.0 86 | """ 87 | 88 | def __init__(self, detail: str) -> None: 89 | super().__init__(detail) # pragma: no cover 90 | 91 | 92 | class InvalidAuthWebSocket(UnauthorizedWebSocket): 93 | """Legacy WebSocket exception class that maps to UnauthorizedWebSocket 94 | TODO: Remove in v6.0.0 95 | """ 96 | 97 | def __init__(self, detail: str) -> None: 98 | super().__init__(detail) # pragma: no cover 99 | 100 | 101 | # --- end backwards-compatible code --- 102 | 103 | 104 | def InvalidRequest(detail: str, request: HTTPConnection) -> InvalidRequestHttp | InvalidRequestWebSocket: 105 | """Factory function for invalid request exceptions (HTTP only, as request validation happens pre-connection)""" 106 | if request.scope['type'] == 'http': 107 | return InvalidRequestHttp(detail) 108 | return InvalidRequestWebSocket(detail) 109 | 110 | 111 | def Unauthorized( 112 | detail: str, request: HTTPConnection, authorization_url: str | None = None, client_id: str | None = None 113 | ) -> UnauthorizedHttp | UnauthorizedWebSocket: 114 | """Factory function for unauthorized exceptions""" 115 | if request.scope["type"] == "http": 116 | return UnauthorizedHttp(detail, authorization_url, client_id) 117 | return UnauthorizedWebSocket(detail) 118 | 119 | 120 | def Forbidden(detail: str, request: HTTPConnection) -> ForbiddenHttp | ForbiddenWebSocket: 121 | """Factory function for forbidden exceptions""" 122 | if request.scope["type"] == "http": 123 | return ForbiddenHttp(detail) 124 | return ForbiddenWebSocket(detail) 125 | -------------------------------------------------------------------------------- /fastapi_azure_auth/openid_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | from typing import TYPE_CHECKING, Any, Dict, List, Optional 4 | 5 | import jwt 6 | from fastapi import HTTPException, status 7 | from httpx import AsyncClient 8 | 9 | if TYPE_CHECKING: # pragma: no cover 10 | from jwt.algorithms import AllowedPublicKeys 11 | 12 | log = logging.getLogger('fastapi_azure_auth') 13 | 14 | 15 | class OpenIdConfig: 16 | def __init__( 17 | self, 18 | tenant_id: Optional[str] = None, 19 | multi_tenant: bool = False, 20 | app_id: Optional[str] = None, 21 | config_url: Optional[str] = None, 22 | ) -> None: 23 | self.tenant_id: Optional[str] = tenant_id 24 | self._config_timestamp: Optional[datetime] = None 25 | self.multi_tenant: bool = multi_tenant 26 | self.app_id = app_id 27 | self.config_url = config_url 28 | 29 | self.authorization_endpoint: str 30 | self.signing_keys: dict[str, 'AllowedPublicKeys'] 31 | self.token_endpoint: str 32 | self.issuer: str 33 | 34 | async def load_config(self) -> None: 35 | """ 36 | Loads config from the Intility openid-config endpoint if it's over 24 hours old (or don't exist) 37 | """ 38 | refresh_time = datetime.now() - timedelta(hours=24) 39 | if not self._config_timestamp or self._config_timestamp < refresh_time: 40 | try: 41 | log.debug('Loading Azure Entra ID OpenID configuration.') 42 | await self._load_openid_config() 43 | self._config_timestamp = datetime.now() 44 | except Exception as error: 45 | log.exception('Unable to fetch OpenID configuration from Azure Entra ID. Error: %s', error) 46 | # We can't fetch an up to date openid-config, so authentication will not work. 47 | if self._config_timestamp: 48 | raise HTTPException( 49 | status_code=status.HTTP_401_UNAUTHORIZED, 50 | detail='Connection to Azure Entra ID is down. Unable to fetch provider configuration', 51 | headers={'WWW-Authenticate': 'Bearer'}, 52 | ) from error 53 | 54 | else: 55 | raise RuntimeError(f'Unable to fetch provider information. {error}') from error 56 | 57 | log.info('fastapi-azure-auth loaded settings from Azure Entra ID.') 58 | log.info('authorization endpoint: %s', self.authorization_endpoint) 59 | log.info('token endpoint: %s', self.token_endpoint) 60 | log.info('issuer: %s', self.issuer) 61 | 62 | async def _load_openid_config(self) -> None: 63 | """ 64 | Load openid config, fetch signing keys 65 | """ 66 | path = 'common' if self.multi_tenant else self.tenant_id 67 | 68 | if self.config_url: 69 | config_url = self.config_url 70 | else: 71 | config_url = f'https://login.microsoftonline.com/{path}/v2.0/.well-known/openid-configuration' 72 | if self.app_id: 73 | config_url += f'?appid={self.app_id}' 74 | 75 | async with AsyncClient(timeout=10) as client: 76 | log.info('Fetching OpenID Connect config from %s', config_url) 77 | openid_response = await client.get(config_url) 78 | openid_response.raise_for_status() 79 | openid_cfg = openid_response.json() 80 | 81 | self.authorization_endpoint = openid_cfg['authorization_endpoint'] 82 | self.token_endpoint = openid_cfg['token_endpoint'] 83 | self.issuer = openid_cfg['issuer'] 84 | 85 | jwks_uri = openid_cfg['jwks_uri'] 86 | log.info('Fetching jwks from %s', jwks_uri) 87 | jwks_response = await client.get(jwks_uri) 88 | jwks_response.raise_for_status() 89 | self._load_keys(jwks_response.json()['keys']) 90 | 91 | def _load_keys(self, keys: List[Dict[str, Any]]) -> None: 92 | """ 93 | Create certificates based on signing keys and store them 94 | """ 95 | self.signing_keys = {} 96 | for key in keys: 97 | if key.get('use') == 'sig': # Only care about keys that are used for signatures, not encryption 98 | log.debug('Loading public key from certificate: %s', key) 99 | cert_obj = jwt.PyJWK(key, 'RS256') 100 | if kid := key.get('kid'): # In case a key would not have a thumbprint we can match, we don't want it. 101 | self.signing_keys[kid] = cert_obj.key 102 | -------------------------------------------------------------------------------- /fastapi_azure_auth/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/fastapi_azure_auth/py.typed -------------------------------------------------------------------------------- /fastapi_azure_auth/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Literal, Optional 2 | 3 | from pydantic import BaseModel, Field, field_validator 4 | 5 | 6 | class Claims(BaseModel): 7 | """ 8 | A more complete overview of the claims available in an access token can be found here: 9 | https://learn.microsoft.com/en-us/azure/active-directory/develop/access-tokens#payload-claims 10 | """ 11 | 12 | aud: str = Field( 13 | ..., 14 | description='Identifies the intended audience of the token. In v2.0 tokens, this value is always the client ID' 15 | ' of the API. In v1.0 tokens, it can be the client ID or the resource URI used in the request.', 16 | ) 17 | iss: str = Field( 18 | ..., 19 | description='Identifies the STS that constructs and returns the token, and the Azure Entra ID tenant of the' 20 | ' authenticated user. If the token issued is a v2.0 token (see the ver claim), the URI ends in /v2.0.', 21 | ) 22 | idp: Optional[str] = Field( 23 | default=None, 24 | description='Records the identity provider that authenticated the subject of the token. This value is identical' 25 | ' to the value of the Issuer claim unless the user account is not in the same tenant as the issuer, such as' 26 | ' guests. Use the value of iss if the claim is not present.', 27 | ) 28 | iat: int = Field( 29 | ..., 30 | description='Specifies when the authentication for this token occurred.', 31 | ) 32 | nbf: int = Field( 33 | ..., 34 | description='Specifies the time after which the JWT can be processed.', 35 | ) 36 | exp: int = Field( 37 | ..., 38 | description='Specifies the expiration time before which the JWT can be accepted for processing.', 39 | ) 40 | aio: Optional[str] = Field( 41 | default=None, 42 | description='An internal claim used by Azure Entra ID to record data for token reuse. Resources should not use this claim.', 43 | ) 44 | name: Optional[str] = Field( 45 | default=None, 46 | description='Provides a human-readable value that identifies the subject of the token.', 47 | ) 48 | scp: List[str] = Field( 49 | default=[], 50 | description='The set of scopes exposed by the application for which the client application has requested (and received) consent. Only included for user tokens.', 51 | ) 52 | roles: List[str] = Field( 53 | default=[], 54 | description='The set of permissions exposed by the application that the requesting application or user has been given permission to call.', 55 | ) 56 | wids: List[str] = Field( 57 | default=[], 58 | description='Denotes the tenant-wide roles assigned to this user, from the section of roles present in Azure Entra ID built-in roles.', 59 | ) 60 | groups: List[str] = Field( 61 | default=[], 62 | description='Provides object IDs that represent the group memberships of the subject.', 63 | ) 64 | sub: str = Field( 65 | ..., 66 | description='The principal associated with the token.', 67 | ) 68 | oid: Optional[str] = Field( 69 | default=None, 70 | description='The immutable identifier for the requestor, which is the verified identity of the user or service principal', 71 | ) 72 | tid: Optional[str] = Field( 73 | default=None, 74 | description='Represents the tenant that the user is signing in to', 75 | ) 76 | uti: Optional[str] = Field( 77 | default=None, 78 | description='Token identifier claim, equivalent to jti in the JWT specification. Unique, per-token identifier that is case-sensitive.', 79 | ) 80 | rh: Optional[str] = Field( 81 | default=None, 82 | description='Token identifier claim, equivalent to jti in the JWT specification. Unique, per-token identifier that is case-sensitive.', 83 | ) 84 | ver: Literal['1.0', '2.0'] = Field( 85 | ..., 86 | description='Indicates the version of the access token.', 87 | ) 88 | 89 | # Optional claims, configured in Azure Entra ID 90 | acct: Optional[int] = Field( 91 | default=None, 92 | description="User's account status in tenant", 93 | ) 94 | auth_time: Optional[int] = Field( 95 | default=None, 96 | description='Time when the user last authenticated; See OpenID Connect spec', 97 | ) 98 | ctry: Optional[str] = Field( 99 | default=None, 100 | description="User's country/region", 101 | ) 102 | email: Optional[str] = Field( 103 | default=None, 104 | description='The addressable email for this user, if the user has one', 105 | ) 106 | family_name: Optional[str] = Field( 107 | default=None, 108 | description='Provides the last name, surname, or family name of the user as defined in the user object', 109 | ) 110 | fwd: Optional[str] = Field( 111 | default=None, 112 | description='IP address', 113 | ) 114 | given_name: Optional[str] = Field( 115 | default=None, 116 | description='Provides the first or "given" name of the user, as set on the user object', 117 | ) 118 | idtyp: Optional[str] = Field( 119 | default=None, 120 | description='Signals whether the token is an app-only token', 121 | ) 122 | in_corp: Optional[str] = Field( 123 | default=None, 124 | description='Signals if the client is logging in from the corporate network; if they are not, the claim is not included', 125 | ) 126 | ipaddr: Optional[str] = Field( 127 | default=None, 128 | description='The IP address the user authenticated from.', 129 | ) 130 | login_hint: Optional[str] = Field( 131 | default=None, 132 | description='Login hint', 133 | ) 134 | onprem_sid: Optional[str] = Field( 135 | default=None, 136 | description='On-premises security identifier', 137 | ) 138 | pwd_exp: Optional[str] = Field( 139 | default=None, 140 | description='The datetime at which the password expires', 141 | ) 142 | pwd_url: Optional[str] = Field( 143 | default=None, 144 | description='A URL that the user can visit to change their password', 145 | ) 146 | sid: Optional[str] = Field( 147 | default=None, 148 | description='Session ID, used for per-session user sign out', 149 | ) 150 | tenant_ctry: Optional[str] = Field( 151 | default=None, 152 | description="Resource tenant's country/region", 153 | ) 154 | tenant_region_scope: Optional[str] = Field( 155 | default=None, 156 | description='Region of the resource tenant', 157 | ) 158 | upn: Optional[str] = Field( 159 | default=None, 160 | description='An identifier for the user that can be used with the username_hint parameter; not a durable identifier for the user and should not be used to key data', 161 | ) 162 | verified_primary_email: List[str] = Field( 163 | default=[], 164 | description="Sourced from the user's PrimaryAuthoritativeEmail", 165 | ) 166 | verified_secondary_email: List[str] = Field( 167 | default=[], 168 | description="Sourced from the user's SecondaryAuthoritativeEmail", 169 | ) 170 | vnet: Optional[str] = Field( 171 | default=None, 172 | description='VNET specifier information', 173 | ) 174 | xms_pdl: Optional[str] = Field( 175 | default=None, 176 | description='Preferred data location', 177 | ) 178 | xms_pl: Optional[str] = Field( 179 | default=None, 180 | description='User-preferred language', 181 | ) 182 | xms_tpl: Optional[str] = Field( 183 | default=None, 184 | description='Tenant-preferred language', 185 | ) 186 | ztdid: Optional[str] = Field( 187 | default=None, 188 | description='Zero-touch Deployment ID', 189 | ) 190 | 191 | # V1.0 only 192 | acr: Optional[Literal['0', '1']] = Field( 193 | default=None, 194 | description='A value of 0 for the "Authentication context class" claim indicates the end-user authentication ' 195 | 'did not meet the requirements of ISO/IEC 29115. Only available in V1.0 tokens', 196 | ) 197 | # V1.0 only 198 | amr: List[str] = Field( 199 | default=[], 200 | description='Identifies the authentication method of the subject of the token. Only available in V1.0 tokens', 201 | ) 202 | # V1.0 only 203 | appid: Optional[str] = Field( 204 | default=None, 205 | description='The application ID of the client using the token. Only available in V1.0 tokens', 206 | ) 207 | # V1.0 only 208 | appidacr: Optional[Literal['0', '1', '2']] = Field( 209 | default=None, 210 | description='Indicates authentication method of the client. Only available in V1.0 tokens', 211 | ) 212 | # V1.0 only 213 | unique_name: Optional[str] = Field( 214 | default=None, 215 | description='Provides a human readable value that identifies the subject of the token. Only available in V1.0 tokens', 216 | ) 217 | 218 | # V2.0 only 219 | azp: Optional[str] = Field( 220 | default=None, 221 | description='The application ID of the client using the token. Only available in V2.0 tokens', 222 | ) 223 | # V2.0 only 224 | azpacr: Optional[Literal['0', '1', '2']] = Field( 225 | default=None, 226 | description='Indicates the authentication method of the client. Only available in V2.0 tokens', 227 | ) 228 | # V2.0 only 229 | preferred_username: Optional[str] = Field( 230 | default=None, 231 | description='The primary username that represents the user. Only available in V2.0 tokens', 232 | ) 233 | 234 | @field_validator('scp', mode="before") 235 | def scopes_to_list(cls, v: object) -> object: 236 | """ 237 | Validator on the scope attribute that convert the space separated list 238 | of scope into an actual list of scope. 239 | """ 240 | return v.split(' ') if isinstance(v, str) else v 241 | 242 | 243 | class User(Claims): 244 | claims: Dict[str, Any] = Field( 245 | ..., 246 | description='The entire decoded token', 247 | ) 248 | access_token: str = Field( 249 | ..., 250 | description='The access_token. Can be used for fetching the Graph API', 251 | ) 252 | is_guest: bool = Field( 253 | False, 254 | description='The user is a guest user in the tenant', 255 | ) 256 | -------------------------------------------------------------------------------- /fastapi_azure_auth/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import jwt 4 | 5 | 6 | def is_guest(claims: Dict[str, Any]) -> bool: 7 | """ 8 | Check if the user is a guest user 9 | """ 10 | # if the user has set up `acct` claim in Azure, that's most efficient. 0 = tenant member, 1 = guest 11 | if claims.get('acct') == 1: 12 | return True 13 | # formula: idp exist and idp != iss: guest user 14 | claims_iss: str = claims.get('iss', '') 15 | idp: str = claims.get('idp', claims_iss) 16 | return idp != claims_iss 17 | 18 | 19 | def get_unverified_header(access_token: str) -> Dict[str, Any]: 20 | """ 21 | Get header from the access token without verifying the signature 22 | """ 23 | return dict(jwt.get_unverified_header(access_token)) 24 | 25 | 26 | def get_unverified_claims(access_token: str) -> Dict[str, Any]: 27 | """ 28 | Get claims from the access token without verifying the signature 29 | """ 30 | return dict(jwt.decode(access_token, options={'verify_signature': False})) 31 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Global options 2 | [mypy] 3 | python_version = 3.11 4 | # flake8-mypy expects the two following for sensible formatting 5 | show_column_numbers = True 6 | show_error_context = False 7 | show_error_codes = True 8 | warn_unused_ignores = True 9 | warn_redundant_casts = True 10 | warn_unused_configs = True 11 | warn_unreachable = True 12 | warn_return_any = True 13 | strict = True 14 | disallow_untyped_decorators = True 15 | disallow_any_generics = False 16 | implicit_reexport = False 17 | 18 | 19 | [mypy-tests.*] 20 | ignore_errors = True 21 | 22 | [pydantic-mypy] 23 | init_forbid_extra = True 24 | init_typed = True 25 | warn_required_dynamic_aliases = True 26 | warn_untyped_fields = True 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-azure-auth" 3 | version = "5.1.1" # Remember to change in __init__.py as well 4 | description = "Easy and secure implementation of Azure Entra ID for your FastAPI APIs" 5 | authors = ["Jonas Krüger Svensson "] 6 | readme = "README.md" 7 | homepage = "https://github.com/intility/fastapi-azure-auth" 8 | repository = "https://github.com/intility/fastapi-azure-auth" 9 | documentation = "https://github.com/intility/fastapi-azure-auth" 10 | keywords = [ 11 | 'ad', 12 | 'async', 13 | 'asyncio', 14 | 'authentication', 15 | 'azure', 16 | 'azure ad', 17 | 'azure entra id', 18 | 'azure entra', 19 | 'entra id', 20 | 'azuread', 21 | 'fastapi', 22 | 'multi tenant', 23 | 'oauth2', 24 | 'oidc', 25 | 'security', 26 | 'single tenant', 27 | 'starlette', 28 | 'trio', 29 | ] 30 | 31 | classifiers = [ 32 | 'Development Status :: 5 - Production/Stable', 33 | 'Environment :: Web Environment', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: MIT License', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 3.11', 39 | 'Topic :: Software Development', 40 | 'Topic :: Software Development :: Libraries', 41 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | ] 44 | 45 | [tool.poetry.dependencies] 46 | python = "^3.8" 47 | fastapi = ">0.68.0" 48 | cryptography = ">=43.0.1" 49 | httpx = ">0.18.2" 50 | pyjwt = "^2.8.0" 51 | 52 | 53 | [tool.poetry.group.dev.dependencies] 54 | pre-commit = "^2.9.3" 55 | black = "^22.1.0" 56 | pytest = "^7.0.1" 57 | pytest-cov = "^3.0.0" 58 | pytest-asyncio = "^0.18.2" 59 | pytest-mock = "^3.5.1" 60 | requests-mock = "^1.8.0" 61 | pytest-socket = "^0.4.0" 62 | pytest-dotenv = "^0.5.2" 63 | pytest-aiohttp = "^1.0.4" 64 | uvicorn = "^0.17.5" 65 | pytest-freezer = "^0.4.8" 66 | anyio = "^3.3.4" 67 | trio = "^0.22.2" 68 | respx = "^0.20.1" 69 | ipython = "^8.2.0" 70 | openapi-spec-validator = "^0.6.0" 71 | pydantic-settings = "^2.0.2" 72 | asgi-lifespan = "^2.1.0" 73 | 74 | 75 | [tool.black] 76 | line-length = 120 77 | skip-string-normalization = true 78 | target-version = ['py38'] 79 | include = '\.pyi?$' 80 | exclude = ''' 81 | ( 82 | (\.eggs|\.git|\.hg|\.mypy_cache|\.tox|\.venv|\venv|\.github|\docs|\tests|\__pycache__) 83 | ) 84 | ''' 85 | 86 | [tool.isort] 87 | profile = "black" 88 | src_paths = ["fastapi_azure_auth"] 89 | combine_as_imports = true 90 | line_length = 120 91 | sections = [ 92 | 'FUTURE', 93 | 'STDLIB', 94 | 'THIRDPARTY', 95 | 'FIRSTPARTY', 96 | 'LOCALFOLDER', 97 | ] 98 | 99 | 100 | [build-system] 101 | requires = ["poetry-core>=1.0.0"] 102 | build-backend = "poetry.core.masonry.api" 103 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --allow-hosts=localhost,127.0.0.1,::1 3 | env_files = 4 | tests/.env.test 5 | env_override_existing_values = 1 6 | asyncio_mode=auto 7 | -------------------------------------------------------------------------------- /tests/.env.test: -------------------------------------------------------------------------------- 1 | SECRET_KEY=YOLO 2 | APP_CLIENT_ID=oauth299-9999-9999-abcd-efghijkl1234567890 3 | OPENAPI_CLIENT_ID=11111111-1111-1111-1111-111111111111 4 | TENANT_ID=intility_tenant_id 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo_project.api.dependencies import azure_scheme 3 | 4 | 5 | @pytest.fixture(autouse=True) 6 | def mock_config_timestamp(): 7 | """ 8 | Make sure the timestmap is reset between every test 9 | """ 10 | azure_scheme.openid_config._config_timestamp = None 11 | yield 12 | -------------------------------------------------------------------------------- /tests/multi_tenant/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/tests/multi_tenant/__init__.py -------------------------------------------------------------------------------- /tests/multi_tenant/conftest.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from demo_project.api.dependencies import azure_scheme 4 | from demo_project.core.config import settings 5 | from demo_project.main import app 6 | from tests.utils import build_openid_keys, keys_url, openid_config_url, openid_configuration 7 | 8 | from fastapi_azure_auth import MultiTenantAzureAuthorizationCodeBearer 9 | 10 | 11 | @pytest.fixture 12 | def multi_tenant_app(): 13 | azure_scheme_overrides = generate_azure_scheme_multi_tenant_object() 14 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 15 | yield 16 | 17 | 18 | @pytest.fixture 19 | def multi_tenant_app_auto_error_false(): 20 | azure_scheme_overrides = generate_azure_scheme_multi_tenant_object(auto_error=False) 21 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 22 | yield 23 | 24 | 25 | @pytest.fixture 26 | def mock_openid(respx_mock): 27 | respx_mock.get(openid_config_url(multi_tenant=True)).respond(json=openid_configuration()) 28 | yield 29 | 30 | 31 | @pytest.fixture 32 | def mock_openid_and_keys(respx_mock, mock_openid): 33 | respx_mock.get(keys_url()).respond(json=build_openid_keys()) 34 | yield 35 | 36 | 37 | @pytest.fixture 38 | def mock_openid_and_empty_keys(respx_mock, mock_openid): 39 | respx_mock.get(keys_url()).respond(json=build_openid_keys(empty_keys=True)) 40 | yield 41 | 42 | 43 | @pytest.fixture 44 | def mock_openid_ok_then_empty(respx_mock, mock_openid): 45 | keys_route = respx_mock.get(keys_url()) 46 | keys_route.side_effect = [ 47 | httpx.Response(json=build_openid_keys(), status_code=200), 48 | httpx.Response(json=build_openid_keys(empty_keys=True), status_code=200), 49 | ] 50 | openid_route = respx_mock.get(openid_config_url()) 51 | openid_route.side_effect = [ 52 | httpx.Response(json=openid_configuration(), status_code=200), 53 | httpx.Response(json=openid_configuration(), status_code=200), 54 | ] 55 | yield 56 | 57 | 58 | @pytest.fixture 59 | def mock_openid_and_no_valid_keys(respx_mock, mock_openid): 60 | respx_mock.get(keys_url()).respond(json=build_openid_keys(no_valid_keys=True)) 61 | yield 62 | 63 | 64 | def generate_azure_scheme_multi_tenant_object(issuer=None, auto_error=True): 65 | """ 66 | This method is used just to generate the Multi Tenant Obj 67 | """ 68 | 69 | async def issuer_fetcher(tid): 70 | tids = {'intility_tenant_id': 'https://login.microsoftonline.com/intility_tenant/v2.0'} 71 | return tids[tid] 72 | 73 | current_issuer = issuer_fetcher 74 | if issuer: 75 | current_issuer = issuer 76 | return MultiTenantAzureAuthorizationCodeBearer( 77 | app_client_id=settings.APP_CLIENT_ID, 78 | scopes={ 79 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 80 | }, 81 | validate_iss=True, 82 | iss_callable=current_issuer, 83 | auto_error=auto_error, 84 | ) 85 | -------------------------------------------------------------------------------- /tests/multi_tenant/multi_auth/README.md: -------------------------------------------------------------------------------- 1 | This folder contains tests to ensure that multiple authentication schemes will work together. -------------------------------------------------------------------------------- /tests/multi_tenant/multi_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/tests/multi_tenant/multi_auth/__init__.py -------------------------------------------------------------------------------- /tests/multi_tenant/multi_auth/test_auto_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo_project.main import app 3 | from httpx import ASGITransport, AsyncClient 4 | from tests.utils import build_access_token, build_access_token_expired 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_normal_azure_user_valid_token(multi_tenant_app, mock_openid_and_keys): 9 | access_token = build_access_token() 10 | async with AsyncClient( 11 | transport=ASGITransport(app=app), base_url='http://test', headers={'Authorization': 'Bearer ' + access_token} 12 | ) as ac: 13 | response = await ac.get('api/v1/hello-multi-auth') 14 | assert response.json() == {'api_key': False, 'azure_auth': True} 15 | assert response.status_code == 200 16 | 17 | 18 | @pytest.mark.anyio 19 | async def test_api_key_valid_key(multi_tenant_app, mock_openid_and_keys): 20 | async with AsyncClient( 21 | transport=ASGITransport(app=app), base_url='http://test', headers={'TEST-API-KEY': 'JonasIsCool'} 22 | ) as ac: 23 | response = await ac.get('api/v1/hello-multi-auth') 24 | assert response.json() == {'api_key': True, 'azure_auth': False} 25 | assert response.status_code == 200 26 | 27 | 28 | @pytest.mark.anyio 29 | async def test_normal_azure_user_but_invalid_token(multi_tenant_app, mock_openid_and_keys): 30 | access_token = build_access_token_expired() 31 | async with AsyncClient( 32 | transport=ASGITransport(app=app), base_url='http://test', headers={'Authorization': 'Bearer ' + access_token} 33 | ) as ac: 34 | response = await ac.get('api/v1/hello-multi-auth') 35 | assert response.json() == { 36 | 'detail': {'error': 'invalid_token', 'message': 'You must either provide a valid bearer token or API key'} 37 | } 38 | assert response.status_code == 401 39 | 40 | 41 | @pytest.mark.anyio 42 | async def test_api_key_but_invalid_key(multi_tenant_app, mock_openid_and_keys): 43 | async with AsyncClient( 44 | transport=ASGITransport(app=app), base_url='http://test', headers={'TEST-API-KEY': 'JonasIsNotCool'} 45 | ) as ac: 46 | response = await ac.get('api/v1/hello-multi-auth') 47 | assert response.json() == { 48 | 'detail': {'error': 'invalid_token', 'message': 'You must either provide a valid bearer token or API key'} 49 | } 50 | assert response.status_code == 401 51 | -------------------------------------------------------------------------------- /tests/multi_tenant/test_multi_tenant.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | 4 | import pytest 5 | from demo_project.api.dependencies import azure_scheme 6 | from demo_project.core.config import settings 7 | from demo_project.main import app 8 | from httpx import ASGITransport, AsyncClient 9 | from tests.multi_tenant.conftest import generate_azure_scheme_multi_tenant_object 10 | from tests.utils import ( 11 | build_access_token, 12 | build_access_token_expired, 13 | build_access_token_guest_user, 14 | build_access_token_invalid_claims, 15 | build_access_token_invalid_scopes, 16 | build_access_token_normal_user, 17 | build_evil_access_token, 18 | ) 19 | 20 | from fastapi_azure_auth import MultiTenantAzureAuthorizationCodeBearer 21 | from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase 22 | from fastapi_azure_auth.exceptions import UnauthorizedHttp 23 | 24 | 25 | @pytest.mark.anyio 26 | async def test_normal_user(multi_tenant_app, mock_openid_and_keys, freezer): 27 | issued_at = int(time.time()) 28 | expires = issued_at + 3600 29 | access_token = build_access_token() 30 | async with AsyncClient( 31 | transport=ASGITransport(app=app), base_url='http://test', headers={'Authorization': f'Bearer {access_token}'} 32 | ) as ac: 33 | response = await ac.get('api/v1/hello') 34 | assert response.json() == { 35 | 'hello': 'world', 36 | 'user': { 37 | 'access_token': access_token, 38 | 'aud': 'oauth299-9999-9999-abcd-efghijkl1234567890', 39 | 'claims': { 40 | '_claim_names': {'groups': 'src1'}, 41 | '_claim_sources': { 42 | 'src1': { 43 | 'endpoint': 'https://graph.windows.net/intility_tenant_id/users/JONASGUID/getMemberObjects' 44 | } 45 | }, 46 | 'aio': 'some long val', 47 | 'aud': 'oauth299-9999-9999-abcd-efghijkl1234567890', 48 | 'azp': 'some long val', 49 | 'azpacr': '0', 50 | 'exp': expires, 51 | 'iat': issued_at, 52 | 'iss': 'https://login.microsoftonline.com/intility_tenant/v2.0', 53 | 'name': 'Jonas Krüger Svensson / Intility AS', 54 | 'nbf': issued_at, 55 | 'oid': '22222222-2222-2222-2222-222222222222', 56 | 'preferred_username': 'jonas.svensson@intility.no', 57 | 'rh': 'some long val', 58 | 'roles': ['AdminUser'], 59 | 'scp': 'user_impersonation', 60 | 'sub': 'some long val', 61 | 'tid': 'intility_tenant_id', 62 | 'uti': 'abcdefghijkl-mnopqrstu', 63 | 'ver': '2.0', 64 | 'wids': ['some long val'], 65 | }, 66 | 'is_guest': False, 67 | 'name': 'Jonas Krüger Svensson / Intility AS', 68 | 'roles': ['AdminUser'], 69 | 'scp': ['user_impersonation'], 70 | 'tid': 'intility_tenant_id', 71 | 'oid': '22222222-2222-2222-2222-222222222222', 72 | 'sub': 'some long val', 73 | 'acct': None, 74 | 'acr': None, 75 | 'aio': 'some long val', 76 | 'amr': [], 77 | 'appid': None, 78 | 'appidacr': None, 79 | 'auth_time': None, 80 | 'azp': 'some long val', 81 | 'azpacr': '0', 82 | 'ctry': None, 83 | 'email': None, 84 | 'exp': expires, 85 | 'family_name': None, 86 | 'fwd': None, 87 | 'given_name': None, 88 | 'groups': [], 89 | 'iat': issued_at, 90 | 'idp': None, 91 | 'idtyp': None, 92 | 'in_corp': None, 93 | 'ipaddr': None, 94 | 'iss': 'https://login.microsoftonline.com/intility_tenant/v2.0', 95 | 'login_hint': None, 96 | 'nbf': issued_at, 97 | 'onprem_sid': None, 98 | 'preferred_username': 'jonas.svensson@intility.no', 99 | 'pwd_exp': None, 100 | 'pwd_url': None, 101 | 'rh': 'some long val', 102 | 'sid': None, 103 | 'tenant_ctry': None, 104 | 'tenant_region_scope': None, 105 | 'unique_name': None, 106 | 'upn': None, 107 | 'uti': 'abcdefghijkl-mnopqrstu', 108 | 'ver': '2.0', 109 | 'verified_primary_email': [], 110 | 'verified_secondary_email': [], 111 | 'vnet': None, 112 | 'wids': ['some long val'], 113 | 'xms_pdl': None, 114 | 'xms_pl': None, 115 | 'xms_tpl': None, 116 | 'ztdid': None, 117 | }, 118 | } 119 | 120 | 121 | @pytest.mark.anyio 122 | async def test_no_keys_to_decode_with(multi_tenant_app, mock_openid_and_empty_keys): 123 | async with AsyncClient( 124 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 125 | ) as ac: 126 | response = await ac.get('api/v1/hello') 127 | assert response.json() == { 128 | 'detail': {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 129 | } 130 | assert response.status_code == 401 131 | 132 | 133 | @pytest.mark.anyio 134 | async def test_iss_callable_raise_error(mock_openid_and_keys): 135 | async def issuer_fetcher(tid): 136 | raise UnauthorizedHttp(f'Tenant {tid} not a valid tenant') 137 | 138 | azure_scheme_overrides = generate_azure_scheme_multi_tenant_object(issuer_fetcher) 139 | 140 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 141 | async with AsyncClient( 142 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 143 | ) as ac: 144 | response = await ac.get('api/v1/hello') 145 | assert response.json() == { 146 | 'detail': {'error': 'invalid_token', 'message': 'Tenant intility_tenant_id not a valid tenant'} 147 | } 148 | assert response.status_code == 401 149 | 150 | 151 | @pytest.mark.anyio 152 | async def test_skip_iss_validation(mock_openid_and_keys): 153 | azure_scheme_overrides = MultiTenantAzureAuthorizationCodeBearer( 154 | app_client_id=settings.APP_CLIENT_ID, 155 | scopes={ 156 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 157 | }, 158 | validate_iss=False, 159 | ) 160 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 161 | async with AsyncClient( 162 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 163 | ) as ac: 164 | response = await ac.get('api/v1/hello') 165 | assert response.status_code == 200, response.json() 166 | 167 | 168 | @pytest.mark.anyio 169 | async def test_normal_user_rejected(multi_tenant_app, mock_openid_and_keys): 170 | async with AsyncClient( 171 | app=app, 172 | base_url='http://test', 173 | headers={'Authorization': 'Bearer ' + build_access_token_normal_user()}, 174 | ) as ac: 175 | response = await ac.get('api/v1/hello') 176 | assert response.json() == {'detail': {'error': 'insufficient_scope', 'message': 'User is not an AdminUser'}} 177 | assert response.status_code == 403 178 | 179 | 180 | @pytest.mark.anyio 181 | async def test_guest_user_rejected(multi_tenant_app, mock_openid_and_keys): 182 | async with AsyncClient( 183 | app=app, 184 | base_url='http://test', 185 | headers={'Authorization': 'Bearer ' + build_access_token_guest_user()}, 186 | ) as ac: 187 | response = await ac.get('api/v1/hello') 188 | assert response.json() == {'detail': {'error': 'insufficient_scope', 'message': 'Guest users not allowed'}} 189 | assert response.status_code == 403 190 | 191 | 192 | @pytest.mark.anyio 193 | async def test_invalid_token_claims(multi_tenant_app, mock_openid_and_keys): 194 | async with AsyncClient( 195 | app=app, 196 | base_url='http://test', 197 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_claims()}, 198 | ) as ac: 199 | response = await ac.get('api/v1/hello') 200 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Token contains invalid claims'}} 201 | assert response.status_code == 401 202 | 203 | 204 | @pytest.mark.anyio 205 | async def test_no_valid_keys_for_token(multi_tenant_app, mock_openid_and_no_valid_keys): 206 | async with AsyncClient( 207 | app=app, 208 | base_url='http://test', 209 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_claims()}, 210 | ) as ac: 211 | response = await ac.get('api/v1/hello') 212 | assert response.json() == { 213 | 'detail': {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 214 | } 215 | assert response.status_code == 401 216 | 217 | 218 | @pytest.mark.anyio 219 | async def test_no_valid_scopes(multi_tenant_app, mock_openid_and_no_valid_keys): 220 | async with AsyncClient( 221 | app=app, 222 | base_url='http://test', 223 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes()}, 224 | ) as ac: 225 | response = await ac.get('api/v1/hello') 226 | assert response.json() == {'detail': {'error': 'insufficient_scope', 'message': 'Required scope missing'}} 227 | assert response.status_code == 403 228 | 229 | 230 | @pytest.mark.anyio 231 | async def test_no_valid_invalid_formatted_scope(multi_tenant_app, mock_openid_and_no_valid_keys): 232 | async with AsyncClient( 233 | app=app, 234 | base_url='http://test', 235 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes(scopes=None)}, 236 | ) as ac: 237 | response = await ac.get('api/v1/hello') 238 | assert response.json() == { 239 | 'detail': {'error': 'insufficient_scope', 'message': 'Token contains invalid formatted scopes'} 240 | } 241 | assert response.status_code == 403 242 | 243 | 244 | @pytest.mark.anyio 245 | async def test_expired_token(multi_tenant_app, mock_openid_and_keys): 246 | async with AsyncClient( 247 | app=app, 248 | base_url='http://test', 249 | headers={'Authorization': 'Bearer ' + build_access_token_expired()}, 250 | ) as ac: 251 | response = await ac.get('api/v1/hello') 252 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Token signature has expired'}} 253 | assert response.status_code == 401 254 | 255 | 256 | @pytest.mark.anyio 257 | async def test_evil_token(multi_tenant_app, mock_openid_and_keys): 258 | """Kid matches what we expect, but it's not signed correctly""" 259 | async with AsyncClient( 260 | app=app, 261 | base_url='http://test', 262 | headers={'Authorization': 'Bearer ' + build_evil_access_token()}, 263 | ) as ac: 264 | response = await ac.get('api/v1/hello') 265 | assert ( 266 | response.json() 267 | == {'detail': {'error': 'invalid_token', 'message': 'Unable to validate token'}} 268 | != {'detail': 'Unable to validate token'} 269 | ) 270 | assert response.status_code == 401 271 | 272 | 273 | @pytest.mark.anyio 274 | async def test_malformed_token(multi_tenant_app, mock_openid_and_keys): 275 | """A short token, that only has a broken header""" 276 | async with AsyncClient( 277 | app=app, base_url='http://test', headers={'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cI6IkpXVCJ9'} 278 | ) as ac: 279 | response = await ac.get('api/v1/hello') 280 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Invalid token format'}} 281 | assert response.status_code == 401 282 | 283 | 284 | @pytest.mark.anyio 285 | async def test_only_header(multi_tenant_app, mock_openid_and_keys): 286 | """Only header token, with a matching kid, so the rest of the logic will be called, but can't be validated""" 287 | async with AsyncClient( 288 | app=app, 289 | base_url='http://test', 290 | headers={ 291 | 'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InJlYWwgdGh1bWJ' 292 | 'wcmludCIsInR5cCI6IkpXVCIsIng1dCI6ImFub3RoZXIgdGh1bWJwcmludCJ9' 293 | }, # {'kid': 'real thumbprint', 'x5t': 'another thumbprint'} 294 | ) as ac: 295 | response = await ac.get('api/v1/hello') 296 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Invalid token format'}} 297 | assert response.status_code == 401 298 | 299 | 300 | @pytest.mark.anyio 301 | async def test_exception_raised(multi_tenant_app, mock_openid_and_keys, mocker): 302 | mocker.patch.object(AzureAuthorizationCodeBearerBase, 'validate', side_effect=ValueError('lol')) 303 | async with AsyncClient( 304 | app=app, 305 | base_url='http://test', 306 | headers={'Authorization': 'Bearer ' + build_access_token_expired()}, 307 | ) as ac: 308 | response = await ac.get('api/v1/hello') 309 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Unable to process token'}} 310 | assert response.status_code == 401 311 | 312 | 313 | @pytest.mark.anyio 314 | async def test_change_of_keys_works(multi_tenant_app, mock_openid_ok_then_empty, freezer): 315 | """ 316 | * Do a successful request. 317 | * Set time to 25 hours later, so that a new OpenAPI config has to be fetched 318 | * Ensure new keys returned is an empty list, so the next request shouldn't work. 319 | * Generate a new, valid token 320 | * Do request 321 | """ 322 | async with AsyncClient( 323 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 324 | ) as ac: 325 | response = await ac.get('api/v1/hello') 326 | assert response.status_code == 200 327 | 328 | freezer.move_to(datetime.now() + timedelta(hours=25)) # The keys fetched are now outdated 329 | 330 | async with AsyncClient( 331 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 332 | ) as ac: 333 | second_resonse = await ac.get('api/v1/hello') 334 | assert second_resonse.json() == { 335 | 'detail': {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 336 | } 337 | assert second_resonse.status_code == 401 338 | -------------------------------------------------------------------------------- /tests/multi_tenant/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_azure_auth import MultiTenantAzureAuthorizationCodeBearer 4 | 5 | 6 | async def iss_callable_do_not_accept_tid_argument(t): 7 | pass 8 | 9 | 10 | @pytest.mark.parametrize('iss_callable', [None, '', True, False, 1, iss_callable_do_not_accept_tid_argument]) 11 | def test_non_accepted_issue_fetcher_given(iss_callable): 12 | with pytest.raises(RuntimeError): 13 | MultiTenantAzureAuthorizationCodeBearer(app_client_id='some id', validate_iss=True, iss_callable=iss_callable) 14 | -------------------------------------------------------------------------------- /tests/multi_tenant/test_websocket.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Annotated 3 | 4 | import pytest 5 | from demo_project.api.dependencies import azure_scheme 6 | from demo_project.core.config import settings 7 | from demo_project.main import app 8 | from fastapi import Depends, FastAPI, Security, WebSocket 9 | from fastapi.testclient import TestClient 10 | from starlette.websockets import WebSocketDisconnect 11 | from tests.multi_tenant.conftest import generate_azure_scheme_multi_tenant_object 12 | from tests.utils import ( 13 | build_access_token, 14 | build_access_token_expired, 15 | build_access_token_guest_user, 16 | build_access_token_invalid_claims, 17 | build_access_token_invalid_scopes, 18 | build_access_token_normal_user, 19 | build_evil_access_token, 20 | ) 21 | 22 | from fastapi_azure_auth import MultiTenantAzureAuthorizationCodeBearer 23 | from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase 24 | from fastapi_azure_auth.exceptions import ForbiddenWebSocket, UnauthorizedWebSocket 25 | from fastapi_azure_auth.openid_config import OpenIdConfig 26 | from fastapi_azure_auth.user import User 27 | 28 | 29 | async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> User: 30 | """ 31 | Validate that a user is in the `AdminUser` role in order to access the API. 32 | Raises a 401 authentication error if not. 33 | """ 34 | if 'AdminUser' not in user.roles: 35 | raise ForbiddenWebSocket('User is not an AdminUser') 36 | return user 37 | 38 | 39 | @app.websocket("/ws") 40 | async def websocket_endpoint_hello(websocket: WebSocket, user: Annotated[User, Depends(azure_scheme)]): 41 | await websocket.accept() 42 | await websocket.send_text(f"Hello, {user.name}!") 43 | await websocket.close() 44 | 45 | 46 | @app.websocket("/ws/admin") 47 | async def websocket_endpoint_admin(websocket: WebSocket, user: Annotated[User, Depends(validate_is_admin_user)]): 48 | await websocket.accept() 49 | await websocket.send_text(f"Hello, {user.name}!") 50 | await websocket.close() 51 | 52 | 53 | @app.websocket("/ws/scope") 54 | async def websocket_endpoint_scope( 55 | websocket: WebSocket, user: Annotated[User, Security(validate_is_admin_user, scopes=['user_impersonation'])] 56 | ): 57 | await websocket.accept() 58 | await websocket.send_text(f"Hello, {user.name}!") 59 | await websocket.close() 60 | 61 | 62 | @app.websocket("/ws/no-error") 63 | async def websocket_endpoint_scope(websocket: WebSocket, no_user=Depends(azure_scheme)): 64 | await websocket.accept() 65 | await websocket.send_text("Hello. User will be None! Do not use this example for production!") 66 | await websocket.close() 67 | 68 | 69 | client = TestClient(app) 70 | 71 | 72 | @pytest.mark.anyio 73 | async def test_no_keys_to_decode_with(multi_tenant_app, mock_openid_and_empty_keys): 74 | with pytest.raises(WebSocketDisconnect) as error: 75 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token()}): 76 | pass 77 | assert error.value.reason == str( 78 | {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 79 | ) 80 | assert error.value.code == 1008 81 | 82 | 83 | @pytest.mark.anyio 84 | async def test_iss_callable_raise_error(mock_openid_and_keys): 85 | async def issuer_fetcher(tid): 86 | raise UnauthorizedWebSocket(f'Tenant {tid} not a valid tenant') 87 | 88 | azure_scheme_overrides = generate_azure_scheme_multi_tenant_object(issuer_fetcher) 89 | 90 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 91 | with pytest.raises(WebSocketDisconnect) as error: 92 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token()}): 93 | pass 94 | assert error.value.reason == str( 95 | {'error': 'invalid_token', 'message': 'Tenant intility_tenant_id not a valid tenant'} 96 | ) 97 | assert error.value.code == 1008 98 | 99 | 100 | @pytest.mark.anyio 101 | async def test_skip_iss_validation(mock_openid_and_keys): 102 | azure_scheme_overrides = MultiTenantAzureAuthorizationCodeBearer( 103 | app_client_id=settings.APP_CLIENT_ID, 104 | scopes={ 105 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 106 | }, 107 | validate_iss=False, 108 | ) 109 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 110 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token()}) as websocket: 111 | data = websocket.receive_text() 112 | assert data == "Hello, Jonas Krüger Svensson / Intility AS!" 113 | 114 | 115 | @pytest.mark.anyio 116 | async def test_normal_user_rejected(multi_tenant_app, mock_openid_and_keys): 117 | with pytest.raises(WebSocketDisconnect) as error: 118 | with client.websocket_connect( 119 | "/ws/admin", headers={'Authorization': 'Bearer ' + build_access_token_normal_user()} 120 | ): 121 | pass 122 | assert error.value.reason == str({'error': 'insufficient_scope', 'message': 'User is not an AdminUser'}) 123 | assert error.value.code == 1008 124 | 125 | 126 | @pytest.mark.anyio 127 | async def test_guest_user_rejected(multi_tenant_app, mock_openid_and_keys): 128 | with pytest.raises(WebSocketDisconnect) as error: 129 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token_guest_user()}): 130 | pass 131 | assert error.value.reason == str({'error': 'insufficient_scope', 'message': 'Guest users not allowed'}) 132 | assert error.value.code == 1008 133 | 134 | 135 | @pytest.mark.anyio 136 | async def test_invalid_token_claims(multi_tenant_app, mock_openid_and_keys): 137 | with pytest.raises(WebSocketDisconnect) as error: 138 | with client.websocket_connect( 139 | "/ws", headers={'Authorization': 'Bearer ' + build_access_token_invalid_claims()} 140 | ): 141 | pass 142 | assert error.value.reason == str({'error': 'invalid_token', 'message': 'Token contains invalid claims'}) 143 | assert error.value.code == 1008 144 | 145 | 146 | @pytest.mark.anyio 147 | async def test_no_valid_keys_for_token(multi_tenant_app, mock_openid_and_no_valid_keys): 148 | with pytest.raises(WebSocketDisconnect) as error: 149 | with client.websocket_connect( 150 | "/ws", headers={'Authorization': 'Bearer ' + build_access_token_invalid_claims()} 151 | ): 152 | pass 153 | assert error.value.reason == str( 154 | {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 155 | ) 156 | assert error.value.code == 1008 157 | 158 | 159 | @pytest.mark.anyio 160 | async def test_no_valid_scopes(multi_tenant_app, mock_openid_and_no_valid_keys): 161 | with pytest.raises(WebSocketDisconnect) as error: 162 | with client.websocket_connect( 163 | "/ws/scope", headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes()} 164 | ): 165 | pass 166 | assert error.value.reason == str({'error': 'insufficient_scope', 'message': 'Required scope missing'}) 167 | assert error.value.code == 1008 168 | 169 | 170 | @pytest.mark.anyio 171 | async def test_no_valid_invalid_formatted_scope(multi_tenant_app, mock_openid_and_no_valid_keys): 172 | with pytest.raises(WebSocketDisconnect) as error: 173 | with client.websocket_connect( 174 | "/ws/scope", headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes(scopes=None)} 175 | ): 176 | pass 177 | assert error.value.reason == str( 178 | {'error': 'insufficient_scope', 'message': 'Token contains invalid formatted scopes'} 179 | ) 180 | assert error.value.code == 1008 181 | 182 | 183 | @pytest.mark.anyio 184 | async def test_expired_token(multi_tenant_app, mock_openid_and_keys): 185 | with pytest.raises(WebSocketDisconnect) as error: 186 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token_expired()}): 187 | pass 188 | assert error.value.reason == str({'error': 'invalid_token', 'message': 'Token signature has expired'}) 189 | assert error.value.code == 1008 190 | 191 | 192 | @pytest.mark.anyio 193 | async def test_evil_token(multi_tenant_app, mock_openid_and_keys): 194 | """Kid matches what we expect, but it's not signed correctly""" 195 | with pytest.raises(WebSocketDisconnect) as error: 196 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_evil_access_token()}): 197 | pass 198 | assert error.value.reason == str({'error': 'invalid_token', 'message': 'Unable to validate token'}) 199 | assert error.value.code == 1008 200 | 201 | 202 | @pytest.mark.anyio 203 | async def test_malformed_token(multi_tenant_app, mock_openid_and_keys): 204 | """A short token, that only has a broken header""" 205 | with pytest.raises(WebSocketDisconnect) as error: 206 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cI6IkpXVCJ9'}): 207 | pass 208 | assert error.value.reason == str({'error': 'invalid_token', 'message': 'Invalid token format'}) 209 | assert error.value.code == 1008 210 | 211 | 212 | @pytest.mark.anyio 213 | async def test_only_header(multi_tenant_app, mock_openid_and_keys): 214 | """Only header token, with a matching kid, so the rest of the logic will be called, but can't be validated""" 215 | with pytest.raises(WebSocketDisconnect) as error: 216 | with client.websocket_connect( 217 | "/ws", 218 | headers={ 219 | 'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InJlYWwgdGh1bWJ' 220 | 'wcmludCIsInR5cCI6IkpXVCIsIng1dCI6ImFub3RoZXIgdGh1bWJwcmludCJ9' 221 | }, # {'kid': 'real thumbprint', 'x5t': 'another thumbprint'} 222 | ): 223 | pass 224 | assert error.value.reason == str({'error': 'invalid_token', 'message': 'Invalid token format'}) 225 | assert error.value.code == 1008 226 | 227 | 228 | @pytest.mark.anyio 229 | async def test_exception_raised_extraction(multi_tenant_app, mock_openid_and_keys, mocker): 230 | mocker.patch.object(AzureAuthorizationCodeBearerBase, 'extract_access_token', side_effect=ValueError('oops')) 231 | 232 | with pytest.raises(WebSocketDisconnect) as error: 233 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token()}): 234 | pass 235 | assert error.value.reason == str({'error': 'invalid_request', 'message': 'Unable to validate token'}) 236 | assert error.value.code == 1008 237 | 238 | 239 | @pytest.mark.anyio 240 | async def test_exception_raised_validation(multi_tenant_app, mock_openid_and_keys, mocker): 241 | mocker.patch.object(AzureAuthorizationCodeBearerBase, 'validate', side_effect=ValueError('lol')) 242 | 243 | with pytest.raises(WebSocketDisconnect) as error: 244 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token()}): 245 | pass 246 | assert error.value.reason == str({'error': 'invalid_token', 'message': 'Unable to process token'}) 247 | assert error.value.code == 1008 248 | 249 | 250 | @pytest.mark.anyio 251 | async def test_exception_raised_unknown(multi_tenant_app, mock_openid_and_keys, mocker): 252 | mocker.patch.object(OpenIdConfig, 'load_config', side_effect=ValueError('lol')) 253 | 254 | with pytest.raises(WebSocketDisconnect) as error: 255 | with client.websocket_connect("/ws", headers={'Authorization': 'Bearer ' + build_access_token()}): 256 | pass 257 | assert error.value.reason == str({'error': 'invalid_request', 'message': 'Unable to validate token'}) 258 | assert error.value.code == 1008 259 | 260 | 261 | @pytest.mark.anyio 262 | async def test_no_error_pass_through(multi_tenant_app_auto_error_false, mock_openid_and_keys, mocker): 263 | """Has a auto_error_true in pytest param, to make any random exception just return None. Used with multi-auth""" 264 | mocker.patch.object(OpenIdConfig, 'load_config', side_effect=ValueError('lol')) 265 | with client.websocket_connect( 266 | "/ws/no-error", headers={'Authorization': 'Bearer ' + build_access_token()} 267 | ) as websocket: 268 | data = websocket.receive_text() 269 | assert data == "Hello. User will be None! Do not use this example for production!" 270 | -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/tests/multi_tenant_b2c/__init__.py -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/conftest.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from demo_project.api.dependencies import azure_scheme 4 | from demo_project.core.config import settings 5 | from demo_project.main import app 6 | from tests.utils import build_openid_keys, keys_url, openid_config_url, openid_configuration 7 | 8 | from fastapi_azure_auth import B2CMultiTenantAuthorizationCodeBearer 9 | 10 | 11 | @pytest.fixture 12 | def multi_tenant_app(): 13 | azure_scheme_overrides = generate_azure_scheme_multi_tenant_b2c_object() 14 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 15 | yield 16 | 17 | 18 | @pytest.fixture 19 | def mock_openid(respx_mock): 20 | respx_mock.get(openid_config_url(multi_tenant=True)).respond(json=openid_configuration()) 21 | yield 22 | 23 | 24 | @pytest.fixture 25 | def mock_openid_and_keys(respx_mock, mock_openid): 26 | respx_mock.get(keys_url()).respond(json=build_openid_keys()) 27 | yield 28 | 29 | 30 | @pytest.fixture 31 | def mock_openid_and_empty_keys(respx_mock, mock_openid): 32 | respx_mock.get(keys_url()).respond(json=build_openid_keys(empty_keys=True)) 33 | yield 34 | 35 | 36 | @pytest.fixture 37 | def mock_openid_ok_then_empty(respx_mock, mock_openid): 38 | keys_route = respx_mock.get(keys_url()) 39 | keys_route.side_effect = [ 40 | httpx.Response(json=build_openid_keys(), status_code=200), 41 | httpx.Response(json=build_openid_keys(empty_keys=True), status_code=200), 42 | ] 43 | openid_route = respx_mock.get(openid_config_url(multi_tenant=True)) 44 | openid_route.side_effect = [ 45 | httpx.Response(json=openid_configuration(), status_code=200), 46 | httpx.Response(json=openid_configuration(), status_code=200), 47 | ] 48 | yield 49 | 50 | 51 | @pytest.fixture 52 | def mock_openid_and_no_valid_keys(respx_mock, mock_openid): 53 | respx_mock.get(keys_url()).respond(json=build_openid_keys(no_valid_keys=True)) 54 | yield 55 | 56 | 57 | def generate_azure_scheme_multi_tenant_b2c_object(issuer=None): 58 | """ 59 | This method is used just to generate the Multi Tenant B2C Obj 60 | """ 61 | 62 | async def issuer_fetcher(tid): 63 | tids = {'intility_tenant_id': 'https://login.microsoftonline.com/intility_tenant/v2.0'} 64 | return tids[tid] 65 | 66 | current_issuer = issuer_fetcher 67 | if issuer: 68 | current_issuer = issuer 69 | return B2CMultiTenantAuthorizationCodeBearer( 70 | app_client_id=settings.APP_CLIENT_ID, 71 | openapi_authorization_url=str(settings.AUTH_URL), 72 | openapi_token_url=str(settings.TOKEN_URL), 73 | # The value below is used only for testing purpose you should use: 74 | # https://login.microsoftonline.com/common/v2.0/oauth2/token 75 | openid_config_url='https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration', 76 | scopes={ 77 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 78 | }, 79 | validate_iss=True, 80 | iss_callable=current_issuer, 81 | ) 82 | -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/multi_auth/README.md: -------------------------------------------------------------------------------- 1 | This folder contains tests to ensure that b2c multiple authentication schemes will work together. -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/multi_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/tests/multi_tenant_b2c/multi_auth/__init__.py -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/multi_auth/test_auto_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo_project.main import app 3 | from httpx import ASGITransport, AsyncClient 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_api_key_valid_key(multi_tenant_app, mock_openid_and_keys, freezer): 8 | async with AsyncClient( 9 | transport=ASGITransport(app=app), base_url='http://test', headers={'TEST-API-KEY': 'JonasIsCool'} 10 | ) as ac: 11 | response = await ac.get('api/v1/hello-multi-auth-b2c') 12 | assert response.json() == {'api_key': True, 'azure_auth': False} 13 | assert response.status_code == 200 14 | 15 | 16 | @pytest.mark.anyio 17 | async def test_api_key_but_invalid_key(multi_tenant_app, mock_openid_and_keys, freezer): 18 | async with AsyncClient( 19 | transport=ASGITransport(app=app), base_url='http://test', headers={'TEST-API-KEY': 'JonasIsNotCool'} 20 | ) as ac: 21 | response = await ac.get('api/v1/hello-multi-auth-b2c') 22 | assert response.json() == { 23 | 'detail': {'error': 'invalid_token', 'message': 'You must either provide a valid bearer token or API key'} 24 | } 25 | assert response.status_code == 401 26 | -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/test_multi_tenant.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | 4 | import pytest 5 | from demo_project.main import app 6 | from httpx import ASGITransport, AsyncClient 7 | from tests.utils import ( 8 | build_access_token, 9 | build_access_token_expired, 10 | build_access_token_guest_user, 11 | build_access_token_invalid_claims, 12 | build_access_token_invalid_scopes, 13 | build_access_token_normal_user, 14 | build_evil_access_token, 15 | ) 16 | 17 | from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase 18 | from fastapi_azure_auth.openid_config import OpenIdConfig 19 | 20 | 21 | @pytest.mark.anyio 22 | async def test_normal_user(multi_tenant_app, mock_openid_and_keys, freezer): 23 | issued_at = int(time.time()) 24 | expires = issued_at + 3600 25 | access_token = build_access_token() 26 | async with AsyncClient( 27 | transport=ASGITransport(app=app), base_url='http://test', headers={'Authorization': 'Bearer ' + access_token} 28 | ) as ac: 29 | response = await ac.get('api/v1/hello') 30 | assert response.json() == { 31 | 'hello': 'world', 32 | 'user': { 33 | 'access_token': access_token, 34 | 'aud': 'oauth299-9999-9999-abcd-efghijkl1234567890', 35 | 'claims': { 36 | '_claim_names': {'groups': 'src1'}, 37 | '_claim_sources': { 38 | 'src1': { 39 | 'endpoint': 'https://graph.windows.net/intility_tenant_id/users/JONASGUID/getMemberObjects' 40 | } 41 | }, 42 | 'aio': 'some long val', 43 | 'aud': 'oauth299-9999-9999-abcd-efghijkl1234567890', 44 | 'azp': 'some long val', 45 | 'azpacr': '0', 46 | 'exp': expires, 47 | 'iat': issued_at, 48 | 'iss': 'https://login.microsoftonline.com/intility_tenant/v2.0', 49 | 'name': 'Jonas Krüger Svensson / Intility AS', 50 | 'nbf': issued_at, 51 | 'oid': '22222222-2222-2222-2222-222222222222', 52 | 'preferred_username': 'jonas.svensson@intility.no', 53 | 'rh': 'some long val', 54 | 'roles': ['AdminUser'], 55 | 'scp': 'user_impersonation', 56 | 'sub': 'some long val', 57 | 'tid': 'intility_tenant_id', 58 | 'uti': 'abcdefghijkl-mnopqrstu', 59 | 'ver': '2.0', 60 | 'wids': ['some long val'], 61 | }, 62 | 'is_guest': False, 63 | 'name': 'Jonas Krüger Svensson / Intility AS', 64 | 'roles': ['AdminUser'], 65 | 'scp': ['user_impersonation'], 66 | 'tid': 'intility_tenant_id', 67 | 'oid': '22222222-2222-2222-2222-222222222222', 68 | 'sub': 'some long val', 69 | 'acct': None, 70 | 'acr': None, 71 | 'aio': 'some long val', 72 | 'amr': [], 73 | 'appid': None, 74 | 'appidacr': None, 75 | 'auth_time': None, 76 | 'azp': 'some long val', 77 | 'azpacr': '0', 78 | 'ctry': None, 79 | 'email': None, 80 | 'exp': expires, 81 | 'family_name': None, 82 | 'fwd': None, 83 | 'given_name': None, 84 | 'groups': [], 85 | 'iat': issued_at, 86 | 'idp': None, 87 | 'idtyp': None, 88 | 'in_corp': None, 89 | 'ipaddr': None, 90 | 'iss': 'https://login.microsoftonline.com/intility_tenant/v2.0', 91 | 'login_hint': None, 92 | 'nbf': issued_at, 93 | 'onprem_sid': None, 94 | 'preferred_username': 'jonas.svensson@intility.no', 95 | 'pwd_exp': None, 96 | 'pwd_url': None, 97 | 'rh': 'some long val', 98 | 'sid': None, 99 | 'tenant_ctry': None, 100 | 'tenant_region_scope': None, 101 | 'unique_name': None, 102 | 'upn': None, 103 | 'uti': 'abcdefghijkl-mnopqrstu', 104 | 'ver': '2.0', 105 | 'verified_primary_email': [], 106 | 'verified_secondary_email': [], 107 | 'vnet': None, 108 | 'wids': ['some long val'], 109 | 'xms_pdl': None, 110 | 'xms_pl': None, 111 | 'xms_tpl': None, 112 | 'ztdid': None, 113 | }, 114 | } 115 | 116 | 117 | @pytest.mark.anyio 118 | async def test_no_keys_to_decode_with(multi_tenant_app, mock_openid_and_empty_keys): 119 | async with AsyncClient( 120 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 121 | ) as ac: 122 | response = await ac.get('api/v1/hello') 123 | assert response.json() == { 124 | 'detail': {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 125 | } 126 | assert response.status_code == 401 127 | 128 | 129 | @pytest.mark.anyio 130 | async def test_normal_user_rejected(multi_tenant_app, mock_openid_and_keys): 131 | async with AsyncClient( 132 | app=app, 133 | base_url='http://test', 134 | headers={'Authorization': 'Bearer ' + build_access_token_normal_user()}, 135 | ) as ac: 136 | response = await ac.get('api/v1/hello') 137 | assert response.json() == {'detail': {'error': 'insufficient_scope', 'message': 'User is not an AdminUser'}} 138 | assert response.status_code == 403 139 | 140 | 141 | @pytest.mark.anyio 142 | async def test_guest_user_allowed_in_b2c(multi_tenant_app, mock_openid_and_keys): 143 | """ 144 | In b2c, we want to allow guest users, as all users will be guests. 145 | """ 146 | async with AsyncClient( 147 | app=app, 148 | base_url='http://test', 149 | headers={'Authorization': 'Bearer ' + build_access_token_guest_user()}, 150 | ) as ac: 151 | response = await ac.get('api/v1/hello') 152 | assert response.status_code == 200 153 | 154 | 155 | @pytest.mark.anyio 156 | async def test_invalid_token_claims(multi_tenant_app, mock_openid_and_keys): 157 | async with AsyncClient( 158 | app=app, 159 | base_url='http://test', 160 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_claims()}, 161 | ) as ac: 162 | response = await ac.get('api/v1/hello') 163 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Token contains invalid claims'}} 164 | assert response.status_code == 401 165 | 166 | 167 | @pytest.mark.anyio 168 | async def test_no_valid_keys_for_token(multi_tenant_app, mock_openid_and_no_valid_keys): 169 | async with AsyncClient( 170 | app=app, 171 | base_url='http://test', 172 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_claims()}, 173 | ) as ac: 174 | response = await ac.get('api/v1/hello') 175 | assert response.json() == { 176 | 'detail': {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 177 | } 178 | assert response.status_code == 401 179 | 180 | 181 | @pytest.mark.anyio 182 | async def test_no_valid_scopes(multi_tenant_app, mock_openid_and_no_valid_keys): 183 | async with AsyncClient( 184 | app=app, 185 | base_url='http://test', 186 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes()}, 187 | ) as ac: 188 | response = await ac.get('api/v1/hello') 189 | assert response.json() == {'detail': {'error': 'insufficient_scope', 'message': 'Required scope missing'}} 190 | assert response.status_code == 403 191 | 192 | 193 | @pytest.mark.anyio 194 | async def test_no_valid_invalid_scope(multi_tenant_app, mock_openid_and_no_valid_keys): 195 | async with AsyncClient( 196 | app=app, 197 | base_url='http://test', 198 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes()}, 199 | ) as ac: 200 | response = await ac.get('api/v1/hello') 201 | assert response.json() == {'detail': {'error': 'insufficient_scope', 'message': 'Required scope missing'}} 202 | assert response.status_code == 403 203 | 204 | 205 | @pytest.mark.anyio 206 | async def test_no_valid_invalid_formatted_scope(multi_tenant_app, mock_openid_and_no_valid_keys): 207 | async with AsyncClient( 208 | app=app, 209 | base_url='http://test', 210 | headers={'Authorization': 'Bearer ' + build_access_token_invalid_scopes(scopes=None)}, 211 | ) as ac: 212 | response = await ac.get('api/v1/hello') 213 | assert response.json() == { 214 | 'detail': {'error': 'insufficient_scope', 'message': 'Token contains invalid formatted scopes'} 215 | } 216 | assert response.status_code == 403 217 | 218 | 219 | @pytest.mark.anyio 220 | async def test_expired_token(multi_tenant_app, mock_openid_and_keys): 221 | async with AsyncClient( 222 | app=app, 223 | base_url='http://test', 224 | headers={'Authorization': 'Bearer ' + build_access_token_expired()}, 225 | ) as ac: 226 | response = await ac.get('api/v1/hello') 227 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Token signature has expired'}} 228 | assert response.status_code == 401 229 | 230 | 231 | @pytest.mark.anyio 232 | async def test_evil_token(multi_tenant_app, mock_openid_and_keys): 233 | """Kid matches what we expect, but it's not signed correctly""" 234 | async with AsyncClient( 235 | app=app, 236 | base_url='http://test', 237 | headers={'Authorization': 'Bearer ' + build_evil_access_token()}, 238 | ) as ac: 239 | response = await ac.get('api/v1/hello') 240 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Unable to validate token'}} 241 | assert response.status_code == 401 242 | 243 | 244 | @pytest.mark.anyio 245 | async def test_malformed_token(multi_tenant_app, mock_openid_and_keys): 246 | """A short token, that only has a broken header""" 247 | async with AsyncClient( 248 | app=app, base_url='http://test', headers={'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cI6IkpXVCJ9'} 249 | ) as ac: 250 | response = await ac.get('api/v1/hello') 251 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Invalid token format'}} 252 | assert response.status_code == 401 253 | 254 | 255 | @pytest.mark.anyio 256 | async def test_only_header(multi_tenant_app, mock_openid_and_keys): 257 | """Only header token, with a matching kid, so the rest of the logic will be called, but can't be validated""" 258 | async with AsyncClient( 259 | app=app, 260 | base_url='http://test', 261 | headers={ 262 | 'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InJlYWwgdGh1bWJ' 263 | 'wcmludCIsInR5cCI6IkpXVCIsIng1dCI6ImFub3RoZXIgdGh1bWJwcmludCJ9' 264 | }, # {'kid': 'real thumbprint', 'x5t': 'another thumbprint'} 265 | ) as ac: 266 | response = await ac.get('api/v1/hello') 267 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Invalid token format'}} 268 | assert response.status_code == 401 269 | 270 | 271 | @pytest.mark.anyio 272 | async def test_exception_raised(multi_tenant_app, mock_openid_and_keys, mocker): 273 | mocker.patch.object(AzureAuthorizationCodeBearerBase, 'validate', side_effect=ValueError('lol')) 274 | mocker.patch.object(OpenIdConfig, 'load_config', return_value=True) 275 | async with AsyncClient( 276 | app=app, 277 | base_url='http://test', 278 | headers={'Authorization': 'Bearer ' + build_access_token_expired()}, 279 | ) as ac: 280 | response = await ac.get('api/v1/hello') 281 | assert response.json() == {'detail': {'error': 'invalid_token', 'message': 'Unable to process token'}} 282 | assert response.status_code == 401 283 | 284 | 285 | @pytest.mark.anyio 286 | async def test_change_of_keys_works(multi_tenant_app, mock_openid_ok_then_empty, freezer): 287 | """ 288 | * Do a successful request. 289 | * Set time to 25 hours later, so that a new OpenAPI config has to be fetched 290 | * Ensure new keys returned is an empty list, so the next request shouldn't work. 291 | * Generate a new, valid token 292 | * Do request 293 | """ 294 | async with AsyncClient( 295 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 296 | ) as ac: 297 | response = await ac.get('api/v1/hello') 298 | assert response.status_code == 200 299 | 300 | freezer.move_to(datetime.now() + timedelta(hours=25)) # The keys fetched are now outdated 301 | 302 | async with AsyncClient( 303 | app=app, base_url='http://test', headers={'Authorization': 'Bearer ' + build_access_token()} 304 | ) as ac: 305 | second_resonse = await ac.get('api/v1/hello') 306 | assert second_resonse.json() == { 307 | 'detail': {'error': 'invalid_token', 'message': 'Unable to verify token, no signing keys found'} 308 | } 309 | assert second_resonse.status_code == 401 310 | -------------------------------------------------------------------------------- /tests/multi_tenant_b2c/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_azure_auth import B2CMultiTenantAuthorizationCodeBearer 4 | 5 | 6 | async def iss_callable_do_not_accept_tid_argument(t): 7 | pass 8 | 9 | 10 | @pytest.mark.parametrize('iss_callable', [None, '', True, False, 1, iss_callable_do_not_accept_tid_argument]) 11 | def test_non_accepted_issue_fetcher_given(iss_callable): 12 | with pytest.raises(RuntimeError): 13 | B2CMultiTenantAuthorizationCodeBearer(app_client_id='some id', validate_iss=True, iss_callable=iss_callable) 14 | -------------------------------------------------------------------------------- /tests/single_tenant/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/fastapi-azure-auth/64f17ad97830ebc828f8c8a8bccc623b6b8410b8/tests/single_tenant/__init__.py -------------------------------------------------------------------------------- /tests/single_tenant/conftest.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from demo_project.api.dependencies import azure_scheme 4 | from demo_project.core.config import settings 5 | from demo_project.main import app 6 | from tests.utils import build_openid_keys, keys_url, openid_config_url, openid_configuration 7 | 8 | from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer 9 | 10 | 11 | @pytest.fixture 12 | def single_tenant_app(): 13 | """ 14 | Single tenant app fixture 15 | """ 16 | azure_scheme_overrides = SingleTenantAzureAuthorizationCodeBearer( 17 | app_client_id=settings.APP_CLIENT_ID, 18 | scopes={ 19 | f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'User impersonation', 20 | }, 21 | tenant_id=settings.TENANT_ID, 22 | ) 23 | app.dependency_overrides[azure_scheme] = azure_scheme_overrides 24 | yield 25 | 26 | 27 | @pytest.fixture 28 | def mock_openid(respx_mock): 29 | respx_mock.get(openid_config_url()).respond(json=openid_configuration()) 30 | yield 31 | 32 | 33 | @pytest.fixture 34 | def mock_openid_and_keys(respx_mock, mock_openid): 35 | respx_mock.get(keys_url()).respond(json=build_openid_keys()) 36 | yield 37 | 38 | 39 | @pytest.fixture 40 | def mock_openid_and_empty_keys(respx_mock, mock_openid): 41 | respx_mock.get(keys_url()).respond(json=build_openid_keys(empty_keys=True)) 42 | yield 43 | 44 | 45 | @pytest.fixture 46 | def mock_openid_ok_then_empty(respx_mock, mock_openid): 47 | keys_route = respx_mock.get(keys_url()) 48 | keys_route.side_effect = [ 49 | httpx.Response(json=build_openid_keys(), status_code=200), 50 | httpx.Response(json=build_openid_keys(empty_keys=True), status_code=200), 51 | ] 52 | openid_route = respx_mock.get(openid_config_url()) 53 | openid_route.side_effect = [ 54 | httpx.Response(json=openid_configuration(), status_code=200), 55 | httpx.Response(json=openid_configuration(), status_code=200), 56 | ] 57 | yield 58 | 59 | 60 | @pytest.fixture 61 | def mock_openid_and_no_valid_keys(respx_mock, mock_openid): 62 | respx_mock.get(keys_url()).respond(json=build_openid_keys(no_valid_keys=True)) 63 | yield 64 | -------------------------------------------------------------------------------- /tests/test_exception_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module tests the exception handling and backwards compatibility of the exceptions module, introduced in 3 | issue https://github.com/intility/fastapi-azure-auth/issues/229. 4 | TODO: Remove this test module in v6.0.0 5 | """ 6 | import pytest 7 | from fastapi import HTTPException, WebSocketException, status 8 | 9 | from fastapi_azure_auth.exceptions import ( 10 | InvalidAuth, 11 | InvalidAuthHttp, 12 | InvalidAuthWebSocket, 13 | UnauthorizedHttp, 14 | UnauthorizedWebSocket, 15 | ) 16 | 17 | 18 | def test_invalid_auth_backwards_compatibility(): 19 | """Test that InvalidAuth maps to correct exceptions and maintains format""" 20 | # Mock HTTP request scope 21 | http_conn = type('HTTPConnection', (), {'scope': {'type': 'http'}})() 22 | 23 | # Mock WebSocket scope 24 | ws_conn = type('HTTPConnection', (), {'scope': {'type': 'websocket'}})() 25 | 26 | # Test HTTP path 27 | http_exc = InvalidAuth("test message", http_conn) 28 | assert isinstance(http_exc, UnauthorizedHttp) 29 | assert isinstance(http_exc, HTTPException) 30 | assert http_exc.status_code == status.HTTP_401_UNAUTHORIZED 31 | assert http_exc.detail == {"error": "invalid_token", "message": "test message"} 32 | 33 | # Test WebSocket path 34 | ws_exc = InvalidAuth("test message", ws_conn) 35 | assert isinstance(ws_exc, UnauthorizedWebSocket) 36 | assert isinstance(ws_exc, WebSocketException) 37 | assert ws_exc.code == status.WS_1008_POLICY_VIOLATION 38 | assert ws_exc.reason == str({"error": "invalid_token", "message": "test message"}) 39 | 40 | 41 | def test_legacy_exception_catching(): 42 | """Test that old exception catching patterns still work""" 43 | # Test HTTP exceptions 44 | http_conn = type('HTTPConnection', (), {'scope': {'type': 'http'}})() 45 | 46 | with pytest.raises((InvalidAuthHttp, UnauthorizedHttp)) as exc_info: 47 | raise InvalidAuth("test message", http_conn) 48 | 49 | assert isinstance(exc_info.value, UnauthorizedHttp) 50 | assert exc_info.value.detail == {"error": "invalid_token", "message": "test message"} 51 | 52 | # Test WebSocket exceptions 53 | ws_conn = type('HTTPConnection', (), {'scope': {'type': 'websocket'}})() 54 | 55 | with pytest.raises((InvalidAuthWebSocket, UnauthorizedWebSocket)) as exc_info: 56 | raise InvalidAuth("test message", ws_conn) 57 | 58 | assert isinstance(exc_info.value, UnauthorizedWebSocket) 59 | assert exc_info.value.reason == str({"error": "invalid_token", "message": "test message"}) 60 | 61 | 62 | def test_new_exceptions_can_be_caught_as_legacy(): 63 | """Test that new exceptions can be caught with legacy catch blocks""" 64 | with pytest.raises((InvalidAuthHttp, UnauthorizedHttp)) as exc_info: 65 | raise UnauthorizedHttp("test message") 66 | 67 | assert exc_info.value.detail == {"error": "invalid_token", "message": "test message"} 68 | 69 | with pytest.raises((InvalidAuthWebSocket, UnauthorizedWebSocket)) as exc_info: 70 | raise UnauthorizedWebSocket("test message") 71 | 72 | assert exc_info.value.reason == str({"error": "invalid_token", "message": "test message"}) 73 | -------------------------------------------------------------------------------- /tests/test_provider_config.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | from asgi_lifespan import LifespanManager 5 | from demo_project.api.dependencies import azure_scheme 6 | from demo_project.main import app 7 | from httpx import AsyncClient 8 | from tests.utils import build_access_token, build_openid_keys, openid_configuration 9 | 10 | from fastapi_azure_auth.openid_config import OpenIdConfig 11 | 12 | 13 | @pytest.mark.anyio 14 | async def test_http_error_old_config_found(respx_mock, mock_config_timestamp): 15 | azure_scheme.openid_config._config_timestamp = datetime.now() - timedelta(weeks=1) 16 | respx_mock.get('https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration').respond( 17 | status_code=500 18 | ) 19 | async with AsyncClient( 20 | app=app, base_url='http://test', headers={'Authorization': f'Bearer {build_access_token()}'} 21 | ) as ac: 22 | response = await ac.get('api/v1/hello') 23 | assert response.json() == {'detail': 'Connection to Azure Entra ID is down. Unable to fetch provider configuration'} 24 | 25 | 26 | @pytest.mark.anyio 27 | async def test_http_error_no_config_cause_crash_on_startup(respx_mock): 28 | respx_mock.get( 29 | 'https://login.microsoftonline.com/intility_tenant_id/v2.0/.well-known/openid-configuration' 30 | ).respond(status_code=500) 31 | with pytest.raises(RuntimeError): 32 | async with LifespanManager(app=app): 33 | async with AsyncClient( 34 | app=app, base_url='http://test', headers={'Authorization': f'Bearer {build_access_token()}'} 35 | ) as ac: 36 | await ac.get('api/v1/hello') 37 | 38 | 39 | @pytest.mark.anyio 40 | async def test_app_id_provided(respx_mock): 41 | openid_config = OpenIdConfig('intility_tenant', multi_tenant=False, app_id='1234567890') 42 | respx_mock.get( 43 | 'https://login.microsoftonline.com/intility_tenant/v2.0/.well-known/openid-configuration?appid=1234567890' 44 | ).respond(json=openid_configuration()) 45 | respx_mock.get('https://login.microsoftonline.com/intility_tenant/discovery/v2.0/keys').respond( 46 | json=build_openid_keys() 47 | ) 48 | await openid_config.load_config() 49 | assert len(openid_config.signing_keys) == 2 50 | 51 | 52 | @pytest.mark.anyio 53 | async def test_custom_config_id(respx_mock): 54 | openid_config = OpenIdConfig( 55 | 'intility_tenant', 56 | multi_tenant=False, 57 | config_url='https://login.microsoftonline.com/override_tenant/v2.0/.well-known/openid-configuration', 58 | ) 59 | respx_mock.get('https://login.microsoftonline.com/override_tenant/v2.0/.well-known/openid-configuration').respond( 60 | json=openid_configuration() 61 | ) 62 | respx_mock.get('https://login.microsoftonline.com/intility_tenant/discovery/v2.0/keys').respond( 63 | json=build_openid_keys() 64 | ) 65 | await openid_config.load_config() 66 | assert len(openid_config.signing_keys) == 2 67 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import datetime 3 | from typing import Dict 4 | 5 | import pytest 6 | 7 | from fastapi_azure_auth.user import User 8 | from fastapi_azure_auth.utils import is_guest 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'claims, expected', 13 | ( 14 | [ 15 | { # v1 appreg 16 | 'iss': 'https://sts.windows.net/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/', 17 | 'idp': 'https://sts.windows.net/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/', 18 | 'ver': '1.0', 19 | }, 20 | False, 21 | ], 22 | [ 23 | { # v2 appreg 24 | 'iss': 'https://login.microsoftonline.com/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/v2.0', 25 | 'ver': '2.0', 26 | }, 27 | False, 28 | ], 29 | [ 30 | { # v1 guest user 31 | 'iss': 'https://sts.windows.net/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/', 32 | 'idp': 'https://sts.windows.net/e49ee8b0-4ec8-486f-93f3-bedaa281a154/', 33 | 'ver': '1.0', 34 | }, 35 | True, 36 | ], 37 | [ 38 | { # v2 guest user 39 | 'iss': 'https://login.microsoftonline.com/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/v2.0', 40 | 'idp': 'https://sts.windows.net/e49ee8b0-4ec8-486f-93f3-bedaa281a154/', 41 | 'ver': '2.0', 42 | }, 43 | True, 44 | ], 45 | [ 46 | { # v1 tenant member user 47 | 'iss': 'https://sts.windows.net/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/', 48 | 'ver': '1.0', 49 | }, 50 | False, 51 | ], 52 | [ 53 | { # v2 tenant member user 54 | 'iss': 'https://login.microsoftonline.com/9b5ff18e-53c0-45a2-8bc2-9c0c8f60b2c6/v2.0', 55 | 'ver': '2.0', 56 | }, 57 | False, 58 | ], 59 | [ 60 | { # acct claim 61 | 'acct': 1, # 1 == guest 62 | }, 63 | True, 64 | ], 65 | [ 66 | { # acct claim 67 | 'acct': 0, # 1 == tenant member 68 | }, 69 | False, 70 | ], 71 | ), 72 | ids=[ 73 | 'v1 appreg', 74 | 'v2 appreg', 75 | 'v1 guest user', 76 | 'v2 guest user', 77 | 'v1 tenant member user', 78 | 'v2 tenant member user', 79 | 'acct guest', 80 | 'acct tenant member', 81 | ], 82 | ) 83 | def test_guest_user(claims: Dict[str, str], expected: bool): 84 | assert is_guest(claims=claims) == expected 85 | 86 | 87 | def get_utc_now_as_unix_timestamp() -> int: 88 | date = datetime.datetime.now(datetime.timezone.utc) 89 | return calendar.timegm(date.utctimetuple()) 90 | 91 | 92 | def test_user_missing_optionals(): 93 | user = User( 94 | aud='Dummy', 95 | access_token='Dummy', 96 | claims={'oid': 'Dummy oid'}, 97 | iss='https://dummy-platform.dummylogin.com/dummy-uid/v2.0/', 98 | iat=get_utc_now_as_unix_timestamp(), 99 | nbf=get_utc_now_as_unix_timestamp(), 100 | exp=get_utc_now_as_unix_timestamp(), 101 | sub='dummy-sub', 102 | ver='1.0', 103 | scp='AccessAsUser', 104 | ) 105 | assert user is not None 106 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Optional 3 | 4 | import jwt 5 | from cryptography.hazmat.backends import default_backend as crypto_default_backend 6 | from cryptography.hazmat.primitives import serialization as crypto_serialization 7 | from cryptography.hazmat.primitives.asymmetric import rsa 8 | 9 | 10 | def generate_private_key(): 11 | """ 12 | Generate a private key 13 | """ 14 | return rsa.generate_private_key(backend=crypto_default_backend(), public_exponent=65537, key_size=2048) 15 | 16 | 17 | def build_access_token(): 18 | """ 19 | Build an access token, coming from the tenant ID we expect 20 | """ 21 | return do_build_access_token(tenant_id='intility_tenant_id') 22 | 23 | 24 | def build_access_token_normal_user(): 25 | """ 26 | Build an access token, coming from the tenant ID we expect, but not an admin user. (Only used to test dependency) 27 | """ 28 | return do_build_access_token(tenant_id='intility_tenant_id', admin=False) 29 | 30 | 31 | def build_access_token_guest_user(): 32 | """ 33 | Build an access token, coming from the tenant ID we expect, but not an admin user. (Only used to test dependency) 34 | """ 35 | return do_build_access_token(tenant_id='intility_tenant_id', admin=True, guest_user=True) 36 | 37 | 38 | def build_evil_access_token(): 39 | """ 40 | Build an access token, but signed with an invalid key (not matching its `kid` 41 | """ 42 | return do_build_access_token(tenant_id='intility_tenant_id', evil=True) 43 | 44 | 45 | def build_access_token_invalid_claims(): 46 | """ 47 | Build an access token, but with invalid claims (audience does not match) 48 | """ 49 | return do_build_access_token(tenant_id='intility_tenant_id', aud='Jonas') 50 | 51 | 52 | def build_access_token_invalid_scopes(scopes='not_user_impersonation'): 53 | """ 54 | Build an access token, but with invalid scopes (not `user_impersonation`) 55 | """ 56 | return do_build_access_token(tenant_id='intility_tenant_id', scopes=scopes) 57 | 58 | 59 | def build_access_token_expired(): 60 | """ 61 | Build an access token, coming from the tenant ID we expect 62 | """ 63 | return do_build_access_token(tenant_id='intility_tenant_id', expired=True) 64 | 65 | 66 | def do_build_access_token( 67 | tenant_id: Optional[str] = None, 68 | aud: Optional[str] = None, 69 | expired: bool = False, 70 | evil: bool = False, 71 | admin: bool = True, 72 | scopes: str = 'user_impersonation', 73 | guest_user=False, 74 | ): 75 | """ 76 | Build the access token and encode it with the signing key. 77 | """ 78 | issued_at = int(time.time()) 79 | expires = issued_at - 1 if expired else issued_at + 3600 80 | claims = { 81 | 'aud': aud or 'oauth299-9999-9999-abcd-efghijkl1234567890', 82 | 'iss': 'https://login.microsoftonline.com/intility_tenant/v2.0', 83 | 'iat': issued_at, 84 | 'nbf': issued_at, 85 | 'exp': expires, 86 | '_claim_names': {'groups': 'src1'}, 87 | '_claim_sources': { 88 | 'src1': {'endpoint': f'https://graph.windows.net/{tenant_id}/users/JONASGUID/getMemberObjects'} 89 | }, 90 | 'aio': 'some long val', 91 | 'azp': 'some long val', 92 | 'azpacr': '0', 93 | 'name': 'Jonas Krüger Svensson / Intility AS', 94 | 'oid': '22222222-2222-2222-2222-222222222222', 95 | 'preferred_username': 'jonas.svensson@intility.no', 96 | 'rh': 'some long val', 97 | 'scp': scopes, 98 | 'sub': 'some long val', 99 | 'tid': tenant_id, 100 | 'uti': 'abcdefghijkl-mnopqrstu', 101 | 'ver': '2.0', 102 | 'wids': ['some long val'], 103 | 'roles': ['AdminUser' if admin else 'NormalUser'], 104 | } 105 | if guest_user: 106 | claims['idp'] = 'https://sts.windows.net/e49ee8b0-4ec8-486f-93f3-bedaa281a154/' 107 | 108 | signing_key = signing_key_a if evil else signing_key_b 109 | return jwt.encode( 110 | claims, 111 | signing_key.private_bytes( 112 | crypto_serialization.Encoding.PEM, 113 | crypto_serialization.PrivateFormat.PKCS8, 114 | crypto_serialization.NoEncryption(), 115 | ), 116 | algorithm='RS256', 117 | headers={'kid': 'real thumbprint', 'x5t': 'real thumbprint'}, 118 | ) 119 | 120 | 121 | def build_openid_keys(empty_keys: bool = False, no_valid_keys: bool = False) -> dict: 122 | """ 123 | Build OpenID keys which we'll host at https://login.microsoftonline.com/common/discovery/keys 124 | """ 125 | if empty_keys: 126 | return {'keys': []} 127 | elif no_valid_keys: 128 | return { 129 | 'keys': [ 130 | { 131 | 'use': 'sig', 132 | 'kid': 'dummythumbprint', 133 | 'x5t': 'dummythumbprint', 134 | **jwt.algorithms.RSAAlgorithm.to_jwk( 135 | signing_key_a, 136 | as_dict=True, 137 | ), 138 | } 139 | ] 140 | } 141 | else: 142 | return { 143 | 'keys': [ 144 | { 145 | 'use': 'sig', 146 | 'kid': 'dummythumbprint', 147 | 'x5t': 'dummythumbprint', 148 | **jwt.algorithms.RSAAlgorithm.to_jwk( 149 | signing_key_a.public_key(), 150 | as_dict=True, 151 | ), 152 | }, 153 | { 154 | 'use': 'sig', 155 | 'kid': 'real thumbprint', 156 | 'x5t': 'real thumbprint', 157 | **jwt.algorithms.RSAAlgorithm.to_jwk( 158 | signing_key_b.public_key(), 159 | as_dict=True, 160 | ), 161 | }, 162 | ] 163 | } 164 | 165 | 166 | def openid_configuration() -> dict: 167 | return { 168 | 'token_endpoint': 'https://login.microsoftonline.com/intility_tenant/oauth2/v2.0/token', 169 | 'token_endpoint_auth_methods_supported': ['client_secret_post', 'private_key_jwt', 'client_secret_basic'], 170 | 'jwks_uri': 'https://login.microsoftonline.com/intility_tenant/discovery/v2.0/keys', 171 | 'response_modes_supported': ['query', 'fragment', 'form_post'], 172 | 'subject_types_supported': ['pairwise'], 173 | 'id_token_signing_alg_values_supported': ['RS256'], 174 | 'response_types_supported': ['code', 'id_token', 'code id_token', 'id_token token'], 175 | 'scopes_supported': ['openid', 'profile', 'email', 'offline_access'], 176 | 'issuer': 'https://login.microsoftonline.com/intility_tenant/v2.0', 177 | 'request_uri_parameter_supported': False, 178 | 'userinfo_endpoint': 'https://graph.microsoft.com/oidc/userinfo', 179 | 'authorization_endpoint': 'https://login.microsoftonline.com/intility_tenant/oauth2/v2.0/authorize', 180 | 'device_authorization_endpoint': 'https://login.microsoftonline.com/intility_tenant/oauth2/v2.0/devicecode', 181 | 'http_logout_supported': True, 182 | 'frontchannel_logout_supported': True, 183 | 'end_session_endpoint': 'https://login.microsoftonline.com/intility_tenant/oauth2/v2.0/logout', 184 | 'claims_supported': [ 185 | 'sub', 186 | 'iss', 187 | 'cloud_instance_name', 188 | 'cloud_instance_host_name', 189 | 'cloud_graph_host_name', 190 | 'msgraph_host', 191 | 'aud', 192 | 'exp', 193 | 'iat', 194 | 'auth_time', 195 | 'acr', 196 | 'nonce', 197 | 'preferred_username', 198 | 'name', 199 | 'tid', 200 | 'ver', 201 | 'at_hash', 202 | 'c_hash', 203 | 'email', 204 | ], 205 | 'kerberos_endpoint': 'https://login.microsoftonline.com/intility_tenant/kerberos', 206 | 'tenant_region_scope': 'EU', 207 | 'cloud_instance_name': 'microsoftonline.com', 208 | 'cloud_graph_host_name': 'graph.windows.net', 209 | 'msgraph_host': 'graph.microsoft.com', 210 | 'rbac_url': 'https://pas.windows.net', 211 | } 212 | 213 | 214 | def openid_config_url(multi_tenant=False) -> str: 215 | if multi_tenant: 216 | return 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration' 217 | return 'https://login.microsoftonline.com/intility_tenant_id/v2.0/.well-known/openid-configuration' 218 | 219 | 220 | def keys_url() -> str: 221 | return 'https://login.microsoftonline.com/intility_tenant/discovery/v2.0/keys' 222 | 223 | 224 | signing_key_a = generate_private_key() 225 | signing_key_b = generate_private_key() 226 | --------------------------------------------------------------------------------