csrd-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csrd_auth-0.1.0/.gitignore +217 -0
- csrd_auth-0.1.0/PKG-INFO +14 -0
- csrd_auth-0.1.0/README.md +81 -0
- csrd_auth-0.1.0/pyproject.toml +29 -0
- csrd_auth-0.1.0/src/csrd/auth/__init__.py +58 -0
- csrd_auth-0.1.0/src/csrd/auth/_authenticators.py +220 -0
- csrd_auth-0.1.0/src/csrd/auth/_factory.py +93 -0
- csrd_auth-0.1.0/src/csrd/auth/_key_providers.py +219 -0
- csrd_auth-0.1.0/src/csrd/auth/_protocols.py +29 -0
- csrd_auth-0.1.0/src/csrd/auth/py.typed +0 -0
- csrd_auth-0.1.0/tests/test_auth.py +348 -0
- csrd_auth-0.1.0/tests/test_auth_providers.py +319 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
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
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
/env/
|
|
142
|
+
/venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
*.db
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Import linter cache
|
|
213
|
+
.import_linter_cache/
|
|
214
|
+
|
|
215
|
+
# IDE
|
|
216
|
+
.idea/
|
|
217
|
+
.idea/*
|
csrd_auth-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pluggable JWT authentication — authenticators, key providers, and FastAPI dependency factories
|
|
5
|
+
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
|
+
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/auth
|
|
7
|
+
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: csrd-context
|
|
11
|
+
Requires-Dist: csrd-models
|
|
12
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
13
|
+
Requires-Dist: pydantic<3,>=2
|
|
14
|
+
Requires-Dist: pyjwt<3,>=2
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# csrd-auth
|
|
2
|
+
|
|
3
|
+
Pluggable JWT authentication for FastAPI — authenticators, key providers, and dependency factories.
|
|
4
|
+
|
|
5
|
+
**Tier 1.5** — depends on `csrd-models` and `csrd-context` only.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "csrd-auth @ git+https://github.com/your-org/fastapi-common.git#subdirectory=packages/auth"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from csrd.auth import (
|
|
17
|
+
JWTAuthenticator,
|
|
18
|
+
StaticKeyProvider,
|
|
19
|
+
create_bearer_dependency,
|
|
20
|
+
create_jwt_bearer,
|
|
21
|
+
)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Quick start — JWT bearer dependency
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# Standalone (provide your own token_finder)
|
|
28
|
+
from csrd.auth import create_jwt_bearer
|
|
29
|
+
|
|
30
|
+
auth_dep = create_jwt_bearer(
|
|
31
|
+
key="your-secret",
|
|
32
|
+
token_finder=lambda: extract_token_from_request(),
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
When used via `csrd.versioning`, `find_token` is injected automatically:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# Via versioning (token_finder = find_token by default)
|
|
40
|
+
from csrd.versioning.auth import create_jwt_bearer
|
|
41
|
+
|
|
42
|
+
auth_dep = create_jwt_bearer(key="your-secret")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Authenticators
|
|
46
|
+
|
|
47
|
+
| Class | Strategy |
|
|
48
|
+
|-------|----------|
|
|
49
|
+
| `JWTAuthenticator` | Decode & verify JWT locally via PyJWT |
|
|
50
|
+
| `StaticAuthenticator` | Accept a fixed token (dev/testing) |
|
|
51
|
+
| `RemoteAuthenticator` | Validate via upstream HTTP endpoint |
|
|
52
|
+
| `CallbackAuthenticator` | Delegate to any sync/async callable |
|
|
53
|
+
| `ChainedAuthenticator` | Try multiple authenticators in order |
|
|
54
|
+
|
|
55
|
+
## Key Providers
|
|
56
|
+
|
|
57
|
+
| Class | Strategy |
|
|
58
|
+
|-------|----------|
|
|
59
|
+
| `StaticKeyProvider` | Fixed string, bytes, or `SecretStr` |
|
|
60
|
+
| `EnvKeyProvider` | Read from `app.state` or settings loader |
|
|
61
|
+
| `JWKSKeyProvider` | Fetch from JWKS endpoint (cached with TTL) |
|
|
62
|
+
| `MultiKeyProvider` | Route by `kid` header or try all |
|
|
63
|
+
| `CallbackKeyProvider` | Delegate to any callable |
|
|
64
|
+
|
|
65
|
+
## Protocols
|
|
66
|
+
|
|
67
|
+
- `Authenticator` — `(Request, str) -> UserClaims`
|
|
68
|
+
- `KeyProvider` — `(token_headers, app) -> key`
|
|
69
|
+
|
|
70
|
+
Both are `@runtime_checkable` protocols.
|
|
71
|
+
|
|
72
|
+
## Dependency Tier
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Tier 1 csrd.models · csrd.lifespan · csrd.context
|
|
76
|
+
Tier 1.5 csrd.auth · csrd.logging
|
|
77
|
+
Tier 2 csrd.delegate · csrd.repository · csrd.service
|
|
78
|
+
Tier 3 csrd.versioning
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`csrd.auth` depends only on `csrd.models` (for `UserClaims`) and `csrd.context` (for `user_info_context`). It has **no dependency** on `csrd.versioning`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-auth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pluggable JWT authentication — authenticators, key providers, and FastAPI dependency factories"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.115,<1",
|
|
9
|
+
"pydantic>=2,<3",
|
|
10
|
+
"PyJWT>=2,<3",
|
|
11
|
+
"csrd-context",
|
|
12
|
+
"csrd-models",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
csrd-context = { workspace = true }
|
|
17
|
+
csrd-models = { workspace = true }
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
21
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/auth"
|
|
22
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/csrd"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Pluggable authentication for FastAPI applications.
|
|
2
|
+
|
|
3
|
+
The auth system is built around two pluggable protocols:
|
|
4
|
+
|
|
5
|
+
1. :class:`Authenticator` — how to validate a token and produce claims.
|
|
6
|
+
2. :class:`KeyProvider` — how to resolve the cryptographic key(s) used
|
|
7
|
+
for JWT verification.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._authenticators import (
|
|
11
|
+
CallbackAuthenticator,
|
|
12
|
+
ChainedAuthenticator,
|
|
13
|
+
JWTAuthenticator,
|
|
14
|
+
RemoteAuthenticator,
|
|
15
|
+
StaticAuthenticator,
|
|
16
|
+
_default_claims_mapper,
|
|
17
|
+
)
|
|
18
|
+
from ._factory import (
|
|
19
|
+
create_bearer_dependency,
|
|
20
|
+
create_jwt_bearer,
|
|
21
|
+
)
|
|
22
|
+
from ._key_providers import (
|
|
23
|
+
CallbackKeyProvider,
|
|
24
|
+
EnvKeyProvider,
|
|
25
|
+
JWKSKeyProvider,
|
|
26
|
+
MultiKeyProvider,
|
|
27
|
+
StaticKeyProvider,
|
|
28
|
+
_coerce_key_provider,
|
|
29
|
+
)
|
|
30
|
+
from ._protocols import (
|
|
31
|
+
AuthCallback,
|
|
32
|
+
Authenticator,
|
|
33
|
+
KeyCallback,
|
|
34
|
+
KeyProvider,
|
|
35
|
+
KeySource,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = (
|
|
39
|
+
"AuthCallback",
|
|
40
|
+
"Authenticator",
|
|
41
|
+
"CallbackAuthenticator",
|
|
42
|
+
"CallbackKeyProvider",
|
|
43
|
+
"ChainedAuthenticator",
|
|
44
|
+
"EnvKeyProvider",
|
|
45
|
+
"JWKSKeyProvider",
|
|
46
|
+
"JWTAuthenticator",
|
|
47
|
+
"KeyCallback",
|
|
48
|
+
"KeyProvider",
|
|
49
|
+
"KeySource",
|
|
50
|
+
"MultiKeyProvider",
|
|
51
|
+
"RemoteAuthenticator",
|
|
52
|
+
"StaticAuthenticator",
|
|
53
|
+
"StaticKeyProvider",
|
|
54
|
+
"_coerce_key_provider",
|
|
55
|
+
"_default_claims_mapper",
|
|
56
|
+
"create_bearer_dependency",
|
|
57
|
+
"create_jwt_bearer",
|
|
58
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# ruff: noqa: B904
|
|
2
|
+
"""Built-in authenticator implementations."""
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import jwt as pyjwt
|
|
11
|
+
from fastapi import HTTPException, Request
|
|
12
|
+
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
13
|
+
|
|
14
|
+
from csrd.models.claims import UserClaims
|
|
15
|
+
|
|
16
|
+
from ._key_providers import _coerce_key_provider
|
|
17
|
+
from ._protocols import AuthCallback, Authenticator, KeySource
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_claims_mapper(payload: dict[str, Any]) -> UserClaims:
|
|
23
|
+
sub = payload.get("sub", "")
|
|
24
|
+
user_name = (
|
|
25
|
+
payload.get("user_name") or payload.get("preferred_username") or payload.get("email") or ""
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
authorities = payload.get("authorities") or payload.get("roles") or []
|
|
29
|
+
if isinstance(authorities, str):
|
|
30
|
+
authorities = authorities.split()
|
|
31
|
+
|
|
32
|
+
return UserClaims(
|
|
33
|
+
sub=sub,
|
|
34
|
+
user_name=user_name,
|
|
35
|
+
authorities=list(authorities),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class JWTAuthenticator:
|
|
41
|
+
"""Decode and verify a JWT token locally using PyJWT."""
|
|
42
|
+
|
|
43
|
+
key: KeySource | None = None
|
|
44
|
+
algorithms: list[str] = field(default_factory=lambda: ["HS256"])
|
|
45
|
+
audience: str | None = None
|
|
46
|
+
issuer: str | None = None
|
|
47
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
self._key_provider = _coerce_key_provider(self.key)
|
|
52
|
+
|
|
53
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
54
|
+
mapper = self.claims_mapper or _default_claims_mapper
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
unverified_headers = pyjwt.get_unverified_header(token)
|
|
58
|
+
except pyjwt.DecodeError:
|
|
59
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Malformed token")
|
|
60
|
+
|
|
61
|
+
resolved = self._key_provider(unverified_headers, request.app)
|
|
62
|
+
if inspect.isawaitable(resolved):
|
|
63
|
+
resolved = await resolved
|
|
64
|
+
|
|
65
|
+
decode_kwargs: dict[str, Any] = {
|
|
66
|
+
"algorithms": self.algorithms,
|
|
67
|
+
"options": dict(self.options),
|
|
68
|
+
}
|
|
69
|
+
if self.audience is not None:
|
|
70
|
+
decode_kwargs["audience"] = self.audience
|
|
71
|
+
if self.issuer is not None:
|
|
72
|
+
decode_kwargs["issuer"] = self.issuer
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = pyjwt.decode(token, resolved, **decode_kwargs)
|
|
76
|
+
except pyjwt.ExpiredSignatureError:
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
79
|
+
detail="Token has expired",
|
|
80
|
+
)
|
|
81
|
+
except pyjwt.InvalidTokenError as exc:
|
|
82
|
+
logger.debug("JWT validation failed: %s", exc)
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
85
|
+
detail="Invalid token",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return mapper(payload)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class StaticAuthenticator:
|
|
93
|
+
"""Accept a fixed token value and return preconfigured claims."""
|
|
94
|
+
|
|
95
|
+
token: str
|
|
96
|
+
claims: UserClaims = field(default_factory=lambda: UserClaims(sub="static"))
|
|
97
|
+
|
|
98
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
99
|
+
import hmac
|
|
100
|
+
|
|
101
|
+
if not hmac.compare_digest(token, self.token):
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
104
|
+
detail="Invalid token",
|
|
105
|
+
)
|
|
106
|
+
return self.claims
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RemoteAuthenticator:
|
|
111
|
+
"""Validate the token by calling an upstream HTTP endpoint."""
|
|
112
|
+
|
|
113
|
+
url: str
|
|
114
|
+
method: str = "GET"
|
|
115
|
+
token_header: str = "Authorization"
|
|
116
|
+
token_prefix: str = "Bearer "
|
|
117
|
+
claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None
|
|
118
|
+
httpx_client_kwargs: dict[str, Any] = field(default_factory=dict)
|
|
119
|
+
client: Any = None
|
|
120
|
+
|
|
121
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
122
|
+
import httpx
|
|
123
|
+
|
|
124
|
+
mapper = self.claims_mapper or _default_claims_mapper
|
|
125
|
+
headers = {self.token_header: f"{self.token_prefix}{token}"}
|
|
126
|
+
|
|
127
|
+
async def _do_request(c: httpx.AsyncClient) -> httpx.Response:
|
|
128
|
+
try:
|
|
129
|
+
return await c.request(self.method, self.url, headers=headers)
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
logger.warning("Remote auth request failed: %s", exc)
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
134
|
+
detail="Authentication service unavailable",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if self.client is not None:
|
|
138
|
+
resp = await _do_request(self.client)
|
|
139
|
+
else:
|
|
140
|
+
client_kwargs = {"timeout": 10.0, **self.httpx_client_kwargs}
|
|
141
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
142
|
+
resp = await _do_request(client)
|
|
143
|
+
|
|
144
|
+
if resp.status_code >= 400:
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Remote auth rejected token: status=%d body=%s",
|
|
147
|
+
resp.status_code,
|
|
148
|
+
resp.text[:200],
|
|
149
|
+
)
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
152
|
+
detail="Token rejected by authentication service",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
payload = resp.json()
|
|
157
|
+
except Exception:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
160
|
+
detail="Invalid response from authentication service",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if isinstance(payload, dict) and payload.get("active") is False:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
166
|
+
detail="Token is not active",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return mapper(payload)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class CallbackAuthenticator:
|
|
174
|
+
"""Delegate authentication to an arbitrary user-provided function."""
|
|
175
|
+
|
|
176
|
+
callback: AuthCallback
|
|
177
|
+
|
|
178
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
179
|
+
try:
|
|
180
|
+
result = self.callback(request, token)
|
|
181
|
+
if inspect.isawaitable(result):
|
|
182
|
+
result = await result
|
|
183
|
+
except HTTPException:
|
|
184
|
+
raise
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.debug("Callback authenticator failed: %s", exc)
|
|
187
|
+
raise HTTPException(
|
|
188
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
189
|
+
detail="Authentication failed",
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
if not isinstance(result, UserClaims):
|
|
193
|
+
raise TypeError(
|
|
194
|
+
f"Authenticator callback must return UserClaims, got {type(result).__name__}"
|
|
195
|
+
)
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class ChainedAuthenticator:
|
|
201
|
+
"""Try multiple authenticators in order; first success wins."""
|
|
202
|
+
|
|
203
|
+
authenticators: list[Authenticator | Any] = field(default_factory=list)
|
|
204
|
+
|
|
205
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
206
|
+
last_exc: HTTPException | None = None
|
|
207
|
+
for auth in self.authenticators:
|
|
208
|
+
try:
|
|
209
|
+
result = auth(request, token)
|
|
210
|
+
if inspect.isawaitable(result):
|
|
211
|
+
result = await result
|
|
212
|
+
return result
|
|
213
|
+
except HTTPException as exc:
|
|
214
|
+
last_exc = exc
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
raise last_exc or HTTPException(
|
|
218
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
219
|
+
detail="All authenticators failed",
|
|
220
|
+
)
|