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.
Files changed (23) hide show
  1. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/PKG-INFO +2 -2
  2. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/pyproject.toml +2 -2
  3. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/pyproject.toml.orig +2 -2
  4. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/__init__.py +1 -1
  5. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/abc.py +22 -13
  6. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/jwt.py +5 -0
  7. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/client.py +19 -6
  8. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/basic.py +17 -5
  9. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/cookie.py +12 -3
  10. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/multi.py +16 -11
  11. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/LICENSE +0 -0
  12. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/README.md +0 -0
  13. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_http.py +0 -0
  14. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_imports.py +0 -0
  15. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/exceptions.py +0 -0
  16. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  17. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/utils.py +0 -0
  18. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/py.typed +0 -0
  19. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/__init__.py +0 -0
  20. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/bearer.py +0 -0
  21. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/header.py +0 -0
  22. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/query.py +0 -0
  23. {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.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.100.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.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.100.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.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.100.0",
34
+ "fastapi>=0.108.0",
35
35
  "httpx>=0.25.0",
36
36
  "itsdangerous>=2.1",
37
37
  ]
@@ -23,7 +23,7 @@ from .sources import (
23
23
  )
24
24
  from .utils import hash_token, verify_token_hash
25
25
 
26
- __version__ = "0.4.0"
26
+ __version__ = "0.4.1"
27
27
 
28
28
  __all__ = [
29
29
  "APIKeyCookieAuth",
@@ -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, Annotated, Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
- from fastapi import Depends, HTTPException, Request
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 discovery_url.endswith(_DISCOVERY_SUFFIX):
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} does not match the "
50
- f"expected issuer {expected!r} derived from the discovery URL"
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`` challenge.
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._realm = realm
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
- if self._realm is not None:
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
- TimestampSigner(
111
+ _precomputed_signer(
102
112
  list(reversed(self._secret_keys)),
103
- salt=f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
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
- for name, param in inspect.signature(source).parameters.items():
60
- if name in ("request", "security_scopes"):
61
- continue
62
- merged.append(param.replace(name=f"_s{i}_{name}"))
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