Coverage for src/local_deep_research/web/dependencies/auth.py: 99%
80 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2FastAPI authentication dependencies.
4Replaces Flask's @login_required decorator and g.db_session / g.current_user
5with explicit dependency injection.
6"""
8from typing import Annotated, Generator
10from fastapi import Depends, HTTPException, Request
11from loguru import logger
12from sqlalchemy.orm import Session
14from ...database.encrypted_db import db_manager
15from ...database.session_context import get_user_db_session
16from ...database.session_passwords import session_password_store
17from ...utilities.db_utils import get_settings_manager
18from ..auth.session_manager import session_manager
21def get_session_username(request: Request) -> str | None:
22 """Get the username from the session, or None if not authenticated."""
23 return request.session.get("username")
26def clear_session_if_unrecoverable(request: Request, username: str) -> bool:
27 """Drop a stale session that has no way back to an open database.
29 Ports ``cleanup_stale_sessions()``, which Flask ran as a before_request
30 handler on every request (``web/auth/session_cleanup.py`` on main, deleted
31 by this migration with no successor). Without it, a session whose password
32 has been evicted keeps a valid ``username`` cookie that 401s on every
33 protected route: the browser still believes it is logged in and nothing
34 tells it otherwise. The root route clears the session on its own error path
35 (``fastapi_app.py`` index()), so someone who navigates to "/" recovers —
36 but an API or XHR client polling endpoints never does.
38 Only clears when recovery is genuinely impossible, matching the original:
39 a temp auth token or a stored session password means
40 ``ensure_user_database()`` can still reopen the connection, so the session
41 is stale rather than dead and must be left alone.
43 Unlike the Flask version this is not throttled. That throttle
44 (``should_skip_session_cleanup()``) existed because a before_request hook
45 ran on every request including static assets; here the check is reached
46 only when authentication has already failed on a missing connection, which
47 is rare.
49 Returns True if the session was cleared.
50 """
51 if request.session.get("temp_auth_token"):
52 # Post-login bootstrap credential — ensure_user_database() consumes it
53 # and opens the database.
54 return False
56 if not db_manager.has_encryption:
57 # Unencrypted databases open with the dummy password; a missing
58 # connection here is not a credential problem.
59 return False
61 session_id = request.session.get("session_id")
62 if session_id and session_password_store.get_session_password(
63 username, session_id
64 ):
65 # The store can still reopen it.
66 return False
68 logger.info(
69 "Clearing stale session for {} — no database connection and no "
70 "recovery credential",
71 username,
72 )
73 request.session.clear()
74 return True
77def require_auth(request: Request) -> str:
78 """Require authentication. Returns username or raises 401.
80 Replaces Flask's @login_required decorator.
81 For API routes, returns JSON 401. For HTML routes, could redirect
82 (but callers handle that distinction).
83 """
84 username = request.session.get("username")
85 if not username:
86 raise HTTPException(status_code=401, detail="Authentication required")
88 if not db_manager.is_user_connected(username):
89 # Stale session with no recoverable credential: clear the cookie so the
90 # client is sent back to login instead of 401-ing on every request
91 # until someone happens to load "/".
92 clear_session_if_unrecoverable(request, username)
93 raise HTTPException(
94 status_code=401, detail="Database connection required"
95 )
97 # Validate the SERVER-SIDE session, not just the username claim inside the
98 # signed cookie.
99 #
100 # Without this, revocation does not work. The checks below it are both
101 # username-scoped: `is_user_connected` is true whenever ANY session for that
102 # user has the database open, and the password resolver
103 # (`get_user_db_session` -> `get_any_session_password`) hands back whichever
104 # session's password is live. So a cookie captured before logout is rejected
105 # only while the user stays logged out, and is accepted again the moment
106 # they — or anyone on any device — logs in again. Demonstrated: log in,
107 # capture cookie, log out (401 as expected), log in again, replay the
108 # original cookie -> 200 with real data.
109 #
110 # Flask did not need this: `get_user_db_session` resolved the password from
111 # `flask_session["session_id"]`, so a replayed cookie whose session had been
112 # destroyed found no password and failed at the database layer.
113 # `get_any_session_password` (added on this branch, absent from main) removed
114 # that incidental protection, because 154 router call sites invoke
115 # `get_user_db_session(username)` without threading a session_id through.
116 #
117 # Validating the session id here restores revocation for every route at one
118 # chokepoint. It also gives `session_timeout_hours` / `remember_me_days`
119 # real effect, since `validate_session` enforces the timeouts and refreshes
120 # last-access on use.
121 if not _server_session_valid(request, username):
122 # Destroyed by logout or password change, expired, or never valid.
123 # Clear the cookie so the client stops presenting a dead session.
124 request.session.clear()
125 raise HTTPException(status_code=401, detail="Authentication required")
127 return username
130def _server_session_valid(request: Request, username: str) -> bool:
131 """Whether the cookie's ``session_id`` still resolves to ``username``.
133 Split out of ``require_auth`` as a named seam rather than inlined, so a
134 test suite can relax the server-side-session gate without relaxing
135 authentication itself. Many route tests authenticate with the legacy
136 idiom — a bare ``username`` in the session plus a mocked ``db_manager``,
137 never creating a server-side session — which this gate correctly
138 rejects. ``tests/conftest.py``'s autouse ``_legacy_bare_username_auth``
139 patches this one function so those tests keep working, while tests that
140 must prove a destroyed session IS rejected opt out with
141 ``@pytest.mark.real_session_check`` and exercise the real check.
143 Only ever called after a username has been confirmed present, so
144 "accept unconditionally" is exactly the pre-revocation contract.
145 """
146 session_id = request.session.get("session_id")
147 return bool(
148 session_id and session_manager.validate_session(session_id) == username
149 )
152def get_db_session_dep(
153 request: Request,
154 username: Annotated[str, Depends(require_auth)],
155) -> Generator[Session, None, None]:
156 """Yield a database session for the authenticated user.
158 Passes the current request's session_id through to
159 get_user_db_session so the password lookup is bound to this
160 request's session rather than falling back to "any active
161 session's password" (cross-session leak risk).
163 NOT WIRED TO ANY ROUTE, AND DO NOT WIRE IT TO ONE AS-IS.
164 Its only consumer is ``get_settings_manager_dep`` below, which is itself
165 referenced only from ``tests/web/routers/test_thread_safety.py``.
167 The hazard: FastAPI drives a *sync generator* dependency through
168 ``contextmanager_in_threadpool``, which dispatches ``__enter__`` and
169 ``__exit__`` as two SEPARATE ``anyio.to_thread.run_sync`` calls. anyio
170 picks a worker with ``idle_workers.pop()`` and gives no task affinity, so
171 the two halves can land on different pooled threads. ``enter_scope()`` /
172 ``exit_scope()`` (database/session_context.py) write to a
173 ``threading.local()``, so a straddle leaves the entering worker's
174 ``scope_depth`` stuck at >=1 forever — and ``ThreadLocalSessionManager.
175 get_session`` then permanently skips its stale-transaction rollback,
176 letting one request's uncommitted ORM state be autoflushed and committed
177 by the next request served on that thread.
179 The same hazard is documented for the streaming generators in
180 ``web/routers/library.py``, which handle it deliberately. An attempt to
181 reproduce the straddle here (120 requests, 24-way concurrency, 44 distinct
182 workers) produced no mismatch on the pinned fastapi/starlette/anyio — but
183 same-thread dispatch is a scheduling accident, not a guarantee.
185 Before using this: either make scope enter/exit a token API that asserts
186 ``threading.get_ident()`` matches, or use ``run_db_sync`` (which keeps the
187 whole unit of work on one worker), as the routes actually do.
188 """
189 from ...database.session_context import DatabaseSessionError
191 session_id = request.session.get("session_id")
192 try:
193 with get_user_db_session(username, session_id=session_id) as session:
194 if session is None:
195 raise HTTPException(
196 status_code=500, detail="Failed to get database session"
197 )
198 yield session
199 except DatabaseSessionError:
200 # Password not available (e.g. server restarted, session expired).
201 # Clear session to force re-login.
202 request.session.clear()
203 raise HTTPException(
204 status_code=401,
205 detail="Session expired — please log in again",
206 )
209def get_settings_manager_dep(
210 db_session: Annotated[Session, Depends(get_db_session_dep)],
211 username: Annotated[str, Depends(require_auth)],
212):
213 """Yield a SettingsManager bound to the current user's DB session."""
214 return get_settings_manager(db_session, username)
217def ensure_user_database(request: Request) -> None:
218 """Ensure the user's encrypted database is open for this request.
220 Ports Flask's ensure_user_database() before_request handler.
221 Uses the same 3-source password fallback:
222 1. Temporary auth token (post-login/register, 10s TTL)
223 2. Session password store (persistent, 24h TTL)
224 3. Dummy password for unencrypted databases
226 Never raises. Any fault while resolving the password or opening the
227 connection degrades to "no connection opened", and the auth gate then
228 rejects the request with a 401 — matching Flask's handler, whose
229 ``try/except Exception`` covered both ``is_user_connected()`` and
230 ``open_user_database()`` (``web/auth/database_middleware.py`` on main).
231 That is not cosmetic here: this runs from ``DatabaseMiddleware``, i.e.
232 before any route, so an escaping exception turns EVERY authenticated
233 request into a 500 for as long as the fault lasts.
234 """
235 username = request.session.get("username")
236 if not username:
237 return
239 password = None
241 # One guard around the whole body rather than main's narrower one.
242 #
243 # main's try wrapped `is_user_connected()` + `open_user_database()`
244 # together, both inside `if password:`. This port hoists the
245 # `is_user_connected()` fast path above the token block (see below),
246 # so a try around `open_user_database()` alone would leave it — and
247 # the token/password-store lookups — unguarded. Covering the lot
248 # keeps main's contract ("a db_manager fault degrades to 401") intact
249 # regardless of that reordering.
250 try:
251 # Source 1: Temporary auth token (post-login/register).
252 #
253 # Consumed BEFORE the is_user_connected() fast path below, and that
254 # ordering is load-bearing. The token is a one-time bootstrap
255 # credential that retrieve_auth() deletes from the store — but only
256 # if we actually call it. Login already opens the connection, so an
257 # early return on is_user_connected() would skip this block on every
258 # subsequent request and the token would never be consumed: it stays
259 # live in the store for its full 10s TTL *and* stays in the client's
260 # cookie. A cookie captured in that window then re-authenticates
261 # after logout, because logout clears session_password_store but
262 # cannot reach into an already-issued cookie. Worse, this block would
263 # then write the recovered password into session_password_store,
264 # promoting a 10s window into a 24h session.
265 #
266 # Flask consumed the token unconditionally here and tested
267 # is_user_connected() only at the point of opening the DB
268 # (web/auth/database_middleware.py).
269 temp_auth_token = request.session.get("temp_auth_token")
270 if temp_auth_token:
271 from ...database.temp_auth import temp_auth_store
273 auth_data = temp_auth_store.retrieve_auth(temp_auth_token)
274 if auth_data:
275 stored_username, password = auth_data
276 if stored_username == username:
277 # Remove token from session after use
278 request.session.pop("temp_auth_token", None)
280 # Store in session password store for future requests
281 session_id = request.session.get("session_id")
282 if session_id: 282 ↛ 291line 282 didn't jump to line 291 because the condition on line 282 was always true
283 session_password_store.store_session_password(
284 username, session_id, password
285 )
287 # Fast path: the connection is already open, so there is nothing left
288 # to do. Placed after the token block above rather than at the top of
289 # the function — see the comment there. In steady state (no token in
290 # the session) reaching here still costs only one dict lookup.
291 if db_manager.is_user_connected(username):
292 return
294 # Source 2: Session password store
295 if not password:
296 session_id = request.session.get("session_id")
297 if session_id:
298 password = session_password_store.get_session_password(
299 username, session_id
300 )
302 # Source 3: Dummy password for unencrypted databases
303 if not password and not db_manager.has_encryption:
304 password = "dummy" # noqa: S105 — not a real password; placeholder for unencrypted DBs
306 if password:
307 engine = db_manager.open_user_database(username, password)
308 if not engine:
309 logger.warning(
310 f"open_user_database returned None for user {username}"
311 )
312 except Exception as exc:
313 # Deliberately no traceback and no exception message: the frames
314 # under this call hold the plaintext password in their locals, and
315 # loguru renders frame locals when `diagnose` is on (its default).
316 # `limits`-style exceptions that echo their input back would leak it
317 # into the message too. main logged a bare warning here for the same
318 # reason; the exception TYPE is added because it costs nothing and
319 # is the one piece of a traceback that cannot carry a credential.
320 logger.warning(
321 f"Failed to open database for user {username} "
322 f"({type(exc).__name__})"
323 )