Coverage for src/local_deep_research/web/dependencies/auth.py: 97%

145 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2FastAPI authentication dependencies. 

3 

4Replaces Flask's @login_required decorator and g.db_session / g.current_user 

5with explicit dependency injection. 

6""" 

7 

8from contextlib import contextmanager 

9from contextvars import ContextVar 

10from typing import Annotated, Generator 

11 

12from fastapi import Depends, HTTPException, Request 

13from loguru import logger 

14from sqlalchemy.orm import Session 

15 

16from ...database.encrypted_db import ( 

17 UserConnectionStatus, 

18 db_manager, 

19 sidecar_wait_budget, 

20) 

21from ...database.rekey_lock import RekeyOperationalError 

22from ...database.session_context import get_user_db_session 

23from ...database.session_passwords import session_password_store 

24from ...utilities.db_utils import get_settings_manager 

25from ..auth.session_manager import session_manager 

26 

27 

28def get_session_username(request: Request) -> str | None: 

29 """Get the username from the session, or None if not authenticated.""" 

30 return request.session.get("username") 

31 

32 

33# Type names of the rekey operational faults that DatabaseMiddleware's reopen 

34# of the user's database met in the current request (ensure_user_database); 

35# None outside a request_database_scope. A list, so the reopen that runs on a 

36# worker thread (with a copy of the request's context) can append to the one 

37# require_auth reads later on another. 

38# 

39# Request-scoped state: contexts copied from the request (research, RAG and 

40# post-login threads) keep this list after the request ends, so it must only 

41# be read by require_auth (through _database_connection_status), in the 

42# request that wrote it, never from background work. 

43_reopen_faults: ContextVar[list[str] | None] = ContextVar( 

44 "ldr_request_reopen_faults", default=None 

45) 

46 

47 

48@contextmanager 

49def request_database_scope() -> Generator[None, None, None]: 

50 """Scope one HTTP request's database bookkeeping (``DatabaseMiddleware``). 

51 

52 * The request's waits for the user's rekey sidecar share a give-up flag 

53 (``sidecar_wait_budget``): DatabaseMiddleware's probe and reopen, 

54 ``require_auth``'s probes and the route's own database opens. A wait 

55 that gives up sets it, and each later claim then tries once without 

56 waiting, until one of them gets the sidecar again. The flag is not a 

57 running total of the time waited. 

58 * A rekey operational fault that the reopen in ``ensure_user_database`` 

59 met is kept for ``require_auth``, which answers the missing engine 

60 with the retryable 503 instead of the 401 that ``/`` turns into a 

61 logout. 

62 """ 

63 token = _reopen_faults.set([]) 

64 try: 

65 with sidecar_wait_budget(): 

66 yield 

67 finally: 

68 _reopen_faults.reset(token) 

69 

70 

71def _record_reopen_fault(fault: RekeyOperationalError) -> None: 

72 faults = _reopen_faults.get() 

73 if faults is not None: 73 ↛ exitline 73 didn't return from function '_record_reopen_fault' because the condition on line 73 was always true

74 faults.append(type(fault).__name__) 

75 

76 

77def clear_session_if_unrecoverable(request: Request, username: str) -> bool: 

78 """Drop a stale session that has no database recovery credential. 

79 

80 Ports ``cleanup_stale_sessions()``, which Flask ran as a before_request 

81 handler on every request (``web/auth/session_cleanup.py`` on main, deleted 

82 by this migration with no successor). Without it, a session whose password 

83 has been evicted keeps a valid ``username`` cookie that 401s on every 

84 protected route: the browser still believes it is logged in and nothing 

85 tells it otherwise. An API or XHR client polling endpoints would never 

86 recover. 

87 

88 Only clears when recovery is genuinely impossible, matching the original: 

89 a temp auth token or a stored session password means 

90 ``ensure_user_database()`` can still reopen the connection, so the session 

91 is stale rather than dead and must be left alone. 

92 

93 This also runs when a server-side session is still valid and its shared 

94 connection is open. Background work or another device can keep that 

95 connection alive after this session loses its required credential. 

96 

97 When it clears the cookie it also destroys the matching server-side 

98 session and disconnects that session's sockets 

99 (``_revoke_server_session``), because the cleared browser can no longer 

100 reach logout to do it. 

101 

102 Returns True if the session was cleared. 

103 """ 

104 if request.session.get("temp_auth_token"): 

105 # Post-login bootstrap credential — ensure_user_database() consumes it 

106 # and opens the database. 

107 return False 

108 

109 if not db_manager.has_encryption: 

110 # Unencrypted databases open with the dummy password; a missing 

111 # connection here is not a credential problem. 

112 return False 

113 

114 session_id = request.session.get("session_id") 

115 if session_id and session_password_store.get_session_password( 

116 username, session_id 

117 ): 

118 # The store can still reopen it. 

119 return False 

120 

121 logger.info( 

122 "Clearing stale session for {} — no recovery credential", username 

123 ) 

124 if session_id: 

125 _revoke_server_session(session_id, username) 

126 request.session.clear() 

127 return True 

128 

129 

130def _revoke_server_session(session_id: str, username: str) -> None: 

131 """Destroy the server-side session behind a cookie that is being cleared. 

132 

133 Clearing only the cookie would leave the ``session_manager`` record alive 

134 for the rest of its idle timeout (up to ``session_remember_me_days``). 

135 Once the cookie is gone the browser cannot reach logout, the path that 

136 normally destroys it. A replayed copy of the cookie would keep renewing 

137 that record, and checks that rely on ``validate_session`` alone (the 

138 Socket.IO connect gate, ``has_active_sessions_for`` in the idle sweeper) 

139 would keep treating the session as live. So destroy the record and 

140 disconnect its sockets, the same per-session teardown logout performs. 

141 

142 Only this session is affected. The user's other sessions keep working. 

143 A session id owned by a different user is left alone. Teardown is 

144 best-effort so a failure here cannot turn the 401 into a 500. 

145 """ 

146 try: 

147 owner = session_manager.validate_session(session_id) 

148 if owner is not None and owner != username: 

149 return 

150 if owner is not None: 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true

151 session_manager.destroy_session(session_id) 

152 from ..services.socketio_asgi import disconnect_session 

153 

154 disconnect_session(session_id) 

155 except Exception as exc: 

156 # Type name only: a rendered traceback would include session_id. 

157 failure = type(exc).__name__ 

158 else: 

159 return 

160 logger.warning( 

161 "Failed to revoke an unrecoverable server-side session: {}", failure 

162 ) 

163 

164 

165def end_rejected_session(request: Request) -> None: 

166 """Log out a browser whose session ``require_auth`` rejected but kept. 

167 

168 ``require_auth`` keeps the cookie on one 401 path on purpose: the user's 

169 database is not connected, but ``clear_session_if_unrecoverable`` still 

170 sees a way to recover (this session's stored password, a temp auth token, 

171 or an unencrypted install). If the reopen then keeps failing (missing or 

172 corrupt database file, a failed migration, the close/rekey window of a 

173 password change), every request 401s with ``username`` still in the 

174 cookie. ``/auth/login`` sends any cookie with a username back to ``/``, 

175 so a page that redirects that 401 to the login page without clearing the 

176 cookie loops between the two until the browser gives up. A cached engine 

177 that merely cannot be checked right now (a rekey sidecar fault), or a 

178 reopen that failed with a rekey operational fault (another holder 

179 keeping the sidecar past the wait bound, for one), is not such a 

180 failure: ``require_auth`` answers it with a 503, which never reaches 

181 this function. 

182 

183 Call this before redirecting such a 401 to the login page. It destroys 

184 the server-side session, disconnects its sockets, drops this session's 

185 stored password, and clears the cookie, so the browser lands on the 

186 login form. Other sessions of the same user are not touched. Does 

187 nothing when ``require_auth`` already cleared the session. 

188 """ 

189 username = request.session.get("username") 

190 if not username: 

191 return 

192 session_id = request.session.get("session_id") 

193 if session_id: 193 ↛ 203line 193 didn't jump to line 203 because the condition on line 193 was always true

194 _revoke_server_session(session_id, username) 

195 try: 

196 session_password_store.clear_session(username, session_id) 

197 except Exception as exc: 

198 # Type name only: a rendered traceback would include session_id. 

199 logger.warning( 

200 "Failed to drop a rejected session's stored password: {}", 

201 type(exc).__name__, 

202 ) 

203 request.session.clear() 

204 

205 

206# Seconds a client is asked to wait before retrying a request answered with 

207# ``database_unavailable_exception``'s 503: the user's database could not be 

208# checked or opened because of a rekey operational fault (a transient 

209# sidecar fault such as an fd-exhaustion spike). 

210DATABASE_UNAVAILABLE_RETRY_AFTER_SECONDS = 5 

211 

212 

213def database_unavailable_exception() -> HTTPException: 

214 """The retryable 503 for a rekey operational fault after authentication. 

215 

216 Every moment that meets such a fault answers the same way: 

217 

218 * ``require_auth``'s own probe cannot check the cached engine, or no 

219 engine is cached because this request's reopen of the database met 

220 such a fault (``UserConnectionStatus.UNAVAILABLE``); 

221 * the probe succeeded, but opening the request's database session a 

222 moment later failed on the same kind of fault 

223 (``DatabaseSessionUnavailableError`` from ``get_user_db_session``), 

224 which an fd-exhaustion spike that comes and goes produces: ``/`` and 

225 ``get_db_session_dep`` raise this for it (``/`` answers a browser, 

226 for this and for ``require_auth``'s own 503, with the branded error 

227 page instead: same status and ``Retry-After``, and a button that 

228 retries ``/``); 

229 * a route left that error, or a ``RekeyOperationalError`` raised by a 

230 direct ``db_manager`` call, unhandled: the app's exception handler 

231 for both (``fastapi_app._register_exception_handlers``) answers this. 

232 That includes a settings read through ``get_settings_manager`` 

233 without a session (``db_manager.get_session`` raises the fault rather 

234 than letting it read as "no database", i.e. every setting at its 

235 default); ``POST /metrics/api/journal-data/download``, whose own 

236 catch-all would answer 500, passes it on to that handler. 

237 

238 None is a verdict on the session, so whoever raises this leaves the 

239 cookie, the server-side session and the stored password alone. A fresh 

240 instance each call: ``HTTPException`` carries mutable headers. 

241 """ 

242 return HTTPException( 

243 status_code=503, 

244 detail="Your database is temporarily unavailable. " 

245 "Please try again in a moment.", 

246 headers={"Retry-After": str(DATABASE_UNAVAILABLE_RETRY_AFTER_SECONDS)}, 

247 ) 

248 

249 

250def _database_connection_status(username: str) -> UserConnectionStatus: 

251 """Whether ``username``'s database can serve this request right now. 

252 

253 ``is_user_connected`` stays the single probe on the common path. Its 

254 False is ambiguous, though: either no usable engine is cached (none, or 

255 a stale one the probe just detached), or a cached engine could not be 

256 checked because the rekey sidecar could not be opened or locked, or the 

257 epoch read failed (an fd-exhaustion spike, EACCES, EIO). The manager 

258 keeps the engine in the second case and re-checks it on the next 

259 probe. So a False is followed by ``probe_user_connection``, which tells 

260 the two apart; its answer is the more recent one, so it decides. 

261 

262 No cached engine is not a credential verdict either when this 

263 request's reopen of the database (``ensure_user_database``) failed with 

264 a rekey operational fault: its cold open could not take the sidecar 

265 (another holder kept it past the wait bound, an fd-exhaustion spike), 

266 could not read or publish the epoch, or found durable rekey state that 

267 needs recovery. None of these says anything against the password, so 

268 that is ``UNAVAILABLE`` too. 

269 """ 

270 try: 

271 if db_manager.is_user_connected(username): 

272 return UserConnectionStatus.CONNECTED 

273 except RekeyOperationalError: 

274 # The probe takes the rekey sidecar (filesystem + a lock it waits 

275 # for at most a few seconds). Opening, locking (a holder that keeps 

276 # it past that bound included) and reading are reported as a plain 

277 # False, but a failure to release the sidecar afterwards 

278 # (unlock/close) raises RekeySidecarIOError, which is not an 

279 # OSError. It is the same transient sidecar I/O fault, not a verdict 

280 # on the session. 

281 return UserConnectionStatus.UNAVAILABLE 

282 connection = db_manager.probe_user_connection(username) 

283 if ( 

284 connection is UserConnectionStatus.NOT_CONNECTED 

285 and _reopen_faults.get() 

286 ): 

287 return UserConnectionStatus.UNAVAILABLE 

288 return connection 

289 

290 

291def require_auth(request: Request) -> str: 

292 """Require authentication. Returns username or raises 401 (or 503). 

293 

294 Replaces Flask's @login_required decorator. 

295 For API routes, returns JSON 401. For HTML routes, could redirect 

296 (but callers handle that distinction). 

297 

298 A session that would otherwise be accepted gets a 503 with 

299 ``Retry-After`` instead while its cached database engine cannot be 

300 checked because of a rekey sidecar fault, or while no engine is cached 

301 because this request's reopen failed with a rekey operational fault 

302 (another holder keeping the sidecar past the wait bound, for one). The 

303 session, its 

304 server-side record and its stored password are all kept: a 401 there 

305 would be turned into a logout by ``/`` (``end_rejected_session``) 

306 after a transient fault such as an fd-exhaustion spike. A fault that 

307 only shows when the route then opens its database session surfaces as 

308 ``DatabaseSessionUnavailableError`` and gets the same 503 

309 (``database_unavailable_exception``). 

310 """ 

311 username = request.session.get("username") 

312 if not username: 

313 raise HTTPException(status_code=401, detail="Authentication required") 

314 

315 connection = _database_connection_status(username) 

316 if connection is not UserConnectionStatus.CONNECTED and ( 

317 connection is not UserConnectionStatus.UNAVAILABLE 

318 ): 

319 # Stale session with no recoverable credential: clear the cookie (and 

320 # revoke the server-side session) so the client is sent back to login 

321 # instead of 401-ing on every request until someone loads "/". 

322 clear_session_if_unrecoverable(request, username) 

323 raise HTTPException( 

324 status_code=401, detail="Database connection required" 

325 ) 

326 

327 # Validate the SERVER-SIDE session, not just the username claim inside the 

328 # signed cookie. 

329 # 

330 # Without this, revocation does not work. The checks below it are both 

331 # username-scoped: `is_user_connected` is true whenever ANY session for that 

332 # user has the database open, and the password resolver 

333 # (`get_user_db_session` -> `get_any_session_password`) hands back whichever 

334 # session's password is live. So a cookie captured before logout is rejected 

335 # only while the user stays logged out, and is accepted again the moment 

336 # they — or anyone on any device — logs in again. Demonstrated: log in, 

337 # capture cookie, log out (401 as expected), log in again, replay the 

338 # original cookie -> 200 with real data. 

339 # 

340 # Flask did not need this: `get_user_db_session` resolved the password from 

341 # `flask_session["session_id"]`, so a replayed cookie whose session had been 

342 # destroyed found no password and failed at the database layer. 

343 # `get_any_session_password` (added on this branch, absent from main) removed 

344 # that incidental protection, because 154 router call sites invoke 

345 # `get_user_db_session(username)` without threading a session_id through. 

346 # 

347 # Validating the session id here restores revocation for every route at one 

348 # chokepoint. It also gives `session_timeout_hours` / `remember_me_days` 

349 # real effect, since `validate_session` enforces the timeouts and refreshes 

350 # last-access on use. 

351 if not _server_session_valid(request, username): 

352 # Destroyed by logout or password change, expired, or never valid. 

353 # Clear the cookie so the client stops presenting a dead session. 

354 request.session.clear() 

355 raise HTTPException(status_code=401, detail="Authentication required") 

356 

357 if connection is UserConnectionStatus.UNAVAILABLE: 

358 # The session is valid, but its database cannot be checked: the 

359 # user's engine is still cached and its rekey epoch could not be 

360 # checked (the sidecar could not be opened, locked or released, or 

361 # the epoch read failed), or no engine is cached and the reopen 

362 # DatabaseMiddleware attempted failed with a rekey operational 

363 # fault (_database_connection_status). Answer a 

364 # retryable 503 and touch nothing: a 401 here reaches "/", whose 

365 # end_rejected_session destroys the session and its stored 

366 # password, so an fd-exhaustion spike or a held sidecar would log 

367 # the user out. With an engine cached, that reopen failed fast too: 

368 # open_user_database raises the same fault before deriving a key. 

369 raise database_unavailable_exception() 

370 

371 return username 

372 

373 

374def _server_session_valid(request: Request, username: str) -> bool: 

375 """Whether the login still owns a usable authenticated session. 

376 

377 An open shared connection alone cannot make an encrypted database usable: 

378 request handlers still need the session's in-memory credential. Require 

379 re-authentication when it is gone, even if background work keeps the 

380 connection open. 

381 

382 Split out of ``require_auth`` as a named seam rather than inlined, so a 

383 test suite can relax the server-side-session gate without relaxing 

384 authentication itself. Many route tests authenticate with the legacy 

385 idiom — a bare ``username`` in the session plus a mocked ``db_manager``, 

386 never creating a server-side session — which this gate correctly 

387 rejects. ``tests/conftest.py``'s autouse ``_legacy_bare_username_auth`` 

388 patches this one function so those tests keep working, while tests that 

389 must prove a destroyed session IS rejected opt out with 

390 ``@pytest.mark.real_session_check`` and exercise the real check. 

391 

392 Only ever called after a username has been confirmed present, so 

393 "accept unconditionally" is exactly the pre-revocation contract. 

394 """ 

395 session_id = request.session.get("session_id") 

396 valid_owner = bool( 

397 session_id and session_manager.validate_session(session_id) == username 

398 ) 

399 return valid_owner and not clear_session_if_unrecoverable(request, username) 

400 

401 

402def get_db_session_dep( 

403 request: Request, 

404 username: Annotated[str, Depends(require_auth)], 

405) -> Generator[Session, None, None]: 

406 """Yield a database session for the authenticated user. 

407 

408 Passes the current request's session_id through to 

409 get_user_db_session so the password lookup is bound to this 

410 request's session rather than falling back to "any active 

411 session's password" (cross-session leak risk). 

412 

413 NOT WIRED TO ANY ROUTE, AND DO NOT WIRE IT TO ONE AS-IS. 

414 Its only consumer is ``get_settings_manager_dep`` below, which is itself 

415 referenced only from ``tests/web/routers/test_thread_safety.py``. 

416 

417 The hazard: FastAPI drives a *sync generator* dependency through 

418 ``contextmanager_in_threadpool``, which dispatches ``__enter__`` and 

419 ``__exit__`` as two SEPARATE ``anyio.to_thread.run_sync`` calls. anyio 

420 picks a worker with ``idle_workers.pop()`` and gives no task affinity, so 

421 the two halves can land on different pooled threads. ``enter_scope()`` / 

422 ``exit_scope()`` (database/session_context.py) write to a 

423 ``threading.local()``, so a straddle leaves the entering worker's 

424 ``scope_depth`` stuck at >=1 forever — and ``ThreadLocalSessionManager. 

425 get_session`` then permanently skips its stale-transaction rollback, 

426 letting one request's uncommitted ORM state be autoflushed and committed 

427 by the next request served on that thread. 

428 

429 The same hazard is documented for the streaming generators in 

430 ``web/routers/library.py``, which handle it deliberately. An attempt to 

431 reproduce the straddle here (120 requests, 24-way concurrency, 44 distinct 

432 workers) produced no mismatch on the pinned fastapi/starlette/anyio — but 

433 same-thread dispatch is a scheduling accident, not a guarantee. 

434 

435 Before using this: either make scope enter/exit a token API that asserts 

436 ``threading.get_ident()`` matches, or use ``run_db_sync`` (which keeps the 

437 whole unit of work on one worker), as the routes actually do. 

438 

439 A ``DatabaseSessionUnavailableError`` (a rekey sidecar or epoch fault, 

440 not a credential problem) is answered with the retryable 503 of 

441 ``database_unavailable_exception`` and leaves the session alone; any 

442 other ``DatabaseSessionError`` clears it and answers 401. 

443 """ 

444 from ...database.session_context import ( 

445 DatabaseSessionError, 

446 DatabaseSessionUnavailableError, 

447 ) 

448 

449 session_id = request.session.get("session_id") 

450 try: 

451 with get_user_db_session(username, session_id=session_id) as session: 

452 if session is None: 

453 raise HTTPException( 

454 status_code=500, detail="Failed to get database session" 

455 ) 

456 yield session 

457 except DatabaseSessionUnavailableError: 

458 # require_auth's probe passed, then opening the session (or a 

459 # session the handler opened) met a transient rekey fault. Clearing 

460 # the session here would log the user out over an fd spike. 

461 raise database_unavailable_exception() from None 

462 except DatabaseSessionError: 

463 # Password not available (e.g. server restarted, session expired). 

464 # Clear session to force re-login. 

465 request.session.clear() 

466 raise HTTPException( 

467 status_code=401, 

468 detail="Session expired — please log in again", 

469 ) 

470 

471 

472def get_settings_manager_dep( 

473 db_session: Annotated[Session, Depends(get_db_session_dep)], 

474 username: Annotated[str, Depends(require_auth)], 

475): 

476 """Yield a SettingsManager bound to the current user's DB session.""" 

477 return get_settings_manager(db_session, username) 

478 

479 

480def ensure_user_database(request: Request) -> None: 

481 """Ensure the user's encrypted database is open for this request. 

482 

483 Ports Flask's ensure_user_database() before_request handler. 

484 Uses the same 3-source password fallback: 

485 1. Temporary auth token (post-login/register, 10s TTL) 

486 2. Session password store (persistent, 24h TTL) 

487 3. Dummy password for unencrypted databases 

488 

489 Never raises. Any fault while resolving the password or opening the 

490 connection degrades to "no connection opened", and the auth gate then 

491 rejects the request with a 401 — matching Flask's handler, whose 

492 ``try/except Exception`` covered both ``is_user_connected()`` and 

493 ``open_user_database()`` (``web/auth/database_middleware.py`` on main). 

494 That is not cosmetic here: this runs from ``DatabaseMiddleware``, i.e. 

495 before any route, so an escaping exception turns EVERY authenticated 

496 request into a 500 for as long as the fault lasts. One exception to the 

497 401: a rekey operational fault (``RekeyOperationalError``) from the 

498 reopen is recorded for the request (``request_database_scope``), and 

499 the auth gate answers it with a retryable 503 that keeps the session 

500 (see ``require_auth``). That includes a cached engine that cannot be 

501 checked, which ``open_user_database`` raises before deriving a key, and 

502 a cold open that could not take the sidecar (another holder keeping it 

503 past the wait bound, an fd-exhaustion spike) or read the epoch. 

504 """ 

505 username = request.session.get("username") 

506 if not username: 

507 return 

508 

509 password = None 

510 

511 # One guard around the whole body rather than main's narrower one. 

512 # 

513 # main's try wrapped `is_user_connected()` + `open_user_database()` 

514 # together, both inside `if password:`. This port hoists the 

515 # `is_user_connected()` fast path above the token block (see below), 

516 # so a try around `open_user_database()` alone would leave it — and 

517 # the token/password-store lookups — unguarded. Covering the lot 

518 # keeps main's contract ("a db_manager fault degrades to 401") intact 

519 # regardless of that reordering. 

520 try: 

521 # Source 1: Temporary auth token (post-login/register). 

522 # 

523 # Consumed BEFORE the is_user_connected() fast path below, and that 

524 # ordering is load-bearing. The token is a one-time bootstrap 

525 # credential that retrieve_auth() deletes from the store — but only 

526 # if we actually call it. Login already opens the connection, so an 

527 # early return on is_user_connected() would skip this block on every 

528 # subsequent request and the token would never be consumed: it stays 

529 # live in the store for its full 10s TTL *and* stays in the client's 

530 # cookie. A cookie captured in that window then re-authenticates 

531 # after logout, because logout clears session_password_store but 

532 # cannot reach into an already-issued cookie. Worse, this block would 

533 # then write the recovered password into session_password_store, 

534 # promoting a 10s window into a 24h session. 

535 # 

536 # Flask consumed the token unconditionally here and tested 

537 # is_user_connected() only at the point of opening the DB 

538 # (web/auth/database_middleware.py). 

539 temp_auth_token = request.session.get("temp_auth_token") 

540 if temp_auth_token: 

541 from ...database.temp_auth import temp_auth_store 

542 

543 auth_data = temp_auth_store.retrieve_auth(temp_auth_token) 

544 if auth_data: 

545 stored_username, password = auth_data 

546 if stored_username == username: 

547 # Remove token from session after use 

548 request.session.pop("temp_auth_token", None) 

549 

550 # Store in session password store for future requests 

551 session_id = request.session.get("session_id") 

552 if session_id: 552 ↛ 570line 552 didn't jump to line 570 because the condition on line 552 was always true

553 session_password_store.store_session_password( 

554 username, session_id, password 

555 ) 

556 else: 

557 # An expired/consumed marker is not a recovery credential. 

558 # A concurrent response can retain this old cookie after 

559 # another request consumed the token. Leaving it in place 

560 # would bypass clear_session_if_unrecoverable indefinitely. 

561 request.session.pop("temp_auth_token", None) 

562 

563 # Fast path: the connection is already open, so there is nothing left 

564 # to do. Placed after the token block above rather than at the top of 

565 # the function — see the comment there. In steady state (no token in 

566 # the session) reaching here costs one is_user_connected() probe, 

567 # which is no longer a bare dict lookup: it takes the per-user 

568 # lifecycle lock and the rekey sidecar flock and re-reads the 

569 # durable rekey epoch to validate the cached engine. 

570 if db_manager.is_user_connected(username): 

571 return 

572 

573 # Source 2: Session password store 

574 if not password: 

575 session_id = request.session.get("session_id") 

576 if session_id: 

577 password = session_password_store.get_session_password( 

578 username, session_id 

579 ) 

580 

581 # Source 3: Dummy password for unencrypted databases 

582 if not password and not db_manager.has_encryption: 

583 # nosemgrep: semgrep.rules.hardcoded-secret-detection, reason: The placeholder is used only when SQLCipher encryption is unavailable 

584 password = "dummy" # noqa: S105 — not a real password; placeholder for unencrypted DBs 

585 

586 if password: 

587 engine = db_manager.open_user_database(username, password) 

588 if not engine: 

589 logger.warning( 

590 f"open_user_database returned None for user {username}" 

591 ) 

592 except RekeyOperationalError as exc: 

593 # Not a verdict on the session: the sidecar or an epoch file could 

594 # not be read, another holder kept the sidecar past the wait bound, 

595 # or the durable rekey state needs recovery first. None of these 

596 # says anything against the password. Recorded for require_auth, 

597 # which answers it with the retryable 503 and keeps the session 

598 # instead of reading the missing engine as a credential failure (a 

599 # 401 that "/" turns into a logout). Type name only, as below. 

600 _record_reopen_fault(exc) 

601 logger.warning( 

602 f"Database for user {username} is temporarily unavailable " 

603 f"({type(exc).__name__})" 

604 ) 

605 except Exception as exc: 

606 # Deliberately no traceback and no exception message: the frames 

607 # under this call hold the plaintext password in their locals, and 

608 # loguru renders frame locals when `diagnose` is on (its default). 

609 # `limits`-style exceptions that echo their input back would leak it 

610 # into the message too. main logged a bare warning here for the same 

611 # reason; the exception TYPE is added because it costs nothing and 

612 # is the one piece of a traceback that cannot carry a credential. 

613 logger.warning( 

614 f"Failed to open database for user {username} " 

615 f"({type(exc).__name__})" 

616 )