paskia 0.7.1__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 (64) hide show
  1. paskia/__init__.py +3 -0
  2. paskia/_version.py +34 -0
  3. paskia/aaguid/__init__.py +32 -0
  4. paskia/aaguid/combined_aaguid.json +1 -0
  5. paskia/authsession.py +112 -0
  6. paskia/bootstrap.py +190 -0
  7. paskia/config.py +25 -0
  8. paskia/db/__init__.py +415 -0
  9. paskia/db/sql.py +1424 -0
  10. paskia/fastapi/__init__.py +3 -0
  11. paskia/fastapi/__main__.py +335 -0
  12. paskia/fastapi/admin.py +850 -0
  13. paskia/fastapi/api.py +308 -0
  14. paskia/fastapi/auth_host.py +97 -0
  15. paskia/fastapi/authz.py +110 -0
  16. paskia/fastapi/mainapp.py +130 -0
  17. paskia/fastapi/remote.py +504 -0
  18. paskia/fastapi/reset.py +101 -0
  19. paskia/fastapi/session.py +52 -0
  20. paskia/fastapi/user.py +162 -0
  21. paskia/fastapi/ws.py +163 -0
  22. paskia/fastapi/wsutil.py +91 -0
  23. paskia/frontend-build/auth/admin/index.html +18 -0
  24. paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
  25. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
  26. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
  27. paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
  28. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
  30. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
  32. paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
  33. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
  34. paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
  35. paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
  36. paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
  37. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
  38. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
  39. paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
  40. paskia/frontend-build/auth/index.html +19 -0
  41. paskia/frontend-build/auth/restricted/index.html +16 -0
  42. paskia/frontend-build/int/forward/index.html +18 -0
  43. paskia/frontend-build/int/reset/index.html +15 -0
  44. paskia/globals.py +71 -0
  45. paskia/remoteauth.py +359 -0
  46. paskia/sansio.py +263 -0
  47. paskia/util/frontend.py +75 -0
  48. paskia/util/hostutil.py +76 -0
  49. paskia/util/htmlutil.py +47 -0
  50. paskia/util/passphrase.py +20 -0
  51. paskia/util/permutil.py +32 -0
  52. paskia/util/pow.py +45 -0
  53. paskia/util/querysafe.py +11 -0
  54. paskia/util/sessionutil.py +37 -0
  55. paskia/util/startupbox.py +75 -0
  56. paskia/util/timeutil.py +47 -0
  57. paskia/util/tokens.py +44 -0
  58. paskia/util/useragent.py +10 -0
  59. paskia/util/userinfo.py +159 -0
  60. paskia/util/wordlist.py +54 -0
  61. paskia-0.7.1.dist-info/METADATA +22 -0
  62. paskia-0.7.1.dist-info/RECORD +64 -0
  63. paskia-0.7.1.dist-info/WHEEL +4 -0
  64. paskia-0.7.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,504 @@
1
+ """
2
+ Remote authentication WebSocket endpoints.
3
+
4
+ This module handles cross-device authentication where one device (requesting)
5
+ wants to log in and another device (authenticating) provides the passkey.
6
+
7
+ Endpoints:
8
+ - /request: Called by the device wanting to be authenticated
9
+ - /pair: Called by the authenticating device to complete the request
10
+ """
11
+
12
+ import asyncio
13
+ from uuid import UUID
14
+
15
+ import base64url
16
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
17
+
18
+ from paskia import remoteauth
19
+ from paskia.authsession import create_session
20
+ from paskia.fastapi.session import infodict
21
+ from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
22
+ from paskia.globals import db, passkey
23
+ from paskia.util import passphrase, pow
24
+
25
+ # Create a FastAPI subapp for remote auth WebSocket endpoints
26
+ app = FastAPI()
27
+
28
+
29
+ @app.websocket("/request")
30
+ @websocket_error_handler
31
+ async def websocket_remote_auth_request(ws: WebSocket):
32
+ """Request authentication from another device.
33
+
34
+ This endpoint is called by the device that wants to be authenticated.
35
+ It creates a remote auth request and waits for another device to authenticate.
36
+
37
+ Flow:
38
+ 1. Client connects
39
+ 2. Server sends HARD PoW challenge, client solves and responds
40
+ 3. Server creates a 3-word pairing code and sends it with expiry
41
+ 4. Server waits for another device to authenticate via /remote-auth/permit
42
+ 5. When auth completes, server sends session_token to this client
43
+ 6. Client can then use the session token to set a cookie
44
+ 7. Connection times out after 5 minutes with explicit timeout message
45
+ """
46
+ origin = validate_origin(ws)
47
+ host = origin.split("://", 1)[1]
48
+
49
+ if remoteauth.instance is None:
50
+ raise ValueError("Remote authentication is not available")
51
+
52
+ # Track this WebSocket connection for load-based PoW difficulty
53
+ remoteauth.instance.increment_connections()
54
+ try:
55
+ # Send PoW challenge immediately with dynamic difficulty based on load
56
+ challenge = pow.generate_challenge()
57
+ work = remoteauth.instance.get_pow_difficulty()
58
+
59
+ await ws.send_json(
60
+ {
61
+ "pow": {
62
+ "challenge": base64url.enc(challenge),
63
+ "work": work,
64
+ }
65
+ }
66
+ )
67
+
68
+ # Receive client response with PoW solution and action
69
+ response = await ws.receive_json()
70
+
71
+ # Verify PoW (required for this endpoint - SECURITY)
72
+ solution_b64 = response.get("pow")
73
+ if not solution_b64:
74
+ raise ValueError("PoW solution required")
75
+
76
+ try:
77
+ solution = base64url.dec(solution_b64)
78
+ except Exception:
79
+ raise ValueError("Invalid PoW solution encoding")
80
+
81
+ pow.verify_pow(challenge, solution, work)
82
+
83
+ # Extract action from the same message
84
+ action = response.get("action", "login")
85
+ if action not in ("login", "register"):
86
+ action = "login"
87
+
88
+ metadata = infodict(ws, "remote-auth-request")
89
+
90
+ # Create the remote auth request
91
+ pairing_code, expiry = await remoteauth.instance.create_request(
92
+ host=host,
93
+ ip=metadata.get("ip") or "",
94
+ user_agent=metadata.get("user_agent") or "",
95
+ action=action,
96
+ )
97
+
98
+ # Send the pairing code to the client
99
+ await ws.send_json(
100
+ {
101
+ "pairing_code": pairing_code,
102
+ "expires": expiry.isoformat().replace("+00:00", "Z"),
103
+ }
104
+ )
105
+
106
+ # Set up async notification for completion
107
+ result_event = asyncio.Event()
108
+ result_data: dict = {}
109
+
110
+ def on_complete(
111
+ session_token: str | None,
112
+ user_uuid: UUID | None,
113
+ credential_uuid: UUID | None,
114
+ reset_token: str | None,
115
+ ):
116
+ # Check if this was an explicit denial (UUID(int=0) is the signal)
117
+ was_denied = user_uuid is not None and user_uuid == UUID(int=0)
118
+ result_data["session_token"] = session_token
119
+ result_data["user_uuid"] = user_uuid
120
+ result_data["credential_uuid"] = credential_uuid
121
+ result_data["reset_token"] = reset_token
122
+ result_data["was_denied"] = was_denied
123
+ result_event.set()
124
+
125
+ await remoteauth.instance.set_notify_callback(pairing_code, on_complete)
126
+
127
+ # Set up async notification for action lock
128
+ locked_event = asyncio.Event()
129
+ locked_data: dict = {}
130
+
131
+ def on_action_locked(action: str):
132
+ locked_data["action"] = action
133
+ locked_event.set()
134
+
135
+ await remoteauth.instance.set_action_locked_callback(
136
+ pairing_code, on_action_locked
137
+ )
138
+
139
+ # 5 minute timeout for the entire remote auth flow
140
+ timeout_seconds = 5 * 60
141
+
142
+ try:
143
+ # Wait for either:
144
+ # 1. Authentication to complete (result_event set)
145
+ # 2. Action locked (locked_event set)
146
+ # 3. Client to disconnect
147
+ # 4. Client to send a cancel or update_action message
148
+ # 5. Timeout after 5 minutes
149
+
150
+ async with asyncio.timeout(timeout_seconds):
151
+ while True:
152
+ # Use asyncio.wait to handle events and websocket
153
+ receive_task = asyncio.create_task(ws.receive_json())
154
+ result_wait_task = asyncio.create_task(result_event.wait())
155
+ locked_wait_task = asyncio.create_task(locked_event.wait())
156
+
157
+ tasks = [receive_task, result_wait_task]
158
+ # Only wait for locked event if not already locked
159
+ if not locked_event.is_set():
160
+ tasks.append(locked_wait_task)
161
+
162
+ done, pending = await asyncio.wait(
163
+ tasks,
164
+ return_when=asyncio.FIRST_COMPLETED,
165
+ )
166
+
167
+ # Cancel pending tasks
168
+ for task in pending:
169
+ task.cancel()
170
+ try:
171
+ await task
172
+ except asyncio.CancelledError:
173
+ pass
174
+
175
+ if result_wait_task in done:
176
+ # Authentication completed (or expired/cancelled/denied)
177
+ was_denied = result_data.get("was_denied", False)
178
+ if result_data.get("session_token") or result_data.get(
179
+ "reset_token"
180
+ ):
181
+ response = {
182
+ "status": "authenticated",
183
+ "user_uuid": str(result_data["user_uuid"]),
184
+ }
185
+ if result_data.get("session_token"):
186
+ response["session_token"] = result_data["session_token"]
187
+ if result_data.get("reset_token"):
188
+ response["reset_token"] = result_data["reset_token"]
189
+ await ws.send_json(response)
190
+ else:
191
+ # Check if it was explicitly denied
192
+ if was_denied:
193
+ await ws.send_json(
194
+ {
195
+ "status": "denied",
196
+ "detail": "Access denied",
197
+ }
198
+ )
199
+ else:
200
+ await ws.send_json(
201
+ {
202
+ "status": "expired",
203
+ "detail": "Remote authentication request expired or was cancelled",
204
+ }
205
+ )
206
+ return
207
+
208
+ if locked_wait_task in done:
209
+ # Action was locked by the authenticating device
210
+ await ws.send_json(
211
+ {
212
+ "status": "locked",
213
+ "action": locked_data.get("action", "login"),
214
+ }
215
+ )
216
+ # Continue waiting for result
217
+
218
+ if receive_task in done:
219
+ # Client sent a message
220
+ msg = receive_task.result()
221
+ if msg.get("action") == "cancel":
222
+ await remoteauth.instance.cancel_request(pairing_code)
223
+ await ws.send_json({"status": "cancelled"})
224
+ return
225
+ elif msg.get("action") == "update_action":
226
+ # Update the action (login/register) if not locked
227
+ new_action = "register" if msg.get("register") else "login"
228
+ await remoteauth.instance.update_action(
229
+ pairing_code, new_action
230
+ )
231
+ # Ignore other messages
232
+
233
+ except TimeoutError:
234
+ # 5 minute timeout reached
235
+ await remoteauth.instance.cancel_request(pairing_code)
236
+ await ws.send_json(
237
+ {
238
+ "status": "timeout",
239
+ "detail": "Remote authentication request timed out after 5 minutes",
240
+ }
241
+ )
242
+ except WebSocketDisconnect:
243
+ # Client disconnected, cancel the request and mark as denied
244
+ await remoteauth.instance.cancel_request(pairing_code, denied=True)
245
+ except Exception:
246
+ await remoteauth.instance.cancel_request(pairing_code)
247
+ raise
248
+ finally:
249
+ # Decrement connection count
250
+ remoteauth.instance.decrement_connections()
251
+
252
+
253
+ @app.websocket("/permit")
254
+ @websocket_error_handler
255
+ async def websocket_remote_auth_permit(ws: WebSocket):
256
+ """Complete a remote authentication request using a 3-word pairing code.
257
+
258
+ This endpoint is called from the user's profile on the authenticating device.
259
+ The user enters the pairing code displayed on the requesting device.
260
+
261
+ Protocol:
262
+ 1. Server sends PoW challenge immediately on connect
263
+ 2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
264
+ 3. Server validates PoW and code:
265
+ - If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
266
+ - If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
267
+ 4. Client can then send {authenticate: true} to start WebAuthn
268
+ 5. Server sends {optionsJSON: ...}
269
+ 6. Client sends WebAuthn response
270
+ 7. Server sends {status: "success", message: "..."}
271
+ """
272
+ from paskia.util import useragent
273
+
274
+ origin = validate_origin(ws)
275
+
276
+ if remoteauth.instance is None:
277
+ raise ValueError("Remote authentication is not available")
278
+
279
+ # Generate initial PoW challenge (always NORMAL for authenticated users)
280
+ challenge = pow.generate_challenge()
281
+ work = pow.NORMAL
282
+
283
+ await ws.send_json(
284
+ {
285
+ "pow": {
286
+ "challenge": base64url.enc(challenge),
287
+ "work": work,
288
+ }
289
+ }
290
+ )
291
+
292
+ request = None
293
+ webauthn_challenge = None
294
+ explicitly_denied = False
295
+
296
+ try:
297
+ while True:
298
+ msg = await ws.receive_json()
299
+
300
+ # Handle deny request first (no PoW needed - already validated during lookup)
301
+ if msg.get("deny") and request is not None:
302
+ # Cancel the request and mark it as denied
303
+ explicitly_denied = True
304
+ await remoteauth.instance.cancel_request(request.key, denied=True)
305
+ await ws.send_json(
306
+ {
307
+ "status": "denied",
308
+ "message": "Request denied",
309
+ }
310
+ )
311
+ break
312
+
313
+ # Handle authenticate request (no PoW needed - already validated during lookup)
314
+ if msg.get("authenticate") and request is not None:
315
+ # Generate authentication options
316
+ options, webauthn_challenge = passkey.instance.auth_generate_options(
317
+ credential_ids=None
318
+ )
319
+ await ws.send_json({"optionsJSON": options})
320
+
321
+ # Wait for WebAuthn response
322
+ credential = passkey.instance.auth_parse(await ws.receive_json())
323
+
324
+ # Fetch and verify credential
325
+ try:
326
+ stored_cred = await db.instance.get_credential_by_id(
327
+ credential.raw_id
328
+ )
329
+ except ValueError:
330
+ raise ValueError(
331
+ f"This passkey is no longer registered with {passkey.instance.rp_name}"
332
+ )
333
+
334
+ # Verify the credential
335
+ passkey.instance.auth_verify(
336
+ credential, webauthn_challenge, stored_cred, origin
337
+ )
338
+
339
+ # Update credential last_used
340
+ await db.instance.login(stored_cred.user_uuid, stored_cred)
341
+
342
+ # Create a session for the REQUESTING device
343
+ assert stored_cred.uuid is not None
344
+
345
+ session_token = None
346
+ reset_token = None
347
+
348
+ if request.action == "register":
349
+ # For registration, create a reset token for device addition
350
+ from paskia.authsession import expires
351
+ from paskia.util import tokens
352
+
353
+ token_str = passphrase.generate()
354
+ expiry = expires()
355
+ await db.instance.create_reset_token(
356
+ user_uuid=stored_cred.user_uuid,
357
+ key=tokens.reset_key(token_str),
358
+ expiry=expiry,
359
+ token_type="device addition",
360
+ )
361
+ reset_token = token_str
362
+ # Also create a session so the device is logged in?
363
+ # User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
364
+ session_token = await create_session(
365
+ user_uuid=stored_cred.user_uuid,
366
+ credential_uuid=stored_cred.uuid,
367
+ host=request.host,
368
+ ip=request.ip,
369
+ user_agent=request.user_agent,
370
+ )
371
+ else:
372
+ # Default login action
373
+ session_token = await create_session(
374
+ user_uuid=stored_cred.user_uuid,
375
+ credential_uuid=stored_cred.uuid,
376
+ host=request.host,
377
+ ip=request.ip,
378
+ user_agent=request.user_agent,
379
+ )
380
+
381
+ # Complete the remote auth request (notifies the waiting device)
382
+ completed = await remoteauth.instance.complete_request(
383
+ token=request.key,
384
+ session_token=session_token,
385
+ user_uuid=stored_cred.user_uuid,
386
+ credential_uuid=stored_cred.uuid,
387
+ reset_token=reset_token,
388
+ )
389
+
390
+ if not completed:
391
+ raise ValueError("Failed to complete remote authentication")
392
+
393
+ msg = "Authentication successful."
394
+ if request.action == "register":
395
+ msg += " The other device can now register a passkey."
396
+ else:
397
+ msg += " The other device is now logged in."
398
+
399
+ await ws.send_json(
400
+ {
401
+ "status": "success",
402
+ "message": msg,
403
+ }
404
+ )
405
+ break
406
+
407
+ # Handle code lookup request - requires PoW validation
408
+ code = msg.get("code", "")
409
+
410
+ # Validate PoW for pairing codes
411
+ solution_b64 = msg.get("pow")
412
+ if not solution_b64:
413
+ raise ValueError("PoW solution required")
414
+
415
+ try:
416
+ solution = base64url.dec(solution_b64)
417
+ except Exception:
418
+ raise ValueError("Invalid PoW solution encoding")
419
+
420
+ try:
421
+ pow.verify_pow(challenge, solution, work)
422
+ except ValueError as e:
423
+ # Invalid PoW - send new challenge
424
+ challenge = pow.generate_challenge()
425
+ await ws.send_json(
426
+ {
427
+ "status": 400,
428
+ "detail": str(e),
429
+ "pow": {
430
+ "challenge": base64url.enc(challenge),
431
+ "work": work,
432
+ },
433
+ }
434
+ )
435
+ continue
436
+
437
+ if not code:
438
+ raise ValueError("Pairing code required")
439
+
440
+ # Look up the remote auth request by pairing code
441
+ request = await remoteauth.instance.get_request(code)
442
+
443
+ # Generate new challenge for next request (always NORMAL for authenticated users)
444
+ challenge = pow.generate_challenge()
445
+
446
+ if request is None:
447
+ await ws.send_json(
448
+ {
449
+ "status": 404,
450
+ "detail": "Code not found",
451
+ "pow": {
452
+ "challenge": base64url.enc(challenge),
453
+ "work": work,
454
+ },
455
+ }
456
+ )
457
+ request = None # Reset for next attempt
458
+ continue
459
+
460
+ # Valid code found - lock the action so it can't be changed anymore
461
+ # This also notifies the requesting device
462
+ locked_action = await remoteauth.instance.lock_action(request.key)
463
+ if locked_action is None:
464
+ # Already locked by another device
465
+ await ws.send_json(
466
+ {
467
+ "status": 409,
468
+ "detail": "This request is already being processed in another window",
469
+ "pow": {
470
+ "challenge": base64url.enc(challenge),
471
+ "work": work,
472
+ },
473
+ }
474
+ )
475
+ request = None # Reset for next attempt
476
+ continue
477
+
478
+ request.action = locked_action # Update local copy with locked value
479
+
480
+ # Send device info to the authenticating device
481
+ await ws.send_json(
482
+ {
483
+ "status": "found",
484
+ "host": request.host,
485
+ "user_agent_pretty": useragent.compact_user_agent(
486
+ request.user_agent
487
+ ),
488
+ "client_ip": request.ip,
489
+ "action": request.action,
490
+ "pow": {
491
+ "challenge": base64url.enc(challenge),
492
+ "work": work,
493
+ },
494
+ }
495
+ )
496
+ except Exception:
497
+ # If websocket disconnects without explicit denial, unlock the request
498
+ if request and not explicitly_denied:
499
+ # Unlock the request so the code can be used again
500
+ async with remoteauth.instance._lock:
501
+ req = remoteauth.instance._requests.get(request.key)
502
+ if req and req.locked:
503
+ req.locked = False
504
+ raise
@@ -0,0 +1,101 @@
1
+ """CLI support for creating user credential reset links.
2
+
3
+ Usage (via main CLI):
4
+ paskia reset [query]
5
+
6
+ If query is omitted, the master admin (first Administration role user in
7
+ an organization granting auth:admin) is targeted. Otherwise query is
8
+ matched as either an exact UUID or a case-insensitive substring of the
9
+ display name. If multiple users match, they are listed and the command
10
+ aborts. A new one-time reset link is always created.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from uuid import UUID
17
+
18
+ from paskia import authsession as _authsession
19
+ from paskia import globals as _g
20
+ from paskia.util import hostutil, passphrase
21
+ from paskia.util import tokens as _tokens
22
+
23
+
24
+ async def _resolve_targets(query: str | None):
25
+ if query:
26
+ # Try UUID
27
+ targets: list[tuple] = []
28
+ try:
29
+ q_uuid = UUID(query)
30
+ perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
31
+ for o in perm_orgs:
32
+ users = await _g.db.instance.get_organization_users(str(o.uuid))
33
+ for u, role_name in users:
34
+ if u.uuid == q_uuid:
35
+ return [(u, role_name)]
36
+ # UUID not found among admin orgs -> fall back to substring search (rare case)
37
+ except ValueError:
38
+ pass
39
+ # Substring search
40
+ needle = query.lower()
41
+ perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
42
+ for o in perm_orgs:
43
+ users = await _g.db.instance.get_organization_users(str(o.uuid))
44
+ for u, role_name in users:
45
+ if needle in (u.display_name or "").lower():
46
+ targets.append((u, role_name))
47
+ # De-duplicate
48
+ seen = set()
49
+ deduped = []
50
+ for u, role_name in targets:
51
+ if u.uuid not in seen:
52
+ seen.add(u.uuid)
53
+ deduped.append((u, role_name))
54
+ return deduped
55
+ # No query -> master admin
56
+ perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
57
+ if not perm_orgs:
58
+ return []
59
+ users = await _g.db.instance.get_organization_users(str(perm_orgs[0].uuid))
60
+ admin_users = [pair for pair in users if pair[1] == "Administration"]
61
+ return admin_users[:1]
62
+
63
+
64
+ async def _create_reset(user, role_name: str):
65
+ token = passphrase.generate()
66
+ expiry = _authsession.reset_expires()
67
+ await _g.db.instance.create_reset_token(
68
+ user_uuid=user.uuid,
69
+ key=_tokens.reset_key(token),
70
+ expiry=expiry,
71
+ token_type="manual reset",
72
+ )
73
+ return hostutil.reset_link_url(token), token
74
+
75
+
76
+ async def _main(query: str | None) -> int:
77
+ try:
78
+ candidates = await _resolve_targets(query)
79
+ if not candidates:
80
+ print("No matching users found")
81
+ return 1
82
+ if len(candidates) > 1:
83
+ print("Multiple matches. Refine your query:")
84
+ for u, role_name in candidates:
85
+ print(f" - {u.display_name} ({u.uuid}) role={role_name}")
86
+ return 2
87
+ user, role_name = candidates[0]
88
+ link, token = await _create_reset(user, role_name)
89
+ print(f"Reset link for {user.display_name} ({user.uuid}):\n{link}\n")
90
+ return 0
91
+ except Exception as e: # pragma: no cover
92
+ print("Failed to create reset link:", e)
93
+ return 1
94
+
95
+
96
+ def run(query: str | None) -> int:
97
+ """Synchronous wrapper for CLI entrypoint."""
98
+ return asyncio.run(_main(query))
99
+
100
+
101
+ __all__ = ["run"]
@@ -0,0 +1,52 @@
1
+ """
2
+ FastAPI-specific session management for WebAuthn authentication.
3
+
4
+ This module provides FastAPI-specific session management functionality:
5
+ - Extracting client information from FastAPI requests
6
+ - Setting and clearing HTTP-only cookies via FastAPI Response objects
7
+
8
+ Generic session management functions have been moved to authsession.py
9
+ """
10
+
11
+ from fastapi import Cookie, Request, Response, WebSocket
12
+
13
+ from paskia.authsession import EXPIRES
14
+
15
+ AUTH_COOKIE_NAME = "__Host-paskia"
16
+ AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
17
+
18
+
19
+ def infodict(request: Request | WebSocket, type: str) -> dict:
20
+ """Extract client information from request."""
21
+ return {
22
+ "ip": request.client.host if request.client else None,
23
+ "user_agent": request.headers.get("user-agent", "")[:500] or None,
24
+ "session_type": type,
25
+ }
26
+
27
+
28
+ def set_session_cookie(response: Response, token: str) -> None:
29
+ """Set the session token as an HTTP-only cookie."""
30
+ response.set_cookie(
31
+ key=AUTH_COOKIE_NAME,
32
+ value=token,
33
+ max_age=int(EXPIRES.total_seconds()),
34
+ httponly=True,
35
+ secure=True,
36
+ path="/",
37
+ samesite="lax",
38
+ )
39
+
40
+
41
+ def clear_session_cookie(response: Response) -> None:
42
+ # FastAPI's delete_cookie does not set the secure attribute
43
+ response.set_cookie(
44
+ key=AUTH_COOKIE_NAME,
45
+ value="",
46
+ max_age=0,
47
+ expires=0,
48
+ httponly=True,
49
+ secure=True,
50
+ path="/",
51
+ samesite="lax",
52
+ )