paskia 0.8.1__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +14 -27
  3. paskia/bootstrap.py +31 -103
  4. paskia/db/__init__.py +25 -51
  5. paskia/db/background.py +17 -37
  6. paskia/db/jsonl.py +168 -6
  7. paskia/db/migrations.py +34 -0
  8. paskia/db/operations.py +400 -723
  9. paskia/db/structs.py +214 -90
  10. paskia/fastapi/__main__.py +24 -28
  11. paskia/fastapi/admin.py +101 -160
  12. paskia/fastapi/api.py +47 -83
  13. paskia/fastapi/mainapp.py +13 -6
  14. paskia/fastapi/remote.py +16 -39
  15. paskia/fastapi/reset.py +27 -17
  16. paskia/fastapi/session.py +2 -2
  17. paskia/fastapi/user.py +21 -27
  18. paskia/fastapi/ws.py +27 -62
  19. paskia/fastapi/wschat.py +62 -0
  20. paskia/frontend-build/auth/admin/index.html +5 -5
  21. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  22. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  23. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  24. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  25. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  26. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  27. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  28. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  29. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  30. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  31. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  32. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  33. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  34. paskia/frontend-build/auth/index.html +5 -5
  35. paskia/frontend-build/auth/restricted/index.html +4 -4
  36. paskia/frontend-build/int/forward/index.html +4 -4
  37. paskia/frontend-build/int/reset/index.html +3 -3
  38. paskia/globals.py +2 -2
  39. paskia/migrate/__init__.py +62 -55
  40. paskia/migrate/sql.py +72 -22
  41. paskia/remoteauth.py +1 -2
  42. paskia/sansio.py +6 -12
  43. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/METADATA +1 -1
  44. paskia-0.9.0.dist-info/RECORD +57 -0
  45. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  46. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  47. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  48. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  49. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  50. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  51. paskia-0.8.1.dist-info/RECORD +0 -55
  52. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
  53. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/fastapi/api.py CHANGED
@@ -17,7 +17,6 @@ from paskia import db
17
17
  from paskia.authsession import (
18
18
  EXPIRES,
19
19
  get_reset,
20
- get_session,
21
20
  refresh_session_token,
22
21
  )
23
22
  from paskia.fastapi import authz, session, user
@@ -27,7 +26,7 @@ from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
27
26
 
28
27
  bearer_auth = HTTPBearer(auto_error=True)
29
28
 
30
- app = FastAPI()
29
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
31
30
 
32
31
  app.mount("/user", user.app)
33
32
 
@@ -78,12 +77,7 @@ async def validate_token(
78
77
  max_age: str | None = Query(None),
79
78
  auth=AUTH_COOKIE,
80
79
  ):
81
- """Validate the current session and extend its expiry.
82
-
83
- Always refreshes the session (sliding expiration) and re-sets the cookie with a
84
- renewed max-age. This keeps active users logged in without needing a separate
85
- refresh endpoint.
86
- """
80
+ """Validate session and return context. Refreshes session expiry."""
87
81
  try:
88
82
  ctx = await authz.verify(
89
83
  auth,
@@ -99,7 +93,7 @@ async def validate_token(
99
93
  consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc))
100
94
  if not timedelta(0) < consumed < _REFRESH_INTERVAL:
101
95
  try:
102
- await refresh_session_token(
96
+ refresh_session_token(
103
97
  auth,
104
98
  ip=request.client.host if request.client else "",
105
99
  user_agent=request.headers.get("user-agent") or "",
@@ -113,8 +107,26 @@ async def validate_token(
113
107
  )
114
108
  return {
115
109
  "valid": True,
116
- "user_uuid": str(ctx.session.user_uuid),
117
110
  "renewed": renewed,
111
+ "ctx": userinfo.format_session_context(ctx),
112
+ }
113
+
114
+
115
+ @app.get("/token-info")
116
+ async def token_info(credentials=Depends(bearer_auth)):
117
+ """Get reset/device-add token info. Pass token via Bearer header."""
118
+ token = credentials.credentials
119
+ if not passphrase.is_well_formed(token):
120
+ raise HTTPException(400, "Invalid token format")
121
+ try:
122
+ reset_token = get_reset(token)
123
+ except ValueError as e:
124
+ raise HTTPException(401, str(e))
125
+
126
+ u = db.data().users.get(reset_token.user)
127
+ return {
128
+ "token_type": reset_token.token_type,
129
+ "display_name": u.display_name,
118
130
  }
119
131
 
120
132
 
@@ -144,9 +156,10 @@ async def forward_authentication(
144
156
  ctx = await authz.verify(
145
157
  auth, perm, host=request.headers.get("host"), max_age=max_age
146
158
  )
147
- role_permissions = set(ctx.role.permissions or [])
148
- if ctx.permissions:
149
- role_permissions.update(permission.scope for permission in ctx.permissions)
159
+ # Build permission scopes for Remote-Groups header
160
+ role_permissions = (
161
+ {p.scope for p in ctx.permissions} if ctx.permissions else set()
162
+ )
150
163
 
151
164
  remote_headers: dict[str, str] = {
152
165
  "Remote-User": str(ctx.user.uuid),
@@ -165,7 +178,7 @@ async def forward_authentication(
165
178
  .isoformat()
166
179
  .replace("+00:00", "Z")
167
180
  ),
168
- "Remote-Credential": str(ctx.session.credential_uuid),
181
+ "Remote-Credential": str(ctx.session.credential),
169
182
  }
170
183
  return Response(status_code=204, headers=remote_headers)
171
184
  except authz.AuthException as e:
@@ -207,78 +220,27 @@ async def get_settings():
207
220
  }
208
221
 
209
222
 
210
- @app.get("/token-info")
211
- async def api_token_info(token: str):
212
- """Get information about a reset token.
213
-
214
- Returns:
215
- - type: "reset"
216
- - user_name: display name of the user
217
- - token_type: type of reset token
218
- """
219
- if not passphrase.is_well_formed(token):
220
- raise HTTPException(status_code=404, detail="Invalid token")
221
-
222
- # Check if this is a reset token
223
- try:
224
- reset_token = await get_reset(token)
225
- user = db.get_user_by_uuid(reset_token.user_uuid)
226
- return {
227
- "type": "reset",
228
- "user_name": user.display_name,
229
- "token_type": reset_token.token_type,
230
- }
231
- except (ValueError, Exception):
232
- raise HTTPException(status_code=404, detail="Token not found or expired")
233
-
234
-
235
223
  @app.post("/user-info")
236
224
  async def api_user_info(
237
225
  request: Request,
238
226
  response: Response,
239
- reset: str | None = None,
240
227
  auth=AUTH_COOKIE,
241
228
  ):
242
- """Get user information including credentials, sessions, and permissions.
243
-
244
- Can be called with either:
245
- - A session cookie (auth) for authenticated users
246
- - A reset token for users in password reset flow
247
- """
248
- authenticated = False
249
- session_record = None
250
- reset_token = None
251
- try:
252
- if reset:
253
- if not passphrase.is_well_formed(reset):
254
- raise ValueError("Invalid reset token")
255
- reset_token = await get_reset(reset)
256
- target_user_uuid = reset_token.user_uuid
257
- else:
258
- if auth is None:
259
- raise authz.AuthException(
260
- status_code=401,
261
- detail="Authentication required",
262
- mode="login",
263
- )
264
- session_record = await get_session(auth, host=request.headers.get("host"))
265
- authenticated = True
266
- target_user_uuid = session_record.user_uuid
267
- except ValueError as e:
268
- raise HTTPException(401, str(e))
269
-
270
- # Return minimal response for reset tokens
271
- if not authenticated and reset_token:
272
- return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
273
-
274
- # Return full user info for authenticated users
275
- assert auth is not None
276
- assert session_record is not None
229
+ """Get full user profile including credentials and sessions."""
230
+ if auth is None:
231
+ raise authz.AuthException(
232
+ status_code=401,
233
+ detail="Authentication required",
234
+ mode="login",
235
+ )
236
+ ctx = db.get_session_context(auth, request.headers.get("host"))
237
+ if not ctx:
238
+ raise HTTPException(401, "Session expired")
277
239
 
278
240
  return await userinfo.format_user_info(
279
- user_uuid=target_user_uuid,
241
+ user_uuid=ctx.user.uuid,
280
242
  auth=auth,
281
- session_record=session_record,
243
+ session_record=ctx.session,
282
244
  request_host=request.headers.get("host"),
283
245
  )
284
246
 
@@ -287,12 +249,12 @@ async def api_user_info(
287
249
  async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
288
250
  if not auth:
289
251
  return {"message": "Already logged out"}
290
- try:
291
- _s = await get_session(auth, host=request.headers.get("host"))
292
- except ValueError:
252
+ host = request.headers.get("host")
253
+ ctx = db.get_session_context(auth, host)
254
+ if not ctx:
293
255
  return {"message": "Already logged out"}
294
256
  with suppress(Exception):
295
- db.delete_session(auth)
257
+ db.delete_session(auth, ctx=ctx)
296
258
  session.clear_session_cookie(response)
297
259
  return {"message": "Logged out successfully"}
298
260
 
@@ -301,9 +263,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
301
263
  async def api_set_session(
302
264
  request: Request, response: Response, auth=Depends(bearer_auth)
303
265
  ):
304
- user = await get_session(auth.credentials, host=request.headers.get("host"))
266
+ ctx = db.get_session_context(auth.credentials, request.headers.get("host"))
267
+ if not ctx:
268
+ raise HTTPException(401, "Session expired")
305
269
  session.set_session_cookie(response, auth.credentials)
306
270
  return {
307
271
  "message": "Session cookie set successfully",
308
- "user_uuid": str(user.user_uuid),
272
+ "user": str(ctx.user.uuid),
309
273
  }
paskia/fastapi/mainapp.py CHANGED
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  import os
3
4
  from contextlib import asynccontextmanager
@@ -7,13 +8,15 @@ from fastapi import FastAPI, HTTPException, Request, Response
7
8
  from fastapi.responses import FileResponse, RedirectResponse
8
9
  from fastapi_vue import Frontend
9
10
 
11
+ from paskia import globals
12
+ from paskia.db import start_background, stop_background
10
13
  from paskia.fastapi import admin, api, auth_host, ws
11
14
  from paskia.fastapi.session import AUTH_COOKIE
12
15
  from paskia.util import hostutil, passphrase, vitedev
13
16
 
14
17
  # Vue Frontend static files
15
18
  frontend = Frontend(
16
- Path(__file__).with_name("frontend-build"),
19
+ Path(__file__).parent.parent / "frontend-build",
17
20
  cached=["/auth/assets/"],
18
21
  )
19
22
 
@@ -30,10 +33,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
30
33
  so that uvicorn reload / multiprocess workers inherit the settings.
31
34
  All keys are guaranteed to exist; values are already normalized by __main__.py.
32
35
  """
33
- import json
34
-
35
- from paskia import globals
36
-
37
36
  config = json.loads(os.environ["PASKIA_CONFIG"])
38
37
 
39
38
  try:
@@ -55,10 +54,18 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
55
54
  logging.getLogger("uvicorn.access").setLevel(logging.INFO)
56
55
 
57
56
  await frontend.load()
57
+ await start_background()
58
58
  yield
59
+ await stop_background()
59
60
 
60
61
 
61
- app = FastAPI(lifespan=lifespan, redirect_slashes=False)
62
+ app = FastAPI(
63
+ lifespan=lifespan,
64
+ redirect_slashes=False,
65
+ docs_url=None,
66
+ redoc_url=None,
67
+ openapi_url=None,
68
+ )
62
69
 
63
70
  # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
64
71
  app.middleware("http")(auth_host.redirect_middleware)
paskia/fastapi/remote.py CHANGED
@@ -16,13 +16,14 @@ import base64url
16
16
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
17
17
 
18
18
  from paskia import db, remoteauth
19
+ from paskia.authsession import expires
19
20
  from paskia.fastapi.session import infodict
21
+ from paskia.fastapi.wschat import authenticate_chat
20
22
  from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
21
- from paskia.globals import passkey
22
- from paskia.util import passphrase, pow
23
+ from paskia.util import hostutil, passphrase, pow, useragent
23
24
 
24
25
  # Create a FastAPI subapp for remote auth WebSocket endpoints
25
- app = FastAPI()
26
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
26
27
 
27
28
 
28
29
  @app.websocket("/request")
@@ -179,7 +180,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
179
180
  ):
180
181
  response = {
181
182
  "status": "authenticated",
182
- "user_uuid": str(result_data["user_uuid"]),
183
+ "user": str(result_data["user_uuid"]),
183
184
  }
184
185
  if result_data.get("session_token"):
185
186
  response["session_token"] = result_data["session_token"]
@@ -268,7 +269,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
268
269
  6. Client sends WebAuthn response
269
270
  7. Server sends {status: "success", message: "..."}
270
271
  """
271
- from paskia.util import useragent
272
272
 
273
273
  origin = validate_origin(ws)
274
274
 
@@ -289,7 +289,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
289
289
  )
290
290
 
291
291
  request = None
292
- webauthn_challenge = None
293
292
  explicitly_denied = False
294
293
 
295
294
  try:
@@ -311,43 +310,21 @@ async def websocket_remote_auth_permit(ws: WebSocket):
311
310
 
312
311
  # Handle authenticate request (no PoW needed - already validated during lookup)
313
312
  if msg.get("authenticate") and request is not None:
314
- # Generate authentication options
315
- options, webauthn_challenge = passkey.instance.auth_generate_options(
316
- credential_ids=None
317
- )
318
- await ws.send_json({"optionsJSON": options})
319
-
320
- # Wait for WebAuthn response
321
- credential = passkey.instance.auth_parse(await ws.receive_json())
322
-
323
- # Fetch and verify credential
324
- try:
325
- stored_cred = db.get_credential_by_id(credential.raw_id)
326
- except ValueError:
327
- raise ValueError(
328
- f"This passkey is no longer registered with {passkey.instance.rp_name}"
329
- )
330
-
331
- # Verify the credential
332
- passkey.instance.auth_verify(
333
- credential, webauthn_challenge, stored_cred, origin
334
- )
313
+ cred, new_sign_count = await authenticate_chat(ws, origin)
335
314
 
336
315
  # Create a session for the REQUESTING device
337
- assert stored_cred.uuid is not None
316
+ assert cred.uuid is not None
338
317
 
339
318
  session_token = None
340
319
  reset_token = None
341
320
 
342
321
  if request.action == "register":
343
322
  # For registration, create a reset token for device addition
344
- from paskia.authsession import expires
345
- from paskia.util import hostutil
346
323
 
347
324
  token_str = passphrase.generate()
348
325
  expiry = expires()
349
326
  db.create_reset_token(
350
- user_uuid=stored_cred.user_uuid,
327
+ user_uuid=cred.user,
351
328
  passphrase=token_str,
352
329
  expiry=expiry,
353
330
  token_type="device addition",
@@ -356,8 +333,9 @@ async def websocket_remote_auth_permit(ws: WebSocket):
356
333
  # Also create a session so the device is logged in
357
334
  normalized_host = hostutil.normalize_host(request.host)
358
335
  session_token = db.login(
359
- user_uuid=stored_cred.user_uuid,
360
- credential=stored_cred,
336
+ user_uuid=cred.user,
337
+ credential_uuid=cred.uuid,
338
+ sign_count=new_sign_count,
361
339
  host=normalized_host,
362
340
  ip=request.ip,
363
341
  user_agent=request.user_agent,
@@ -365,13 +343,12 @@ async def websocket_remote_auth_permit(ws: WebSocket):
365
343
  )
366
344
  else:
367
345
  # Default login action
368
- from paskia.authsession import expires
369
- from paskia.util import hostutil
370
346
 
371
347
  normalized_host = hostutil.normalize_host(request.host)
372
348
  session_token = db.login(
373
- user_uuid=stored_cred.user_uuid,
374
- credential=stored_cred,
349
+ user_uuid=cred.user,
350
+ credential_uuid=cred.uuid,
351
+ sign_count=new_sign_count,
375
352
  host=normalized_host,
376
353
  ip=request.ip,
377
354
  user_agent=request.user_agent,
@@ -382,8 +359,8 @@ async def websocket_remote_auth_permit(ws: WebSocket):
382
359
  completed = await remoteauth.instance.complete_request(
383
360
  token=request.key,
384
361
  session_token=session_token,
385
- user_uuid=stored_cred.user_uuid,
386
- credential_uuid=stored_cred.uuid,
362
+ user_uuid=cred.user,
363
+ credential_uuid=cred.uuid,
387
364
  reset_token=reset_token,
388
365
  )
389
366
 
paskia/fastapi/reset.py CHANGED
@@ -16,7 +16,7 @@ import asyncio
16
16
  from uuid import UUID
17
17
 
18
18
  from paskia import authsession as _authsession
19
- from paskia import db as _db
19
+ from paskia import db
20
20
  from paskia.util import hostutil, passphrase
21
21
 
22
22
 
@@ -26,23 +26,30 @@ async def _resolve_targets(query: str | None):
26
26
  targets: list[tuple] = []
27
27
  try:
28
28
  q_uuid = UUID(query)
29
- perm_orgs = _db.get_permission_organizations("auth:admin")
30
- for o in perm_orgs:
31
- users = _db.get_organization_users(str(o.uuid))
32
- for u, role_name in users:
33
- if u.uuid == q_uuid:
34
- return [(u, role_name)]
29
+ p = next(
30
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"),
31
+ None,
32
+ )
33
+ if p:
34
+ for org_uuid in p.orgs:
35
+ users = db.get_organization_users(org_uuid)
36
+ for u, role_name in users:
37
+ if u.uuid == q_uuid:
38
+ return [(u, role_name)]
35
39
  # UUID not found among admin orgs -> fall back to substring search (rare case)
36
40
  except ValueError:
37
41
  pass
38
42
  # Substring search
39
43
  needle = query.lower()
40
- perm_orgs = _db.get_permission_organizations("auth:admin")
41
- for o in perm_orgs:
42
- users = _db.get_organization_users(str(o.uuid))
43
- for u, role_name in users:
44
- if needle in (u.display_name or "").lower():
45
- targets.append((u, role_name))
44
+ p = next(
45
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
46
+ )
47
+ if p:
48
+ for org_uuid in p.orgs:
49
+ users = db.get_organization_users(org_uuid)
50
+ for u, role_name in users:
51
+ if needle in (u.display_name or "").lower():
52
+ targets.append((u, role_name))
46
53
  # De-duplicate
47
54
  seen = set()
48
55
  deduped = []
@@ -52,10 +59,13 @@ async def _resolve_targets(query: str | None):
52
59
  deduped.append((u, role_name))
53
60
  return deduped
54
61
  # No query -> master admin
55
- perm_orgs = _db.get_permission_organizations("auth:admin")
56
- if not perm_orgs:
62
+ p = next(
63
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
64
+ )
65
+ if not p or not p.orgs:
57
66
  return []
58
- users = _db.get_organization_users(str(perm_orgs[0].uuid))
67
+ first_org_uuid = next(iter(p.orgs))
68
+ users = db.get_organization_users(first_org_uuid)
59
69
  admin_users = [pair for pair in users if pair[1] == "Administration"]
60
70
  return admin_users[:1]
61
71
 
@@ -63,7 +73,7 @@ async def _resolve_targets(query: str | None):
63
73
  async def _create_reset(user, role_name: str):
64
74
  token = passphrase.generate()
65
75
  expiry = _authsession.reset_expires()
66
- _db.create_reset_token(
76
+ db.create_reset_token(
67
77
  passphrase=token,
68
78
  user_uuid=user.uuid,
69
79
  expiry=expiry,
paskia/fastapi/session.py CHANGED
@@ -19,8 +19,8 @@ AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
19
19
  def infodict(request: Request | WebSocket, type: str) -> dict:
20
20
  """Extract client information from request."""
21
21
  return {
22
- "ip": request.client.host if request.client else None,
23
- "user_agent": request.headers.get("user-agent", "")[:500] or None,
22
+ "ip": request.client.host if request.client else "",
23
+ "user_agent": request.headers.get("user-agent", "")[:500],
24
24
  "session_type": type,
25
25
  }
26
26
 
paskia/fastapi/user.py CHANGED
@@ -14,13 +14,12 @@ from paskia import db
14
14
  from paskia.authsession import (
15
15
  delete_credential,
16
16
  expires,
17
- get_session,
18
17
  )
19
18
  from paskia.fastapi import authz, session
20
19
  from paskia.fastapi.session import AUTH_COOKIE
21
20
  from paskia.util import hostutil, passphrase
22
21
 
23
- app = FastAPI()
22
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
24
23
 
25
24
 
26
25
  @app.exception_handler(authz.AuthException)
@@ -43,18 +42,18 @@ async def user_update_display_name(
43
42
  raise authz.AuthException(
44
43
  status_code=401, detail="Authentication Required", mode="login"
45
44
  )
46
- try:
47
- s = await get_session(auth, host=request.headers.get("host"))
48
- except ValueError as e:
45
+ host = request.headers.get("host")
46
+ ctx = db.get_session_context(auth, host)
47
+ if not ctx:
49
48
  raise authz.AuthException(
50
49
  status_code=401, detail="Session expired", mode="login"
51
- ) from e
50
+ )
52
51
  new_name = (payload.get("display_name") or "").strip()
53
52
  if not new_name:
54
53
  raise HTTPException(status_code=400, detail="display_name required")
55
54
  if len(new_name) > 64:
56
55
  raise HTTPException(status_code=400, detail="display_name too long")
57
- db.update_user_display_name(s.user_uuid, new_name)
56
+ db.update_user_display_name(ctx.user.uuid, new_name, ctx=ctx)
58
57
  return {"status": "ok"}
59
58
 
60
59
 
@@ -62,13 +61,13 @@ async def user_update_display_name(
62
61
  async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
63
62
  if not auth:
64
63
  return {"message": "Already logged out"}
65
- try:
66
- s = await get_session(auth, host=request.headers.get("host"))
67
- except ValueError:
64
+ host = request.headers.get("host")
65
+ ctx = db.get_session_context(auth, host)
66
+ if not ctx:
68
67
  raise authz.AuthException(
69
68
  status_code=401, detail="Session expired", mode="login"
70
69
  )
71
- db.delete_sessions_for_user(s.user_uuid)
70
+ db.delete_sessions_for_user(ctx.user.uuid, ctx=ctx)
72
71
  session.clear_session_cookie(response)
73
72
  return {"message": "Logged out from all hosts"}
74
73
 
@@ -84,18 +83,18 @@ async def api_delete_session(
84
83
  raise authz.AuthException(
85
84
  status_code=401, detail="Authentication Required", mode="login"
86
85
  )
87
- try:
88
- current_session = await get_session(auth, host=request.headers.get("host"))
89
- except ValueError as exc:
86
+ host = request.headers.get("host")
87
+ ctx = db.get_session_context(auth, host)
88
+ if not ctx:
90
89
  raise authz.AuthException(
91
90
  status_code=401, detail="Session expired", mode="login"
92
- ) from exc
91
+ )
93
92
 
94
- target_session = db.get_session(session_id)
95
- if not target_session or target_session.user_uuid != current_session.user_uuid:
93
+ target_session = db.data().sessions.get(session_id)
94
+ if not target_session or target_session.user != ctx.user.uuid:
96
95
  raise HTTPException(status_code=404, detail="Session not found")
97
96
 
98
- db.delete_session(session_id)
97
+ db.delete_session(session_id, ctx=ctx)
99
98
  current_terminated = session_id == auth
100
99
  if current_terminated:
101
100
  session.clear_session_cookie(response) # explicit because 200
@@ -112,7 +111,7 @@ async def api_delete_credential(
112
111
  # Require recent authentication for sensitive operation
113
112
  await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
114
113
  try:
115
- await delete_credential(uuid, auth, host=request.headers.get("host"))
114
+ delete_credential(uuid, auth, host=request.headers.get("host"))
116
115
  except ValueError as e:
117
116
  raise authz.AuthException(
118
117
  status_code=401, detail="Session expired", mode="login"
@@ -127,20 +126,15 @@ async def api_create_link(
127
126
  auth=AUTH_COOKIE,
128
127
  ):
129
128
  # Require recent authentication for sensitive operation
130
- await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
131
- try:
132
- s = await get_session(auth, host=request.headers.get("host"))
133
- except ValueError as e:
134
- raise authz.AuthException(
135
- status_code=401, detail="Session expired", mode="login"
136
- ) from e
129
+ ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
137
130
  token = passphrase.generate()
138
131
  expiry = expires()
139
132
  db.create_reset_token(
140
- user_uuid=s.user_uuid,
133
+ user_uuid=ctx.user.uuid,
141
134
  passphrase=token,
142
135
  expiry=expiry,
143
136
  token_type="device addition",
137
+ ctx=ctx,
144
138
  )
145
139
  url = hostutil.reset_link_url(token)
146
140
  return {