├── .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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | ## 🚀 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 | 
164 |
165 | The user can select which scopes to authenticate with, based on your configuration.
166 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
145 |
146 | Select the `user_impersonation` scope, and press **Add a permission**.
147 |
148 | Your view should now look something like this:
149 |
150 | 
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 | 
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 | 
172 |
173 |
174 | Keep all defaults for now and choose user attributes and token claims as required, and press **Create**
175 |
176 | 
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 |

11 |

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 | 
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 | 
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 | 
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 | 
59 |
60 | Add a scope named `user_impersonation` that can be consented by `Admins and users`.
61 | 
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 | 
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 | 
103 |
104 | Press **Save**
105 |
106 | ### Step 3 - Note down your application IDs
107 | You should now be redirected to the `Overview`.
108 |
109 | 
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 | 
125 |
126 | Select the `user_impersonation` scope, and press **Add a permission**.
127 |
128 | Your view should now look something like this:
129 |
130 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
135 |
136 | Select the `user_impersonation` scope, and press **Add a permission**.
137 |
138 | Your view should now look something like this:
139 |
140 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
9 |
10 | Create a role:
11 | 
12 |
13 | Go to Overview and click the Enterprise application link:
14 | 
15 |
16 | Go to `Users and groups` and add a user/group:
17 | 
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 | 
21 |
22 | Click `select role` and assign the user/role to your application role:
23 | 
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 |
--------------------------------------------------------------------------------