fastapi-multiauth 0.3.0__tar.gz → 0.4.1__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.3.0 → fastapi_multiauth-0.4.1}/PKG-INFO +4 -3
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/README.md +2 -1
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/pyproject.toml +7 -2
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/pyproject.toml.orig +4 -2
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/__init__.py +1 -1
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/abc.py +56 -43
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/jwt.py +5 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/client.py +19 -6
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/basic.py +17 -5
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/cookie.py +82 -10
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/multi.py +16 -11
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/LICENSE +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/exceptions.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/utils.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/bearer.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/header.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/query.py +0 -0
- {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/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.1
|
|
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
|
|
@@ -25,7 +25,7 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
25
25
|
Classifier: Typing :: Typed
|
|
26
26
|
Requires-Dist: anyio>=3.7
|
|
27
27
|
Requires-Dist: async-lru>=2.0
|
|
28
|
-
Requires-Dist: fastapi>=0.
|
|
28
|
+
Requires-Dist: fastapi>=0.108.0
|
|
29
29
|
Requires-Dist: httpx>=0.25.0
|
|
30
30
|
Requires-Dist: itsdangerous>=2.1
|
|
31
31
|
Requires-Dist: pyjwt[crypto]>=2.13.0 ; extra == 'jwt'
|
|
@@ -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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.1"
|
|
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"
|
|
@@ -36,7 +36,7 @@ classifiers = [
|
|
|
36
36
|
dependencies = [
|
|
37
37
|
"anyio>=3.7",
|
|
38
38
|
"async-lru>=2.0",
|
|
39
|
-
"fastapi>=0.
|
|
39
|
+
"fastapi>=0.108.0",
|
|
40
40
|
"httpx>=0.25.0",
|
|
41
41
|
"itsdangerous>=2.1",
|
|
42
42
|
]
|
|
@@ -67,6 +67,7 @@ dev = [
|
|
|
67
67
|
tests = [
|
|
68
68
|
"coverage>=7.0.0",
|
|
69
69
|
"pytest-anyio>=0.0.0",
|
|
70
|
+
"pytest-codspeed>=5.0",
|
|
70
71
|
"pytest-cov>=4.0.0",
|
|
71
72
|
"pytest>=8.0.0",
|
|
72
73
|
"respx>=0.22",
|
|
@@ -88,6 +89,10 @@ extend-select = ["S"]
|
|
|
88
89
|
"S",
|
|
89
90
|
"B008",
|
|
90
91
|
]
|
|
92
|
+
"benchmarks/**" = [
|
|
93
|
+
"S",
|
|
94
|
+
"B008",
|
|
95
|
+
]
|
|
91
96
|
|
|
92
97
|
[tool.pytest.ini_options]
|
|
93
98
|
testpaths = ["tests"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.1"
|
|
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"
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"anyio>=3.7",
|
|
33
33
|
"async-lru>=2.0",
|
|
34
|
-
"fastapi>=0.
|
|
34
|
+
"fastapi>=0.108.0",
|
|
35
35
|
"httpx>=0.25.0",
|
|
36
36
|
"itsdangerous>=2.1",
|
|
37
37
|
]
|
|
@@ -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",
|
|
@@ -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"]
|
|
@@ -4,9 +4,9 @@ import copy
|
|
|
4
4
|
import inspect
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
-
from typing import TYPE_CHECKING,
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
|
-
from fastapi import
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
10
|
from fastapi.security import SecurityScopes
|
|
11
11
|
from fastapi.security.base import SecurityBase
|
|
12
12
|
|
|
@@ -17,23 +17,29 @@ if TYPE_CHECKING:
|
|
|
17
17
|
from typing_extensions import Self
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"on the route via Security(..., scopes=[...]) are injected "
|
|
26
|
-
"automatically. Use a different keyword name."
|
|
27
|
-
)
|
|
20
|
+
_RESERVED_KWARGS = {
|
|
21
|
+
"scopes": "injected from the route by Security(..., scopes=[...])",
|
|
22
|
+
"session_id": "injected by APIKeyCookieAuth(session_id=True)",
|
|
23
|
+
}
|
|
24
|
+
|
|
28
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
|
+
)
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
|
@@ -77,6 +72,24 @@ class _DocOnlyScheme(SecurityBase):
|
|
|
77
72
|
return None
|
|
78
73
|
|
|
79
74
|
|
|
75
|
+
_SCHEME_CLASSES: dict[type, type] = {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _carry_scheme(dependency: Any, scheme: SecurityBase) -> None:
|
|
79
|
+
"""Make *dependency* itself the security scheme FastAPI puts in OpenAPI."""
|
|
80
|
+
cls = type(dependency)
|
|
81
|
+
promoted = _SCHEME_CLASSES.get(cls)
|
|
82
|
+
if promoted is None:
|
|
83
|
+
promoted = _SCHEME_CLASSES[cls] = type(
|
|
84
|
+
cls.__name__,
|
|
85
|
+
(cls, SecurityBase),
|
|
86
|
+
{"__module__": cls.__module__, "__doc__": cls.__doc__},
|
|
87
|
+
)
|
|
88
|
+
dependency.__class__ = promoted
|
|
89
|
+
dependency.model = scheme.model
|
|
90
|
+
dependency.scheme_name = scheme.scheme_name
|
|
91
|
+
|
|
92
|
+
|
|
80
93
|
class AuthSource(ABC):
|
|
81
94
|
"""Abstract base class for authentication sources.
|
|
82
95
|
|
|
@@ -95,6 +108,8 @@ class AuthSource(ABC):
|
|
|
95
108
|
metadata is used, extraction always goes through :meth:`extract`.
|
|
96
109
|
"""
|
|
97
110
|
self.scheme = _DocOnlyScheme(scheme) if scheme is not None else None
|
|
111
|
+
if self.scheme is not None:
|
|
112
|
+
_carry_scheme(self, self.scheme)
|
|
98
113
|
|
|
99
114
|
parameters = [
|
|
100
115
|
inspect.Parameter(
|
|
@@ -108,17 +123,6 @@ class AuthSource(ABC):
|
|
|
108
123
|
annotation=SecurityScopes,
|
|
109
124
|
),
|
|
110
125
|
]
|
|
111
|
-
if self.scheme is not None:
|
|
112
|
-
# Declared only so the scheme is registered in OpenAPI; the
|
|
113
|
-
# extracted value is ignored in favor of extract().
|
|
114
|
-
parameters.append(
|
|
115
|
-
inspect.Parameter(
|
|
116
|
-
"credentials",
|
|
117
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
118
|
-
annotation=Annotated[Any, Depends(self.scheme)],
|
|
119
|
-
default=None,
|
|
120
|
-
)
|
|
121
|
-
)
|
|
122
126
|
self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
|
|
123
127
|
|
|
124
128
|
@abstractmethod
|
|
@@ -193,18 +197,24 @@ class ValidatedAuthSource(AuthSource):
|
|
|
193
197
|
validator: Sync or async callable returning the identity.
|
|
194
198
|
scheme: Optional ``fastapi.security`` scheme for OpenAPI.
|
|
195
199
|
**kwargs: Extra keyword arguments forwarded to the validator on
|
|
196
|
-
every call.
|
|
200
|
+
every call. Names the library injects itself are reserved:
|
|
201
|
+
``scopes`` and ``session_id``.
|
|
197
202
|
"""
|
|
198
|
-
|
|
203
|
+
_reject_reserved_kwargs(kwargs)
|
|
199
204
|
self._validator = ensure_async(validator)
|
|
200
|
-
self._accepts_scopes =
|
|
205
|
+
self._accepts_scopes = _accepts_kwarg(validator, "scopes")
|
|
201
206
|
self._kwargs = kwargs
|
|
202
207
|
super().__init__(scheme)
|
|
203
208
|
|
|
204
|
-
async def _call_validator(
|
|
209
|
+
async def _call_validator(
|
|
210
|
+
self, *args: Any, scopes: list[str], **injected: Any
|
|
211
|
+
) -> Any:
|
|
205
212
|
"""Invoke the validator with scope and configured kwargs forwarding."""
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
if self._accepts_scopes:
|
|
214
|
+
injected["scopes"] = scopes
|
|
215
|
+
elif scopes: # fail closed: the validator cannot check them
|
|
216
|
+
raise _unenforceable_scopes_error(self, scopes)
|
|
217
|
+
return await self._validator(*args, **self._kwargs, **injected)
|
|
208
218
|
|
|
209
219
|
async def authenticate(self, credential: str) -> Any:
|
|
210
220
|
"""Validate a credential and return the identity (no route scopes)."""
|
|
@@ -215,8 +225,11 @@ class ValidatedAuthSource(AuthSource):
|
|
|
215
225
|
return await self._call_validator(credential, scopes=scopes)
|
|
216
226
|
|
|
217
227
|
def require(self, **kwargs: Any) -> "Self":
|
|
218
|
-
"""Return a copy of this source with additional (or overriding) validator kwargs.
|
|
219
|
-
|
|
228
|
+
"""Return a copy of this source with additional (or overriding) validator kwargs.
|
|
229
|
+
|
|
230
|
+
Reserved names (``scopes``, ``session_id``) are rejected here too.
|
|
231
|
+
"""
|
|
232
|
+
_reject_reserved_kwargs(kwargs)
|
|
220
233
|
clone = copy.copy(self)
|
|
221
234
|
clone._kwargs = {**self._kwargs, **kwargs}
|
|
222
235
|
return clone
|
|
@@ -256,5 +256,10 @@ class JWTValidator:
|
|
|
256
256
|
# provider's JWKS must not take down every login.
|
|
257
257
|
continue
|
|
258
258
|
keys[entry.get("kid") or ""] = key
|
|
259
|
+
if not keys:
|
|
260
|
+
raise _JWKSFetchError(
|
|
261
|
+
f"JWKS document has no usable signing key "
|
|
262
|
+
f"({len(document['keys'])} entries)"
|
|
263
|
+
)
|
|
259
264
|
self._keys = keys
|
|
260
265
|
self._fetched_at = time.monotonic()
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import functools
|
|
5
5
|
from typing import Any, Literal, NamedTuple
|
|
6
|
+
from urllib.parse import urlsplit
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
from async_lru import alru_cache
|
|
@@ -41,13 +42,25 @@ class OIDCEndpoints(NamedTuple):
|
|
|
41
42
|
|
|
42
43
|
def _validate_issuer(discovery_url: str, issuer: Any) -> None:
|
|
43
44
|
"""Check the discovery document claims the issuer it was fetched from."""
|
|
44
|
-
if not
|
|
45
|
-
return
|
|
46
|
-
expected = discovery_url.removesuffix(_DISCOVERY_SUFFIX).rstrip("/")
|
|
47
|
-
if not isinstance(issuer, str) or issuer.rstrip("/") != expected:
|
|
45
|
+
if not isinstance(issuer, str) or not issuer:
|
|
48
46
|
raise OAuthDiscoveryError(
|
|
49
|
-
f"discovery document issuer {issuer!r}
|
|
50
|
-
|
|
47
|
+
f"discovery document has no usable 'issuer' (got {issuer!r})"
|
|
48
|
+
)
|
|
49
|
+
claimed = issuer.rstrip("/")
|
|
50
|
+
parts = urlsplit(discovery_url)
|
|
51
|
+
origin = f"{parts.scheme}://{parts.netloc}"
|
|
52
|
+
path = parts.path.rstrip("/")
|
|
53
|
+
if path.endswith(_DISCOVERY_SUFFIX):
|
|
54
|
+
expected = (origin + path.removesuffix(_DISCOVERY_SUFFIX)).rstrip("/")
|
|
55
|
+
if claimed != expected:
|
|
56
|
+
raise OAuthDiscoveryError(
|
|
57
|
+
f"discovery document issuer {issuer!r} does not match the "
|
|
58
|
+
f"expected issuer {expected!r} derived from the discovery URL"
|
|
59
|
+
)
|
|
60
|
+
elif claimed != origin and not claimed.startswith(f"{origin}/"):
|
|
61
|
+
raise OAuthDiscoveryError(
|
|
62
|
+
f"discovery document issuer {issuer!r} is not served by "
|
|
63
|
+
f"{origin!r}, the origin the document was fetched from"
|
|
51
64
|
)
|
|
52
65
|
|
|
53
66
|
|
|
@@ -13,6 +13,20 @@ from ..abc import ValidatedAuthSource
|
|
|
13
13
|
from ..utils import authorization_credential
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _basic_challenge(realm: str | None) -> str:
|
|
17
|
+
"""Build the ``WWW-Authenticate`` challenge, escaping *realm* (RFC 7230 §3.2.6)."""
|
|
18
|
+
if realm is None:
|
|
19
|
+
return "Basic"
|
|
20
|
+
if not (realm.isascii() and realm.isprintable()):
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"realm must be printable US-ASCII (RFC 7617 §2.1), got {realm!r}: "
|
|
23
|
+
"control characters have no escaped form in a quoted-string, and "
|
|
24
|
+
"non-ASCII cannot be carried in a header value"
|
|
25
|
+
)
|
|
26
|
+
escaped = realm.replace("\\", "\\\\").replace('"', '\\"')
|
|
27
|
+
return f'Basic realm="{escaped}"'
|
|
28
|
+
|
|
29
|
+
|
|
16
30
|
class HTTPBasicAuth(ValidatedAuthSource):
|
|
17
31
|
"""HTTP Basic authentication source (wraps ``HTTPBasic`` for OpenAPI, RFC 7617).
|
|
18
32
|
|
|
@@ -23,7 +37,7 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
23
37
|
validator: Sync or async callable receiving ``(username, password)`` and
|
|
24
38
|
returning the identity; raises
|
|
25
39
|
:class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
|
|
26
|
-
realm: Optional protection-space name for the ``WWW-Authenticate
|
|
40
|
+
realm: Optional protection-space name for the ``WWW-Authenticate``.
|
|
27
41
|
scheme_name: OpenAPI security scheme name (default ``HTTPBasic``).
|
|
28
42
|
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
29
43
|
"""
|
|
@@ -36,7 +50,7 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
36
50
|
scheme_name: str | None = None,
|
|
37
51
|
**kwargs: Any,
|
|
38
52
|
) -> None:
|
|
39
|
-
self.
|
|
53
|
+
self._challenge = _basic_challenge(realm)
|
|
40
54
|
super().__init__(
|
|
41
55
|
validator,
|
|
42
56
|
HTTPBasic(auto_error=False, scheme_name=scheme_name),
|
|
@@ -45,9 +59,7 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
45
59
|
|
|
46
60
|
def www_authenticate(self) -> str:
|
|
47
61
|
"""``Basic`` challenge, with the realm when configured (RFC 7617 §2)."""
|
|
48
|
-
|
|
49
|
-
return f'Basic realm="{self._realm}"'
|
|
50
|
-
return "Basic"
|
|
62
|
+
return self._challenge
|
|
51
63
|
|
|
52
64
|
async def extract(self, request: Request) -> str | None:
|
|
53
65
|
"""Return the base64 credentials blob, or ``None`` when absent or empty.
|
|
@@ -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."
|
|
@@ -35,6 +36,16 @@ def _normalize_secret_keys(secret_key: str | Sequence[str] | None) -> list[str]:
|
|
|
35
36
|
return keys
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
def _precomputed_signer(keys: list[str], salt: str) -> TimestampSigner:
|
|
40
|
+
"""A ``TimestampSigner`` deriving its keys once here instead of per ``unsign``."""
|
|
41
|
+
probe = TimestampSigner(keys, salt=salt, digest_method=hashlib.sha256)
|
|
42
|
+
return TimestampSigner(
|
|
43
|
+
[probe.derive_key(key) for key in keys],
|
|
44
|
+
digest_method=hashlib.sha256,
|
|
45
|
+
key_derivation="none",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
38
49
|
class APIKeyCookieAuth(ValidatedAuthSource):
|
|
39
50
|
"""Cookie-based authentication source (wraps ``APIKeyCookie`` for OpenAPI).
|
|
40
51
|
|
|
@@ -48,6 +59,10 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
48
59
|
after signature verification when signed) and returning the identity.
|
|
49
60
|
secret_key: One key or a sequence to rotate (first signs, all verify);
|
|
50
61
|
each at least 32 bytes. When ``None``, the cookie is not signed.
|
|
62
|
+
session_id: Mint a random id per :meth:`set_cookie` and inject it into
|
|
63
|
+
the validator as ``session_id``. Requires ``secret_key`` and a
|
|
64
|
+
validator declaring that parameter; changing it invalidates
|
|
65
|
+
existing cookies.
|
|
51
66
|
ttl: Cookie lifetime in seconds (default 24 h); also enforced
|
|
52
67
|
server-side when the cookie is signed.
|
|
53
68
|
secure: Set the ``Secure`` flag (default ``True``); disable only in local dev.
|
|
@@ -64,6 +79,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
64
79
|
validator: Callable[..., Any],
|
|
65
80
|
*,
|
|
66
81
|
secret_key: str | Sequence[str] | None = None,
|
|
82
|
+
session_id: bool = False,
|
|
67
83
|
ttl: int = 86400,
|
|
68
84
|
secure: bool = True,
|
|
69
85
|
samesite: SameSite = "lax",
|
|
@@ -76,13 +92,25 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
76
92
|
raise ValueError("ttl must be a positive number of seconds")
|
|
77
93
|
self._name = name
|
|
78
94
|
self._secret_keys = _normalize_secret_keys(secret_key)
|
|
95
|
+
if session_id:
|
|
96
|
+
if not self._secret_keys:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"session_id requires secret_key: without a signature the "
|
|
99
|
+
"client could forge or swap the session id"
|
|
100
|
+
)
|
|
101
|
+
if not _accepts_kwarg(validator, "session_id"):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"session_id requires a validator declaring a 'session_id' "
|
|
104
|
+
"parameter: the minted id is injected on every call"
|
|
105
|
+
)
|
|
106
|
+
self._session_id = session_id
|
|
79
107
|
# itsdangerous verifies against every key in the list but signs with
|
|
80
108
|
# the last one — reverse so the first configured key is the signer.
|
|
109
|
+
# The salt also separates the two payload layouts.
|
|
81
110
|
self._signer = (
|
|
82
|
-
|
|
111
|
+
_precomputed_signer(
|
|
83
112
|
list(reversed(self._secret_keys)),
|
|
84
|
-
|
|
85
|
-
digest_method=hashlib.sha256,
|
|
113
|
+
f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
|
|
86
114
|
)
|
|
87
115
|
if self._secret_keys
|
|
88
116
|
else None
|
|
@@ -118,17 +146,53 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
118
146
|
except BadSignature:
|
|
119
147
|
raise UnauthorizedError() from None
|
|
120
148
|
|
|
149
|
+
@property
|
|
150
|
+
def name(self) -> str:
|
|
151
|
+
"""Cookie name. Read-only: the signing salt is derived from it."""
|
|
152
|
+
return self._name
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def ttl(self) -> int:
|
|
156
|
+
"""Cookie lifetime in seconds: use it to derive your session's expiry."""
|
|
157
|
+
return self._ttl
|
|
158
|
+
|
|
121
159
|
async def extract(self, request: Request) -> str | None:
|
|
122
160
|
"""Return the raw cookie value, or ``None`` when absent or empty."""
|
|
123
161
|
return request.cookies.get(self._name) or None
|
|
124
162
|
|
|
163
|
+
def session_id_of(self, request: Request) -> str | None:
|
|
164
|
+
"""Return *request*'s session id without running the validator.
|
|
165
|
+
|
|
166
|
+
``None`` when ``session_id`` is off, or the cookie is absent, expired
|
|
167
|
+
or forged.
|
|
168
|
+
"""
|
|
169
|
+
if not self._session_id:
|
|
170
|
+
return None
|
|
171
|
+
cookie_value = request.cookies.get(self._name)
|
|
172
|
+
if not cookie_value:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
return self._verify(cookie_value).partition(".")[0]
|
|
176
|
+
except UnauthorizedError:
|
|
177
|
+
return None
|
|
178
|
+
|
|
125
179
|
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
126
180
|
"""Verify the cookie, forwarding route-declared scopes to the validator."""
|
|
127
181
|
plain = self._verify(credential)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
182
|
+
if not self._session_id:
|
|
183
|
+
return await self._call_validator(plain, scopes=scopes)
|
|
184
|
+
sid, _, value = plain.partition(".")
|
|
185
|
+
return await self._call_validator(value, scopes=scopes, session_id=sid)
|
|
186
|
+
|
|
187
|
+
def set_cookie(self, response: Response, value: str) -> str | None:
|
|
188
|
+
"""Attach the cookie to *response*, signing it when ``secret_key`` is set.
|
|
189
|
+
|
|
190
|
+
Returns the minted session id: always a ``str`` when ``session_id`` is
|
|
191
|
+
on, always ``None`` when it is off.
|
|
192
|
+
"""
|
|
193
|
+
sid = secrets.token_urlsafe(16) if self._session_id else None
|
|
194
|
+
if sid is not None:
|
|
195
|
+
value = f"{sid}.{value}"
|
|
132
196
|
cookie_value = self._sign(value) if self._signer else value
|
|
133
197
|
response.set_cookie(
|
|
134
198
|
self._name,
|
|
@@ -140,9 +204,16 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
140
204
|
domain=self._domain,
|
|
141
205
|
path=self._path,
|
|
142
206
|
)
|
|
207
|
+
return sid
|
|
208
|
+
|
|
209
|
+
def delete_cookie(
|
|
210
|
+
self, response: Response, request: Request | None = None
|
|
211
|
+
) -> str | None:
|
|
212
|
+
"""Clear the session cookie (logout) from this client only.
|
|
143
213
|
|
|
144
|
-
|
|
145
|
-
|
|
214
|
+
Returns *request*'s session id when it verifies (revoke it server-side
|
|
215
|
+
for a hard logout), ``None`` otherwise.
|
|
216
|
+
"""
|
|
146
217
|
response.delete_cookie(
|
|
147
218
|
self._name,
|
|
148
219
|
httponly=True,
|
|
@@ -151,3 +222,4 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
151
222
|
domain=self._domain,
|
|
152
223
|
path=self._path,
|
|
153
224
|
)
|
|
225
|
+
return self.session_id_of(request) if request is not None else None
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
-
from typing import Any, cast
|
|
4
|
+
from typing import Annotated, Any, cast
|
|
5
5
|
|
|
6
|
-
from fastapi import Request
|
|
6
|
+
from fastapi import Depends, Request
|
|
7
7
|
from fastapi.security import SecurityScopes
|
|
8
8
|
|
|
9
9
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
10
10
|
|
|
11
|
-
from ..abc import AuthSource
|
|
11
|
+
from ..abc import AuthSource, _carry_scheme
|
|
12
12
|
from ..utils import challenge_headers
|
|
13
13
|
|
|
14
14
|
|
|
@@ -39,10 +39,6 @@ class MultiAuth:
|
|
|
39
39
|
)
|
|
40
40
|
self._sources = sources
|
|
41
41
|
|
|
42
|
-
# Build a merged signature that includes the security-scheme Depends()
|
|
43
|
-
# parameters from every source so FastAPI registers them in OpenAPI
|
|
44
|
-
# docs. Parameters are renamed `_s{i}_{name}` — unique per source
|
|
45
|
-
# index — so two sources of the same type both keep their scheme.
|
|
46
42
|
merged: list[inspect.Parameter] = [
|
|
47
43
|
inspect.Parameter(
|
|
48
44
|
"request",
|
|
@@ -55,11 +51,20 @@ class MultiAuth:
|
|
|
55
51
|
annotation=SecurityScopes,
|
|
56
52
|
),
|
|
57
53
|
]
|
|
54
|
+
carried = sources[0].scheme if sources else None
|
|
55
|
+
if carried is not None:
|
|
56
|
+
_carry_scheme(self, carried)
|
|
58
57
|
for i, source in enumerate(sources):
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
if source.scheme is None or (i == 0 and carried is not None):
|
|
59
|
+
continue
|
|
60
|
+
merged.append(
|
|
61
|
+
inspect.Parameter(
|
|
62
|
+
f"_s{i}_credentials",
|
|
63
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
64
|
+
annotation=Annotated[Any, Depends(cast(Any, source.scheme))],
|
|
65
|
+
default=None,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
63
68
|
self.__signature__ = inspect.Signature(merged, return_annotation=Any)
|
|
64
69
|
|
|
65
70
|
# The combined challenge is fixed once the sources are known; build it
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|