├── .editorconfig ├── .flake8 ├── .github └── workflows │ ├── cd.yml │ ├── ci.yml │ ├── codecov.yml │ └── docs.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── index.md ├── examples └── app1 │ ├── README.md │ ├── __init__.py │ ├── app.py │ ├── db.py │ ├── models.py │ └── settings.py ├── fastapi_security ├── __init__.py ├── _optional_dependencies.py ├── api.py ├── basic.py ├── entities.py ├── exceptions.py ├── oauth2.py ├── oidc.py ├── permissions.py ├── py.typed └── schemes.py ├── mkdocs.yml ├── mypy.ini ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── examples ├── __init__.py ├── helpers.py └── test_app1.py ├── helpers ├── __init__.py ├── jwks.py └── oidc.py ├── integration ├── __init__.py ├── test_api.py ├── test_basic_auth.py ├── test_oauth2.py ├── test_oidc.py ├── test_permission_overrides.py ├── test_permissions.py ├── test_user_data.py └── test_www_authenticate.py └── unit ├── __init__.py ├── test_entities.py ├── test_oauth2.py └── test_oidc.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | indent_style = space 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | [*.{md}] 16 | trim_trailing_whitespace = false 17 | 18 | [*.py] 19 | indent_size = 4 20 | 21 | [*.{js,jsx,ts,tsx}] 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203,E501,E711,E712,W503,W504 3 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | release_created: ${{ steps.release.outputs.release_created }} 11 | steps: 12 | - uses: google-github-actions/release-please-action@v3 13 | id: release 14 | with: 15 | release-type: python 16 | package-name: fastapi-security 17 | bump-minor-pre-major: true 18 | cd: 19 | runs-on: ubuntu-latest 20 | needs: [release-please] 21 | if: needs.release-please.outputs.release_created 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-python@v2 25 | with: 26 | python-version: '3.10' 27 | - uses: abatilo/actions-poetry@v2.1.4 28 | with: 29 | poetry-version: '1.1.13' 30 | - run: poetry build 31 | - run: poetry publish 32 | env: 33 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | unittests: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | python-version: ['3.10', '3.9', '3.8', '3.7'] 14 | os: [ubuntu-latest, macos-latest, windows-latest] 15 | extras: ["", "oauth2"] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - uses: abatilo/actions-poetry@v2.1.4 22 | with: 23 | poetry-version: '1.1.13' 24 | - run: poetry install --extras ${{ matrix.extras }} 25 | if: matrix.extras != '' 26 | - run: poetry install 27 | if: matrix.extras == '' 28 | - run: poetry run pytest -v 29 | 30 | style: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: actions/setup-python@v2 35 | with: 36 | python-version: '3.10' 37 | - uses: abatilo/actions-poetry@v2.1.4 38 | with: 39 | poetry-version: '1.1.13' 40 | - run: poetry install --extras oauth2 41 | - run: poetry run flake8 42 | - run: poetry run mypy --ignore-missing-imports . 43 | - run: poetry run isort --check --diff . 44 | - run: poetry run black --check --diff . 45 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: codecov 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | code_coverage: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.10' 16 | - uses: abatilo/actions-poetry@v2.1.4 17 | with: 18 | poetry-version: '1.1.13' 19 | - run: poetry install --extras oauth2 20 | - run: poetry run pytest --cov=fastapi_security --cov-report=xml --cov-report=term 21 | - uses: codecov/codecov-action@v1 22 | with: 23 | files: ./coverage.xml 24 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v2 12 | with: 13 | python-version: '3.9' 14 | - uses: abatilo/actions-poetry@v2.1.4 15 | with: 16 | poetry-version: '1.1.13' 17 | - run: poetry install 18 | - run: poetry run mkdocs build 19 | - uses: peaceiris/actions-gh-pages@v3.7.3 20 | with: 21 | github_token: "${{ secrets.GITHUB_TOKEN }}" 22 | publish_dir: ./site 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.sublime-* 4 | /.coverage 5 | /.mypy_cache 6 | /.pytest_cache 7 | /.tox 8 | /build 9 | /coverage.xml 10 | /dist 11 | /htmlcov 12 | /poetry.lock 13 | /site 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output = 3 3 | include_trailing_comma = True 4 | force_grid_wrap = 0 5 | use_parentheses = True 6 | line_length = 88 7 | known_third_party = aiohttp,aioresponses,cryptography,fastapi,jwt,pydantic,pytest,setuptools,starlette 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | 4 | repos: 5 | - repo: https://gitlab.com/pycqa/flake8 6 | rev: 4.0.1 7 | hooks: 8 | - id: flake8 9 | - repo: https://github.com/pycqa/isort 10 | rev: 5.10.1 11 | hooks: 12 | - id: isort 13 | - repo: https://github.com/psf/black 14 | rev: 22.1.0 15 | hooks: 16 | - id: black 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: v0.931 19 | hooks: 20 | - id: mypy 21 | additional_dependencies: [pydantic, types-requests] 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.0](https://github.com/jacobsvante/fastapi-security/compare/v0.4.0...v0.5.0) (2022-03-11) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * Make oauth2 dependencies optional 9 | 10 | ### Bug Fixes 11 | 12 | * Make oauth2 dependencies optional ([e0db0f4](https://github.com/jacobsvante/fastapi-security/commit/e0db0f45761d7295b1e500d5cde67d3c1f263b78)) 13 | 14 | 15 | ### Documentation 16 | 17 | * Document new extra for oauth2 support ([73e1696](https://github.com/jacobsvante/fastapi-security/commit/73e1696915f63ddf6d204adfddbfd49b10d3e4f5)) 18 | * Update changelog to conform to release-please format ([c9bfb16](https://github.com/jacobsvante/fastapi-security/commit/c9bfb16277efeb0ddfb19e3cc1e289608ce0ae94)) 19 | 20 | ## [0.3.1](https://github.com/jmagnusson/fastapi-security/compare/v0.3.0...v0.3.1) (2021-03-29) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | - Handle permission overrides iterators that are exhaustable 26 | - Ensure that a string permission override is always equal to `*` 27 | 28 | ## [0.3.0](https://github.com/jmagnusson/fastapi-security/compare/v0.2.0...v0.3.0) (2021-03-26) 29 | 30 | 31 | ### Features 32 | 33 | - OAuth2 and OIDC can now be enabled by just passing an OIDC discovery URL to `FastAPISecurity.init_oauth2_through_oidc` 34 | - Cached data is now used for JWKS and OIDC endpoints in case the "refresh requests" fail. 35 | - `UserPermission` objects are now created via `FastAPISecurity.user_permission`. 36 | - `FastAPISecurity.init` was split into three distinct methods: `.init_basic_auth`, `.init_oauth2_through_oidc` and `.init_oauth2_through_jwks`. 37 | - Broke out the `permission_overrides` argument from the old `.init` method and added a distinct method for adding new overrides `add_permission_overrides`. This method can be called multiple times. 38 | - The dependency `FastAPISecurity.has_permission` and `FastAPISecurity.user_with_permissions` has been replaced by `FastAPISecurity.user_holding`. API is the same (takes a variable number of UserPermission arguments, i.e. compatible with both). 39 | - Remove `app` argument to the `FastAPISecurity.init...` methods (it wasn't used before) 40 | - The global permissions registry has been removed. Now there should be no global mutable state left. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jacob Magnusson 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 | # FastAPI Security 2 | 3 | [![Continuous Integration Status](https://github.com/jacobsvante/fastapi-security/actions/workflows/ci.yml/badge.svg)](https://github.com/jacobsvante/fastapi-security/actions/workflows/ci.yml) 4 | [![Continuous Delivery Status](https://github.com/jacobsvante/fastapi-security/actions/workflows/cd.yml/badge.svg)](https://github.com/jacobsvante/fastapi-security/actions/workflows/cd.yml) 5 | [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-security.svg)](https://pypi.org/project/fastapi-security/) 6 | [![Code Coverage](https://img.shields.io/codecov/c/github/jacobsvante/fastapi-security?color=%2334D058)](https://codecov.io/gh/jacobsvante/fastapi-security) 7 | [![PyPI Package](https://img.shields.io/pypi/v/fastapi-security?color=%2334D058&label=pypi%20package)](https://pypi.org/project/fastapi-security) 8 | 9 | Add authentication and authorization to your FastAPI app via dependencies. 10 | 11 | ## Installation 12 | 13 | With OAuth2/OIDC support: 14 | 15 | ```bash 16 | pip install fastapi-security[oauth2] 17 | ``` 18 | 19 | With basic auth only: 20 | 21 | ```bash 22 | pip install fastapi-security 23 | ``` 24 | 25 | ## Documentation 26 | 27 | [The documentation for FastAPI-Security is found here](https://jacobsvante.github.io/fastapi-security/). 28 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # FastAPI-Security 2 | 3 | FastAPI-Security is a package that you can use together with [FastAPI](https://fastapi.tiangolo.com/) to easily add authentication and authorization. 4 | 5 | ## Installation 6 | 7 | With OAuth2/OIDC support: 8 | 9 | ```bash 10 | pip install fastapi-security[oauth2] 11 | ``` 12 | 13 | With basic auth only: 14 | 15 | ```bash 16 | pip install fastapi-security 17 | ``` 18 | 19 | ## Key features 20 | 21 | With base install: 22 | - Authentication via HTTP Basic Auth 23 | - Pydantic-based `User` model for authenticated and anonymous users 24 | - Limit endpoint access to authenticated users 25 | - Limit endpoint access to users with an explicit set of user permissions 26 | - Easily create endpoint for users to check their user info and permissions 27 | 28 | With extra `oauth2`: 29 | - Authentication via JWT-based OAuth 2 access tokens in addition to HTTP Basic Auth 30 | - Ability to extract user info from access tokens via OpenID Connect 31 | - Permissions are checked agains the `permissions` attribute returned in OAuth 2 access tokens 32 | 33 | ## Current limitations 34 | 35 | - Only supports validating access tokens using public keys from a JSON Web Key Set (JWKS) endpoint. I.e. for use with external identity providers such as Auth0 and ORY Hydra. 36 | - Permissions can only be picked up automatically from OAuth2 tokens, from the non-standard `permissions` list attribute (Auth0 provides this, maybe other identity providers as well). For all other use cases, `permission_overrides` must be used. For example if there's a basic auth user called `user1` you can set `permission_overrides={"user1": ["*"]}` to give the user access to all permissions, or `permission_overrides={"user1": ["products:create"]}` to only assign `user1` with the permission `products:create`. 37 | 38 | ## Usage example 39 | 40 | An example app using FastAPI-Security [can be found here](https://github.com/jacobsvante/fastapi-security/tree/main/examples). 41 | -------------------------------------------------------------------------------- /examples/app1/README.md: -------------------------------------------------------------------------------- 1 | # FastAPI-Security Example App 2 | 3 | To try out: 4 | 5 | ```bash 6 | pip install fastapi-security uvicorn 7 | export OIDC_DISCOVERY_URL='https://my-auth0-tenant.eu.auth0.com/.well-known/openid-configuration' 8 | export OAUTH2_AUDIENCES='["my-audience"]' 9 | export BASIC_AUTH_CREDENTIALS='[{"username": "user1", "password": "test"}]' 10 | export PERMISSION_OVERRIDES='{"user1": ["products:create"]}' 11 | uvicorn app1:app 12 | ``` 13 | 14 | You would need to replace the `my-auth0-tenant.eu.auth0.com` part to make it work. 15 | -------------------------------------------------------------------------------- /examples/app1/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import app # noqa 2 | -------------------------------------------------------------------------------- /examples/app1/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | from fastapi import Depends, FastAPI 5 | 6 | from fastapi_security import FastAPISecurity, User 7 | 8 | from . import db 9 | from .models import Product 10 | from .settings import get_settings 11 | 12 | app = FastAPI() 13 | 14 | settings = get_settings() 15 | 16 | security = FastAPISecurity() 17 | 18 | if settings.basic_auth_credentials: 19 | security.init_basic_auth(settings.basic_auth_credentials) 20 | 21 | if settings.oidc_discovery_url: 22 | security.init_oauth2_through_oidc( 23 | settings.oidc_discovery_url, 24 | audiences=settings.oauth2_audiences, 25 | ) 26 | elif settings.oauth2_jwks_url: 27 | security.init_oauth2_through_jwks( 28 | settings.oauth2_jwks_url, 29 | audiences=settings.oauth2_audiences, 30 | ) 31 | 32 | security.add_permission_overrides(settings.permission_overrides or {}) 33 | 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | create_product_perm = security.user_permission("products:create") 38 | 39 | 40 | @app.get("/users/me") 41 | async def get_user_details(user: User = Depends(security.user_with_info)): 42 | """Return user details, regardless of whether user is authenticated or not""" 43 | return user.without_access_token() 44 | 45 | 46 | @app.get("/users/me/permissions", response_model=List[str]) 47 | def get_user_permissions(user: User = Depends(security.authenticated_user_or_401)): 48 | """Return user permissions or HTTP401 if not authenticated""" 49 | return user.permissions 50 | 51 | 52 | @app.post("/products", response_model=Product, status_code=201) 53 | async def create_product( 54 | product: Product, 55 | user: User = Depends(security.user_holding(create_product_perm)), 56 | ): 57 | """Create product 58 | 59 | Requires the authenticated user to have the `products:create` permission 60 | """ 61 | await db.persist(product) 62 | return product 63 | -------------------------------------------------------------------------------- /examples/app1/db.py: -------------------------------------------------------------------------------- 1 | async def persist(model): 2 | ... # Dummy implementation 3 | -------------------------------------------------------------------------------- /examples/app1/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Product(BaseModel): 5 | name: str 6 | -------------------------------------------------------------------------------- /examples/app1/settings.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseSettings 5 | 6 | from fastapi_security import HTTPBasicCredentials, PermissionOverrides 7 | 8 | __all__ = ("get_settings",) 9 | 10 | 11 | class _Settings(BaseSettings): 12 | # NOTE: You only need to supply `oidc_discovery_url` (preferred) OR `oauth2_jwks_url` 13 | oidc_discovery_url: Optional[str] = None 14 | oauth2_jwks_url: Optional[str] = None 15 | oauth2_audiences: Optional[List[str]] = None 16 | basic_auth_credentials: Optional[List[HTTPBasicCredentials]] = None 17 | permission_overrides: PermissionOverrides = {} 18 | 19 | 20 | @lru_cache() 21 | def get_settings() -> _Settings: 22 | return _Settings() # Reads variables from environment 23 | -------------------------------------------------------------------------------- /fastapi_security/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import * # noqa 2 | from .basic import * # noqa 3 | from .entities import * # noqa 4 | from .oauth2 import * # noqa 5 | from .oidc import * # noqa 6 | from .permissions import * # noqa 7 | from .schemes import * # noqa 8 | -------------------------------------------------------------------------------- /fastapi_security/_optional_dependencies.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | "OAUTH2_DEPENDENCY_INSTALLED", 3 | "_RSAPublicKey", 4 | "RSAAlgorithm", 5 | "aiohttp", 6 | "jwt", 7 | ) 8 | 9 | try: 10 | import aiohttp 11 | except ImportError: 12 | 13 | class aiohttp: # type: ignore[no-redef] 14 | pass 15 | 16 | 17 | try: 18 | import jwt 19 | from jwt.algorithms import RSAAlgorithm 20 | except ImportError: 21 | OAUTH2_DEPENDENCY_INSTALLED = False 22 | 23 | class jwt: # type: ignore[no-redef] 24 | pass 25 | 26 | class RSAAlgorithm: # type: ignore[no-redef] 27 | pass 28 | 29 | else: 30 | OAUTH2_DEPENDENCY_INSTALLED = True 31 | 32 | 33 | try: 34 | from cryptography.hazmat.backends.openssl.rsa import _RSAPublicKey 35 | except ImportError: 36 | 37 | class _RSAPublicKey: # type: ignore[no-redef] 38 | pass 39 | -------------------------------------------------------------------------------- /fastapi_security/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, Dict, Iterable, List, Optional, Type 3 | 4 | from fastapi import Depends, HTTPException 5 | from fastapi.security.http import HTTPAuthorizationCredentials 6 | from starlette.datastructures import Headers 7 | 8 | from .basic import BasicAuthValidator, IterableOfHTTPBasicCredentials 9 | from .entities import AuthMethod, User, UserAuth, UserInfo 10 | from .exceptions import AuthNotConfigured 11 | from .oauth2 import Oauth2JwtAccessTokenValidator 12 | from .oidc import OpenIdConnectDiscovery 13 | from .permissions import PermissionOverrides, UserPermission 14 | from .schemes import http_basic_scheme, jwt_bearer_scheme 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | __all__ = ("FastAPISecurity",) 19 | 20 | 21 | class FastAPISecurity: 22 | """FastAPI Security main class, to be instantiated by users of the package 23 | 24 | Must be initialized after object creation via the `init()` method. 25 | """ 26 | 27 | def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermission): 28 | self.basic_auth = BasicAuthValidator() 29 | self.oauth2_jwt = Oauth2JwtAccessTokenValidator() 30 | self.oidc_discovery = OpenIdConnectDiscovery() 31 | self._permission_overrides: Dict[str, List[str]] = {} 32 | self._user_permission_class = user_permission_class 33 | self._all_permissions: List[UserPermission] = [] 34 | self._oauth2_init_through_oidc = False 35 | self._oauth2_audiences: List[str] = [] 36 | 37 | def init_basic_auth(self, basic_auth_credentials: IterableOfHTTPBasicCredentials): 38 | self.basic_auth.init(basic_auth_credentials) 39 | 40 | def init_oauth2_through_oidc( 41 | self, oidc_discovery_url: str, *, audiences: Iterable[str] = None 42 | ): 43 | """Initialize OIDC and OAuth2 authentication/authorization 44 | 45 | OAuth2 JWKS URL is lazily fetched from the OIDC endpoint once it's needed for the first time. 46 | 47 | This method is preferred over `init_oauth2_through_jwks` as you get all the 48 | benefits of OIDC, with less configuration supplied. 49 | """ 50 | self._oauth2_audiences.extend(audiences or []) 51 | self.oidc_discovery.init(oidc_discovery_url) 52 | 53 | def init_oauth2_through_jwks( 54 | self, jwks_uri: str, *, audiences: Iterable[str] = None 55 | ): 56 | """Initialize OAuth2 57 | 58 | It's recommended to use `init_oauth2_through_oidc` instead. 59 | """ 60 | self._oauth2_audiences.extend(audiences or []) 61 | self.oauth2_jwt.init(jwks_uri, audiences=self._oauth2_audiences) 62 | 63 | def add_permission_overrides(self, overrides: PermissionOverrides): 64 | """Add wildcard or specific permissions to basic auth and/or OAuth2 users 65 | 66 | Example: 67 | security = FastAPISecurity() 68 | create_product = security.user_permission("products:create") 69 | 70 | # Give all permissions to the user johndoe 71 | security.add_permission_overrides({"johndoe": "*"}) 72 | 73 | # Give the OAuth2 user `7ZmI5ycgNHeZ9fHPZZwTNbIRd9Ectxca@clients` the 74 | # "products:create" permission. 75 | security.add_permission_overrides({ 76 | "7ZmI5ycgNHeZ9fHPZZwTNbIRd9Ectxca@clients": ["products:create"], 77 | }) 78 | 79 | """ 80 | for user, val in overrides.items(): 81 | lst: List[str] = self._permission_overrides.setdefault(user, []) 82 | if isinstance(val, str): 83 | assert ( 84 | val == "*" 85 | ), "Only `*` is accepted as permission override when specified as a string" 86 | logger.debug(f"Adding wildcard `*` permission to user {user}") 87 | lst.append("*") 88 | else: 89 | for p in val: 90 | logger.debug(f"Adding permission {p} to user {user}") 91 | lst.append(p) 92 | 93 | @property 94 | def user(self) -> Callable: 95 | """Dependency that returns User object, authenticated or not""" 96 | 97 | async def dependency(user_auth: UserAuth = Depends(self._user_auth)): 98 | return User(auth=user_auth) 99 | 100 | return dependency 101 | 102 | @property 103 | def authenticated_user_or_401(self) -> Callable: 104 | """Dependency that returns User object if authenticated, 105 | otherwise raises HTTP401 106 | """ 107 | 108 | async def dependency(user_auth: UserAuth = Depends(self._user_auth_or_401)): 109 | return User(auth=user_auth) 110 | 111 | return dependency 112 | 113 | @property 114 | def user_with_info(self) -> Callable: 115 | """Dependency that returns User object with user info, authenticated or not""" 116 | 117 | async def dependency(user_auth: UserAuth = Depends(self._user_auth)): 118 | if user_auth.is_oauth2() and user_auth.access_token: 119 | info = await self.oidc_discovery.get_user_info(user_auth.access_token) 120 | else: 121 | info = UserInfo.make_dummy() 122 | return User(auth=user_auth, info=info) 123 | 124 | return dependency 125 | 126 | @property 127 | def authenticated_user_with_info_or_401(self) -> Callable: 128 | """Dependency that returns User object along with user info if authenticated, 129 | otherwise raises HTTP401 130 | """ 131 | 132 | async def dependency(user_auth: UserAuth = Depends(self._user_auth_or_401)): 133 | if user_auth.is_oauth2() and user_auth.access_token: 134 | info = await self.oidc_discovery.get_user_info(user_auth.access_token) 135 | else: 136 | info = UserInfo.make_dummy() 137 | return User(auth=user_auth, info=info) 138 | 139 | return dependency 140 | 141 | def user_permission(self, identifier: str) -> UserPermission: 142 | perm = self._user_permission_class(identifier) 143 | self._all_permissions.append(perm) 144 | return perm 145 | 146 | def user_holding(self, *permissions: UserPermission) -> Callable: 147 | """Dependency that returns the user if it has the given permissions, otherwise 148 | raises HTTP403 149 | """ 150 | 151 | async def dependency( 152 | user: User = Depends(self.authenticated_user_or_401), 153 | ) -> User: 154 | for perm in permissions: 155 | self._has_permission_or_raise_forbidden(user, perm) 156 | return user 157 | 158 | return dependency 159 | 160 | @property 161 | def _user_auth(self) -> Callable: 162 | """Dependency that returns UserAuth object if authentication was successful""" 163 | 164 | async def dependency( 165 | bearer_credentials: HTTPAuthorizationCredentials = Depends( 166 | jwt_bearer_scheme 167 | ), 168 | http_credentials: HTTPAuthorizationCredentials = Depends(http_basic_scheme), 169 | ) -> Optional[UserAuth]: 170 | oidc_configured = self.oidc_discovery.is_configured() 171 | oauth2_configured = self.oauth2_jwt.is_configured() 172 | basic_auth_configured = self.basic_auth.is_configured() 173 | 174 | if not any([oidc_configured, oauth2_configured, basic_auth_configured]): 175 | raise AuthNotConfigured() 176 | 177 | if oidc_configured and not oauth2_configured: 178 | jwks_uri = await self.oidc_discovery.get_jwks_uri() 179 | self.init_oauth2_through_jwks(jwks_uri) 180 | 181 | if bearer_credentials is not None: 182 | bearer_token = bearer_credentials.credentials 183 | access_token = await self.oauth2_jwt.parse(bearer_token) 184 | if access_token: 185 | return self._maybe_override_permissions( 186 | UserAuth.from_jwt_access_token(access_token) 187 | ) 188 | elif http_credentials is not None and self.basic_auth.is_configured(): 189 | if self.basic_auth.validate(http_credentials): 190 | return self._maybe_override_permissions( 191 | UserAuth( 192 | subject=http_credentials.username, 193 | auth_method=AuthMethod.basic_auth, 194 | ) 195 | ) 196 | 197 | return UserAuth.make_anonymous() 198 | 199 | return dependency 200 | 201 | @property 202 | def _user_auth_or_401(self) -> Callable: 203 | """Dependency that returns UserAuth object on success, or raises HTTP401""" 204 | 205 | async def dependency( 206 | user_auth: UserAuth = Depends(self._user_auth), 207 | http_credentials: HTTPAuthorizationCredentials = Depends(http_basic_scheme), 208 | ): 209 | 210 | if user_auth and user_auth.is_authenticated(): 211 | return user_auth 212 | 213 | options = [] 214 | 215 | if self.basic_auth.is_configured(): 216 | options.append("Basic") 217 | if self.oauth2_jwt.is_configured(): 218 | options.append("Bearer") 219 | 220 | raise HTTPException( 221 | status_code=401, 222 | detail="Could not validate credentials", 223 | headers=Headers( # type: ignore[arg-type] 224 | raw=[(b"WWW-Authenticate", o.encode("latin-1")) for o in options], 225 | ), 226 | ) 227 | 228 | return dependency 229 | 230 | def _has_permission_or_raise_forbidden(self, user: User, perm: UserPermission): 231 | if not user.has_permission(perm.identifier): 232 | self._raise_forbidden(perm.identifier) 233 | 234 | def _raise_forbidden(self, required_permission: str): 235 | raise HTTPException( 236 | 403, 237 | detail=f"Missing required permission {required_permission}", 238 | ) 239 | 240 | def _maybe_override_permissions(self, user_auth: UserAuth) -> UserAuth: 241 | overrides = self._permission_overrides.get(user_auth.subject) 242 | 243 | all_permission_identifiers = [p.identifier for p in self._all_permissions] 244 | 245 | if overrides is None: 246 | return user_auth.with_permissions( 247 | [ 248 | incoming_id 249 | for incoming_id in user_auth.permissions 250 | if incoming_id in all_permission_identifiers 251 | ] 252 | ) 253 | elif "*" in overrides: 254 | return user_auth.with_permissions(all_permission_identifiers) 255 | else: 256 | return user_auth.with_permissions( 257 | [p for p in overrides if p in all_permission_identifiers] 258 | ) 259 | -------------------------------------------------------------------------------- /fastapi_security/basic.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Dict, Iterable, List, Union 3 | 4 | from fastapi.security.http import HTTPBasicCredentials 5 | 6 | __all__ = ("HTTPBasicCredentials",) 7 | 8 | IterableOfHTTPBasicCredentials = Iterable[Union[HTTPBasicCredentials, Dict]] 9 | 10 | 11 | class BasicAuthValidator: 12 | def __init__(self): 13 | self._credentials = [] 14 | 15 | def init(self, credentials: IterableOfHTTPBasicCredentials): 16 | self._credentials = self._make_credentials(credentials) 17 | 18 | def is_configured(self) -> bool: 19 | return len(self._credentials) > 0 20 | 21 | def validate(self, credentials: HTTPBasicCredentials) -> bool: 22 | if not self.is_configured(): 23 | return False 24 | return any( 25 | ( 26 | secrets.compare_digest(c.username, credentials.username) 27 | and secrets.compare_digest(c.password, credentials.password) 28 | ) 29 | for c in self._credentials 30 | ) 31 | 32 | def _make_credentials( 33 | self, credentials: IterableOfHTTPBasicCredentials 34 | ) -> List[HTTPBasicCredentials]: 35 | return [ 36 | c if isinstance(c, HTTPBasicCredentials) else HTTPBasicCredentials(**c) 37 | for c in credentials 38 | ] 39 | -------------------------------------------------------------------------------- /fastapi_security/entities.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Any, Dict, List, Optional 4 | 5 | from pydantic import BaseModel, Field, validator 6 | 7 | __all__ = ("User",) 8 | 9 | 10 | class JwtAccessToken(BaseModel): 11 | iss: str = Field(..., description="Issuer") 12 | sub: str = Field(..., description="Subject") 13 | aud: List[str] = Field(..., description="Audience") 14 | iat: datetime = Field(..., description="Issued At") 15 | exp: datetime = Field(..., description="Expiration Time") 16 | azp: str = Field( 17 | None, 18 | description="Authorized party - the party to which the ID Token was issued", 19 | ) 20 | gty: str = Field( 21 | "", 22 | description="Grant type (auth0 specific, see https://auth0.com/docs/applications/concepts/application-grant-types)", 23 | ) 24 | scope: List[str] = Field("", description="Scope Values") 25 | permissions: List[str] = Field( 26 | [], 27 | description="Permissions (auth0 specific, intended for first-party app authorization)", 28 | ) 29 | raw: str = Field(..., description="The raw access token") 30 | 31 | @validator("aud", pre=True, always=True) 32 | def aud_to_list(cls, v): 33 | return v.split(" ") if isinstance(v, str) else v 34 | 35 | @validator("scope", pre=True, always=True) 36 | def scope_to_list(cls, v): 37 | if isinstance(v, str): 38 | return [s for s in v.split(" ") if s] 39 | else: 40 | return v 41 | 42 | @validator("permissions", pre=True, always=True) 43 | def permissions_to_list(cls, v): 44 | return v.split(" ") if isinstance(v, str) else v 45 | 46 | def is_client_credentials(self): 47 | return self.gty == "client-credentials" 48 | 49 | 50 | class AuthMethod(str, Enum): 51 | none = "none" 52 | basic_auth = "basic_auth" 53 | oauth2 = "oauth2" 54 | oauth2_client_credentials = "oauth2_client_credentials" 55 | 56 | def is_oauth2_type(self): 57 | return self in (AuthMethod.oauth2_client_credentials, AuthMethod.oauth2) 58 | 59 | 60 | class UserInfo(BaseModel): 61 | given_name: Optional[str] = None 62 | family_name: Optional[str] = None 63 | nickname: Optional[str] = None 64 | name: Optional[str] = None 65 | picture: Optional[str] = None 66 | locale: Optional[str] = None 67 | updated_at: Optional[datetime] = None 68 | email: Optional[str] = None 69 | email_verified: Optional[bool] = None 70 | 71 | @classmethod 72 | def from_oidc_endpoint(cls, data: Dict[str, Any]) -> "UserInfo": 73 | return cls(**data) 74 | 75 | @classmethod 76 | def make_dummy(cls): 77 | return cls() 78 | 79 | 80 | class UserAuth(BaseModel): 81 | subject: str 82 | auth_method: AuthMethod 83 | issuer: Optional[str] = None 84 | audience: List[str] = [] 85 | issued_at: Optional[datetime] = None 86 | expires_at: Optional[datetime] = None 87 | scopes: List[str] = [] 88 | permissions: List[str] = [] 89 | access_token: Optional[str] = None 90 | 91 | def is_authenticated(self) -> bool: 92 | return self.auth_method is not AuthMethod.none 93 | 94 | def is_anonymous(self) -> bool: 95 | return not self.is_authenticated() 96 | 97 | def is_oauth2(self) -> bool: 98 | return self.auth_method.is_oauth2_type() 99 | 100 | def get_user_id(self) -> str: 101 | return self.subject 102 | 103 | def with_permissions(self, permissions: List[str]) -> "UserAuth": 104 | return self.copy(deep=True, update={"permissions": permissions}) 105 | 106 | def has_permission(self, permission: str) -> bool: 107 | return permission in self.permissions 108 | 109 | @classmethod 110 | def from_jwt_access_token(cls, access_token: JwtAccessToken) -> "UserAuth": 111 | if access_token.is_client_credentials(): 112 | auth_method = AuthMethod.oauth2_client_credentials 113 | else: 114 | auth_method = AuthMethod.oauth2 115 | return cls( 116 | auth_method=auth_method, 117 | subject=access_token.sub, 118 | issuer=access_token.iss, 119 | audience=access_token.aud, 120 | issued_at=access_token.iat, 121 | expires_at=access_token.exp, 122 | scopes=access_token.scope, 123 | permissions=access_token.permissions, 124 | access_token=access_token.raw, 125 | ) 126 | 127 | @classmethod 128 | def make_anonymous(cls) -> "UserAuth": 129 | return cls(subject="anonymous", auth_method=AuthMethod.none) 130 | 131 | 132 | class User(BaseModel): 133 | auth: UserAuth 134 | info: Optional[UserInfo] = None 135 | 136 | @property 137 | def permissions(self) -> List[str]: 138 | return self.auth.permissions 139 | 140 | def is_authenticated(self) -> bool: 141 | return self.auth.is_authenticated() 142 | 143 | def is_anonymous(self) -> bool: 144 | return self.auth.is_anonymous() 145 | 146 | def get_user_id(self) -> str: 147 | return self.auth.get_user_id() 148 | 149 | def has_permission(self, permission: str) -> bool: 150 | return self.auth.has_permission(permission) 151 | 152 | def without_access_token(self) -> "User": 153 | return self.copy(deep=True, exclude={"auth": {"access_token"}}) 154 | -------------------------------------------------------------------------------- /fastapi_security/exceptions.py: -------------------------------------------------------------------------------- 1 | __all__ = ("AuthNotConfigured", "MissingDependency") 2 | 3 | 4 | class AuthNotConfigured(Exception): 5 | """Raised when no authentication backend has been set up""" 6 | 7 | 8 | class MissingDependency(Exception): 9 | """Raised when a python dependency is missing that's needed""" 10 | -------------------------------------------------------------------------------- /fastapi_security/oauth2.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from typing import Any, Dict, Iterable, List, Optional 5 | 6 | from pydantic import ValidationError 7 | 8 | from ._optional_dependencies import RSAAlgorithm, _RSAPublicKey, aiohttp, jwt 9 | from .entities import JwtAccessToken 10 | from .exceptions import MissingDependency 11 | 12 | __all__ = () 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | DEFAULT_JWKS_RESPONSE_CACHE_PERIOD = 3600 # 1 hour 18 | 19 | 20 | class Oauth2JwtAccessTokenValidator: 21 | """Parses and validates JWT-format OAuth2 access tokens against a JWKS endpoint 22 | 23 | Fetches public keys from the provided JWKS endpoint which is used to verify 24 | the validity of the access token. 25 | 26 | Only supports the RS256 algorithm. 27 | 28 | The public keys from the JWKS endpoint are cached for one hour by default, to 29 | minimize the number of external requests that have to be made. 30 | 31 | Not ready to use until it's been initialized via the `init` method. 32 | """ 33 | 34 | def __init__(self): 35 | self._jwks_url: Optional[str] = None 36 | self._audiences: Optional[List[str]] = None 37 | self._jwks_kid_mapping: Dict[str, _RSAPublicKey] = None 38 | self._jwks_cache_period: float = float(DEFAULT_JWKS_RESPONSE_CACHE_PERIOD) 39 | self._jwks_cached_at: Optional[float] = None 40 | 41 | def init( 42 | self, 43 | jwks_url: str, 44 | audiences: Iterable[str], 45 | *, 46 | jwks_cache_period: int = DEFAULT_JWKS_RESPONSE_CACHE_PERIOD, 47 | ): 48 | """Set up Oauth 2.0 JWT validation 49 | 50 | Args: 51 | jwks_url: 52 | The JWKS endpoint to fetch the public keys from. Usually in the 53 | format: "https://domain/.well-known/jwks.json" 54 | audiences: 55 | Accepted `aud` values for incoming access tokens 56 | jwks_cache_period: 57 | How many seconds to cache the JWKS response. Defaults to 1 hour. 58 | """ 59 | if aiohttp is None: 60 | raise MissingDependency( 61 | "`aiohttp` dependency not installed, ensure its availability with `pip install fastapi-security[oauth2]`" 62 | ) 63 | if jwt is None: 64 | raise MissingDependency( 65 | "`jwt` dependency not installed, ensure its availability with `pip install fastapi-security[oauth2]`" 66 | ) 67 | self._jwks_url = jwks_url 68 | self._jwks_cache_period = float(jwks_cache_period) 69 | self._audiences = list(audiences) 70 | 71 | def is_configured(self) -> bool: 72 | return bool(self._jwks_url) 73 | 74 | async def parse(self, access_token: str) -> Optional[JwtAccessToken]: 75 | """Parse the supplied JWT-formatted access token 76 | 77 | Returns a parsed JwtAccessToken object on successful verification, 78 | otherwise `None`. 79 | """ 80 | if not self.is_configured(): 81 | logger.info("JWT Access Token validator is not set up!") 82 | return None 83 | 84 | if not access_token: 85 | logger.debug("No JWT token provided") 86 | return None 87 | 88 | try: 89 | unverified_header = jwt.get_unverified_header(access_token) 90 | except jwt.InvalidTokenError as ex: 91 | logger.debug(f"Decoding unverified JWT token failed with error: {ex!r}") 92 | return None 93 | 94 | try: 95 | token_kid = unverified_header["kid"] 96 | except KeyError: 97 | logger.debug("No `kid` found in JWT token") 98 | return None 99 | 100 | try: 101 | public_key = await self._get_public_key(token_kid) 102 | except KeyError: 103 | logger.debug("No matching `kid` for JWT token") 104 | return None 105 | 106 | try: 107 | decoded = self._decode_jwt_token(public_key, access_token) 108 | except jwt.InvalidTokenError as ex: 109 | logger.debug(f"Decoding verified JWT token failed with error: {ex!r}") 110 | return None 111 | 112 | try: 113 | parsed_access_token = JwtAccessToken(**decoded, raw=access_token) 114 | except ValidationError as ex: 115 | logger.debug(f"Failed to parse JWT token with {ex}") 116 | return None 117 | 118 | return parsed_access_token 119 | 120 | async def _get_public_key(self, kid: str) -> _RSAPublicKey: 121 | mapping = await self._get_jwks_kid_mapping() 122 | return mapping[kid] 123 | 124 | async def _get_jwks_kid_mapping(self) -> Dict[str, _RSAPublicKey]: 125 | if ( 126 | self._jwks_cached_at is None 127 | or (time.monotonic() - self._jwks_cached_at) > self._jwks_cache_period 128 | ): 129 | try: 130 | jwks_data = await self._fetch_jwks_data() 131 | except Exception as ex: 132 | if self._jwks_kid_mapping is None: 133 | raise 134 | else: 135 | logger.info( 136 | f"Failed to refresh JWKS kid mapping, re-using old data. " 137 | f"Exception was: {ex!r}" 138 | ) 139 | self._jwks_cached_at = time.monotonic() 140 | else: 141 | self._jwks_kid_mapping = { 142 | k["kid"]: RSAAlgorithm.from_jwk(json.dumps(k)) 143 | for k in jwks_data["keys"] 144 | if k["kty"] == "RSA" and k["alg"] == "RS256" 145 | } 146 | self._jwks_cached_at = time.monotonic() 147 | assert len(self._jwks_kid_mapping) > 0 148 | 149 | return self._jwks_kid_mapping 150 | 151 | async def _fetch_jwks_data(self): 152 | timeout = aiohttp.ClientTimeout(total=10) 153 | 154 | logger.info(f"Fetching JWKS data from {self._jwks_url}") 155 | 156 | async with aiohttp.ClientSession(timeout=timeout) as session: 157 | async with session.get(self._jwks_url) as response: 158 | return await response.json() 159 | 160 | def _decode_jwt_token( 161 | self, public_key: _RSAPublicKey, access_token: str 162 | ) -> Dict[str, Any]: 163 | # NOTE: jwt.decode has erroneously set key: str 164 | return jwt.decode(access_token, key=public_key, audience=self._audiences, algorithms=["RS256"]) # type: ignore 165 | -------------------------------------------------------------------------------- /fastapi_security/oidc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import Any, Dict, Optional 4 | 5 | from ._optional_dependencies import aiohttp 6 | from .entities import UserInfo 7 | from .exceptions import MissingDependency 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | DEFAULT_DISCOVERY_RESPONSE_CACHE_PERIOD = 3600 # 1 hour 13 | 14 | 15 | class OpenIdConnectDiscovery: 16 | """Retrieve info from OpenID Connect (OIDC) endpoints""" 17 | 18 | def __init__(self): 19 | self._discovery_url: Optional[str] = None 20 | self._discovery_data_cached_at: Optional[float] = None 21 | self._discovery_cache_period: float = float( 22 | DEFAULT_DISCOVERY_RESPONSE_CACHE_PERIOD 23 | ) 24 | self._discovery_data: Optional[Dict[str, Any]] = None 25 | 26 | def init( 27 | self, 28 | discovery_url: str, 29 | *, 30 | discovery_cache_period: int = DEFAULT_DISCOVERY_RESPONSE_CACHE_PERIOD, 31 | ): 32 | """Set up OpenID Connect data fetching 33 | 34 | Args: 35 | discovery_url: 36 | The well-known OpenID Connect discovery endpoint 37 | Example: "https://domain/.well-known/openid-connect" 38 | discovery_cache_period: 39 | How many seconds to cache the OpenID Discovery endpoint response. Defaults to 1 hour. 40 | """ 41 | if aiohttp is None: 42 | raise MissingDependency( 43 | "`aiohttp` dependency not installed, ensure its availability with `pip install fastapi-security[oauth2]`" 44 | ) 45 | self._discovery_url = discovery_url 46 | self._discovery_cache_period = float(discovery_cache_period) 47 | 48 | def is_configured(self) -> bool: 49 | return bool(self._discovery_url) 50 | 51 | async def get_user_info(self, access_token: str) -> Optional[UserInfo]: 52 | """Get user info for the given OAuth 2 access token 53 | 54 | Returns a parsed UserInfo object on successful verification, 55 | otherwise `None`. 56 | """ 57 | if not self.is_configured(): 58 | logger.info("OpenID Connect discovery URL is not set up!") 59 | return None 60 | 61 | if not access_token: 62 | logger.debug("No access token provided") 63 | return None 64 | 65 | user_info = await self._fetch_user_info(access_token) 66 | 67 | if user_info is None: 68 | return UserInfo.make_dummy() 69 | else: 70 | return UserInfo.from_oidc_endpoint(user_info) 71 | 72 | async def get_jwks_uri(self) -> str: 73 | """Get or fetch the JWKS URI""" 74 | data = await self.get_discovery_data() 75 | return data["jwks_uri"] 76 | 77 | async def _fetch_user_info(self, access_token: str) -> Optional[Dict[str, Any]]: 78 | timeout = aiohttp.ClientTimeout(total=10) 79 | url = await self.get_user_info_endpoint() 80 | headers = {"Authorization": f"Bearer {access_token}"} 81 | 82 | logger.debug(f"Fetching user info from {url}") 83 | 84 | async with aiohttp.ClientSession(timeout=timeout) as session: 85 | async with session.get(url, headers=headers) as response: 86 | if response.status == 200: 87 | return await response.json() 88 | else: 89 | logger.debug( 90 | "User info could not be fetched (might be a machine user)" 91 | ) 92 | return None 93 | 94 | async def get_user_info_endpoint(self) -> str: 95 | data = await self.get_discovery_data() 96 | return data["userinfo_endpoint"] 97 | 98 | async def get_discovery_data(self) -> Dict[str, Any]: 99 | if ( 100 | self._discovery_data is None 101 | or self._discovery_data_cached_at is None 102 | or ( 103 | (time.monotonic() - self._discovery_data_cached_at) 104 | > self._discovery_cache_period 105 | ) 106 | ): 107 | try: 108 | self._discovery_data = await self._fetch_discovery_data() 109 | except Exception as ex: 110 | if self._discovery_data is None: 111 | raise 112 | else: 113 | logger.info( 114 | f"Failed to refresh OIDC discovery data, re-using old data. " 115 | f"Exception was: {ex!r}" 116 | ) 117 | self._discovery_data_cached_at = time.monotonic() 118 | else: 119 | self._discovery_data_cached_at = time.monotonic() 120 | 121 | return self._discovery_data 122 | 123 | async def _fetch_discovery_data(self) -> Dict[str, Any]: 124 | timeout = aiohttp.ClientTimeout(total=10) 125 | assert self._discovery_url, "No OIDC discovery URL specified" 126 | 127 | logger.debug(f"Fetching OIDC discovery data from {self._discovery_url}") 128 | 129 | async with aiohttp.ClientSession(timeout=timeout, raise_for_status=True) as s: 130 | async with s.get(self._discovery_url) as response: 131 | return await response.json() 132 | -------------------------------------------------------------------------------- /fastapi_security/permissions.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, MutableSequence, Union 2 | 3 | __all__ = ("PermissionOverrides",) 4 | 5 | 6 | PermissionOverrides = Dict[str, Union[str, MutableSequence[str]]] 7 | 8 | 9 | class UserPermission: 10 | """Represents a user permission 11 | 12 | Creating a new permission is done like this: 13 | 14 | security = FastAPISecurity() 15 | create_item_permission = security.user_permission("item:create") 16 | 17 | Usage: 18 | 19 | @app.post( 20 | "/products", 21 | dependencies=[Depends(security.user_holding(create_item_permission))] 22 | ) 23 | def create_product(...): 24 | ... 25 | 26 | Or: 27 | @app.post("/products") 28 | def create_product( 29 | user: Depends(security.user_holding(create_item_permission)) 30 | ): 31 | ... 32 | 33 | """ 34 | 35 | def __init__(self, identifier: str): 36 | self.identifier = identifier 37 | 38 | def __str__(self): 39 | return self.identifier 40 | 41 | def __repr__(self): 42 | return f"{self.__class__.__name__}({self.identifier})" 43 | -------------------------------------------------------------------------------- /fastapi_security/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/fastapi-security/88de74d24cad47b0e6a06a6ff807fc1cc423c810/fastapi_security/py.typed -------------------------------------------------------------------------------- /fastapi_security/schemes.py: -------------------------------------------------------------------------------- 1 | from fastapi.security.http import HTTPBasic, HTTPBearer 2 | 3 | __all__ = () 4 | 5 | jwt_bearer_scheme = HTTPBearer( 6 | auto_error=False, 7 | bearerFormat="JWT-formatted OAuth2 Access Token", 8 | scheme_name="JWT-formatted OAuth2 Access Token", 9 | ) 10 | http_basic_scheme = HTTPBasic(auto_error=False) 11 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI-Security 2 | theme: 3 | name: material 4 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-security" 3 | version = "0.5.0" 4 | description = "Add authentication and authorization to your FastAPI app via dependencies." 5 | authors = ["Jacob Magnusson "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://jmagnusson.github.io/fastapi-security/" 9 | repository = "https://github.com/jmagnusson/fastapi-security" 10 | documentation = "https://jmagnusson.github.io/fastapi-security/" 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Environment :: Web Environment", 14 | "Framework :: AsyncIO", 15 | "Intended Audience :: Developers", 16 | "Intended Audience :: Information Technology", 17 | "Intended Audience :: System Administrators", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python", 27 | "Topic :: Internet :: WWW/HTTP", 28 | "Topic :: Internet", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | "Topic :: Software Development :: Libraries", 31 | "Topic :: Software Development", 32 | "Typing :: Typed", 33 | ] 34 | 35 | [tool.poetry.dependencies] 36 | python = ">=3.7,<4.0" 37 | aiohttp = { version = "^3", optional = true } 38 | fastapi = "^0" 39 | pydantic = "^1" 40 | PyJWT = { version = "^2", extras = ["crypto"], optional = true } 41 | 42 | [tool.poetry.extras] 43 | oauth2 = ["aiohttp", "PyJWT"] 44 | 45 | [tool.poetry.dev-dependencies] 46 | aioresponses = "^0.7.3" 47 | black = "^22.1.0" 48 | isort = "^5.10.1" 49 | pytest = "^7.0.1" 50 | pytest-cov = "^3.0.0" 51 | requests = "^2.27.1" 52 | mypy = "^0.931" 53 | flake8 = "^4.0.1" 54 | mkdocs-material = "^8.2.4" 55 | pytest-asyncio = "^0.18.1" 56 | uvicorn = "^0.17.5" 57 | types-requests = "^2.27.11" 58 | 59 | 60 | [build-system] 61 | requires = ["poetry-core>=1.0.0"] 62 | build-backend = "poetry.core.masonry.api" 63 | 64 | [tool.black] 65 | line-length = 88 66 | target-version = ['py37', 'py38'] 67 | 68 | [tool.pytest.ini_options] 69 | markers = [ 70 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 71 | ] 72 | asyncio_mode = "strict" 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/fastapi-security/88de74d24cad47b0e6a06a6ff807fc1cc423c810/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from starlette.testclient import TestClient 4 | 5 | 6 | @pytest.fixture 7 | def app(): 8 | return FastAPI() 9 | 10 | 11 | @pytest.fixture 12 | def client(app): 13 | return TestClient(app) 14 | -------------------------------------------------------------------------------- /tests/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/fastapi-security/88de74d24cad47b0e6a06a6ff807fc1cc423c810/tests/examples/__init__.py -------------------------------------------------------------------------------- /tests/examples/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import subprocess 4 | from contextlib import contextmanager 5 | from typing import Dict 6 | 7 | 8 | def available_port(ip: str = "127.0.0.1"): 9 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 10 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 11 | s.bind((ip, 0)) 12 | port = s.getsockname()[1] 13 | s.close() 14 | return port 15 | 16 | 17 | @contextmanager 18 | def run_example_app(app_path: str, *, env: Dict[str, str] = None): 19 | port = available_port() 20 | proc = subprocess.Popen( 21 | ["uvicorn", app_path, f"--port={port}"], 22 | stderr=subprocess.PIPE, 23 | env={**os.environ, **(env or {})}, 24 | ) 25 | 26 | while True: 27 | if proc.stderr: 28 | line = proc.stderr.readline() 29 | if b"Uvicorn running on" in line: 30 | break 31 | elif b"Traceback" in line: 32 | lines = proc.stderr.read() 33 | proc.terminate() 34 | raise RuntimeError(lines.decode()) 35 | else: 36 | break 37 | 38 | try: 39 | yield f"http://127.0.0.1:{port}" 40 | finally: 41 | proc.terminate() 42 | -------------------------------------------------------------------------------- /tests/examples/test_app1.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | from pathlib import Path 3 | 4 | import pytest 5 | import requests 6 | 7 | from .helpers import run_example_app 8 | 9 | app1_path = Path("./examples/app1") 10 | 11 | pytestmark = [ 12 | pytest.mark.skipif(not app1_path.exists(), reason="app1 example couldn't be found"), 13 | pytest.mark.skipif( 14 | importlib.util.find_spec("uvicorn") is None, reason="`uvicorn` isn't installed" 15 | ), 16 | pytest.mark.slow, 17 | ] 18 | 19 | 20 | basic_auth_env = { 21 | "BASIC_AUTH_CREDENTIALS": '[{"username": "user1", "password": "test"}]' 22 | } 23 | 24 | 25 | def test_users_me_basic_auth_anonymous(): 26 | with run_example_app("examples.app1:app", env=basic_auth_env) as base_url: 27 | resp = requests.get(f"{base_url}/users/me") 28 | assert resp.status_code == 200 29 | data = resp.json() 30 | assert data["auth"] == { 31 | "subject": "anonymous", 32 | "auth_method": "none", 33 | "issuer": None, 34 | "audience": [], 35 | "issued_at": None, 36 | "expires_at": None, 37 | "scopes": [], 38 | "permissions": [], 39 | } 40 | 41 | 42 | def test_users_me_basic_auth_authenticated(): 43 | with run_example_app("examples.app1:app", env=basic_auth_env) as base_url: 44 | resp = requests.get(f"{base_url}/users/me", auth=("user1", "test")) 45 | assert resp.status_code == 200 46 | data = resp.json() 47 | assert data["auth"] == { 48 | "subject": "user1", 49 | "auth_method": "basic_auth", 50 | "issuer": None, 51 | "audience": [], 52 | "issued_at": None, 53 | "expires_at": None, 54 | "scopes": [], 55 | "permissions": [], 56 | } 57 | 58 | 59 | def test_user_permissions_basic_auth_authenticated(): 60 | with run_example_app( 61 | "examples.app1:app", 62 | env={**basic_auth_env, "PERMISSION_OVERRIDES": '{"user1": ["*"]}'}, 63 | ) as base_url: 64 | resp = requests.get(f"{base_url}/users/me/permissions", auth=("user1", "test")) 65 | assert resp.status_code == 200 66 | data = resp.json() 67 | assert data == ["products:create"] 68 | 69 | 70 | def test_create_product_unauthenticated(): 71 | with run_example_app("examples.app1:app", env=basic_auth_env) as base_url: 72 | resp = requests.post(f"{base_url}/products") 73 | assert resp.status_code == 401 74 | data = resp.json() 75 | assert data == {"detail": "Could not validate credentials"} 76 | 77 | 78 | def test_create_product_authenticated(): 79 | with run_example_app( 80 | "examples.app1:app", 81 | env={**basic_auth_env, "PERMISSION_OVERRIDES": '{"user1": ["*"]}'}, 82 | ) as base_url: 83 | resp = requests.post( 84 | f"{base_url}/products", auth=("user1", "test"), json={"name": "T-shirt"} 85 | ) 86 | assert resp.status_code == 201 87 | data = resp.json() 88 | assert data == {"name": "T-shirt"} 89 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/fastapi-security/88de74d24cad47b0e6a06a6ff807fc1cc423c810/tests/helpers/__init__.py -------------------------------------------------------------------------------- /tests/helpers/jwks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Any, Dict, Iterable 4 | 5 | import pytest 6 | 7 | from fastapi_security._optional_dependencies import ( 8 | OAUTH2_DEPENDENCY_INSTALLED, 9 | RSAAlgorithm, 10 | jwt, 11 | ) 12 | 13 | __all__ = ( 14 | "OAUTH2_DEPENDENCY_INSTALLED", 15 | "dummy_audience", 16 | "dummy_jwks_response_data", 17 | "dummy_jwks_uri", 18 | "dummy_jwt_headers", 19 | "make_access_token", 20 | ) 21 | 22 | skipif_oauth2_dependency_not_installed = pytest.mark.skipif( 23 | not OAUTH2_DEPENDENCY_INSTALLED, reason="`oauth2` extra not installed" 24 | ) 25 | 26 | 27 | if OAUTH2_DEPENDENCY_INSTALLED: 28 | from cryptography.hazmat.backends import default_backend as crypto_default_backend 29 | from cryptography.hazmat.primitives import serialization as crypto_serialization 30 | from cryptography.hazmat.primitives.asymmetric import rsa 31 | 32 | key = rsa.generate_private_key( 33 | backend=crypto_default_backend(), public_exponent=65537, key_size=2048 34 | ) 35 | 36 | private_key = key.private_bytes( 37 | crypto_serialization.Encoding.PEM, 38 | crypto_serialization.PrivateFormat.PKCS8, 39 | crypto_serialization.NoEncryption(), 40 | ) 41 | public_key_obj = key.public_key() 42 | public_key = public_key_obj.public_bytes( 43 | crypto_serialization.Encoding.OpenSSH, crypto_serialization.PublicFormat.OpenSSH 44 | ) 45 | 46 | _jwk = json.loads(RSAAlgorithm.to_jwk(public_key_obj)) 47 | 48 | dummy_alg = "RS256" 49 | dummy_kid = "test123" 50 | dummy_jwks_uri = "https://identity-provider/.well-known/jwks.json" 51 | dummy_audience = "https://some-resource" 52 | dummy_jwks_response_data = { 53 | "keys": [ 54 | { 55 | "alg": dummy_alg, 56 | "kty": _jwk["kty"], 57 | "use": "sig", 58 | "n": _jwk["n"], 59 | "e": _jwk["e"], 60 | "kid": dummy_kid, 61 | }, 62 | ], 63 | } 64 | dummy_jwt_headers = {"alg": dummy_alg, "typ": "JWT", "kid": dummy_kid} 65 | 66 | def make_access_token_data( 67 | *, 68 | sub: str, 69 | expire_in: int = 3600, 70 | delete_fields: Iterable[str] = None, 71 | **extra: Dict[str, Any], 72 | ) -> Dict[str, Any]: 73 | utcnow = datetime.now(tz=timezone.utc) 74 | expire_at = utcnow + timedelta(seconds=expire_in) 75 | 76 | data: Dict[str, Any] = {**extra} 77 | 78 | data["sub"] = sub 79 | data.setdefault("aud", dummy_audience) 80 | data.setdefault("iss", "https://identity-provider/") 81 | data.setdefault("iat", int(utcnow.timestamp())) 82 | data.setdefault("exp", int(expire_at.timestamp())) 83 | 84 | for field in delete_fields or []: 85 | if field in data: 86 | del data[field] 87 | 88 | return data 89 | 90 | def make_access_token( 91 | *, 92 | sub: str, 93 | expire_in: int = 3600, 94 | delete_fields: Iterable[str] = None, 95 | headers: Dict[str, str] = None, 96 | **extra: Dict[str, Any], 97 | ) -> str: 98 | data = make_access_token_data( 99 | sub=sub, expire_in=expire_in, delete_fields=delete_fields, **extra 100 | ) 101 | headers = headers or dummy_jwt_headers 102 | return jwt.encode( 103 | data, 104 | private_key.decode(), 105 | algorithm=dummy_alg, 106 | headers=headers, 107 | ) 108 | 109 | else: 110 | dummy_audience = "" 111 | dummy_jwks_response_data = {} 112 | dummy_jwks_uri = "" 113 | dummy_jwt_headers = {} 114 | 115 | def make_access_token( 116 | *, 117 | sub: str, 118 | expire_in: int = 3600, 119 | delete_fields: Iterable[str] = None, 120 | headers: Dict[str, str] = None, 121 | **extra: Dict[str, Any], 122 | ) -> str: 123 | raise NotImplementedError 124 | -------------------------------------------------------------------------------- /tests/helpers/oidc.py: -------------------------------------------------------------------------------- 1 | dummy_oidc_url = "https://oidc-provider/.well-known/openid-configuration" 2 | dummy_userinfo_endpoint_url = "https://oidc-provider/userinfo" 3 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/fastapi-security/88de74d24cad47b0e6a06a6ff807fc1cc423c810/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends 3 | 4 | from fastapi_security import FastAPISecurity, User 5 | from fastapi_security.exceptions import AuthNotConfigured 6 | 7 | 8 | def test_that_endpoints_raise_exception_when_auth_is_unconfigured(app, client): 9 | security = FastAPISecurity() 10 | 11 | @app.get("/") 12 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 13 | return [] 14 | 15 | with pytest.raises(AuthNotConfigured): 16 | client.get("/") 17 | -------------------------------------------------------------------------------- /tests/integration/test_basic_auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | 3 | from fastapi_security import FastAPISecurity, HTTPBasicCredentials, User 4 | from fastapi_security.basic import BasicAuthValidator 5 | 6 | from ..helpers.jwks import ( 7 | dummy_audience, 8 | dummy_jwks_uri, 9 | skipif_oauth2_dependency_not_installed, 10 | ) 11 | 12 | 13 | def test_that_basic_auth_doesnt_validate_any_credentials_if_unconfigured(): 14 | validator = BasicAuthValidator() 15 | creds = HTTPBasicCredentials(username="johndoe", password="123") 16 | assert validator.validate(creds) is False 17 | 18 | 19 | @skipif_oauth2_dependency_not_installed 20 | def test_that_uninitialized_basic_auth_doesnt_accept_any_credentials(app, client): 21 | security = FastAPISecurity() 22 | 23 | @app.get("/") 24 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 25 | return [] 26 | 27 | # NOTE: Not passing basic_auth_credentials, which means Basic Auth will be disabled 28 | # NOTE: We are passing 29 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 30 | 31 | resp = client.get("/") 32 | assert resp.status_code == 401 33 | 34 | resp = client.get("/", auth=("username", "password")) 35 | assert resp.status_code == 401 36 | 37 | 38 | def test_that_basic_auth_rejects_incorrect_credentials(app, client): 39 | security = FastAPISecurity() 40 | 41 | @app.get("/") 42 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 43 | return [] 44 | 45 | credentials = [{"username": "user", "password": "pass"}] 46 | security.init_basic_auth(credentials) 47 | 48 | resp = client.get("/") 49 | assert resp.status_code == 401 50 | 51 | resp = client.get("/", auth=("user", "")) 52 | assert resp.status_code == 401 53 | 54 | resp = client.get("/", auth=("", "pass")) 55 | assert resp.status_code == 401 56 | 57 | resp = client.get("/", auth=("abc", "123")) 58 | assert resp.status_code == 401 59 | 60 | 61 | def test_that_basic_auth_accepts_correct_credentials(app, client): 62 | security = FastAPISecurity() 63 | 64 | @app.get("/") 65 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 66 | return [] 67 | 68 | credentials = [{"username": "user", "password": "pass"}] 69 | security.init_basic_auth(credentials) 70 | 71 | resp = client.get("/", auth=("user", "pass")) 72 | assert resp.status_code == 200 73 | -------------------------------------------------------------------------------- /tests/integration/test_oauth2.py: -------------------------------------------------------------------------------- 1 | from aioresponses import aioresponses 2 | from fastapi import Depends 3 | 4 | from fastapi_security import FastAPISecurity, User 5 | 6 | from ..helpers.jwks import ( 7 | dummy_audience, 8 | dummy_jwks_response_data, 9 | dummy_jwks_uri, 10 | make_access_token, 11 | skipif_oauth2_dependency_not_installed, 12 | ) 13 | 14 | pytestmark = skipif_oauth2_dependency_not_installed 15 | 16 | 17 | def test_that_oauth2_rejects_incorrect_token(app, client): 18 | 19 | security = FastAPISecurity() 20 | 21 | @app.get("/") 22 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 23 | return [] 24 | 25 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 26 | 27 | resp = client.get("/") 28 | assert resp.status_code == 401 29 | 30 | resp = client.get("/", headers={"Authorization": "Bearer abc"}) 31 | assert resp.status_code == 401 32 | 33 | resp = client.get("/", headers={"Authorization": "Bearer abc.xyz.def"}) 34 | assert resp.status_code == 401 35 | 36 | 37 | def test_that_oauth2_accepts_correct_token(app, client): 38 | 39 | security = FastAPISecurity() 40 | 41 | @app.get("/") 42 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 43 | return [] 44 | 45 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 46 | 47 | access_token = make_access_token(sub="test-subject") 48 | 49 | with aioresponses() as mock: 50 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 51 | 52 | resp = client.get("/", headers={"Authorization": f"Bearer {access_token}"}) 53 | 54 | assert resp.status_code == 200 55 | 56 | 57 | def test_that_oauth2_rejects_expired_token(app, client): 58 | 59 | security = FastAPISecurity() 60 | 61 | @app.get("/") 62 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 63 | return [] 64 | 65 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 66 | 67 | access_token = make_access_token(sub="test-subject", expire_in=-1) 68 | 69 | with aioresponses() as mock: 70 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 71 | 72 | resp = client.get("/", headers={"Authorization": f"Bearer {access_token}"}) 73 | 74 | assert resp.status_code == 401 75 | -------------------------------------------------------------------------------- /tests/integration/test_oidc.py: -------------------------------------------------------------------------------- 1 | from aioresponses import aioresponses 2 | from fastapi import Depends 3 | 4 | from fastapi_security import FastAPISecurity, User 5 | 6 | from ..helpers.jwks import ( 7 | dummy_audience, 8 | dummy_jwks_response_data, 9 | dummy_jwks_uri, 10 | make_access_token, 11 | skipif_oauth2_dependency_not_installed, 12 | ) 13 | from ..helpers.oidc import dummy_oidc_url, dummy_userinfo_endpoint_url 14 | 15 | pytestmark = skipif_oauth2_dependency_not_installed 16 | 17 | 18 | def test_that_auth_can_be_enabled_through_oidc(app, client): 19 | 20 | security = FastAPISecurity() 21 | 22 | @app.get("/") 23 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 24 | return [] 25 | 26 | security.init_oauth2_through_oidc(dummy_oidc_url, audiences=[dummy_audience]) 27 | 28 | access_token = make_access_token(sub="test-subject") 29 | 30 | with aioresponses() as mock: 31 | mock.get( 32 | dummy_oidc_url, 33 | payload={ 34 | "userinfo_endpoint": dummy_userinfo_endpoint_url, 35 | "jwks_uri": dummy_jwks_uri, 36 | }, 37 | ) 38 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 39 | mock.get(dummy_userinfo_endpoint_url, payload={"nickname": "jacobsvante"}) 40 | 41 | unauthenticated_resp = client.get("/") 42 | assert unauthenticated_resp.status_code == 401 43 | 44 | authenticated_resp = client.get( 45 | "/", headers={"Authorization": f"Bearer {access_token}"} 46 | ) 47 | assert authenticated_resp.status_code == 200 48 | 49 | 50 | def test_that_oidc_info_is_returned(app, client): 51 | 52 | security = FastAPISecurity() 53 | 54 | @app.get("/users/me") 55 | async def get_user_details(user: User = Depends(security.user_with_info)): 56 | """Return user details, regardless of whether user is authenticated or not""" 57 | return user.without_access_token() 58 | 59 | security.init_oauth2_through_oidc(dummy_oidc_url, audiences=[dummy_audience]) 60 | 61 | access_token = make_access_token(sub="test-subject") 62 | 63 | with aioresponses() as mock: 64 | mock.get( 65 | dummy_oidc_url, 66 | payload={ 67 | "userinfo_endpoint": dummy_userinfo_endpoint_url, 68 | "jwks_uri": dummy_jwks_uri, 69 | }, 70 | ) 71 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 72 | mock.get(dummy_userinfo_endpoint_url, payload={"nickname": "jacobsvante"}) 73 | 74 | resp = client.get( 75 | "/users/me", headers={"Authorization": f"Bearer {access_token}"} 76 | ) 77 | 78 | assert resp.status_code == 200 79 | data = resp.json() 80 | assert data["info"]["nickname"] == "jacobsvante" 81 | -------------------------------------------------------------------------------- /tests/integration/test_permission_overrides.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | 3 | from fastapi_security import FastAPISecurity, HTTPBasicCredentials, User 4 | 5 | 6 | def test_that_explicit_permission_overrides_are_applied(app, client): 7 | cred = HTTPBasicCredentials(username="johndoe", password="123") 8 | 9 | security = FastAPISecurity() 10 | 11 | create_product_perm = security.user_permission("products:create") 12 | 13 | security.init_basic_auth([cred]) 14 | security.add_permission_overrides({"johndoe": ["products:create"]}) 15 | 16 | @app.post("/products") 17 | def create_product( 18 | user: User = Depends(security.user_holding(create_product_perm)), 19 | ): 20 | return {"ok": True} 21 | 22 | resp = client.post("/products", auth=("johndoe", "123")) 23 | 24 | assert resp.status_code == 200 25 | assert resp.json() == {"ok": True} 26 | 27 | 28 | def test_that_wildcard_permission_overrides_are_applied(app, client): 29 | cred = HTTPBasicCredentials(username="johndoe", password="123") 30 | 31 | security = FastAPISecurity() 32 | 33 | create_product_perm = security.user_permission("products:create") 34 | 35 | security.init_basic_auth([cred]) 36 | security.add_permission_overrides({"johndoe": "*"}) 37 | 38 | @app.post("/products") 39 | def create_product( 40 | user: User = Depends(security.user_holding(create_product_perm)), 41 | ): 42 | return {"ok": True} 43 | 44 | resp = client.post("/products", auth=("johndoe", "123")) 45 | 46 | assert resp.status_code == 200 47 | assert resp.json() == {"ok": True} 48 | 49 | 50 | def test_that_permission_overrides_can_be_an_exhaustable_iterator(app, client): 51 | cred = HTTPBasicCredentials(username="johndoe", password="123") 52 | 53 | security = FastAPISecurity() 54 | 55 | create_product_perm = security.user_permission("products:create") 56 | 57 | security.init_basic_auth([cred]) 58 | 59 | overrides = iter(["products:create"]) 60 | security.add_permission_overrides({"johndoe": overrides}) 61 | 62 | @app.post("/products") 63 | def create_product( 64 | user: User = Depends(security.user_holding(create_product_perm)), 65 | ): 66 | return {"ok": True} 67 | 68 | # NOTE: Before v0.3.1, the second iteration would give a HTTP403, as the overrides 69 | # iterator had been exhausted on the first try. 70 | for _ in range(2): 71 | resp = client.post("/products", auth=("johndoe", "123")) 72 | assert resp.status_code == 200 73 | assert resp.json() == {"ok": True} 74 | -------------------------------------------------------------------------------- /tests/integration/test_permissions.py: -------------------------------------------------------------------------------- 1 | from aioresponses import aioresponses 2 | from fastapi import Depends 3 | 4 | from fastapi_security import FastAPISecurity, User 5 | from fastapi_security.permissions import UserPermission 6 | 7 | from ..helpers.jwks import ( 8 | dummy_audience, 9 | dummy_jwks_response_data, 10 | dummy_jwks_uri, 11 | make_access_token, 12 | skipif_oauth2_dependency_not_installed, 13 | ) 14 | 15 | 16 | def test_user_permission_repr(): 17 | perm = UserPermission("inventory:list") 18 | assert repr(perm) == "UserPermission(inventory:list)" 19 | 20 | 21 | def test_user_permission_str(): 22 | perm = UserPermission("inventory:list") 23 | assert str(perm) == "inventory:list" 24 | 25 | 26 | @skipif_oauth2_dependency_not_installed 27 | def test_that_missing_permission_results_in_403(app, client): 28 | 29 | security = FastAPISecurity() 30 | 31 | can_list = security.user_permission("users:list") # noqa 32 | 33 | @app.get("/users") 34 | def get_user_list(user: User = Depends(security.user_holding(can_list))): 35 | return [user] 36 | 37 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 38 | 39 | access_token = make_access_token(sub="test-user", permissions=[]) 40 | 41 | with aioresponses() as mock: 42 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 43 | 44 | resp = client.get("/users", headers={"Authorization": f"Bearer {access_token}"}) 45 | assert resp.status_code == 403 46 | assert resp.json() == {"detail": "Missing required permission users:list"} 47 | 48 | 49 | @skipif_oauth2_dependency_not_installed 50 | def test_that_assigned_permission_result_in_200(app, client): 51 | 52 | security = FastAPISecurity() 53 | 54 | can_list = security.user_permission("users:list") # noqa 55 | 56 | @app.get("/users") 57 | def get_user_list(user: User = Depends(security.user_holding(can_list))): 58 | return [user] 59 | 60 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 61 | 62 | access_token = make_access_token(sub="test-user", permissions=["users:list"]) 63 | 64 | with aioresponses() as mock: 65 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 66 | 67 | resp = client.get("/users", headers={"Authorization": f"Bearer {access_token}"}) 68 | assert resp.status_code == 200 69 | (user1,) = resp.json() 70 | assert user1["auth"]["subject"] == "test-user" 71 | 72 | 73 | @skipif_oauth2_dependency_not_installed 74 | def test_that_user_must_have_all_permissions(app, client): 75 | 76 | security = FastAPISecurity() 77 | 78 | can_list = security.user_permission("users:list") # noqa 79 | can_view = security.user_permission("users:view") # noqa 80 | 81 | @app.get("/users") 82 | def get_user_list(user: User = Depends(security.user_holding(can_list, can_view))): 83 | return [user] 84 | 85 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 86 | 87 | bad_token = make_access_token(sub="test-user", permissions=["users:list"]) 88 | valid_token = make_access_token( 89 | sub="JaneDoe", 90 | permissions=["users:list", "users:view"], 91 | ) 92 | 93 | with aioresponses() as mock: 94 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 95 | 96 | resp = client.get("/users", headers={"Authorization": f"Bearer {bad_token}"}) 97 | assert resp.status_code == 403 98 | assert resp.json() == {"detail": "Missing required permission users:view"} 99 | 100 | resp = client.get("/users", headers={"Authorization": f"Bearer {valid_token}"}) 101 | assert resp.status_code == 200 102 | (user1,) = resp.json() 103 | assert user1["auth"]["subject"] == "JaneDoe" 104 | -------------------------------------------------------------------------------- /tests/integration/test_user_data.py: -------------------------------------------------------------------------------- 1 | from aioresponses import aioresponses 2 | from fastapi import Depends 3 | 4 | from fastapi_security import FastAPISecurity, User 5 | 6 | from ..helpers.jwks import ( 7 | dummy_audience, 8 | dummy_jwks_response_data, 9 | dummy_jwks_uri, 10 | make_access_token, 11 | skipif_oauth2_dependency_not_installed, 12 | ) 13 | from ..helpers.oidc import dummy_oidc_url, dummy_userinfo_endpoint_url 14 | 15 | pytestmark = skipif_oauth2_dependency_not_installed 16 | 17 | 18 | def test_that_authenticated_user_auth_data_is_returned_as_expected(app, client): 19 | 20 | security = FastAPISecurity() 21 | 22 | @app.get("/users/me") 23 | def get_user_info(user: User = Depends(security.authenticated_user_or_401)): 24 | return user.without_access_token() 25 | 26 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 27 | 28 | access_token = make_access_token(sub="test-subject", scope=["email"]) 29 | 30 | with aioresponses() as mock: 31 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 32 | 33 | resp = client.get( 34 | "/users/me", headers={"Authorization": f"Bearer {access_token}"} 35 | ) 36 | assert resp.status_code == 200 37 | data = resp.json()["auth"] 38 | del data["expires_at"] 39 | del data["issued_at"] 40 | assert data == { 41 | "audience": ["https://some-resource"], 42 | "auth_method": "oauth2", 43 | "issuer": "https://identity-provider/", 44 | "permissions": [], 45 | "scopes": ["email"], 46 | "subject": "test-subject", 47 | } 48 | 49 | 50 | def test_that_user_dependency_works_authenticated_or_not(app, client): 51 | 52 | security = FastAPISecurity() 53 | 54 | @app.get("/users/me") 55 | def get_user_info(user: User = Depends(security.user)): 56 | return user.without_access_token() 57 | 58 | security.init_basic_auth([{"username": "JaneDoe", "password": "abc123"}]) 59 | 60 | # Anonymous 61 | resp = client.get("/users/me") 62 | assert resp.status_code == 200 63 | data = resp.json()["auth"] 64 | del data["expires_at"] 65 | del data["issued_at"] 66 | assert data == { 67 | "audience": [], 68 | "auth_method": "none", 69 | "issuer": None, 70 | "permissions": [], 71 | "scopes": [], 72 | "subject": "anonymous", 73 | } 74 | 75 | # Authenticated 76 | resp = client.get("/users/me", auth=("JaneDoe", "abc123")) 77 | assert resp.status_code == 200 78 | data = resp.json()["auth"] 79 | del data["expires_at"] 80 | del data["issued_at"] 81 | assert data == { 82 | "audience": [], 83 | "auth_method": "basic_auth", 84 | "issuer": None, 85 | "permissions": [], 86 | "scopes": [], 87 | "subject": "JaneDoe", 88 | } 89 | 90 | 91 | def test_that_user_with_info_dependency_works_unauthenticated(app, client): 92 | 93 | security = FastAPISecurity() 94 | 95 | @app.get("/users/me") 96 | def get_user_info(user: User = Depends(security.user_with_info)): 97 | return user.without_access_token() 98 | 99 | security.init_basic_auth([{"username": "a", "password": "b"}]) 100 | 101 | resp = client.get("/users/me") 102 | assert resp.status_code == 200 103 | info = resp.json()["info"] 104 | assert info["nickname"] is None 105 | 106 | 107 | def test_that_user_with_info_dependency_works_authenticated(app, client, caplog): 108 | import logging 109 | 110 | caplog.set_level(logging.DEBUG) 111 | security = FastAPISecurity() 112 | 113 | @app.get("/users/me") 114 | def get_user_info(user: User = Depends(security.user_with_info)): 115 | return user.without_access_token() 116 | 117 | security.init_oauth2_through_oidc(dummy_oidc_url, audiences=[dummy_audience]) 118 | 119 | with aioresponses() as mock: 120 | mock.get( 121 | dummy_oidc_url, 122 | payload={ 123 | "userinfo_endpoint": dummy_userinfo_endpoint_url, 124 | "jwks_uri": dummy_jwks_uri, 125 | }, 126 | ) 127 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 128 | mock.get(dummy_userinfo_endpoint_url, payload={"nickname": "jacobsvante"}) 129 | token = make_access_token(sub="GMqBbybGfBQeR6NgCY4NyXKnpFzaaTAn@clients") 130 | resp = client.get("/users/me", headers={"Authorization": f"Bearer {token}"}) 131 | assert resp.status_code == 200 132 | data = resp.json() 133 | info = data["info"] 134 | assert info["nickname"] == "jacobsvante" 135 | 136 | 137 | def test_that_authenticated_user_with_info_or_401_works_as_expected(app, client): 138 | security = FastAPISecurity() 139 | 140 | @app.get("/users/me") 141 | def get_user_info( 142 | user: User = Depends(security.authenticated_user_with_info_or_401), 143 | ): 144 | return user.without_access_token() 145 | 146 | security.init_oauth2_through_oidc(dummy_oidc_url, audiences=[dummy_audience]) 147 | security.init_basic_auth([{"username": "a", "password": "b"}]) 148 | 149 | with aioresponses() as mock: 150 | mock.get( 151 | dummy_oidc_url, 152 | payload={ 153 | "userinfo_endpoint": dummy_userinfo_endpoint_url, 154 | "jwks_uri": dummy_jwks_uri, 155 | }, 156 | ) 157 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 158 | mock.get(dummy_userinfo_endpoint_url, payload={"nickname": "jacobsvante"}) 159 | token = make_access_token(sub="GMqBbybGfBQeR6NgCY4NyXKnpFzaaTAn@clients") 160 | resp = client.get("/users/me", headers={"Authorization": f"Bearer {token}"}) 161 | assert resp.status_code == 200 162 | info = resp.json()["info"] 163 | assert info["nickname"] == "jacobsvante" 164 | 165 | # Basic auth 166 | resp = client.get("/users/me", auth=("a", "b")) 167 | assert resp.status_code == 200 168 | info = resp.json()["info"] 169 | assert info["nickname"] is None 170 | 171 | # Unauthenticated 172 | resp = client.get("/users/me") 173 | assert resp.status_code == 401 174 | assert resp.json() == {"detail": "Could not validate credentials"} 175 | 176 | 177 | def test_that_existing_permissions_are_added(app, client): 178 | 179 | security = FastAPISecurity() 180 | 181 | permission = security.user_permission("users:list") # noqa 182 | 183 | @app.get("/users/me") 184 | def get_user_info(user: User = Depends(security.authenticated_user_or_401)): 185 | return user.without_access_token() 186 | 187 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 188 | 189 | access_token = make_access_token( 190 | sub="test-subject", 191 | permissions=["users:list"], 192 | ) 193 | 194 | with aioresponses() as mock: 195 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 196 | 197 | resp = client.get( 198 | "/users/me", headers={"Authorization": f"Bearer {access_token}"} 199 | ) 200 | assert resp.status_code == 200 201 | data = resp.json()["auth"] 202 | del data["expires_at"] 203 | del data["issued_at"] 204 | assert data == { 205 | "audience": ["https://some-resource"], 206 | "auth_method": "oauth2", 207 | "issuer": "https://identity-provider/", 208 | "permissions": ["users:list"], 209 | "scopes": [], 210 | "subject": "test-subject", 211 | } 212 | 213 | 214 | def test_that_nonexisting_permissions_are_ignored(app, client): 215 | 216 | security = FastAPISecurity() 217 | 218 | @app.get("/users/me") 219 | def get_user_info(user: User = Depends(security.authenticated_user_or_401)): 220 | return user.without_access_token() 221 | 222 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 223 | 224 | access_token = make_access_token( 225 | sub="test-subject", 226 | permissions=["users:list"], 227 | ) 228 | 229 | with aioresponses() as mock: 230 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 231 | 232 | resp = client.get( 233 | "/users/me", headers={"Authorization": f"Bearer {access_token}"} 234 | ) 235 | assert resp.status_code == 200 236 | data = resp.json()["auth"] 237 | del data["expires_at"] 238 | del data["issued_at"] 239 | assert data == { 240 | "audience": ["https://some-resource"], 241 | "auth_method": "oauth2", 242 | "issuer": "https://identity-provider/", 243 | "permissions": [], 244 | "scopes": [], 245 | "subject": "test-subject", 246 | } 247 | -------------------------------------------------------------------------------- /tests/integration/test_www_authenticate.py: -------------------------------------------------------------------------------- 1 | from aioresponses import aioresponses 2 | from fastapi import Depends 3 | 4 | from fastapi_security import FastAPISecurity, User 5 | 6 | from ..helpers.jwks import ( 7 | dummy_audience, 8 | dummy_jwks_response_data, 9 | dummy_jwks_uri, 10 | skipif_oauth2_dependency_not_installed, 11 | ) 12 | 13 | 14 | def test_that_header_is_returned_for_basic_auth(app, client): 15 | security = FastAPISecurity() 16 | security.init_basic_auth([{"username": "user", "password": "pass"}]) 17 | 18 | @app.get("/") 19 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 20 | return [] 21 | 22 | resp = client.get("/") 23 | assert resp.headers["WWW-Authenticate"] == "Basic" 24 | 25 | 26 | @skipif_oauth2_dependency_not_installed 27 | def test_that_header_is_returned_for_oauth2(app, client): 28 | security = FastAPISecurity() 29 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 30 | 31 | @app.get("/") 32 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 33 | return [] 34 | 35 | with aioresponses() as mock: 36 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 37 | resp = client.get("/") 38 | assert resp.headers["WWW-Authenticate"] == "Bearer" 39 | 40 | 41 | @skipif_oauth2_dependency_not_installed 42 | def test_that_headers_are_returned_for_oauth2_and_basic_auth(app, client): 43 | security = FastAPISecurity() 44 | security.init_basic_auth([{"username": "user", "password": "pass"}]) 45 | security.init_oauth2_through_jwks(dummy_jwks_uri, audiences=[dummy_audience]) 46 | 47 | @app.get("/") 48 | def get_products(user: User = Depends(security.authenticated_user_or_401)): 49 | return [] 50 | 51 | with aioresponses() as mock: 52 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 53 | resp = client.get("/") 54 | # NOTE: They are actually set as separate headers 55 | assert resp.headers["WWW-Authenticate"] == "Basic, Bearer" 56 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/fastapi-security/88de74d24cad47b0e6a06a6ff807fc1cc423c810/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_entities.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from fastapi_security.entities import ( 4 | AuthMethod, 5 | JwtAccessToken, 6 | User, 7 | UserAuth, 8 | UserInfo, 9 | ) 10 | 11 | 12 | def test_make_dummy_user_info(): 13 | dummy = UserInfo.make_dummy() 14 | assert dummy.dict() == { 15 | "given_name": None, 16 | "family_name": None, 17 | "nickname": None, 18 | "name": None, 19 | "picture": None, 20 | "locale": None, 21 | "updated_at": None, 22 | "email": None, 23 | "email_verified": None, 24 | } 25 | 26 | 27 | def test_anonymous_user_auth(): 28 | anon = UserAuth.make_anonymous() 29 | assert anon.is_anonymous() 30 | assert anon.dict() == { 31 | "subject": "anonymous", 32 | "auth_method": AuthMethod.none, 33 | "issuer": None, 34 | "audience": [], 35 | "issued_at": None, 36 | "expires_at": None, 37 | "scopes": [], 38 | "permissions": [], 39 | "access_token": None, 40 | } 41 | 42 | 43 | def test_user_auth_get_user_id(): 44 | u = UserAuth(subject="johndoe", auth_method="basic_auth") 45 | assert u.get_user_id() == "johndoe" 46 | 47 | 48 | def test_that_user_auth_accepts_client_credentials_grant_type(): 49 | jwt_token = JwtAccessToken( 50 | iss="a", 51 | sub="johndoe", 52 | aud="a", 53 | iat="2021-03-26 11:25", 54 | exp="2021-03-27 11:25", 55 | raw="", 56 | gty="client-credentials", 57 | ) 58 | assert jwt_token.is_client_credentials() 59 | 60 | auth = UserAuth.from_jwt_access_token(jwt_token) 61 | assert auth.is_oauth2() 62 | 63 | 64 | def test_that_user_methods_work_correctly(): 65 | jwt_token = JwtAccessToken( 66 | iss="a", 67 | sub="johndoe", 68 | aud="a", 69 | iat="2021-03-26 11:25", 70 | exp="2021-03-27 11:25", 71 | raw="", 72 | permissions=["products:create"], 73 | ) 74 | auth = UserAuth.from_jwt_access_token(jwt_token) 75 | user = User(auth=auth) 76 | assert user.permissions == ["products:create"] 77 | # NOTE: Expiry etc is validated in a higher layer 78 | assert user.is_authenticated() 79 | assert not user.is_anonymous() 80 | assert user.get_user_id() == "johndoe" 81 | assert user.has_permission("products:create") 82 | 83 | assert user.dict()["auth"] == { 84 | "access_token": "", 85 | "audience": ["a"], 86 | "auth_method": AuthMethod.oauth2, 87 | "expires_at": datetime.datetime(2021, 3, 27, 11, 25), 88 | "issued_at": datetime.datetime(2021, 3, 26, 11, 25), 89 | "issuer": "a", 90 | "permissions": ["products:create"], 91 | "scopes": [], 92 | "subject": "johndoe", 93 | } 94 | -------------------------------------------------------------------------------- /tests/unit/test_oauth2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import aiohttp 4 | import pytest 5 | from aioresponses import aioresponses 6 | 7 | from fastapi_security.entities import JwtAccessToken 8 | from fastapi_security.oauth2 import Oauth2JwtAccessTokenValidator 9 | 10 | from ..helpers.jwks import ( 11 | dummy_audience, 12 | dummy_jwks_response_data, 13 | dummy_jwks_uri, 14 | dummy_jwt_headers, 15 | make_access_token, 16 | skipif_oauth2_dependency_not_installed, 17 | ) 18 | 19 | pytestmark = [ 20 | pytest.mark.asyncio, 21 | skipif_oauth2_dependency_not_installed, 22 | ] 23 | 24 | 25 | async def test_that_jwt_cant_be_validated_when_uninitialized(caplog): 26 | caplog.set_level(logging.INFO) 27 | validator = Oauth2JwtAccessTokenValidator() 28 | # validator.init(dummy_jwks_uri, [dummy_audience]) 29 | parsed = await validator.parse("abc") 30 | assert "JWT Access Token validator is not set up!" in caplog.text 31 | assert parsed is None 32 | 33 | 34 | @pytest.mark.parametrize("empty_data", ((None, "", 0))) 35 | async def test_that_empty_jwt_is_invalid(caplog, empty_data): 36 | caplog.set_level(logging.DEBUG) 37 | validator = Oauth2JwtAccessTokenValidator() 38 | validator.init(dummy_jwks_uri, [dummy_audience]) 39 | parsed = await validator.parse(empty_data) 40 | assert "No JWT token provided" in caplog.text 41 | assert parsed is None 42 | 43 | 44 | async def test_that_unparseable_token_is_invalid(caplog): 45 | caplog.set_level(logging.DEBUG) 46 | validator = Oauth2JwtAccessTokenValidator() 47 | validator.init(dummy_jwks_uri, [dummy_audience]) 48 | parsed = await validator.parse("badDATA") 49 | assert ( 50 | "Decoding unverified JWT token failed with error: DecodeError('Not enough segments" 51 | in caplog.text 52 | ) 53 | assert parsed is None 54 | 55 | 56 | async def test_that_missing_kid_field_is_invalid(caplog): 57 | caplog.set_level(logging.DEBUG) 58 | validator = Oauth2JwtAccessTokenValidator() 59 | validator.init(dummy_jwks_uri, [dummy_audience]) 60 | token = make_access_token(sub="johndoe", headers={"alg": "RS256", "typ": "JWT"}) 61 | parsed = await validator.parse(token) 62 | assert "No `kid` found in JWT token" in caplog.text 63 | assert parsed is None 64 | 65 | 66 | async def test_that_mismatching_kid_field_fails(caplog): 67 | caplog.set_level(logging.DEBUG) 68 | validator = Oauth2JwtAccessTokenValidator() 69 | validator.init(dummy_jwks_uri, [dummy_audience]) 70 | token = make_access_token( 71 | sub="johndoe", 72 | headers={"alg": "RS256", "typ": "JWT", "kid": "someother"}, 73 | ) 74 | 75 | with aioresponses() as mock: 76 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 77 | parsed = await validator.parse(token) 78 | 79 | assert "No matching `kid` for JWT token" in caplog.text 80 | assert parsed is None 81 | 82 | 83 | async def test_that_hs256_doesnt_work(caplog): 84 | caplog.set_level(logging.DEBUG) 85 | validator = Oauth2JwtAccessTokenValidator() 86 | validator.init(dummy_jwks_uri, [dummy_audience]) 87 | token = make_access_token( 88 | sub="johndoe", 89 | headers={**dummy_jwt_headers, "alg": "HS256"}, 90 | ) 91 | 92 | with aioresponses() as mock: 93 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 94 | parsed = await validator.parse(token) 95 | 96 | assert ( 97 | "Decoding verified JWT token failed with error: InvalidAlgorithmError('The specified alg value is not allowed" 98 | in caplog.text 99 | ) 100 | assert parsed is None 101 | 102 | 103 | async def test_that_missing_audience_fails(caplog): 104 | caplog.set_level(logging.DEBUG) 105 | validator = Oauth2JwtAccessTokenValidator() 106 | validator.init(dummy_jwks_uri, [dummy_audience]) 107 | token = make_access_token( 108 | sub="johndoe", 109 | delete_fields=["aud"], 110 | ) 111 | 112 | with aioresponses() as mock: 113 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 114 | parsed = await validator.parse(token) 115 | 116 | assert ( 117 | "Decoding verified JWT token failed with error: MissingRequiredClaimError('aud" 118 | in caplog.text 119 | ) 120 | assert parsed is None 121 | 122 | 123 | async def test_that_missing_expiry_date_fails(caplog): 124 | caplog.set_level(logging.DEBUG) 125 | validator = Oauth2JwtAccessTokenValidator() 126 | validator.init(dummy_jwks_uri, [dummy_audience]) 127 | token = make_access_token( 128 | sub="johndoe", 129 | delete_fields=["exp"], 130 | ) 131 | 132 | with aioresponses() as mock: 133 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 134 | parsed = await validator.parse(token) 135 | 136 | assert ( 137 | "Failed to parse JWT token with 1 validation error for JwtAccessToken\nexp\n field required (type=value_error.missing)" 138 | in caplog.text 139 | ) 140 | assert parsed is None 141 | 142 | 143 | async def test_that_initial_failure_to_get_jwks_kid_data_raises_exception(): 144 | validator = Oauth2JwtAccessTokenValidator() 145 | validator.init(dummy_jwks_uri, [dummy_audience]) 146 | 147 | token = make_access_token(sub="johndoe") 148 | 149 | with pytest.raises( 150 | aiohttp.client_exceptions.ClientConnectorError, 151 | match="Cannot connect to host identity-provider:443", 152 | ): 153 | await validator.parse(token) 154 | 155 | 156 | async def test_that_subsequent_failure_to_fetch_jwks_kid_data_is_handled(caplog): 157 | validator = Oauth2JwtAccessTokenValidator() 158 | validator.init(dummy_jwks_uri, [dummy_audience]) 159 | 160 | token = make_access_token(sub="johndoe") 161 | 162 | with aioresponses() as mock: 163 | mock.get(dummy_jwks_uri, payload=dummy_jwks_response_data) 164 | await validator.parse(token) 165 | 166 | caplog.set_level(logging.INFO) 167 | 168 | # NOTE: Reaching into the internals to trigger JWKS kid data refresh 169 | validator._jwks_cached_at = -3600 170 | # NOTE: Not mocking JWKS endpoint, which would cause a "Cannot connect" error on 171 | # the first try. 172 | parsed = await validator.parse(token) 173 | 174 | assert isinstance(parsed, JwtAccessToken) 175 | assert ( 176 | "Failed to refresh JWKS kid mapping, re-using old data. Exception was: ClientConnectorError" 177 | in caplog.text 178 | ) 179 | -------------------------------------------------------------------------------- /tests/unit/test_oidc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import aiohttp 4 | import pytest 5 | from aioresponses import aioresponses 6 | 7 | from fastapi_security.oidc import OpenIdConnectDiscovery 8 | 9 | from ..helpers.jwks import make_access_token, skipif_oauth2_dependency_not_installed 10 | from ..helpers.oidc import dummy_oidc_url, dummy_userinfo_endpoint_url 11 | 12 | pytestmark = [ 13 | pytest.mark.asyncio, 14 | skipif_oauth2_dependency_not_installed, 15 | ] 16 | 17 | 18 | async def test_that_getting_user_info_doesnt_work_uninitialized(caplog): 19 | caplog.set_level(logging.INFO) 20 | token = make_access_token(sub="janedoe") 21 | oidc = OpenIdConnectDiscovery() 22 | user_info = await oidc.get_user_info(token) 23 | assert user_info is None 24 | assert "OpenID Connect discovery URL is not set up!" in caplog.text 25 | 26 | 27 | async def test_that_getting_user_info_with_empty_access_token_doesnt_work(caplog): 28 | caplog.set_level(logging.DEBUG) 29 | oidc = OpenIdConnectDiscovery() 30 | oidc.init(dummy_oidc_url) 31 | user_info = await oidc.get_user_info("") 32 | assert user_info is None 33 | assert "No access token provided" in caplog.text 34 | 35 | 36 | @skipif_oauth2_dependency_not_installed 37 | async def test_that_dummy_user_info_is_returned_when_endpoint_returns_non_200(caplog): 38 | caplog.set_level(logging.DEBUG) 39 | oidc = OpenIdConnectDiscovery() 40 | oidc.init(dummy_oidc_url) 41 | token = make_access_token(sub="JaneDoe") 42 | 43 | with aioresponses() as mock: 44 | mock.get( 45 | dummy_oidc_url, 46 | payload={"userinfo_endpoint": dummy_userinfo_endpoint_url}, 47 | ) 48 | mock.get(dummy_userinfo_endpoint_url, status=503) 49 | 50 | user_info = await oidc.get_user_info(token) 51 | 52 | assert all(v is None for v in user_info.dict().values()) 53 | 54 | 55 | async def test_that_initial_failure_to_fetch_discovery_data_raises_exception(): 56 | oidc = OpenIdConnectDiscovery() 57 | oidc.init(dummy_oidc_url) 58 | 59 | with pytest.raises( 60 | aiohttp.client_exceptions.ClientConnectorError, 61 | match="Cannot connect to host oidc-provider:443", 62 | ): 63 | await oidc.get_discovery_data() 64 | 65 | 66 | async def test_that_subsequent_failure_to_fetch_discovery_data_is_handled(caplog): 67 | oidc = OpenIdConnectDiscovery() 68 | oidc.init(dummy_oidc_url) 69 | 70 | with aioresponses() as mock: 71 | mock.get( 72 | dummy_oidc_url, payload={"userinfo_endpoint": dummy_userinfo_endpoint_url} 73 | ) 74 | await oidc.get_discovery_data() 75 | 76 | caplog.set_level(logging.INFO) 77 | 78 | # NOTE: Reaching into the internals to trigger JWKS kid data refresh 79 | oidc._discovery_data_cached_at = -3600 80 | # NOTE: Not mocking JWKS endpoint, which would cause a "Cannot connect" error on 81 | # the first try. 82 | parsed = await oidc.get_discovery_data() 83 | 84 | assert parsed == {"userinfo_endpoint": dummy_userinfo_endpoint_url} 85 | assert ( 86 | "Failed to refresh OIDC discovery data, re-using old data. Exception was: ClientConnectorError" 87 | in caplog.text 88 | ) 89 | --------------------------------------------------------------------------------