Run auth password work off the event loop

* fix: run bcrypt off the event loop in auth routes

The auth routes are async, but each bcrypt call ran synchronously on the event
loop. bcrypt (checkpw/hashpw) is intentionally CPU-expensive (~100-300 ms), so
every login / signup / setup / change-password froze the single event loop for
that window, stalling all other in-flight requests (chat streams, polling, ...).

/api/auth/login is the worst case: it is reachable unauthenticated, runs bcrypt
twice (verify_password, then create_session re-verifies), and is rate-limited
only per-IP. A burst of login attempts serializes the whole server — cheap
DoS amplification.

Offload the bcrypt-bearing AuthManager calls (setup, signup/create_user,
login's verify_password + create_session, change_password) via
asyncio.to_thread, matching how the codebase already offloads blocking work
(e.g. src/builtin_actions._run_subprocess, email summarize). The event loop
stays responsive while bcrypt runs on a worker thread.

Add tests/test_auth_event_loop.py: asserts login runs verify_password and
create_session on a worker thread, not the loop thread. Fails if those calls
are awaited inline again.

* test: isolate auth event-loop test from heavy core/* import chain

The regression test imported routes.auth_routes, which pulls in
core.auth and so triggers core/__init__.py — transitively importing
src.llm_core (hangs at import under the project venv) and the SQLAlchemy
declarative models (metaclass error on a bare core.database import / under
the conftest sqlalchemy stubs). Reported by the maintainer: collection
failed on system Python and hung under the venv.

Stub core.auth/core.database before the import, mirroring the existing
_ensure_stub pattern in test_auth_regressions.py and test_null_owner_gates.py.
AuthManager is only a type hint here and the handler is exercised with a
MagicMock, so no real core machinery is needed. Test now imports cleanly
and passes in <0.3s without bcrypt/sqlalchemy installed.
This commit is contained in:
Collin
2026-06-01 10:12:12 -04:00
committed by GitHub
parent a51a1fc4fc
commit 11c2931efb
2 changed files with 119 additions and 5 deletions
+6 -5
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Request, Response, HTTPException
from pydantic import BaseModel
from typing import Optional
import asyncio
import logging
import os
@@ -90,7 +91,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(400, "Already configured")
if len(body.password) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
ok = auth_manager.setup(body.username, body.password)
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
if not ok:
raise HTTPException(500, "Setup failed")
return {"ok": True, "message": "Admin account created"}
@@ -108,7 +109,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(400, "Password must be at least 8 characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
ok = auth_manager.create_user(body.username, body.password, is_admin=False)
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
if not ok:
raise HTTPException(409, "Username already taken")
return {"ok": True, "message": "Account created"}
@@ -119,7 +120,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(429, "Too many requests — try again later")
# Verify password first
username = body.username.strip().lower()
if not auth_manager.verify_password(username, body.password):
if not await asyncio.to_thread(auth_manager.verify_password, username, body.password):
raise HTTPException(401, "Invalid credentials")
# Check 2FA if enabled
if auth_manager.totp_enabled(username):
@@ -129,7 +130,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
if not auth_manager.totp_verify(username, body.totp_code):
raise HTTPException(401, "Invalid 2FA code")
# All checks passed — create session
token = auth_manager.create_session(username, body.password)
token = await asyncio.to_thread(auth_manager.create_session, username, body.password)
if not token:
raise HTTPException(401, "Invalid credentials")
cookie_kwargs = dict(
@@ -177,7 +178,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(401, "Not authenticated")
if len(body.new_password) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
ok = auth_manager.change_password(user, body.current_password, body.new_password)
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
if not ok:
raise HTTPException(400, "Current password is incorrect")
return {"ok": True}