├── eoapi └── auth_utils │ ├── errors.py │ ├── __init__.py │ ├── types.py │ ├── config.py │ └── auth.py ├── .github └── workflows │ ├── lint.yaml │ ├── test.yaml │ └── publish.yaml ├── .pre-commit-config.yaml ├── pyproject.toml ├── LICENSE ├── README.md ├── .gitignore └── tests └── test_auth.py /eoapi/auth_utils/errors.py: -------------------------------------------------------------------------------- 1 | class OidcFetchError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /eoapi/auth_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import OpenIdConnectAuth # noqa 2 | from .config import OpenIdConnectSettings # noqa 3 | 4 | __version__ = "0.4.1" 5 | -------------------------------------------------------------------------------- /eoapi/auth_utils/types.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TypedDict 2 | 3 | 4 | class Scope(TypedDict, total=False): 5 | """More strict version of Starlette's Scope.""" 6 | 7 | # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 8 | path: str 9 | method: str 10 | type: Optional[str] 11 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**/*.py" 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.11" 18 | cache: "pip" 19 | 20 | - name: Install pre-commit 21 | run: pip install -e ".[lint]" 22 | 23 | - name: Run pre-commit 24 | run: pre-commit run --all-files 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**/*.py" 7 | - "pyproject.toml" 8 | 9 | jobs: 10 | pytest: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.11" 19 | cache: "pip" 20 | 21 | - name: Install dependencies 22 | run: pip install -e ".[testing]" 23 | 24 | - name: Run tests 25 | run: pytest 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 5.13.2 4 | hooks: 5 | - id: isort 6 | language_version: python 7 | args: ["-m", "3", "--trailing-comma", "-l", "88"] 8 | 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.4.4 11 | hooks: 12 | - id: ruff 13 | args: ["--fix"] 14 | - id: ruff-format 15 | 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.10.0 18 | hooks: 19 | - id: mypy 20 | language_version: python 21 | additional_dependencies: 22 | - types-requests 23 | - types-attrs 24 | - types-PyYAML 25 | -------------------------------------------------------------------------------- /eoapi/auth_utils/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Sequence 2 | 3 | from pydantic import AnyHttpUrl 4 | 5 | try: 6 | from pydantic.v1 import BaseSettings # type:ignore 7 | except ImportError: 8 | from pydantic import BaseSettings # type:ignore 9 | 10 | 11 | class OpenIdConnectSettings(BaseSettings): 12 | # Swagger UI config for Authorization Code Flow 13 | client_id: str = "" 14 | use_pkce: bool = True 15 | openid_configuration_url: Optional[AnyHttpUrl] = None 16 | openid_configuration_internal_url: Optional[AnyHttpUrl] = None 17 | 18 | allowed_jwt_audiences: Optional[Sequence[str]] = [] 19 | 20 | model_config = { 21 | "env_prefix": "EOAPI_AUTH_", 22 | "env_file": ".env", 23 | "extra": "allow", 24 | } 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [tool.pdm.version] 6 | path = "eoapi/auth_utils/__init__.py" 7 | source = "file" 8 | 9 | [tool.pdm.build] 10 | excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"] 11 | includes = ["eoapi"] 12 | 13 | [project] 14 | authors = [ 15 | {name = "Anthony Lukach", email = "anthony@developmentseed.org"}, 16 | ] 17 | dependencies = [ 18 | "cryptography>=43.0.0", 19 | "fastapi>=0.7.0", 20 | "pyjwt>=2.9.0", 21 | ] 22 | description = "Authentication & authorization helpers for eoAPI" 23 | dynamic = ["version"] 24 | license = {file = "LICENSE"} 25 | name = "eoapi-auth-utils" 26 | readme = "README.md" 27 | requires-python = ">=3.8" 28 | 29 | [project.optional-dependencies] 30 | lint = [ 31 | "pre-commit", 32 | ] 33 | testing = [ 34 | "coverage", 35 | "httpx>=0.27.0", 36 | "jwcrypto>=1.5.6", 37 | "pytest>=6.0", 38 | ] 39 | 40 | [tool.mypy] 41 | no_implicit_optional = true 42 | strict_optional = true 43 | namespace_packages = true 44 | explicit_package_bases = true 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Development Seed 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 | # eoAPI Auth Utils 2 | 3 | Helpers for authentication & authorization patterns for [eoAPI applications](https://eoapi.dev). 4 | 5 | [![PyPI - Version](https://img.shields.io/pypi/v/eoapi.auth-utils)](https://pypi.org/project/eoapi.auth-utils/) 6 | 7 | ## Usage 8 | 9 | ### Installation 10 | 11 | ``` 12 | pip install eoapi.auth-utils 13 | ``` 14 | 15 | ### Integration 16 | 17 | In your eoAPI application: 18 | 19 | ```py 20 | from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth 21 | from fastapi import FastAPI 22 | from fastapi.routing import APIRoute 23 | from stac_fastapi.api.app import StacApi 24 | 25 | auth_settings = AuthSettings(_env_prefix="AUTH_") 26 | 27 | api = StacApi( 28 | app=FastAPI( 29 | # ... 30 | swagger_ui_init_oauth={ 31 | "clientId": auth_settings.client_id, 32 | "usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, 33 | }, 34 | ), 35 | # ... 36 | ) 37 | 38 | if auth_settings.openid_configuration_url: 39 | oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) 40 | 41 | # Implement your custom app-specific auth logic here... 42 | restricted_routes = { 43 | "/collections": ("POST", "stac:collection:create"), 44 | "/collections/{collection_id}": ("PUT", "stac:collection:update"), 45 | "/collections/{collection_id}": ("DELETE", "stac:collection:delete"), 46 | "/collections/{collection_id}/items": ("POST", "stac:item:create"), 47 | "/collections/{collection_id}/items/{item_id}": ("PUT", "stac:item:update"), 48 | "/collections/{collection_id}/items/{item_id}": ("DELETE", "stac:item:delete"), 49 | } 50 | api_routes = { 51 | route.path: route for route in api.app.routes if isinstance(route, APIRoute) 52 | } 53 | for endpoint, (method, scope) in restricted_routes.items(): 54 | route = api_routes.get(endpoint) 55 | if route and method in route.methods: 56 | oidc_auth.apply_auth_dependencies(route, required_token_scopes=[scope]) 57 | ``` 58 | 59 | 60 | ## Development 61 | 62 | ### Releases 63 | 64 | Releases are managed via CICD workflow, as described in the [Python Packaging User Guide](https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/). To create a new release: 65 | 66 | 1. Update the version in `eoapi/auth_utils/__init__.py` following appropriate [Semantic Versioning convention](https://semver.org/). 67 | 1. Push a tagged commit to `main`, with the tag matching the package's new version number. 68 | 69 | > [!NOTE] 70 | > This package makes use of Github's [automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes). These can be later augmented if one sees fit. 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 2 | name: Publish to PyPI and TestPyPI 3 | 4 | on: push 5 | 6 | jobs: 7 | build: 8 | name: Build distribution 📦 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.x" 17 | - name: Rewrite image URLs 18 | run: >- 19 | sed 20 | --in-place 21 | -E 's|!\[([^]]*)\]\((\./)?([^)]*)\)|![\1](https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/\3)|g' 22 | README.md 23 | - name: Install pypa/build 24 | run: >- 25 | python3 -m 26 | pip install 27 | build 28 | --user 29 | - name: Build a binary wheel and a source tarball 30 | run: python3 -m build 31 | - name: Store the distribution packages 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: python-package-distributions 35 | path: dist/ 36 | 37 | publish-to-pypi: 38 | name: >- 39 | Publish to PyPI 40 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 41 | needs: 42 | - build 43 | runs-on: ubuntu-latest 44 | environment: 45 | name: pypi 46 | url: https://pypi.org/p/eoapi-auth-utils 47 | permissions: 48 | id-token: write # IMPORTANT: mandatory for trusted publishing 49 | 50 | steps: 51 | - name: Download all the dists 52 | uses: actions/download-artifact@v4 53 | with: 54 | name: python-package-distributions 55 | path: dist/ 56 | - name: Publish distribution 📦 to PyPI 57 | uses: pypa/gh-action-pypi-publish@release/v1 58 | 59 | github-release: 60 | name: >- 61 | Sign the Python 🐍 distribution 📦 with Sigstore 62 | and upload them to GitHub Release 63 | needs: 64 | - publish-to-pypi 65 | runs-on: ubuntu-latest 66 | 67 | permissions: 68 | contents: write # IMPORTANT: mandatory for making GitHub Releases 69 | id-token: write # IMPORTANT: mandatory for sigstore 70 | 71 | steps: 72 | - name: Download all the dists 73 | uses: actions/download-artifact@v4 74 | with: 75 | name: python-package-distributions 76 | path: dist/ 77 | - name: Sign the dists with Sigstore 78 | uses: sigstore/gh-action-sigstore-python@v3.0.0 79 | with: 80 | inputs: >- 81 | ./dist/*.tar.gz 82 | ./dist/*.whl 83 | - name: Create GitHub Release 84 | env: 85 | GITHUB_TOKEN: ${{ github.token }} 86 | run: >- 87 | gh release create 88 | '${{ github.ref_name }}' 89 | --repo '${{ github.repository }}' 90 | --generate-notes 91 | - name: Upload artifact signatures to GitHub Release 92 | env: 93 | GITHUB_TOKEN: ${{ github.token }} 94 | # Upload to GitHub Release using the `gh` CLI. 95 | # `dist/` contains the built packages, and the 96 | # sigstore-produced signatures and certificates. 97 | run: >- 98 | gh release upload 99 | '${{ github.ref_name }}' dist/** 100 | --repo '${{ github.repository }}' 101 | 102 | publish-to-testpypi: 103 | name: Publish to TestPyPI 104 | needs: 105 | - build 106 | runs-on: ubuntu-latest 107 | 108 | environment: 109 | name: testpypi 110 | url: https://test.pypi.org/p/eoapi-auth-utils 111 | 112 | permissions: 113 | id-token: write # IMPORTANT: mandatory for trusted publishing 114 | 115 | steps: 116 | - name: Download all the dists 117 | uses: actions/download-artifact@v4 118 | with: 119 | name: python-package-distributions 120 | path: dist/ 121 | - name: Publish distribution 📦 to TestPyPI 122 | uses: pypa/gh-action-pypi-publish@release/v1 123 | with: 124 | repository-url: https://test.pypi.org/legacy/ 125 | continue-on-error: true 126 | -------------------------------------------------------------------------------- /eoapi/auth_utils/auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import urllib.request 4 | from dataclasses import dataclass, field 5 | from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional, Sequence 6 | 7 | import jwt 8 | from fastapi import HTTPException, Security, routing, security, status 9 | from fastapi.dependencies.utils import get_parameterless_sub_dependant 10 | from fastapi.security.base import SecurityBase 11 | from pydantic import AnyHttpUrl 12 | 13 | from .errors import OidcFetchError 14 | 15 | if TYPE_CHECKING: 16 | from .config import OpenIdConnectSettings 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @dataclass 22 | class OpenIdConnectAuth: 23 | openid_configuration_url: AnyHttpUrl 24 | openid_configuration_internal_url: Optional[AnyHttpUrl] = None 25 | allowed_jwt_audiences: Optional[Sequence[str]] = None 26 | 27 | # Generated attributes 28 | auth_scheme: SecurityBase = field(init=False) 29 | jwks_client: jwt.PyJWKClient = field(init=False) 30 | valid_token_dependency: Callable[..., Any] = field(init=False) 31 | 32 | def __post_init__(self): 33 | logger.debug("Requesting OIDC config") 34 | with urllib.request.urlopen( 35 | str(self.openid_configuration_internal_url or self.openid_configuration_url) 36 | ) as response: 37 | if response.status != 200: 38 | logger.error( 39 | "Received a non-200 response when fetching OIDC config: %s", 40 | response.text, 41 | ) 42 | raise OidcFetchError( 43 | f"Request for OIDC config failed with status {response.status}" 44 | ) 45 | oidc_config = json.load(response) 46 | self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) 47 | 48 | self.auth_scheme = security.OpenIdConnect( 49 | openIdConnectUrl=str(self.openid_configuration_url) 50 | ) 51 | self.valid_token_dependency = self.create_auth_token_dependency( 52 | auth_scheme=self.auth_scheme, 53 | jwks_client=self.jwks_client, 54 | allowed_jwt_audiences=self.allowed_jwt_audiences, 55 | ) 56 | 57 | @classmethod 58 | def from_settings(cls, settings: "OpenIdConnectSettings") -> "OpenIdConnectAuth": 59 | return cls(**settings.model_dump(include=cls.__dataclass_fields__.keys())) 60 | 61 | @staticmethod 62 | def create_auth_token_dependency( 63 | auth_scheme: SecurityBase, 64 | jwks_client: jwt.PyJWKClient, 65 | allowed_jwt_audiences: Sequence[str], 66 | ): 67 | """ 68 | Create a dependency that validates JWT tokens & scopes. 69 | """ 70 | 71 | def auth_token( 72 | auth_header: Annotated[str, Security(auth_scheme)], 73 | required_scopes: security.SecurityScopes, 74 | ): 75 | # Extract token from header 76 | token_parts = auth_header.split(" ") 77 | if len(token_parts) != 2 or token_parts[0].lower() != "bearer": 78 | logger.error(f"Invalid token: {auth_header}") 79 | raise HTTPException( 80 | status_code=status.HTTP_401_UNAUTHORIZED, 81 | detail="Could not validate credentials", 82 | headers={"WWW-Authenticate": "Bearer"}, 83 | ) 84 | [_, token] = token_parts 85 | 86 | # Parse & validate token 87 | try: 88 | key = jwks_client.get_signing_key_from_jwt(token).key 89 | payload = jwt.decode( 90 | token, 91 | key, 92 | algorithms=["RS256"], 93 | # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) 94 | audience=allowed_jwt_audiences, 95 | ) 96 | except (jwt.exceptions.InvalidTokenError, jwt.exceptions.DecodeError) as e: 97 | logger.exception(f"InvalidTokenError: {e=}") 98 | raise HTTPException( 99 | status_code=status.HTTP_401_UNAUTHORIZED, 100 | detail="Could not validate credentials", 101 | headers={"WWW-Authenticate": "Bearer"}, 102 | ) from e 103 | 104 | # Validate scopes (if required) 105 | for scope in required_scopes.scopes: 106 | if scope not in payload["scope"]: 107 | raise HTTPException( 108 | status_code=status.HTTP_401_UNAUTHORIZED, 109 | detail="Not enough permissions", 110 | headers={ 111 | "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' 112 | }, 113 | ) 114 | 115 | return payload 116 | 117 | return auth_token 118 | 119 | def apply_auth_dependencies( 120 | self, 121 | api_route: routing.APIRoute, 122 | required_token_scopes: Optional[Sequence[str]] = None, 123 | dependency: Optional[Callable[..., Any]] = None, 124 | ): 125 | """ 126 | Apply auth dependencies to a route. 127 | """ 128 | # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect 129 | if not hasattr(api_route, "dependant"): 130 | logger.warning( 131 | f"Route {api_route} has no dependant, not apply auth dependency" 132 | ) 133 | return 134 | 135 | depends = Security( 136 | dependency or self.valid_token_dependency, scopes=required_token_scopes 137 | ) 138 | logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") 139 | 140 | # Mimicking how APIRoute handles dependencies: 141 | # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 142 | api_route.dependant.dependencies.insert( 143 | 0, 144 | get_parameterless_sub_dependant( 145 | depends=depends, path=api_route.path_format 146 | ), 147 | ) 148 | 149 | # Register dependencies directly on route so that they aren't ignored if 150 | # the routes are later associated with an app (e.g. 151 | # app.include_router(router)) 152 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 153 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 154 | api_route.dependencies.extend([depends]) 155 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict 3 | from unittest.mock import MagicMock, patch 4 | 5 | import jwt 6 | import pytest 7 | from cryptography.hazmat.primitives.asymmetric import rsa 8 | from fastapi import FastAPI, HTTPException, Security, status, testclient 9 | from jwcrypto.jwt import JWK, JWT 10 | 11 | from eoapi.auth_utils import OpenIdConnectAuth 12 | 13 | 14 | @pytest.fixture 15 | def test_key() -> "JWK": 16 | return JWK.generate( 17 | kty="RSA", size=2048, kid="test", use="sig", e="AQAB", alg="RS256" 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def public_key(test_key: "JWK") -> Dict[str, Any]: 23 | return test_key.export_public(as_dict=True) 24 | 25 | 26 | @pytest.fixture 27 | def private_key(test_key: "JWK") -> Dict[str, Any]: 28 | return test_key.export_private(as_dict=True) 29 | 30 | 31 | @pytest.fixture(autouse=True) 32 | def mock_jwks(public_key: "rsa.RSAPrivateKey"): 33 | mock_oidc_config = {"jwks_uri": "https://example.com/jwks"} 34 | 35 | mock_jwks = {"keys": [public_key]} 36 | 37 | with ( 38 | patch("urllib.request.urlopen") as mock_urlopen, 39 | patch("jwt.PyJWKClient.fetch_data") as mock_fetch_data, 40 | ): 41 | mock_oidc_config_response = MagicMock() 42 | mock_oidc_config_response.read.return_value = json.dumps( 43 | mock_oidc_config 44 | ).encode() 45 | mock_oidc_config_response.status = 200 46 | 47 | mock_urlopen.return_value.__enter__.return_value = mock_oidc_config_response 48 | mock_fetch_data.return_value = mock_jwks 49 | yield mock_urlopen 50 | 51 | 52 | @pytest.fixture 53 | def token_builder(test_key: "JWK"): 54 | def build_token(payload: Dict[str, Any], key=None) -> str: 55 | jwt_token = JWT( 56 | header={k: test_key.get(k) for k in ["alg", "kid"]}, 57 | claims=payload, 58 | ) 59 | jwt_token.make_signed_token(key or test_key) 60 | return jwt_token.serialize() 61 | 62 | return build_token 63 | 64 | 65 | @pytest.fixture 66 | def test_app(): 67 | app = FastAPI() 68 | 69 | @app.get("/test-route") 70 | def test(): 71 | return {"message": "Hello World"} 72 | 73 | return app 74 | 75 | 76 | @pytest.fixture 77 | def test_client(test_app): 78 | return testclient.TestClient(test_app) 79 | 80 | 81 | def test_oidc_auth_initialization(mock_jwks: MagicMock): 82 | """ 83 | Auth object is initialized with the correct dependencies. 84 | """ 85 | openid_configuration_url = "https://example.com/.well-known/openid-configuration" 86 | auth = OpenIdConnectAuth(openid_configuration_url=openid_configuration_url) 87 | assert auth.jwks_client is not None 88 | assert auth.auth_scheme is not None 89 | assert auth.valid_token_dependency is not None 90 | mock_jwks.assert_called_once_with(openid_configuration_url) 91 | 92 | 93 | def test_auth_token_valid(token_builder): 94 | """ 95 | Auth token dependency returns the token payload when the token is valid. 96 | """ 97 | token = token_builder({"scope": "test_scope"}) 98 | 99 | auth = OpenIdConnectAuth( 100 | openid_configuration_url="https://example.com/.well-known/openid-configuration" 101 | ) 102 | 103 | token_payload = auth.valid_token_dependency( 104 | auth_header=f"Bearer {token}", required_scopes=Security([]) 105 | ) 106 | assert token_payload["scope"] == "test_scope" 107 | 108 | 109 | def test_auth_token_invalid_audience(token_builder): 110 | """ 111 | Auth token dependency throws 401 when the token audience is invalid. 112 | """ 113 | token = token_builder({"scope": "test_scope", "aud": "test_audience"}) 114 | 115 | auth = OpenIdConnectAuth( 116 | openid_configuration_url="https://example.com/.well-known/openid-configuration" 117 | ) 118 | 119 | with pytest.raises(HTTPException) as exc_info: 120 | auth.valid_token_dependency( 121 | auth_header=f"Bearer {token}", required_scopes=Security([]) 122 | ) 123 | 124 | assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED 125 | assert exc_info.value.detail == "Could not validate credentials" 126 | assert isinstance(exc_info.value.__cause__, jwt.exceptions.InvalidAudienceError) 127 | 128 | 129 | def test_auth_token_invalid_signature(token_builder): 130 | """ 131 | Auth token dependency throws 401 when the token signature is invalid. 132 | """ 133 | other_key = JWK.generate( 134 | kty="RSA", size=2048, kid="test", use="sig", e="AQAB", alg="RS256" 135 | ) 136 | token = token_builder({"scope": "test_scope", "aud": "test_audience"}, other_key) 137 | 138 | auth = OpenIdConnectAuth( 139 | openid_configuration_url="https://example.com/.well-known/openid-configuration" 140 | ) 141 | 142 | with pytest.raises(HTTPException) as exc_info: 143 | auth.valid_token_dependency( 144 | auth_header=f"Bearer {token}", required_scopes=Security([]) 145 | ) 146 | 147 | assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED 148 | assert exc_info.value.detail == "Could not validate credentials" 149 | assert isinstance(exc_info.value.__cause__, jwt.exceptions.InvalidSignatureError) 150 | 151 | 152 | @pytest.mark.parametrize( 153 | "token", 154 | [ 155 | "foo", 156 | "Bearer foo", 157 | "Bearer foo.bar.xyz", 158 | "Basic foo", 159 | ], 160 | ) 161 | def test_auth_token_invalid_token(token): 162 | """ 163 | Auth token dependency throws 401 when the token is invalid. 164 | """ 165 | auth = OpenIdConnectAuth( 166 | openid_configuration_url="https://example.com/.well-known/openid-configuration" 167 | ) 168 | 169 | with pytest.raises(HTTPException) as exc_info: 170 | auth.valid_token_dependency(auth_header=token, required_scopes=Security([])) 171 | 172 | assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED 173 | assert exc_info.value.detail == "Could not validate credentials" 174 | 175 | 176 | def test_apply_auth_dependencies(test_app, test_client): 177 | auth = OpenIdConnectAuth( 178 | openid_configuration_url="https://example.com/.well-known/openid-configuration" 179 | ) 180 | 181 | for route in test_app.routes: 182 | auth.apply_auth_dependencies( 183 | api_route=route, required_token_scopes=["test_scope"] 184 | ) 185 | 186 | resp = test_client.get("/test-route") 187 | assert resp.json() == {"detail": "Not authenticated"} 188 | assert resp.status_code == status.HTTP_403_FORBIDDEN 189 | 190 | 191 | @pytest.mark.parametrize( 192 | "required_sent_response", 193 | [ 194 | ("a", "b", status.HTTP_401_UNAUTHORIZED), 195 | ("a b c", "a b", status.HTTP_401_UNAUTHORIZED), 196 | ("a", "a", status.HTTP_200_OK), 197 | (None, None, status.HTTP_200_OK), 198 | (None, "a", status.HTTP_200_OK), 199 | ("a b c", "d c b a", status.HTTP_200_OK), 200 | ], 201 | ) 202 | def test_reject_wrong_scope( 203 | test_app, test_client, token_builder, required_sent_response 204 | ): 205 | auth = OpenIdConnectAuth( 206 | openid_configuration_url="https://example.com/.well-known/openid-configuration" 207 | ) 208 | 209 | scope_required, scope_sent, expected_status = required_sent_response 210 | for route in test_app.routes: 211 | auth.apply_auth_dependencies( 212 | api_route=route, 213 | required_token_scopes=scope_required.split(" ") if scope_required else None, 214 | ) 215 | 216 | token = token_builder({"scope": scope_sent}) 217 | resp = test_client.get("/test-route", headers={"Authorization": f"Bearer {token}"}) 218 | assert resp.status_code == expected_status 219 | --------------------------------------------------------------------------------