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.
Files changed (23) hide show
  1. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/PKG-INFO +4 -3
  2. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/README.md +2 -1
  3. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/pyproject.toml +7 -2
  4. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/pyproject.toml.orig +4 -2
  5. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/__init__.py +1 -1
  6. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/abc.py +56 -43
  7. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/jwt.py +5 -0
  8. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/client.py +19 -6
  9. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/basic.py +17 -5
  10. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/cookie.py +82 -10
  11. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/multi.py +16 -11
  12. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/LICENSE +0 -0
  13. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_http.py +0 -0
  14. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/_imports.py +0 -0
  15. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/exceptions.py +0 -0
  16. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  17. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/oauth/utils.py +0 -0
  18. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/py.typed +0 -0
  19. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/__init__.py +0 -0
  20. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/bearer.py +0 -0
  21. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/header.py +0 -0
  22. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.1}/src/fastapi_multiauth/sources/query.py +0 -0
  23. {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.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'
@@ -45,6 +45,7 @@ Composable authentication sources for FastAPI: bring your own validator, combine
45
45
 
46
46
  [![CI](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml)
47
47
  [![codecov](https://codecov.io/gh/d3vyce/fastapi-multiauth/graph/badge.svg)](https://codecov.io/gh/d3vyce/fastapi-multiauth)
48
+ [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://app.codspeed.io//badge.json)](https://app.codspeed.io//d3vyce/fastapi-multiauth?utm_source=badge)
48
49
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
49
50
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
50
51
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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 key rotation.
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
  [![CI](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml)
6
6
  [![codecov](https://codecov.io/gh/d3vyce/fastapi-multiauth/graph/badge.svg)](https://codecov.io/gh/d3vyce/fastapi-multiauth)
7
+ [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://app.codspeed.io//badge.json)](https://app.codspeed.io//d3vyce/fastapi-multiauth?utm_source=badge)
7
8
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
8
9
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
9
10
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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 key rotation.
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.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
  ]
@@ -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.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
  ]
@@ -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"]
@@ -23,7 +23,7 @@ from .sources import (
23
23
  )
24
24
  from .utils import hash_token, verify_token_hash
25
25
 
26
- __version__ = "0.3.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
 
@@ -17,23 +17,29 @@ if TYPE_CHECKING:
17
17
  from typing_extensions import Self
18
18
 
19
19
 
20
- def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None:
21
- """Reject ``scopes`` as a validator kwarg."""
22
- if "scopes" in kwargs:
23
- raise ValueError(
24
- "'scopes' is a reserved validator kwarg: security scopes declared "
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
- def _accepts_scopes(fn: Callable[..., Any]) -> bool:
31
- """Return whether *fn* declares a ``scopes`` parameter."""
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("scopes")
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. ``scopes`` is reserved (injected from the route).
200
+ every call. Names the library injects itself are reserved:
201
+ ``scopes`` and ``session_id``.
197
202
  """
198
- _reject_scopes_kwarg(kwargs)
203
+ _reject_reserved_kwargs(kwargs)
199
204
  self._validator = ensure_async(validator)
200
- self._accepts_scopes = _accepts_scopes(validator)
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(self, *args: Any, scopes: list[str]) -> Any:
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
- extra = _scope_kwargs(self, self._accepts_scopes, scopes)
207
- return await self._validator(*args, **extra, **self._kwargs)
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
- _reject_scopes_kwarg(kwargs)
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 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.
@@ -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
- TimestampSigner(
111
+ _precomputed_signer(
83
112
  list(reversed(self._secret_keys)),
84
- salt=f"{_SALT_PREFIX}{name}",
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
- return await self._call_validator(plain, scopes=scopes)
129
-
130
- def set_cookie(self, response: Response, value: str) -> None:
131
- """Attach the cookie to *response*, signing it when ``secret_key`` is set."""
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
- def delete_cookie(self, response: Response) -> None:
145
- """Clear the session cookie (logout) from this client only."""
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
- 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