fastapi-multiauth 0.2.0__tar.gz → 0.4.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.
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/PKG-INFO +3 -2
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/README.md +2 -1
- fastapi_multiauth-0.4.0/pyproject.toml +111 -0
- fastapi_multiauth-0.2.0/pyproject.toml → fastapi_multiauth-0.4.0/pyproject.toml.orig +4 -2
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/__init__.py +1 -1
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/_http.py +8 -2
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/abc.py +36 -32
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/jwt.py +1 -1
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/client.py +22 -4
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/cookie.py +71 -8
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/LICENSE +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/exceptions.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/basic.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/bearer.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/header.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/multi.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/query.py +0 -0
- {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-multiauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction
|
|
5
5
|
Keywords: fastapi,authentication,security,oauth,api-key,bearer,cookie
|
|
6
6
|
Author: d3vyce
|
|
@@ -45,6 +45,7 @@ Composable authentication sources for FastAPI: bring your own validator, combine
|
|
|
45
45
|
|
|
46
46
|
[](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml)
|
|
47
47
|
[](https://codecov.io/gh/d3vyce/fastapi-multiauth)
|
|
48
|
+
[](https://app.codspeed.io//d3vyce/fastapi-multiauth?utm_source=badge)
|
|
48
49
|
[](https://github.com/astral-sh/ty)
|
|
49
50
|
[](https://github.com/astral-sh/uv)
|
|
50
51
|
[](https://github.com/astral-sh/ruff)
|
|
@@ -118,7 +119,7 @@ async def me(user=Security(auth)):
|
|
|
118
119
|
- **`MultiAuth`**: try several sources in order on a single route (e.g. web session cookie + API bearer token), with all schemes documented in OpenAPI.
|
|
119
120
|
- **Built-in sources**: covering the standard `fastapi.security` schemes:
|
|
120
121
|
- **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation.
|
|
121
|
-
- **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, and
|
|
122
|
+
- **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, key rotation, and optional per-session ids so your app can list and revoke sessions individually.
|
|
122
123
|
- **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
|
|
123
124
|
- **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
|
|
124
125
|
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
@@ -4,6 +4,7 @@ Composable authentication sources for FastAPI: bring your own validator, combine
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml)
|
|
6
6
|
[](https://codecov.io/gh/d3vyce/fastapi-multiauth)
|
|
7
|
+
[](https://app.codspeed.io//d3vyce/fastapi-multiauth?utm_source=badge)
|
|
7
8
|
[](https://github.com/astral-sh/ty)
|
|
8
9
|
[](https://github.com/astral-sh/uv)
|
|
9
10
|
[](https://github.com/astral-sh/ruff)
|
|
@@ -77,7 +78,7 @@ async def me(user=Security(auth)):
|
|
|
77
78
|
- **`MultiAuth`**: try several sources in order on a single route (e.g. web session cookie + API bearer token), with all schemes documented in OpenAPI.
|
|
78
79
|
- **Built-in sources**: covering the standard `fastapi.security` schemes:
|
|
79
80
|
- **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation.
|
|
80
|
-
- **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, and
|
|
81
|
+
- **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, key rotation, and optional per-session ids so your app can list and revoke sessions individually.
|
|
81
82
|
- **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
|
|
82
83
|
- **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
|
|
83
84
|
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-multiauth"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = [
|
|
10
|
+
"fastapi",
|
|
11
|
+
"authentication",
|
|
12
|
+
"security",
|
|
13
|
+
"oauth",
|
|
14
|
+
"api-key",
|
|
15
|
+
"bearer",
|
|
16
|
+
"cookie",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
"Framework :: FastAPI",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
31
|
+
"Topic :: Security",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
33
|
+
"Topic :: Software Development :: Libraries",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"anyio>=3.7",
|
|
38
|
+
"async-lru>=2.0",
|
|
39
|
+
"fastapi>=0.100.0",
|
|
40
|
+
"httpx>=0.25.0",
|
|
41
|
+
"itsdangerous>=2.1",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[project.authors]]
|
|
45
|
+
name = "d3vyce"
|
|
46
|
+
email = "contact@d3vyce.fr"
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/d3vyce/fastapi-multiauth"
|
|
50
|
+
Documentation = "https://fastapi-multiauth.d3vyce.fr/"
|
|
51
|
+
Repository = "https://github.com/d3vyce/fastapi-multiauth"
|
|
52
|
+
Issues = "https://github.com/d3vyce/fastapi-multiauth/issues"
|
|
53
|
+
|
|
54
|
+
[project.optional-dependencies]
|
|
55
|
+
jwt = ["pyjwt[crypto]>=2.13.0"]
|
|
56
|
+
oauth = ["httpx-oauth>=0.16"]
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
{ include-group = "tests" },
|
|
61
|
+
{ include-group = "docs" },
|
|
62
|
+
"fastapi_multiauth[jwt,oauth]",
|
|
63
|
+
"prek>=0.3.8",
|
|
64
|
+
"ruff>=0.1.0",
|
|
65
|
+
"ty>=0.0.34",
|
|
66
|
+
]
|
|
67
|
+
tests = [
|
|
68
|
+
"coverage>=7.0.0",
|
|
69
|
+
"pytest-anyio>=0.0.0",
|
|
70
|
+
"pytest-codspeed>=5.0",
|
|
71
|
+
"pytest-cov>=4.0.0",
|
|
72
|
+
"pytest>=8.0.0",
|
|
73
|
+
"respx>=0.22",
|
|
74
|
+
]
|
|
75
|
+
docs = [
|
|
76
|
+
"mkdocstrings-python>=2.0.2",
|
|
77
|
+
"zensical>=0.0.30",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[build-system]
|
|
81
|
+
requires = ["uv_build>=0.11.21,<0.13.0"]
|
|
82
|
+
build-backend = "uv_build"
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint]
|
|
85
|
+
extend-select = ["S"]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.per-file-ignores]
|
|
88
|
+
"tests/**" = [
|
|
89
|
+
"S",
|
|
90
|
+
"B008",
|
|
91
|
+
]
|
|
92
|
+
"benchmarks/**" = [
|
|
93
|
+
"S",
|
|
94
|
+
"B008",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
[tool.pytest.ini_options]
|
|
98
|
+
testpaths = ["tests"]
|
|
99
|
+
anyio_mode = "auto"
|
|
100
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
101
|
+
|
|
102
|
+
[tool.coverage.run]
|
|
103
|
+
source = ["src/fastapi_multiauth"]
|
|
104
|
+
branch = true
|
|
105
|
+
|
|
106
|
+
[tool.coverage.report]
|
|
107
|
+
exclude_lines = [
|
|
108
|
+
"pragma: no cover",
|
|
109
|
+
"if TYPE_CHECKING:",
|
|
110
|
+
"raise NotImplementedError",
|
|
111
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -62,6 +62,7 @@ dev = [
|
|
|
62
62
|
tests = [
|
|
63
63
|
"coverage>=7.0.0",
|
|
64
64
|
"pytest-anyio>=0.0.0",
|
|
65
|
+
"pytest-codspeed>=5.0",
|
|
65
66
|
"pytest-cov>=4.0.0",
|
|
66
67
|
"pytest>=8.0.0",
|
|
67
68
|
"respx>=0.22",
|
|
@@ -72,7 +73,7 @@ docs = [
|
|
|
72
73
|
]
|
|
73
74
|
|
|
74
75
|
[build-system]
|
|
75
|
-
requires = ["uv_build>=0.11.21,<0.
|
|
76
|
+
requires = ["uv_build>=0.11.21,<0.13.0"]
|
|
76
77
|
build-backend = "uv_build"
|
|
77
78
|
|
|
78
79
|
[tool.ruff.lint]
|
|
@@ -81,6 +82,7 @@ extend-select = ["S"]
|
|
|
81
82
|
|
|
82
83
|
[tool.ruff.lint.per-file-ignores]
|
|
83
84
|
"tests/**" = ["S", "B008"]
|
|
85
|
+
"benchmarks/**" = ["S", "B008"]
|
|
84
86
|
|
|
85
87
|
[tool.pytest.ini_options]
|
|
86
88
|
testpaths = ["tests"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Shared HTTP transport hygiene for the OAuth and JWT modules."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import json
|
|
4
5
|
from typing import Any
|
|
5
6
|
from urllib.parse import urlsplit
|
|
@@ -31,11 +32,16 @@ async def _get_json(
|
|
|
31
32
|
error_cls: type[Exception],
|
|
32
33
|
description: str,
|
|
33
34
|
headers: dict[str, str] | None = None,
|
|
35
|
+
client: httpx.AsyncClient | None = None,
|
|
34
36
|
) -> Any:
|
|
35
37
|
"""GET *url* and decode the JSON body, mapping failures to *error_cls*."""
|
|
36
|
-
async with
|
|
38
|
+
async with (
|
|
39
|
+
contextlib.nullcontext(client)
|
|
40
|
+
if client is not None
|
|
41
|
+
else httpx.AsyncClient(timeout=timeout)
|
|
42
|
+
) as http:
|
|
37
43
|
try:
|
|
38
|
-
resp = await
|
|
44
|
+
resp = await http.get(url, headers=headers)
|
|
39
45
|
resp.raise_for_status()
|
|
40
46
|
return resp.json()
|
|
41
47
|
except httpx.HTTPError as exc:
|
|
@@ -17,23 +17,29 @@ if TYPE_CHECKING:
|
|
|
17
17
|
from typing_extensions import Self
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
_RESERVED_KWARGS = {
|
|
21
|
+
"scopes": "injected from the route by Security(..., scopes=[...])",
|
|
22
|
+
"session_id": "injected by APIKeyCookieAuth(session_id=True)",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reject_reserved_kwargs(kwargs: dict[str, Any]) -> None:
|
|
27
|
+
"""Reject validator kwargs the library injects itself."""
|
|
28
|
+
for name, injected in _RESERVED_KWARGS.items():
|
|
29
|
+
if name in kwargs:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"'{name}' is a reserved validator kwarg ({injected}): "
|
|
32
|
+
"the library sets it. Use a different keyword name."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _accepts_kwarg(fn: Callable[..., Any], name: str) -> bool:
|
|
37
|
+
"""Return whether *fn* declares a *name* parameter."""
|
|
32
38
|
try:
|
|
33
39
|
parameters = inspect.signature(fn).parameters
|
|
34
40
|
except (TypeError, ValueError):
|
|
35
41
|
return False
|
|
36
|
-
param = parameters.get(
|
|
42
|
+
param = parameters.get(name)
|
|
37
43
|
return param is not None and param.kind in (
|
|
38
44
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
39
45
|
inspect.Parameter.KEYWORD_ONLY,
|
|
@@ -51,17 +57,6 @@ def _unenforceable_scopes_error(owner: object, scopes: list[str]) -> RuntimeErro
|
|
|
51
57
|
)
|
|
52
58
|
|
|
53
59
|
|
|
54
|
-
def _scope_kwargs(
|
|
55
|
-
owner: object, accepts_scopes: bool, scopes: list[str]
|
|
56
|
-
) -> dict[str, Any]:
|
|
57
|
-
"""Return the ``scopes`` kwarg for a validator, failing closed when unsupported."""
|
|
58
|
-
if accepts_scopes:
|
|
59
|
-
return {"scopes": scopes}
|
|
60
|
-
if scopes:
|
|
61
|
-
raise _unenforceable_scopes_error(owner, scopes)
|
|
62
|
-
return {}
|
|
63
|
-
|
|
64
|
-
|
|
65
60
|
class _DocOnlyScheme(SecurityBase):
|
|
66
61
|
"""Inert stand-in for a ``fastapi.security`` scheme.
|
|
67
62
|
|
|
@@ -193,18 +188,24 @@ class ValidatedAuthSource(AuthSource):
|
|
|
193
188
|
validator: Sync or async callable returning the identity.
|
|
194
189
|
scheme: Optional ``fastapi.security`` scheme for OpenAPI.
|
|
195
190
|
**kwargs: Extra keyword arguments forwarded to the validator on
|
|
196
|
-
every call.
|
|
191
|
+
every call. Names the library injects itself are reserved:
|
|
192
|
+
``scopes`` and ``session_id``.
|
|
197
193
|
"""
|
|
198
|
-
|
|
194
|
+
_reject_reserved_kwargs(kwargs)
|
|
199
195
|
self._validator = ensure_async(validator)
|
|
200
|
-
self._accepts_scopes =
|
|
196
|
+
self._accepts_scopes = _accepts_kwarg(validator, "scopes")
|
|
201
197
|
self._kwargs = kwargs
|
|
202
198
|
super().__init__(scheme)
|
|
203
199
|
|
|
204
|
-
async def _call_validator(
|
|
200
|
+
async def _call_validator(
|
|
201
|
+
self, *args: Any, scopes: list[str], **injected: Any
|
|
202
|
+
) -> Any:
|
|
205
203
|
"""Invoke the validator with scope and configured kwargs forwarding."""
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
if self._accepts_scopes:
|
|
205
|
+
injected["scopes"] = scopes
|
|
206
|
+
elif scopes: # fail closed: the validator cannot check them
|
|
207
|
+
raise _unenforceable_scopes_error(self, scopes)
|
|
208
|
+
return await self._validator(*args, **self._kwargs, **injected)
|
|
208
209
|
|
|
209
210
|
async def authenticate(self, credential: str) -> Any:
|
|
210
211
|
"""Validate a credential and return the identity (no route scopes)."""
|
|
@@ -215,8 +216,11 @@ class ValidatedAuthSource(AuthSource):
|
|
|
215
216
|
return await self._call_validator(credential, scopes=scopes)
|
|
216
217
|
|
|
217
218
|
def require(self, **kwargs: Any) -> "Self":
|
|
218
|
-
"""Return a copy of this source with additional (or overriding) validator kwargs.
|
|
219
|
-
|
|
219
|
+
"""Return a copy of this source with additional (or overriding) validator kwargs.
|
|
220
|
+
|
|
221
|
+
Reserved names (``scopes``, ``session_id``) are rejected here too.
|
|
222
|
+
"""
|
|
223
|
+
_reject_reserved_kwargs(kwargs)
|
|
220
224
|
clone = copy.copy(self)
|
|
221
225
|
clone._kwargs = {**self._kwargs, **kwargs}
|
|
222
226
|
return clone
|
|
@@ -149,7 +149,7 @@ class JWTValidator:
|
|
|
149
149
|
leeway=self._leeway,
|
|
150
150
|
options=self._decode_options,
|
|
151
151
|
)
|
|
152
|
-
except self._jwt.
|
|
152
|
+
except (self._jwt.exceptions.PyJWTError, TypeError, ValueError):
|
|
153
153
|
raise UnauthorizedError() from None
|
|
154
154
|
if scopes:
|
|
155
155
|
missing = set(scopes) - self._granted_scopes(claims)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""OAuth 2.0 / OIDC network operations: discovery, code exchange, userinfo."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import functools
|
|
4
5
|
from typing import Any, Literal, NamedTuple
|
|
5
6
|
|
|
@@ -74,6 +75,7 @@ def _oauth2_client_class() -> type:
|
|
|
74
75
|
access_token_endpoint: str,
|
|
75
76
|
timeout: float,
|
|
76
77
|
token_endpoint_auth_method: TokenEndpointAuthMethod,
|
|
78
|
+
client: httpx.AsyncClient | None,
|
|
77
79
|
) -> None:
|
|
78
80
|
super().__init__(
|
|
79
81
|
client_id,
|
|
@@ -85,8 +87,13 @@ def _oauth2_client_class() -> type:
|
|
|
85
87
|
token_endpoint_auth_method=token_endpoint_auth_method,
|
|
86
88
|
)
|
|
87
89
|
self._timeout = timeout
|
|
90
|
+
self._client = client
|
|
88
91
|
|
|
89
|
-
def get_httpx_client(self) ->
|
|
92
|
+
def get_httpx_client(self) -> Any:
|
|
93
|
+
# httpx-oauth only uses the return value as an async context
|
|
94
|
+
# manager, so a borrowed client is wrapped to survive the exit.
|
|
95
|
+
if self._client is not None:
|
|
96
|
+
return contextlib.nullcontext(self._client)
|
|
90
97
|
return httpx.AsyncClient(timeout=self._timeout)
|
|
91
98
|
|
|
92
99
|
return _TimeoutOAuth2
|
|
@@ -159,6 +166,7 @@ async def oauth_exchange_code(
|
|
|
159
166
|
code_verifier: str | None = None,
|
|
160
167
|
token_endpoint_auth_method: TokenEndpointAuthMethod = "client_secret_post", # noqa: S107 — method name, not a secret
|
|
161
168
|
timeout: float = DEFAULT_TIMEOUT,
|
|
169
|
+
client: httpx.AsyncClient | None = None,
|
|
162
170
|
) -> dict[str, Any]:
|
|
163
171
|
"""Exchange an authorization code for tokens and return the token response.
|
|
164
172
|
|
|
@@ -176,7 +184,11 @@ async def oauth_exchange_code(
|
|
|
176
184
|
(from :func:`~fastapi_multiauth.oauth.oauth_generate_pkce_pair`).
|
|
177
185
|
token_endpoint_auth_method: How credentials are sent — in the POST body
|
|
178
186
|
(``client_secret_post``, default) or as a Basic header.
|
|
179
|
-
timeout: Timeout in seconds for the token request.
|
|
187
|
+
timeout: Timeout in seconds for the token request. Ignored when
|
|
188
|
+
*client* is provided.
|
|
189
|
+
client: Optional ``httpx.AsyncClient`` to reuse (connection pooling
|
|
190
|
+
across the login flow). Borrowed, not closed; its own configuration
|
|
191
|
+
(timeouts included) governs the request.
|
|
180
192
|
|
|
181
193
|
Returns:
|
|
182
194
|
The full token response as a ``dict`` (``access_token`` plus whatever
|
|
@@ -190,7 +202,7 @@ async def oauth_exchange_code(
|
|
|
190
202
|
_require_https(token_url, "OAuth token_url")
|
|
191
203
|
|
|
192
204
|
oauth_client = _oauth2_client_class()(
|
|
193
|
-
client_id, client_secret, token_url, timeout, token_endpoint_auth_method
|
|
205
|
+
client_id, client_secret, token_url, timeout, token_endpoint_auth_method, client
|
|
194
206
|
)
|
|
195
207
|
try:
|
|
196
208
|
token_data = await oauth_client.get_access_token(
|
|
@@ -221,13 +233,18 @@ async def oauth_fetch_userinfo(
|
|
|
221
233
|
userinfo_url: str,
|
|
222
234
|
access_token: str,
|
|
223
235
|
timeout: float = DEFAULT_TIMEOUT,
|
|
236
|
+
client: httpx.AsyncClient | None = None,
|
|
224
237
|
) -> dict[str, Any]:
|
|
225
238
|
"""Fetch the userinfo payload with a bearer access token.
|
|
226
239
|
|
|
227
240
|
Args:
|
|
228
241
|
userinfo_url: Provider's userinfo endpoint.
|
|
229
242
|
access_token: Access token from :func:`oauth_exchange_code`.
|
|
230
|
-
timeout: Timeout in seconds for the userinfo request.
|
|
243
|
+
timeout: Timeout in seconds for the userinfo request. Ignored when
|
|
244
|
+
*client* is provided.
|
|
245
|
+
client: Optional ``httpx.AsyncClient`` to reuse (connection pooling
|
|
246
|
+
across the login flow). Borrowed, not closed; its own configuration
|
|
247
|
+
(timeouts included) governs the request.
|
|
231
248
|
|
|
232
249
|
Returns:
|
|
233
250
|
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
|
@@ -243,4 +260,5 @@ async def oauth_fetch_userinfo(
|
|
|
243
260
|
error_cls=OAuthUserinfoError,
|
|
244
261
|
description="userinfo",
|
|
245
262
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
263
|
+
client=client,
|
|
246
264
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Cookie-based authentication source."""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
+
import secrets
|
|
4
5
|
from collections.abc import Callable, Sequence
|
|
5
6
|
from typing import Any, Literal
|
|
6
7
|
|
|
@@ -10,7 +11,7 @@ from itsdangerous import BadSignature, TimestampSigner
|
|
|
10
11
|
|
|
11
12
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
12
13
|
|
|
13
|
-
from ..abc import ValidatedAuthSource
|
|
14
|
+
from ..abc import ValidatedAuthSource, _accepts_kwarg
|
|
14
15
|
|
|
15
16
|
_MIN_SECRET_KEY_BYTES = 32
|
|
16
17
|
_SALT_PREFIX = "fastapi-multiauth.cookie."
|
|
@@ -48,6 +49,10 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
48
49
|
after signature verification when signed) and returning the identity.
|
|
49
50
|
secret_key: One key or a sequence to rotate (first signs, all verify);
|
|
50
51
|
each at least 32 bytes. When ``None``, the cookie is not signed.
|
|
52
|
+
session_id: Mint a random id per :meth:`set_cookie` and inject it into
|
|
53
|
+
the validator as ``session_id``. Requires ``secret_key`` and a
|
|
54
|
+
validator declaring that parameter; changing it invalidates
|
|
55
|
+
existing cookies.
|
|
51
56
|
ttl: Cookie lifetime in seconds (default 24 h); also enforced
|
|
52
57
|
server-side when the cookie is signed.
|
|
53
58
|
secure: Set the ``Secure`` flag (default ``True``); disable only in local dev.
|
|
@@ -64,6 +69,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
64
69
|
validator: Callable[..., Any],
|
|
65
70
|
*,
|
|
66
71
|
secret_key: str | Sequence[str] | None = None,
|
|
72
|
+
session_id: bool = False,
|
|
67
73
|
ttl: int = 86400,
|
|
68
74
|
secure: bool = True,
|
|
69
75
|
samesite: SameSite = "lax",
|
|
@@ -76,12 +82,25 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
76
82
|
raise ValueError("ttl must be a positive number of seconds")
|
|
77
83
|
self._name = name
|
|
78
84
|
self._secret_keys = _normalize_secret_keys(secret_key)
|
|
85
|
+
if session_id:
|
|
86
|
+
if not self._secret_keys:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"session_id requires secret_key: without a signature the "
|
|
89
|
+
"client could forge or swap the session id"
|
|
90
|
+
)
|
|
91
|
+
if not _accepts_kwarg(validator, "session_id"):
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"session_id requires a validator declaring a 'session_id' "
|
|
94
|
+
"parameter: the minted id is injected on every call"
|
|
95
|
+
)
|
|
96
|
+
self._session_id = session_id
|
|
79
97
|
# itsdangerous verifies against every key in the list but signs with
|
|
80
98
|
# the last one — reverse so the first configured key is the signer.
|
|
99
|
+
# The salt also separates the two payload layouts.
|
|
81
100
|
self._signer = (
|
|
82
101
|
TimestampSigner(
|
|
83
102
|
list(reversed(self._secret_keys)),
|
|
84
|
-
salt=f"{_SALT_PREFIX}{name}",
|
|
103
|
+
salt=f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
|
|
85
104
|
digest_method=hashlib.sha256,
|
|
86
105
|
)
|
|
87
106
|
if self._secret_keys
|
|
@@ -118,17 +137,53 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
118
137
|
except BadSignature:
|
|
119
138
|
raise UnauthorizedError() from None
|
|
120
139
|
|
|
140
|
+
@property
|
|
141
|
+
def name(self) -> str:
|
|
142
|
+
"""Cookie name. Read-only: the signing salt is derived from it."""
|
|
143
|
+
return self._name
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def ttl(self) -> int:
|
|
147
|
+
"""Cookie lifetime in seconds: use it to derive your session's expiry."""
|
|
148
|
+
return self._ttl
|
|
149
|
+
|
|
121
150
|
async def extract(self, request: Request) -> str | None:
|
|
122
151
|
"""Return the raw cookie value, or ``None`` when absent or empty."""
|
|
123
152
|
return request.cookies.get(self._name) or None
|
|
124
153
|
|
|
154
|
+
def session_id_of(self, request: Request) -> str | None:
|
|
155
|
+
"""Return *request*'s session id without running the validator.
|
|
156
|
+
|
|
157
|
+
``None`` when ``session_id`` is off, or the cookie is absent, expired
|
|
158
|
+
or forged.
|
|
159
|
+
"""
|
|
160
|
+
if not self._session_id:
|
|
161
|
+
return None
|
|
162
|
+
cookie_value = request.cookies.get(self._name)
|
|
163
|
+
if not cookie_value:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return self._verify(cookie_value).partition(".")[0]
|
|
167
|
+
except UnauthorizedError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
125
170
|
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
126
171
|
"""Verify the cookie, forwarding route-declared scopes to the validator."""
|
|
127
172
|
plain = self._verify(credential)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
173
|
+
if not self._session_id:
|
|
174
|
+
return await self._call_validator(plain, scopes=scopes)
|
|
175
|
+
sid, _, value = plain.partition(".")
|
|
176
|
+
return await self._call_validator(value, scopes=scopes, session_id=sid)
|
|
177
|
+
|
|
178
|
+
def set_cookie(self, response: Response, value: str) -> str | None:
|
|
179
|
+
"""Attach the cookie to *response*, signing it when ``secret_key`` is set.
|
|
180
|
+
|
|
181
|
+
Returns the minted session id: always a ``str`` when ``session_id`` is
|
|
182
|
+
on, always ``None`` when it is off.
|
|
183
|
+
"""
|
|
184
|
+
sid = secrets.token_urlsafe(16) if self._session_id else None
|
|
185
|
+
if sid is not None:
|
|
186
|
+
value = f"{sid}.{value}"
|
|
132
187
|
cookie_value = self._sign(value) if self._signer else value
|
|
133
188
|
response.set_cookie(
|
|
134
189
|
self._name,
|
|
@@ -140,9 +195,16 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
140
195
|
domain=self._domain,
|
|
141
196
|
path=self._path,
|
|
142
197
|
)
|
|
198
|
+
return sid
|
|
199
|
+
|
|
200
|
+
def delete_cookie(
|
|
201
|
+
self, response: Response, request: Request | None = None
|
|
202
|
+
) -> str | None:
|
|
203
|
+
"""Clear the session cookie (logout) from this client only.
|
|
143
204
|
|
|
144
|
-
|
|
145
|
-
|
|
205
|
+
Returns *request*'s session id when it verifies (revoke it server-side
|
|
206
|
+
for a hard logout), ``None`` otherwise.
|
|
207
|
+
"""
|
|
146
208
|
response.delete_cookie(
|
|
147
209
|
self._name,
|
|
148
210
|
httponly=True,
|
|
@@ -151,3 +213,4 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
151
213
|
domain=self._domain,
|
|
152
214
|
path=self._path,
|
|
153
215
|
)
|
|
216
|
+
return self.session_id_of(request) if request is not None else None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|