fastapi-multiauth 0.4.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.4.0 → fastapi_multiauth-0.4.1}/PKG-INFO +2 -2
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/pyproject.toml +2 -2
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/pyproject.toml.orig +2 -2
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/__init__.py +1 -1
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/abc.py +22 -13
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/jwt.py +5 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/client.py +19 -6
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/basic.py +17 -5
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/cookie.py +12 -3
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/multi.py +16 -11
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/LICENSE +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/README.md +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/exceptions.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/utils.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/bearer.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/header.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/query.py +0 -0
- {fastapi_multiauth-0.4.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.4.
|
|
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'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.4.
|
|
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
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.4.
|
|
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
|
]
|
|
@@ -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
|
|
|
@@ -72,6 +72,24 @@ class _DocOnlyScheme(SecurityBase):
|
|
|
72
72
|
return None
|
|
73
73
|
|
|
74
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
|
+
|
|
75
93
|
class AuthSource(ABC):
|
|
76
94
|
"""Abstract base class for authentication sources.
|
|
77
95
|
|
|
@@ -90,6 +108,8 @@ class AuthSource(ABC):
|
|
|
90
108
|
metadata is used, extraction always goes through :meth:`extract`.
|
|
91
109
|
"""
|
|
92
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)
|
|
93
113
|
|
|
94
114
|
parameters = [
|
|
95
115
|
inspect.Parameter(
|
|
@@ -103,17 +123,6 @@ class AuthSource(ABC):
|
|
|
103
123
|
annotation=SecurityScopes,
|
|
104
124
|
),
|
|
105
125
|
]
|
|
106
|
-
if self.scheme is not None:
|
|
107
|
-
# Declared only so the scheme is registered in OpenAPI; the
|
|
108
|
-
# extracted value is ignored in favor of extract().
|
|
109
|
-
parameters.append(
|
|
110
|
-
inspect.Parameter(
|
|
111
|
-
"credentials",
|
|
112
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
113
|
-
annotation=Annotated[Any, Depends(self.scheme)],
|
|
114
|
-
default=None,
|
|
115
|
-
)
|
|
116
|
-
)
|
|
117
126
|
self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
|
|
118
127
|
|
|
119
128
|
@abstractmethod
|
|
@@ -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.
|
|
@@ -36,6 +36,16 @@ def _normalize_secret_keys(secret_key: str | Sequence[str] | None) -> list[str]:
|
|
|
36
36
|
return keys
|
|
37
37
|
|
|
38
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
|
+
|
|
39
49
|
class APIKeyCookieAuth(ValidatedAuthSource):
|
|
40
50
|
"""Cookie-based authentication source (wraps ``APIKeyCookie`` for OpenAPI).
|
|
41
51
|
|
|
@@ -98,10 +108,9 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
98
108
|
# the last one — reverse so the first configured key is the signer.
|
|
99
109
|
# The salt also separates the two payload layouts.
|
|
100
110
|
self._signer = (
|
|
101
|
-
|
|
111
|
+
_precomputed_signer(
|
|
102
112
|
list(reversed(self._secret_keys)),
|
|
103
|
-
|
|
104
|
-
digest_method=hashlib.sha256,
|
|
113
|
+
f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
|
|
105
114
|
)
|
|
106
115
|
if self._secret_keys
|
|
107
116
|
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
|
|
File without changes
|
{fastapi_multiauth-0.4.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
|