fastapi-multiauth 0.1.0__tar.gz → 0.3.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.1.0 → fastapi_multiauth-0.3.0}/PKG-INFO +12 -3
  2. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/README.md +10 -1
  3. fastapi_multiauth-0.3.0/pyproject.toml +106 -0
  4. fastapi_multiauth-0.1.0/pyproject.toml → fastapi_multiauth-0.3.0/pyproject.toml.orig +4 -4
  5. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/__init__.py +1 -1
  6. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/_http.py +9 -3
  7. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/abc.py +6 -4
  8. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/jwt.py +3 -3
  9. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/oauth/client.py +22 -4
  10. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/oauth/utils.py +10 -4
  11. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/basic.py +2 -1
  12. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/bearer.py +2 -1
  13. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/cookie.py +2 -2
  14. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/header.py +2 -1
  15. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/query.py +2 -1
  16. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/utils.py +4 -2
  17. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/LICENSE +0 -0
  18. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/_imports.py +0 -0
  19. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/exceptions.py +0 -0
  20. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  21. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/py.typed +0 -0
  22. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  23. {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/multi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-multiauth
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -28,7 +28,7 @@ Requires-Dist: async-lru>=2.0
28
28
  Requires-Dist: fastapi>=0.100.0
29
29
  Requires-Dist: httpx>=0.25.0
30
30
  Requires-Dist: itsdangerous>=2.1
31
- Requires-Dist: pyjwt[crypto]>=2.8 ; extra == 'jwt'
31
+ Requires-Dist: pyjwt[crypto]>=2.13.0 ; extra == 'jwt'
32
32
  Requires-Dist: httpx-oauth>=0.16 ; extra == 'oauth'
33
33
  Requires-Python: >=3.10
34
34
  Project-URL: Homepage, https://github.com/d3vyce/fastapi-multiauth
@@ -79,7 +79,13 @@ uv add "fastapi-multiauth[jwt,oauth]"
79
79
 
80
80
  ```python
81
81
  from fastapi import FastAPI, Security
82
- from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError
82
+ from fastapi_multiauth import (
83
+ HTTPBearerAuth,
84
+ APIKeyCookieAuth,
85
+ MultiAuth,
86
+ UnauthorizedError,
87
+ )
88
+
83
89
 
84
90
  async def validate_token(token: str) -> dict:
85
91
  user = await lookup_user_by_token(token)
@@ -87,9 +93,11 @@ async def validate_token(token: str) -> dict:
87
93
  raise UnauthorizedError()
88
94
  return user
89
95
 
96
+
90
97
  async def validate_session(value: str) -> dict:
91
98
  return await lookup_user_by_session(value)
92
99
 
100
+
93
101
  bearer = HTTPBearerAuth(validate_token, prefix="user_")
94
102
  session = APIKeyCookieAuth("session", validate_session, secret_key="...")
95
103
 
@@ -98,6 +106,7 @@ auth = MultiAuth(bearer, session)
98
106
 
99
107
  app = FastAPI()
100
108
 
109
+
101
110
  @app.get("/me")
102
111
  async def me(user=Security(auth)):
103
112
  return user
@@ -38,7 +38,13 @@ uv add "fastapi-multiauth[jwt,oauth]"
38
38
 
39
39
  ```python
40
40
  from fastapi import FastAPI, Security
41
- from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError
41
+ from fastapi_multiauth import (
42
+ HTTPBearerAuth,
43
+ APIKeyCookieAuth,
44
+ MultiAuth,
45
+ UnauthorizedError,
46
+ )
47
+
42
48
 
43
49
  async def validate_token(token: str) -> dict:
44
50
  user = await lookup_user_by_token(token)
@@ -46,9 +52,11 @@ async def validate_token(token: str) -> dict:
46
52
  raise UnauthorizedError()
47
53
  return user
48
54
 
55
+
49
56
  async def validate_session(value: str) -> dict:
50
57
  return await lookup_user_by_session(value)
51
58
 
59
+
52
60
  bearer = HTTPBearerAuth(validate_token, prefix="user_")
53
61
  session = APIKeyCookieAuth("session", validate_session, secret_key="...")
54
62
 
@@ -57,6 +65,7 @@ auth = MultiAuth(bearer, session)
57
65
 
58
66
  app = FastAPI()
59
67
 
68
+
60
69
  @app.get("/me")
61
70
  async def me(user=Security(auth)):
62
71
  return user
@@ -0,0 +1,106 @@
1
+ [project]
2
+ name = "fastapi-multiauth"
3
+ version = "0.3.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-cov>=4.0.0",
71
+ "pytest>=8.0.0",
72
+ "respx>=0.22",
73
+ ]
74
+ docs = [
75
+ "mkdocstrings-python>=2.0.2",
76
+ "zensical>=0.0.30",
77
+ ]
78
+
79
+ [build-system]
80
+ requires = ["uv_build>=0.11.21,<0.13.0"]
81
+ build-backend = "uv_build"
82
+
83
+ [tool.ruff.lint]
84
+ extend-select = ["S"]
85
+
86
+ [tool.ruff.lint.per-file-ignores]
87
+ "tests/**" = [
88
+ "S",
89
+ "B008",
90
+ ]
91
+
92
+ [tool.pytest.ini_options]
93
+ testpaths = ["tests"]
94
+ anyio_mode = "auto"
95
+ filterwarnings = ["ignore::DeprecationWarning"]
96
+
97
+ [tool.coverage.run]
98
+ source = ["src/fastapi_multiauth"]
99
+ branch = true
100
+
101
+ [tool.coverage.report]
102
+ exclude_lines = [
103
+ "pragma: no cover",
104
+ "if TYPE_CHECKING:",
105
+ "raise NotImplementedError",
106
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-multiauth"
3
- version = "0.1.0"
3
+ version = "0.3.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"
@@ -44,7 +44,7 @@ Issues = "https://github.com/d3vyce/fastapi-multiauth/issues"
44
44
 
45
45
  [project.optional-dependencies]
46
46
  jwt = [
47
- "pyjwt[crypto]>=2.8",
47
+ "pyjwt[crypto]>=2.13.0",
48
48
  ]
49
49
  oauth = [
50
50
  "httpx-oauth>=0.16",
@@ -72,7 +72,7 @@ docs = [
72
72
  ]
73
73
 
74
74
  [build-system]
75
- requires = ["uv_build>=0.11.8,<0.12.0"]
75
+ requires = ["uv_build>=0.11.21,<0.13.0"]
76
76
  build-backend = "uv_build"
77
77
 
78
78
  [tool.ruff.lint]
@@ -80,7 +80,7 @@ build-backend = "uv_build"
80
80
  extend-select = ["S"]
81
81
 
82
82
  [tool.ruff.lint.per-file-ignores]
83
- "tests/**" = ["S"]
83
+ "tests/**" = ["S", "B008"]
84
84
 
85
85
  [tool.pytest.ini_options]
86
86
  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.1.0"
26
+ __version__ = "0.3.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
@@ -15,7 +16,7 @@ DEFAULT_TIMEOUT = 10.0
15
16
  def _require_https(url: str, description: str) -> str:
16
17
  """Reject non-string URLs and URLs that would send credentials over plaintext HTTP."""
17
18
  if not isinstance(url, str):
18
- raise ValueError(f"{description} must be a string URL (got {url!r})")
19
+ raise ValueError(f"{description} must be a string URL (got {url!r})") # noqa: TRY004
19
20
  parsed = urlsplit(url)
20
21
  if parsed.scheme == "https":
21
22
  return url
@@ -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:
@@ -3,7 +3,8 @@
3
3
  import copy
4
4
  import inspect
5
5
  from abc import ABC, abstractmethod
6
- from typing import Annotated, Any, Callable, TypeVar
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, Annotated, Any
7
8
 
8
9
  from fastapi import Depends, HTTPException, Request
9
10
  from fastapi.security import SecurityScopes
@@ -12,7 +13,8 @@ from fastapi.security.base import SecurityBase
12
13
  from fastapi_multiauth.exceptions import UnauthorizedError
13
14
  from fastapi_multiauth.utils import add_challenge, challenge_headers, ensure_async
14
15
 
15
- _V = TypeVar("_V", bound="ValidatedAuthSource")
16
+ if TYPE_CHECKING:
17
+ from typing_extensions import Self
16
18
 
17
19
 
18
20
  def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None:
@@ -71,7 +73,7 @@ class _DocOnlyScheme(SecurityBase):
71
73
  self.model = scheme.model
72
74
  self.scheme_name = scheme.scheme_name
73
75
 
74
- async def __call__(self, request: Request) -> None: # noqa: ARG002
76
+ async def __call__(self, request: Request) -> None:
75
77
  return None
76
78
 
77
79
 
@@ -212,7 +214,7 @@ class ValidatedAuthSource(AuthSource):
212
214
  """Validate a credential, forwarding route-declared scopes to the validator."""
213
215
  return await self._call_validator(credential, scopes=scopes)
214
216
 
215
- def require(self: _V, **kwargs: Any) -> _V:
217
+ def require(self, **kwargs: Any) -> "Self":
216
218
  """Return a copy of this source with additional (or overriding) validator kwargs."""
217
219
  _reject_scopes_kwarg(kwargs)
218
220
  clone = copy.copy(self)
@@ -2,8 +2,8 @@
2
2
 
3
3
  import logging
4
4
  import time
5
- from collections.abc import Sequence
6
- from typing import Any, Callable
5
+ from collections.abc import Callable, Sequence
6
+ from typing import Any
7
7
 
8
8
  import anyio
9
9
  from fastapi import HTTPException, status
@@ -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
  )
@@ -98,9 +98,10 @@ def oauth_encode_state(url: str, state_token: str) -> str:
98
98
 
99
99
  def _destination_allowed(destination: str, allowed_hosts: Sequence[str]) -> bool:
100
100
  """Check a decoded destination against the open-redirect allowlist."""
101
- # Browsers normalize backslashes to slashes, turning "/\evil.com" into a
102
- # scheme-relative redirect — reject them outright.
103
- if "\\" in destination:
101
+ # Browsers normalize backslashes to slashes ("/\evil.com" becomes a
102
+ # scheme-relative redirect) and strip tabs/newlines before parsing
103
+ # ("/\t/evil.com" becomes "//evil.com") — reject control chars outright.
104
+ if "\\" in destination or any(ch <= " " for ch in destination):
104
105
  return False
105
106
  parsed = urlsplit(destination)
106
107
  if not parsed.scheme and not parsed.netloc:
@@ -125,7 +126,8 @@ def oauth_decode_state(
125
126
  Args:
126
127
  state: Raw ``state`` query parameter from the callback.
127
128
  expected_state_token: Token stored before the authorization redirect;
128
- a mismatch returns ``fallback``.
129
+ a mismatch — or an empty token (e.g. a session miss) — returns
130
+ ``fallback``.
129
131
  fallback: URL returned when ``state`` is absent, malformed, or fails
130
132
  verification.
131
133
  allowed_hosts: Open-redirect guard — the decoded destination must be a
@@ -135,6 +137,10 @@ def oauth_decode_state(
135
137
  Returns:
136
138
  The destination URL embedded in ``state``, or ``fallback``.
137
139
  """
140
+ # An empty expected token (e.g. session.get(..., "") on a session miss)
141
+ # must never match — compare_digest("", "") is True.
142
+ if not expected_state_token:
143
+ return fallback
138
144
  if not state or state == "null": # "null" guards against JS JSON.stringify(null)
139
145
  return fallback
140
146
  try:
@@ -1,7 +1,8 @@
1
1
  """HTTP Basic authentication source."""
2
2
 
3
3
  import base64
4
- from typing import Any, Callable
4
+ from collections.abc import Callable
5
+ from typing import Any
5
6
 
6
7
  from fastapi import Request
7
8
  from fastapi.security import HTTPBasic
@@ -1,7 +1,8 @@
1
1
  """Bearer token authentication source."""
2
2
 
3
3
  import secrets
4
- from typing import Any, Callable
4
+ from collections.abc import Callable
5
+ from typing import Any
5
6
 
6
7
  from fastapi import Request
7
8
  from fastapi.security import HTTPBearer
@@ -1,8 +1,8 @@
1
1
  """Cookie-based authentication source."""
2
2
 
3
3
  import hashlib
4
- from collections.abc import Sequence
5
- from typing import Any, Callable, Literal
4
+ from collections.abc import Callable, Sequence
5
+ from typing import Any, Literal
6
6
 
7
7
  from fastapi import Request, Response
8
8
  from fastapi.security import APIKeyCookie
@@ -1,6 +1,7 @@
1
1
  """API key header authentication source."""
2
2
 
3
- from typing import Any, Callable
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from fastapi import Request
6
7
  from fastapi.security import APIKeyHeader
@@ -1,6 +1,7 @@
1
1
  """API key query-string authentication source."""
2
2
 
3
- from typing import Any, Callable
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from fastapi import Request
6
7
  from fastapi.security import APIKeyQuery
@@ -4,8 +4,10 @@ import functools
4
4
  import hashlib
5
5
  import hmac
6
6
  import inspect
7
- from typing import Any, Callable
7
+ from collections.abc import Callable
8
+ from typing import Any
8
9
 
10
+ import anyio.to_thread
9
11
  from fastapi import HTTPException, Request
10
12
 
11
13
 
@@ -43,7 +45,7 @@ def ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
43
45
 
44
46
  @functools.wraps(fn)
45
47
  async def wrapper(*args: Any, **kwargs: Any) -> Any:
46
- result = fn(*args, **kwargs)
48
+ result = await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
47
49
  if inspect.isawaitable(result):
48
50
  return await result
49
51
  return result