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

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 typing import Annotated, Generator 

9 

10from fastapi import Depends, HTTPException, Request 

11from loguru import logger 

12from sqlalchemy.orm import Session 

13 

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 

19 

20 

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") 

24 

25 

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

27 """Drop a stale session that has no way back to an open database. 

28 

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. 

37 

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. 

42 

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. 

48 

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 

55 

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 

60 

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 

67 

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 

75 

76 

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

78 """Require authentication. Returns username or raises 401. 

79 

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") 

87 

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 ) 

96 

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") 

126 

127 return username 

128 

129 

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

131 """Whether the cookie's ``session_id`` still resolves to ``username``. 

132 

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. 

142 

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 ) 

150 

151 

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. 

157 

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). 

162 

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``. 

166 

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. 

178 

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. 

184 

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 

190 

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 ) 

207 

208 

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) 

215 

216 

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

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

219 

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 

225 

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 

238 

239 password = None 

240 

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 

272 

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) 

279 

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 ) 

286 

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 

293 

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 ) 

301 

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 

305 

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 )