fastapi-multiauth 0.2.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.2.0 → fastapi_multiauth-0.4.0}/PKG-INFO +3 -2
  2. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/README.md +2 -1
  3. fastapi_multiauth-0.4.0/pyproject.toml +111 -0
  4. fastapi_multiauth-0.2.0/pyproject.toml → fastapi_multiauth-0.4.0/pyproject.toml.orig +4 -2
  5. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/__init__.py +1 -1
  6. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/_http.py +8 -2
  7. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/abc.py +36 -32
  8. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/jwt.py +1 -1
  9. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/client.py +22 -4
  10. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/cookie.py +71 -8
  11. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/LICENSE +0 -0
  12. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/_imports.py +0 -0
  13. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/exceptions.py +0 -0
  14. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  15. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
  16. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/py.typed +0 -0
  17. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  18. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/basic.py +0 -0
  19. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/bearer.py +0 -0
  20. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/header.py +0 -0
  21. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/multi.py +0 -0
  22. {fastapi_multiauth-0.2.0 → fastapi_multiauth-0.4.0}/src/fastapi_multiauth/sources/query.py +0 -0
  23. {fastapi_multiauth-0.2.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.2.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.
@@ -0,0 +1,111 @@
1
+ [project]
2
+ name = "fastapi-multiauth"
3
+ version = "0.4.0"
4
+ description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ keywords = [
10
+ "fastapi",
11
+ "authentication",
12
+ "security",
13
+ "oauth",
14
+ "api-key",
15
+ "bearer",
16
+ "cookie",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Framework :: AsyncIO",
21
+ "Framework :: FastAPI",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Internet :: WWW/HTTP :: Session",
31
+ "Topic :: Security",
32
+ "Topic :: Software Development :: Libraries :: Python Modules",
33
+ "Topic :: Software Development :: Libraries",
34
+ "Typing :: Typed",
35
+ ]
36
+ dependencies = [
37
+ "anyio>=3.7",
38
+ "async-lru>=2.0",
39
+ "fastapi>=0.100.0",
40
+ "httpx>=0.25.0",
41
+ "itsdangerous>=2.1",
42
+ ]
43
+
44
+ [[project.authors]]
45
+ name = "d3vyce"
46
+ email = "contact@d3vyce.fr"
47
+
48
+ [project.urls]
49
+ Homepage = "https://github.com/d3vyce/fastapi-multiauth"
50
+ Documentation = "https://fastapi-multiauth.d3vyce.fr/"
51
+ Repository = "https://github.com/d3vyce/fastapi-multiauth"
52
+ Issues = "https://github.com/d3vyce/fastapi-multiauth/issues"
53
+
54
+ [project.optional-dependencies]
55
+ jwt = ["pyjwt[crypto]>=2.13.0"]
56
+ oauth = ["httpx-oauth>=0.16"]
57
+
58
+ [dependency-groups]
59
+ dev = [
60
+ { include-group = "tests" },
61
+ { include-group = "docs" },
62
+ "fastapi_multiauth[jwt,oauth]",
63
+ "prek>=0.3.8",
64
+ "ruff>=0.1.0",
65
+ "ty>=0.0.34",
66
+ ]
67
+ tests = [
68
+ "coverage>=7.0.0",
69
+ "pytest-anyio>=0.0.0",
70
+ "pytest-codspeed>=5.0",
71
+ "pytest-cov>=4.0.0",
72
+ "pytest>=8.0.0",
73
+ "respx>=0.22",
74
+ ]
75
+ docs = [
76
+ "mkdocstrings-python>=2.0.2",
77
+ "zensical>=0.0.30",
78
+ ]
79
+
80
+ [build-system]
81
+ requires = ["uv_build>=0.11.21,<0.13.0"]
82
+ build-backend = "uv_build"
83
+
84
+ [tool.ruff.lint]
85
+ extend-select = ["S"]
86
+
87
+ [tool.ruff.lint.per-file-ignores]
88
+ "tests/**" = [
89
+ "S",
90
+ "B008",
91
+ ]
92
+ "benchmarks/**" = [
93
+ "S",
94
+ "B008",
95
+ ]
96
+
97
+ [tool.pytest.ini_options]
98
+ testpaths = ["tests"]
99
+ anyio_mode = "auto"
100
+ filterwarnings = ["ignore::DeprecationWarning"]
101
+
102
+ [tool.coverage.run]
103
+ source = ["src/fastapi_multiauth"]
104
+ branch = true
105
+
106
+ [tool.coverage.report]
107
+ exclude_lines = [
108
+ "pragma: no cover",
109
+ "if TYPE_CHECKING:",
110
+ "raise NotImplementedError",
111
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-multiauth"
3
- version = "0.2.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",
@@ -72,7 +73,7 @@ docs = [
72
73
  ]
73
74
 
74
75
  [build-system]
75
- requires = ["uv_build>=0.11.21,<0.12.0"]
76
+ requires = ["uv_build>=0.11.21,<0.13.0"]
76
77
  build-backend = "uv_build"
77
78
 
78
79
  [tool.ruff.lint]
@@ -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.2.0"
26
+ __version__ = "0.4.0"
27
27
 
28
28
  __all__ = [
29
29
  "APIKeyCookieAuth",
@@ -1,5 +1,6 @@
1
1
  """Shared HTTP transport hygiene for the OAuth and JWT modules."""
2
2
 
3
+ import contextlib
3
4
  import json
4
5
  from typing import Any
5
6
  from urllib.parse import urlsplit
@@ -31,11 +32,16 @@ async def _get_json(
31
32
  error_cls: type[Exception],
32
33
  description: str,
33
34
  headers: dict[str, str] | None = None,
35
+ client: httpx.AsyncClient | None = None,
34
36
  ) -> Any:
35
37
  """GET *url* and decode the JSON body, mapping failures to *error_cls*."""
36
- async with httpx.AsyncClient(timeout=timeout) as client:
38
+ async with (
39
+ contextlib.nullcontext(client)
40
+ if client is not None
41
+ else httpx.AsyncClient(timeout=timeout)
42
+ ) as http:
37
43
  try:
38
- resp = await client.get(url, headers=headers)
44
+ resp = await http.get(url, headers=headers)
39
45
  resp.raise_for_status()
40
46
  return resp.json()
41
47
  except httpx.HTTPError as exc:
@@ -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
@@ -149,7 +149,7 @@ class JWTValidator:
149
149
  leeway=self._leeway,
150
150
  options=self._decode_options,
151
151
  )
152
- except self._jwt.InvalidTokenError:
152
+ except (self._jwt.exceptions.PyJWTError, TypeError, ValueError):
153
153
  raise UnauthorizedError() from None
154
154
  if scopes:
155
155
  missing = set(scopes) - self._granted_scopes(claims)
@@ -1,5 +1,6 @@
1
1
  """OAuth 2.0 / OIDC network operations: discovery, code exchange, userinfo."""
2
2
 
3
+ import contextlib
3
4
  import functools
4
5
  from typing import Any, Literal, NamedTuple
5
6
 
@@ -74,6 +75,7 @@ def _oauth2_client_class() -> type:
74
75
  access_token_endpoint: str,
75
76
  timeout: float,
76
77
  token_endpoint_auth_method: TokenEndpointAuthMethod,
78
+ client: httpx.AsyncClient | None,
77
79
  ) -> None:
78
80
  super().__init__(
79
81
  client_id,
@@ -85,8 +87,13 @@ def _oauth2_client_class() -> type:
85
87
  token_endpoint_auth_method=token_endpoint_auth_method,
86
88
  )
87
89
  self._timeout = timeout
90
+ self._client = client
88
91
 
89
- def get_httpx_client(self) -> httpx.AsyncClient:
92
+ def get_httpx_client(self) -> Any:
93
+ # httpx-oauth only uses the return value as an async context
94
+ # manager, so a borrowed client is wrapped to survive the exit.
95
+ if self._client is not None:
96
+ return contextlib.nullcontext(self._client)
90
97
  return httpx.AsyncClient(timeout=self._timeout)
91
98
 
92
99
  return _TimeoutOAuth2
@@ -159,6 +166,7 @@ async def oauth_exchange_code(
159
166
  code_verifier: str | None = None,
160
167
  token_endpoint_auth_method: TokenEndpointAuthMethod = "client_secret_post", # noqa: S107 — method name, not a secret
161
168
  timeout: float = DEFAULT_TIMEOUT,
169
+ client: httpx.AsyncClient | None = None,
162
170
  ) -> dict[str, Any]:
163
171
  """Exchange an authorization code for tokens and return the token response.
164
172
 
@@ -176,7 +184,11 @@ async def oauth_exchange_code(
176
184
  (from :func:`~fastapi_multiauth.oauth.oauth_generate_pkce_pair`).
177
185
  token_endpoint_auth_method: How credentials are sent — in the POST body
178
186
  (``client_secret_post``, default) or as a Basic header.
179
- timeout: Timeout in seconds for the token request.
187
+ timeout: Timeout in seconds for the token request. Ignored when
188
+ *client* is provided.
189
+ client: Optional ``httpx.AsyncClient`` to reuse (connection pooling
190
+ across the login flow). Borrowed, not closed; its own configuration
191
+ (timeouts included) governs the request.
180
192
 
181
193
  Returns:
182
194
  The full token response as a ``dict`` (``access_token`` plus whatever
@@ -190,7 +202,7 @@ async def oauth_exchange_code(
190
202
  _require_https(token_url, "OAuth token_url")
191
203
 
192
204
  oauth_client = _oauth2_client_class()(
193
- client_id, client_secret, token_url, timeout, token_endpoint_auth_method
205
+ client_id, client_secret, token_url, timeout, token_endpoint_auth_method, client
194
206
  )
195
207
  try:
196
208
  token_data = await oauth_client.get_access_token(
@@ -221,13 +233,18 @@ async def oauth_fetch_userinfo(
221
233
  userinfo_url: str,
222
234
  access_token: str,
223
235
  timeout: float = DEFAULT_TIMEOUT,
236
+ client: httpx.AsyncClient | None = None,
224
237
  ) -> dict[str, Any]:
225
238
  """Fetch the userinfo payload with a bearer access token.
226
239
 
227
240
  Args:
228
241
  userinfo_url: Provider's userinfo endpoint.
229
242
  access_token: Access token from :func:`oauth_exchange_code`.
230
- timeout: Timeout in seconds for the userinfo request.
243
+ timeout: Timeout in seconds for the userinfo request. Ignored when
244
+ *client* is provided.
245
+ client: Optional ``httpx.AsyncClient`` to reuse (connection pooling
246
+ across the login flow). Borrowed, not closed; its own configuration
247
+ (timeouts included) governs the request.
231
248
 
232
249
  Returns:
233
250
  The JSON payload returned by the userinfo endpoint as a plain ``dict``.
@@ -243,4 +260,5 @@ async def oauth_fetch_userinfo(
243
260
  error_cls=OAuthUserinfoError,
244
261
  description="userinfo",
245
262
  headers={"Authorization": f"Bearer {access_token}"},
263
+ client=client,
246
264
  )
@@ -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