Skip to content

Commit b70b876

Browse files
refactor(internal): adopt Litestar 2.24 NamedDependency DI and thread-offloaded Argon2
Mark every plugin-provided dependency (handlers, dynamic signatures, providers) with explicit NamedDependency annotations ahead of Litestar 3.0; export typed dependency aliases from litestar_auth.extensions for extension authors. Move all Argon2 hashing off the event loop via anyio.to_thread.run_sync with anyio as a direct dependency. Includes toolchain bumps (ruff 0.15.17, ty 0.0.49), demo-db untracking, and removal of the dead zip-safe build key.
1 parent f84be30 commit b70b876

51 files changed

Lines changed: 979 additions & 319 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ cover/
6161
local_settings.py
6262
db.sqlite3
6363
db.sqlite3-journal
64+
*.db
6465

6566
# Flask stuff:
6667
instance/

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ repos:
104104

105105
- repo: https://github.com/astral-sh/uv-pre-commit
106106
# uv version.
107-
rev: c665864e22f27255a10d82e14f8ccea6843fd65e # frozen: 0.11.19
107+
rev: fe063293315003e95dff96d43c4928f8799a5c62 # frozen: 0.11.21
108108
hooks:
109109
# Update the uv lockfile
110110
- id: uv-lock

CHANGELOG.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,66 @@
1+
## Unreleased
2+
3+
### Added
4+
5+
- **`litestar_auth.totp.abuild_recovery_code_index()` is now public.** Async extension and controller
6+
code can build TOTP recovery-code lookup indexes through the worker-thread-offloaded helper instead
7+
of calling the synchronous `build_recovery_code_index()` on the event loop. The existing synchronous
8+
export is unchanged for CLI and script contexts.
9+
10+
- **`LITESTAR_AUTH_PASSWORD_WORKER_THREAD_LIMIT` environment variable.** The shared Argon2
11+
worker-thread cap (default **8** concurrent password operations per event loop — per process for
12+
the typical one-loop server) is now configurable.
13+
The value is read once, at import of `litestar_auth`, and must be a positive integer; invalid
14+
values fail fast with `ConfigurationError`. Size it with the memory formula
15+
`limit * Argon2 memory cost` (roughly `limit * 64 MiB` with the bundled default policy) — see
16+
the security configuration guide. This is the library's first environment-variable setting; it is
17+
environment-based rather than config-object-based because the limiter cap is resolved at import,
18+
before any `LitestarAuthConfig` exists, and applies to every loop in the process.
19+
20+
### Changed
21+
22+
- **Litestar 2.24 explicit dependency annotations; minimum Litestar is now `>=2.24.0,<3.0`.**
23+
Litestar 2.24 deprecates inferred dependencies (removal in 3.0), so every bundled controller
24+
handler and dynamically built dependency provider now marks injected parameters with
25+
`NamedDependency[...]`. **Breaking:** Litestar 2.23 is no longer supported. Applications and
26+
extensions that define their own handlers consuming litestar-auth dependencies should annotate
27+
those parameters with the new typed aliases exported from `litestar_auth.extensions`
28+
(`UserManagerDependency`, `AuthBackendsDependency`, `OrganizationStoreDependency`,
29+
`ResolvedPermissionsDependency`) — see the Migration guide.
30+
31+
- **Argon2 work no longer blocks the event loop.** All password hashing, verification, and dummy-hash
32+
construction in async flows — registration, login (including eager unknown-user timing
33+
equalization), password change, password reset, forgot-password token construction, API-key
34+
current-password confirmation — and the TOTP recovery-code paths (recovery-code verification and
35+
recovery-code index construction, previously up to N sequential Argon2 hashes per request) now run
36+
in an AnyIO worker thread. The public synchronous `PasswordHelper` API and the
37+
one-Argon2-verify-per-call timing-equalization properties are unchanged. Custom `password_helper`
38+
implementations supplied to managers now execute in a worker thread and must not rely on
39+
event-loop thread affinity. Password and TOTP recovery-code Argon2 offloads now share one dedicated
40+
AnyIO `CapacityLimiter` per event loop capped at 8 concurrent operations, bounding default pwdlib
41+
Argon2 memory pressure to roughly 8 x 64 MiB per loop instead of AnyIO's broader default
42+
worker-thread pool.
43+
44+
- **Unknown-user dummy hashes are cached per `PasswordHelper` identity.** The timing-equalization
45+
dummy hash for unknown-user login and forgot-password paths is now cached process-globally for the
46+
`PasswordHelper` instance that produced it instead of per manager instance. Because the plugin builds
47+
a manager per request and memoizes the default `PasswordHelper` per config, the default dummy Argon2
48+
hash is now computed once per process rather than once per request on unknown-user paths.
49+
50+
- **`anyio` is now a direct dependency** (`anyio>=4.13.0,<5.0`). It was already required
51+
transitively via Litestar, so dependency trees do not change.
52+
53+
- **`cryptography` minimum for the OAuth and TOTP extras is now `>=48.0.1`.** The upstream 48.0.1
54+
release updates official wheels to OpenSSL 4.0.1; its release notes do not list a CVE-specific fix.
55+
56+
### Internal
57+
58+
- Removed the accidentally committed `demo_litestar_auth.db` SQLite artifact from the repository and
59+
added a `*.db` ignore rule; runnable demos create their databases at startup as before.
60+
- Removed the inert setuptools-era `zip-safe` key from `[tool.hatch.build.targets.wheel]`; it is not
61+
a hatchling option and had no effect on built wheels.
62+
- Raised the development `pip-audit` minimum to `>=2.10.1`.
63+
164
## 5.0.2 (2026-06-11)
265

366
### Fix

demo_litestar_auth.db

-80 KB
Binary file not shown.

docs/api/totp.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ Time-based one-time passwords in **litestar-auth** split into a **low-level cryp
1010

1111
Generated recovery codes are 28 lowercase hex characters (112 bits). They are returned only from
1212
confirm-enable or regenerate responses and are stored as HMAC lookup digests mapped to Argon2
13-
hashes by the manager/store surface.
13+
hashes by the manager/store surface. Async extension or controller code should build that persisted
14+
index with **`abuild_recovery_code_index()`** so Argon2 hashing runs in the library's worker-thread
15+
offload path; the synchronous **`build_recovery_code_index()`** remains available for CLI and script
16+
contexts.
1417

1518
## Persisted secret encryption
1619

docs/configuration/security.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ Before deploying that default into an environment with unsupported stored hashes
8080
those credentials out of band. If you inject `UserManagerSecurity(password_helper=...)`, that
8181
password policy is fully application-owned and outside the library default described here.
8282

83+
Password hashing and TOTP recovery-code hashing run through a shared AnyIO worker-thread limiter
84+
instead of the event loop. The default cap is **8** concurrent password operations per event loop —
85+
equivalent to per process for the typical one-loop server. Set
86+
`LITESTAR_AUTH_PASSWORD_WORKER_THREAD_LIMIT` to a positive integer before importing `litestar_auth`
87+
when a deployment needs a different cap. Size this with the memory formula
88+
`limit * Argon2 memory cost`; with the bundled default Argon2 policy, plan for roughly
89+
`limit * 64 MiB` per event loop before other application memory. Invalid values fail fast with
90+
`ConfigurationError` at import time.
91+
8392
## Schemas and DI
8493

8594
| Field | Default | Meaning |

docs/migration.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
11
# Migration Guide
22

3+
## Litestar 2.24: explicit dependency annotations
4+
5+
litestar-auth now requires Litestar `>=2.24.0`. Litestar 2.24 deprecates *inferred*
6+
dependencies — handler parameters that are resolved purely by matching a registered
7+
dependency key by name. Every such parameter must carry an explicit
8+
`NamedDependency[...]` annotation, or Litestar emits a `LitestarDeprecationWarning`
9+
(and stops injecting the value entirely in Litestar 3.0).
10+
11+
All bundled controllers and the plugin's dependency providers are already migrated.
12+
If your application or extension defines handlers that consume litestar-auth
13+
dependencies (`litestar_auth_user_manager`, `litestar_auth_backends`,
14+
`litestar_auth_organization_store`, `litestar_auth_permissions`), annotate them with
15+
the typed aliases exported from `litestar_auth.extensions`:
16+
17+
```python
18+
from litestar import get
19+
from litestar_auth.extensions import UserManagerDependency
20+
21+
22+
@get("/admin/probe")
23+
async def probe(litestar_auth_user_manager: UserManagerDependency) -> dict[str, bool]:
24+
return {"has_manager": litestar_auth_user_manager is not None}
25+
```
26+
27+
For the request-scoped database session (default key `db_session`, configurable via
28+
`db_session_dependency_key`), annotate with `NamedDependency` directly:
29+
30+
```python
31+
from litestar import get
32+
from litestar.di import NamedDependency
33+
from sqlalchemy.ext.asyncio import AsyncSession
34+
35+
_DbSessionDep = NamedDependency[AsyncSession]
36+
37+
38+
@get("/admin/session-probe")
39+
async def probe(db_session: _DbSessionDep) -> dict[str, bool]:
40+
return {"in_transaction": db_session.in_transaction()}
41+
```
42+
343
## User hard-delete cascade
444

545
Bundled user-owned auth tables declare `ON DELETE CASCADE` on foreign keys that point at the

litestar_auth/_concurrency.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Shared concurrency limits for expensive authentication work.
2+
3+
Password hashing and recovery-code hashing use memory-hard Argon2 work. The
4+
shared password-operation worker limiter caps concurrent operations per event
5+
loop (one loop per process for typical servers); worst-case Argon2 memory is
6+
roughly ``PASSWORD_WORKER_THREAD_LIMIT * Argon2 memory cost`` per loop. With
7+
the bundled default pwdlib policy, that is the resolved limit times about
8+
64 MiB. The limiter is loop-scoped because AnyIO capacity limiters hold
9+
loop-bound waiter events and must not be shared across event loops.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import secrets
15+
from os import environ
16+
from threading import Lock as ThreadLock
17+
from typing import TYPE_CHECKING
18+
from weakref import WeakKeyDictionary
19+
20+
from anyio import CapacityLimiter
21+
from anyio.lowlevel import RunVar
22+
from anyio.to_thread import run_sync as _run_sync_in_worker_thread
23+
24+
from litestar_auth.exceptions import ConfigurationError
25+
26+
if TYPE_CHECKING:
27+
from collections.abc import Callable
28+
29+
from litestar_auth.password import PasswordHelper
30+
31+
_DEFAULT_PASSWORD_WORKER_THREAD_LIMIT = 8
32+
_PASSWORD_WORKER_THREAD_LIMIT_ENV_VAR = "LITESTAR_AUTH_PASSWORD_WORKER_THREAD_LIMIT" # noqa: S105
33+
34+
35+
def _resolve_password_worker_thread_limit(raw_limit: str | None) -> int:
36+
"""Resolve the password worker-thread cap from optional environment input.
37+
38+
Returns:
39+
Positive worker-thread limit.
40+
41+
Raises:
42+
ConfigurationError: If the configured limit is not a positive integer.
43+
"""
44+
if raw_limit is None:
45+
return _DEFAULT_PASSWORD_WORKER_THREAD_LIMIT
46+
47+
msg = f"{_PASSWORD_WORKER_THREAD_LIMIT_ENV_VAR} must be a positive integer; got {raw_limit!r}."
48+
try:
49+
limit = int(raw_limit)
50+
except ValueError as exc:
51+
raise ConfigurationError(msg) from exc
52+
53+
if limit < 1:
54+
raise ConfigurationError(msg)
55+
return limit
56+
57+
58+
# Maximum concurrent memory-hard password operations inside one process. Bounds
59+
# worst-case Argon2 memory to this limit times the configured memory cost
60+
# (pwdlib's bundled Argon2 hasher defaults to 64 MiB per operation).
61+
PASSWORD_WORKER_THREAD_LIMIT = _resolve_password_worker_thread_limit(
62+
environ.get(_PASSWORD_WORKER_THREAD_LIMIT_ENV_VAR),
63+
)
64+
65+
# Loop-scoped limiter: asyncio-backed CapacityLimiter waiters are Events bound
66+
# to the event loop that created them, so one limiter must never serve two loops.
67+
_PASSWORD_OP_LIMITER: RunVar[CapacityLimiter] = RunVar("litestar_auth_password_op_limiter")
68+
_DUMMY_HASH_CACHE: WeakKeyDictionary[PasswordHelper, str] = WeakKeyDictionary()
69+
_DUMMY_HASH_CACHE_GUARD = ThreadLock()
70+
71+
72+
def _password_op_limiter() -> CapacityLimiter:
73+
"""Return the current event loop's password-operation capacity limiter.
74+
75+
Returns:
76+
The loop-scoped limiter, created on first use within the running loop.
77+
"""
78+
try:
79+
return _PASSWORD_OP_LIMITER.get()
80+
except LookupError:
81+
limiter = CapacityLimiter(PASSWORD_WORKER_THREAD_LIMIT)
82+
_PASSWORD_OP_LIMITER.set(limiter)
83+
return limiter
84+
85+
86+
def build_dummy_hash(password_helper: PasswordHelper) -> str:
87+
"""Return a freshly salted dummy password hash for timing equalization."""
88+
return password_helper.hash(secrets.token_urlsafe(32))
89+
90+
91+
def _cached_dummy_hash(password_helper: PasswordHelper) -> str | None:
92+
"""Return a cached dummy hash without keeping the helper alive."""
93+
with _DUMMY_HASH_CACHE_GUARD:
94+
return _DUMMY_HASH_CACHE.get(password_helper)
95+
96+
97+
def _cache_dummy_hash(password_helper: PasswordHelper, dummy_hash: str) -> None:
98+
"""Store the helper-scoped dummy hash after worker-thread construction."""
99+
with _DUMMY_HASH_CACHE_GUARD:
100+
_DUMMY_HASH_CACHE[password_helper] = dummy_hash
101+
102+
103+
async def run_password_op_in_worker_thread[T](func: Callable[..., T], *args: object) -> T:
104+
"""Run memory-hard password work in AnyIO's worker pool with a dedicated cap.
105+
106+
Returns:
107+
The callable result.
108+
"""
109+
return await _run_sync_in_worker_thread(func, *args, limiter=_password_op_limiter())
110+
111+
112+
async def get_cached_dummy_hash(password_helper: PasswordHelper) -> str:
113+
"""Return the per-helper dummy hash used to equalize unknown-secret checks."""
114+
cached_hash = _cached_dummy_hash(password_helper)
115+
if cached_hash is not None:
116+
return cached_hash
117+
118+
dummy_hash = await run_password_op_in_worker_thread(build_dummy_hash, password_helper)
119+
_cache_dummy_hash(password_helper, dummy_hash)
120+
return dummy_hash

litestar_auth/_manager/account_tokens.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import jwt
1414

15+
from litestar_auth._concurrency import run_password_op_in_worker_thread as _run_password_op
1516
from litestar_auth._jwt_headers import JwtDecodeConfig, decode_signed_jwt, jwt_encode_headers
1617
from litestar_auth._manager._coercions import _managed_user
1718
from litestar_auth._manager._protocols import UserDatabaseManagerProtocol
@@ -535,7 +536,9 @@ async def reset_password(self, token: str, password: str) -> UP:
535536
raise InvalidResetPasswordTokenError
536537

537538
self._policy.validate_password(password)
538-
update_dict = {"hashed_password": self._policy.password_helper.hash(password)}
539+
update_dict = {
540+
"hashed_password": await _run_password_op(self._policy.password_helper.hash, password),
541+
}
539542
updated_user = await self._manager.user_db.update(user, update_dict)
540543
await self._manager._invalidate_all_tokens(updated_user)
541544
await self._consume_token(payload)

litestar_auth/_manager/api_key_service.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import litestar_auth._manager.api_key_creation as _api_key_creation
99
import litestar_auth._manager.api_key_secrets as _api_key_secrets
10+
from litestar_auth._concurrency import run_password_op_in_worker_thread as _run_password_op
1011
from litestar_auth._manager.api_key_config import (
1112
ApiKeyConfigProtocol,
1213
ApiKeyManagerConfig,
@@ -50,7 +51,7 @@ async def create_api_key(
5051
) -> _api_key_secrets.ApiKeyCreateResult[ApiKeyRowProtocol]:
5152
"""Create an API key and return the one-time raw credential.""" # noqa: DOC201, DOC501
5253
data = _api_key_creation.coerce_api_key_create_options(options)
53-
self._verify_current_password_if_supplied(user, data.current_password)
54+
await self._verify_current_password_if_supplied(user, data.current_password)
5455
normalized_scopes = self._normalize_requested_scopes(data.scopes)
5556
store = self._require_store()
5657
user_id = cast("ID", user.id)
@@ -119,7 +120,7 @@ async def update_api_key(
119120
current_password: str | None = None,
120121
) -> ApiKeyRowProtocol:
121122
"""Update mutable API-key metadata owned by ``user``.""" # noqa: DOC201, DOC501
122-
self._verify_current_password_if_supplied(user, current_password)
123+
await self._verify_current_password_if_supplied(user, current_password)
123124
api_key = await self.get_api_key(user, key_id)
124125
normalized_scopes = None if scopes is None else self._normalize_requested_scopes(scopes)
125126
if name is None and normalized_scopes is None:
@@ -194,11 +195,12 @@ def _normalize_requested_scopes(self, scopes: Sequence[str]) -> list[str]:
194195
raise ApiKeyScopeDeniedError(denied_scopes=denied)
195196
return list(requested)
196197

197-
def _verify_current_password_if_supplied(self, user: UP, current_password: str | None) -> None:
198+
async def _verify_current_password_if_supplied(self, user: UP, current_password: str | None) -> None:
198199
if current_password is None:
199200
return
200201
hashed_password = getattr(user, "hashed_password", None)
201-
if not isinstance(hashed_password, str) or not self._manager.password_helper.verify(
202+
if not isinstance(hashed_password, str) or not await _run_password_op(
203+
self._manager.password_helper.verify,
202204
current_password,
203205
hashed_password,
204206
):

0 commit comments

Comments
 (0)