fastapi-multiauth 0.3.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.
Files changed (23) hide show
  1. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/PKG-INFO +3 -2
  2. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/README.md +2 -1
  3. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/pyproject.toml +6 -1
  4. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/pyproject.toml.orig +3 -1
  5. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/__init__.py +1 -1
  6. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/abc.py +36 -32
  7. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/cookie.py +71 -8
  8. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/LICENSE +0 -0
  9. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/_http.py +0 -0
  10. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/_imports.py +0 -0
  11. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/exceptions.py +0 -0
  12. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/jwt.py +0 -0
  13. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  14. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/client.py +0 -0
  15. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
  16. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/py.typed +0 -0
  17. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  18. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/basic.py +0 -0
  19. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/bearer.py +0 -0
  20. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/header.py +0 -0
  21. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/multi.py +0 -0
  22. {fastapi_multiauth-0.3.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/query.py +0 -0
  23. {fastapi_multiauth-0.3.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.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
  [![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.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"
@@ -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.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",
@@ -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.0"
27
27
 
28
28
  __all__ = [
29
29
  "APIKeyCookieAuth",
@@ -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
- )
28
-
29
-
30
- def _accepts_scopes(fn: Callable[..., Any]) -> bool:
31
- """Return whether *fn* declares a ``scopes`` parameter."""
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("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
 
@@ -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. ``scopes`` is reserved (injected from the route).
191
+ every call. Names the library injects itself are reserved:
192
+ ``scopes`` and ``session_id``.
197
193
  """
198
- _reject_scopes_kwarg(kwargs)
194
+ _reject_reserved_kwargs(kwargs)
199
195
  self._validator = ensure_async(validator)
200
- self._accepts_scopes = _accepts_scopes(validator)
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(self, *args: Any, scopes: list[str]) -> Any:
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
- extra = _scope_kwargs(self, self._accepts_scopes, scopes)
207
- return await self._validator(*args, **extra, **self._kwargs)
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
- _reject_scopes_kwarg(kwargs)
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
@@ -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
- 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."""
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
- def delete_cookie(self, response: Response) -> None:
145
- """Clear the session cookie (logout) from this client only."""
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