paskia 0.8.1__py3-none-any.whl → 0.9.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 (58) hide show
  1. paskia/_version.py +2 -2
  2. paskia/aaguid/__init__.py +5 -4
  3. paskia/authsession.py +15 -43
  4. paskia/bootstrap.py +31 -103
  5. paskia/db/__init__.py +27 -55
  6. paskia/db/background.py +20 -40
  7. paskia/db/jsonl.py +196 -46
  8. paskia/db/logging.py +233 -0
  9. paskia/db/migrations.py +33 -0
  10. paskia/db/operations.py +409 -825
  11. paskia/db/structs.py +408 -94
  12. paskia/fastapi/__main__.py +25 -28
  13. paskia/fastapi/admin.py +147 -329
  14. paskia/fastapi/api.py +68 -110
  15. paskia/fastapi/logging.py +218 -0
  16. paskia/fastapi/mainapp.py +25 -8
  17. paskia/fastapi/remote.py +16 -39
  18. paskia/fastapi/reset.py +27 -19
  19. paskia/fastapi/response.py +22 -0
  20. paskia/fastapi/session.py +2 -2
  21. paskia/fastapi/user.py +24 -30
  22. paskia/fastapi/ws.py +25 -60
  23. paskia/fastapi/wschat.py +62 -0
  24. paskia/fastapi/wsutil.py +15 -2
  25. paskia/frontend-build/auth/admin/index.html +5 -5
  26. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  27. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  28. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  29. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  30. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  31. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  32. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  33. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  34. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  35. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  36. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  37. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  38. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  39. paskia/frontend-build/auth/index.html +5 -5
  40. paskia/frontend-build/auth/restricted/index.html +4 -4
  41. paskia/frontend-build/int/forward/index.html +4 -4
  42. paskia/frontend-build/int/reset/index.html +3 -3
  43. paskia/globals.py +2 -2
  44. paskia/migrate/__init__.py +67 -60
  45. paskia/migrate/sql.py +94 -37
  46. paskia/remoteauth.py +7 -8
  47. paskia/sansio.py +6 -12
  48. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
  49. paskia-0.9.1.dist-info/RECORD +60 -0
  50. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  51. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  52. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  53. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  54. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  55. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  56. paskia-0.8.1.dist-info/RECORD +0 -55
  57. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
  58. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
paskia/fastapi/reset.py CHANGED
@@ -10,13 +10,11 @@ display name. If multiple users match, they are listed and the command
10
10
  aborts. A new one-time reset link is always created.
11
11
  """
12
12
 
13
- from __future__ import annotations
14
-
15
13
  import asyncio
16
14
  from uuid import UUID
17
15
 
18
16
  from paskia import authsession as _authsession
19
- from paskia import db as _db
17
+ from paskia import db
20
18
  from paskia.util import hostutil, passphrase
21
19
 
22
20
 
@@ -26,23 +24,30 @@ async def _resolve_targets(query: str | None):
26
24
  targets: list[tuple] = []
27
25
  try:
28
26
  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)]
27
+ p = next(
28
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"),
29
+ None,
30
+ )
31
+ if p:
32
+ for org_uuid in p.orgs:
33
+ users = db.get_organization_users(org_uuid)
34
+ for u, role_name in users:
35
+ if u.uuid == q_uuid:
36
+ return [(u, role_name)]
35
37
  # UUID not found among admin orgs -> fall back to substring search (rare case)
36
38
  except ValueError:
37
39
  pass
38
40
  # Substring search
39
41
  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))
42
+ p = next(
43
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
44
+ )
45
+ if p:
46
+ for org_uuid in p.orgs:
47
+ users = db.get_organization_users(org_uuid)
48
+ for u, role_name in users:
49
+ if needle in (u.display_name or "").lower():
50
+ targets.append((u, role_name))
46
51
  # De-duplicate
47
52
  seen = set()
48
53
  deduped = []
@@ -52,10 +57,13 @@ async def _resolve_targets(query: str | None):
52
57
  deduped.append((u, role_name))
53
58
  return deduped
54
59
  # No query -> master admin
55
- perm_orgs = _db.get_permission_organizations("auth:admin")
56
- if not perm_orgs:
60
+ p = next(
61
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
62
+ )
63
+ if not p or not p.orgs:
57
64
  return []
58
- users = _db.get_organization_users(str(perm_orgs[0].uuid))
65
+ first_org_uuid = next(iter(p.orgs))
66
+ users = db.get_organization_users(first_org_uuid)
59
67
  admin_users = [pair for pair in users if pair[1] == "Administration"]
60
68
  return admin_users[:1]
61
69
 
@@ -63,7 +71,7 @@ async def _resolve_targets(query: str | None):
63
71
  async def _create_reset(user, role_name: str):
64
72
  token = passphrase.generate()
65
73
  expiry = _authsession.reset_expires()
66
- _db.create_reset_token(
74
+ db.create_reset_token(
67
75
  passphrase=token,
68
76
  user_uuid=user.uuid,
69
77
  expiry=expiry,
@@ -0,0 +1,22 @@
1
+ """FastAPI response utilities for msgspec.Struct serialization."""
2
+
3
+ import msgspec
4
+ from fastapi import Response
5
+
6
+
7
+ class MsgspecResponse(Response):
8
+ """Response that uses msgspec for JSON encoding.
9
+
10
+ Use this for returning msgspec.Struct, dict, or list with proper serialization.
11
+ """
12
+
13
+ media_type = "application/json"
14
+
15
+ def __init__(
16
+ self,
17
+ content: msgspec.Struct | dict | list,
18
+ status_code: int = 200,
19
+ headers: dict | None = None,
20
+ ):
21
+ body = msgspec.json.encode(content)
22
+ super().__init__(content=body, status_code=status_code, headers=headers)
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
@@ -1,4 +1,4 @@
1
- from datetime import timezone
1
+ from datetime import UTC
2
2
  from uuid import UUID
3
3
 
4
4
  from fastapi import (
@@ -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.data().session_ctx(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.data().session_ctx(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.data().session_ctx(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_uuid != 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,28 +126,23 @@ 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 {
147
141
  "message": "Registration link generated successfully",
148
142
  "url": url,
149
143
  "expires": (
150
- expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
144
+ expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
151
145
  if expiry.tzinfo
152
- else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
146
+ else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
153
147
  ),
154
148
  }
paskia/fastapi/ws.py CHANGED
@@ -1,40 +1,21 @@
1
- from uuid import UUID
2
-
3
1
  from fastapi import FastAPI, WebSocket
4
2
 
5
3
  from paskia import db
6
- from paskia.authsession import expires, get_reset, get_session
4
+ from paskia.authsession import expires, get_reset
7
5
  from paskia.fastapi import authz, remote
8
6
  from paskia.fastapi.session import AUTH_COOKIE, infodict
7
+ from paskia.fastapi.wschat import authenticate_chat, register_chat
9
8
  from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
10
9
  from paskia.globals import passkey
11
10
  from paskia.util import hostutil, passphrase
12
11
 
13
12
  # Create a FastAPI subapp for WebSocket endpoints
14
- app = FastAPI()
13
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
15
14
 
16
15
  # Mount the remote auth WebSocket endpoints
17
16
  app.mount("/remote-auth", remote.app)
18
17
 
19
18
 
20
- async def register_chat(
21
- ws: WebSocket,
22
- user_uuid: UUID,
23
- user_name: str,
24
- origin: str,
25
- credential_ids: list[bytes] | None = None,
26
- ):
27
- """Generate registration options and send them to the client."""
28
- options, challenge = passkey.instance.reg_generate_options(
29
- user_id=user_uuid,
30
- user_name=user_name,
31
- credential_ids=credential_ids,
32
- )
33
- await ws.send_json({"optionsJSON": options})
34
- response = await ws.receive_json()
35
- return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
36
-
37
-
38
19
  @app.websocket("/register")
39
20
  @websocket_error_handler
40
21
  async def websocket_register_add(
@@ -56,7 +37,7 @@ async def websocket_register_add(
56
37
  raise ValueError(
57
38
  f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
58
39
  )
59
- s = await get_reset(reset)
40
+ s = get_reset(reset)
60
41
  user_uuid = s.user_uuid
61
42
  else:
62
43
  # Require recent authentication for adding a new passkey
@@ -65,16 +46,16 @@ async def websocket_register_add(
65
46
  s = ctx.session
66
47
 
67
48
  # Get user information and determine effective user_name for this registration
68
- user = db.get_user_by_uuid(user_uuid)
49
+ user = db.data().users.get(user_uuid)
69
50
  user_name = user.display_name
70
51
  if name is not None:
71
52
  stripped = name.strip()
72
53
  if stripped:
73
54
  user_name = stripped
74
- challenge_ids = db.get_credentials_by_user_uuid(user_uuid)
55
+ credential_ids = db.get_user_credential_ids(user_uuid) or None
75
56
 
76
57
  # WebAuthn registration
77
- credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
58
+ credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
78
59
 
79
60
  # Create a new session and store everything in database
80
61
  metadata = infodict(ws, "authenticated")
@@ -84,16 +65,16 @@ async def websocket_register_add(
84
65
  reset_key=(s.key if reset is not None else None),
85
66
  display_name=user_name,
86
67
  host=host,
87
- ip=metadata.get("ip"),
88
- user_agent=metadata.get("user_agent"),
68
+ ip=metadata["ip"],
69
+ user_agent=metadata["user_agent"],
89
70
  )
90
71
  auth = token
91
72
 
92
73
  assert isinstance(auth, str) and len(auth) == 16
93
74
  await ws.send_json(
94
75
  {
95
- "user_uuid": str(user.uuid),
96
- "credential_uuid": str(credential.uuid),
76
+ "user": str(user.uuid),
77
+ "credential": str(credential.uuid),
97
78
  "session_token": auth,
98
79
  "message": "New credential added successfully",
99
80
  }
@@ -110,36 +91,19 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
110
91
  session_user_uuid = None
111
92
  credential_ids = None
112
93
  if auth:
113
- try:
114
- session = await get_session(auth, host=host)
115
- session_user_uuid = session.user_uuid
116
- credential_ids = db.get_credentials_by_user_uuid(session_user_uuid)
117
- except ValueError:
118
- pass # Invalid/expired session - allow normal authentication
119
-
120
- options, challenge = passkey.instance.auth_generate_options(
121
- credential_ids=credential_ids
122
- )
123
- await ws.send_json({"optionsJSON": options})
124
- # Wait for the client to use his authenticator to authenticate
125
- credential = passkey.instance.auth_parse(await ws.receive_json())
126
- # Fetch from the database by credential ID
127
- try:
128
- stored_cred = db.get_credential_by_id(credential.raw_id)
129
- except ValueError:
130
- raise ValueError(
131
- f"This passkey is no longer registered with {passkey.instance.rp_name}"
132
- )
94
+ ctx = db.data().session_ctx(auth, host)
95
+ if ctx:
96
+ session_user_uuid = ctx.user.uuid
97
+ credential_ids = db.get_user_credential_ids(session_user_uuid) or None
98
+
99
+ cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
133
100
 
134
101
  # If reauth mode, verify the credential belongs to the session's user
135
- if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
102
+ if session_user_uuid and cred.user_uuid != session_user_uuid:
136
103
  raise ValueError("This passkey belongs to a different account")
137
104
 
138
- # Verify the credential matches the stored data
139
- passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
140
-
141
105
  # Create session and update user/credential in a single transaction
142
- assert stored_cred.uuid is not None
106
+ assert cred.uuid is not None
143
107
  metadata = infodict(ws, "auth")
144
108
  normalized_host = hostutil.normalize_host(host)
145
109
  if not normalized_host:
@@ -150,17 +114,18 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
150
114
  raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
151
115
 
152
116
  token = db.login(
153
- user_uuid=stored_cred.user_uuid,
154
- credential=stored_cred,
117
+ user_uuid=cred.user_uuid,
118
+ credential_uuid=cred.uuid,
119
+ sign_count=new_sign_count,
155
120
  host=normalized_host,
156
- ip=metadata.get("ip") or "",
157
- user_agent=metadata.get("user_agent") or "",
121
+ ip=metadata["ip"],
122
+ user_agent=metadata["user_agent"],
158
123
  expiry=expires(),
159
124
  )
160
125
 
161
126
  await ws.send_json(
162
127
  {
163
- "user_uuid": str(stored_cred.user_uuid),
128
+ "user": str(cred.user_uuid),
164
129
  "session_token": token,
165
130
  }
166
131
  )
@@ -0,0 +1,62 @@
1
+ """
2
+ WebSocket chat functions for WebAuthn registration and authentication flows.
3
+ """
4
+
5
+ from uuid import UUID
6
+
7
+ from fastapi import WebSocket
8
+
9
+ from paskia import db
10
+ from paskia.db import Credential
11
+ from paskia.globals import passkey
12
+
13
+
14
+ async def register_chat(
15
+ ws: WebSocket,
16
+ user_uuid: UUID,
17
+ user_name: str,
18
+ origin: str,
19
+ credential_ids: list[bytes] | None = None,
20
+ ):
21
+ """Run WebAuthn registration flow and return the verified credential."""
22
+ options, challenge = passkey.instance.reg_generate_options(
23
+ user_id=user_uuid,
24
+ user_name=user_name,
25
+ credential_ids=credential_ids,
26
+ )
27
+ await ws.send_json({"optionsJSON": options})
28
+ response = await ws.receive_json()
29
+ return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
30
+
31
+
32
+ async def authenticate_chat(
33
+ ws: WebSocket,
34
+ origin: str,
35
+ credential_ids: list[bytes] | None = None,
36
+ ) -> tuple[Credential, int]:
37
+ """Run WebAuthn authentication flow and return the credential and new sign count.
38
+
39
+ Returns:
40
+ tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
41
+ """
42
+ options, challenge = passkey.instance.auth_generate_options(
43
+ credential_ids=credential_ids
44
+ )
45
+ await ws.send_json({"optionsJSON": options})
46
+ authcred = passkey.instance.auth_parse(await ws.receive_json())
47
+
48
+ cred = next(
49
+ (
50
+ c
51
+ for c in db.data().credentials.values()
52
+ if c.credential_id == authcred.raw_id
53
+ ),
54
+ None,
55
+ )
56
+ if not cred:
57
+ raise ValueError(
58
+ f"This passkey is no longer registered with {passkey.instance.rp_name}"
59
+ )
60
+
61
+ verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
62
+ return cred, verification.new_sign_count
paskia/fastapi/wsutil.py CHANGED
@@ -3,6 +3,7 @@ Shared WebSocket utilities for FastAPI endpoints.
3
3
  """
4
4
 
5
5
  import logging
6
+ import time
6
7
  from functools import wraps
7
8
 
8
9
  import base64url
@@ -10,6 +11,7 @@ from fastapi import WebSocket, WebSocketDisconnect
10
11
  from webauthn.helpers.exceptions import InvalidAuthenticationResponse
11
12
 
12
13
  from paskia.fastapi import authz
14
+ from paskia.fastapi.logging import log_ws_close, log_ws_open
13
15
  from paskia.globals import passkey
14
16
  from paskia.util import pow
15
17
 
@@ -19,11 +21,19 @@ def websocket_error_handler(func):
19
21
 
20
22
  @wraps(func)
21
23
  async def wrapper(ws: WebSocket, *args, **kwargs):
24
+ client = ws.client.host if ws.client else "-"
25
+ host = ws.headers.get("host", "-")
26
+ path = ws.url.path
27
+
28
+ start = time.perf_counter()
29
+ ws_id = log_ws_open(client, host, path)
30
+ close_code = None
31
+
22
32
  try:
23
33
  await ws.accept()
24
34
  return await func(ws, *args, **kwargs)
25
- except WebSocketDisconnect:
26
- pass
35
+ except WebSocketDisconnect as e:
36
+ close_code = e.code
27
37
  except authz.AuthException as e:
28
38
  await ws.send_json(
29
39
  {
@@ -36,6 +46,9 @@ def websocket_error_handler(func):
36
46
  except Exception:
37
47
  logging.exception("Internal Server Error")
38
48
  await ws.send_json({"status": 500, "detail": "Internal Server Error"})
49
+ finally:
50
+ duration_ms = (time.perf_counter() - start) * 1000
51
+ log_ws_close(client, ws_id, close_code, duration_ms)
39
52
 
40
53
  return wrapper
41
54
 
@@ -4,13 +4,13 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Admin</title>
7
- <script type="module" crossorigin src="/auth/assets/admin-tVs8oyLv.js"></script>
8
- <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
7
+ <script type="module" crossorigin src="/auth/assets/admin-CPE1pLMm.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js">
9
9
  <link rel="modulepreload" crossorigin href="/auth/assets/helpers-DzjFIx78.js">
10
- <link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-aTdCvz9k.js">
10
+ <link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-Fmeb6EtF.js">
11
11
  <link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
12
- <link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-Bc249ASC.css">
13
- <link rel="stylesheet" crossorigin href="/auth/assets/admin-BeNu48FR.css">
12
+ <link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-DPkUS8LZ.css">
13
+ <link rel="stylesheet" crossorigin href="/auth/assets/admin-DzzjSg72.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="admin-app"></div>
@@ -1 +1 @@
1
- .breadcrumbs[data-v-6344dbb8]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-6344dbb8]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-6344dbb8]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-6344dbb8]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs .sep[data-v-6344dbb8]{color:var(--color-text-muted);margin:0}.btn-card-delete{display:none}.credential-item:focus .btn-card-delete{display:block}.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr 2fr;grid-template-areas:"heading heading extra" "org org extra" "label1 value1 extra" "label2 value2 extra" "label3 value3 extra"}.user-info[data-v-ce373d6c]:not(.has-extra){grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3"}@media(max-width:720px){.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3" "extra extra"}}.user-name-heading[data-v-ce373d6c]{grid-area:heading;display:flex;align-items:center;flex-wrap:wrap;margin:0 0 .25rem}.org-role-sub[data-v-ce373d6c]{grid-area:org;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-ce373d6c]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-ce373d6c]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.info-label[data-v-ce373d6c]:nth-of-type(1){grid-area:label1}.info-value[data-v-ce373d6c]:nth-of-type(2){grid-area:value1}.info-label[data-v-ce373d6c]:nth-of-type(3){grid-area:label2}.info-value[data-v-ce373d6c]:nth-of-type(4){grid-area:value2}.info-label[data-v-ce373d6c]:nth-of-type(5){grid-area:label3}.info-value[data-v-ce373d6c]:nth-of-type(6){grid-area:value3}.user-info-extra[data-v-ce373d6c]{grid-area:extra;padding-left:2rem;border-left:1px solid var(--color-border)}.user-name-row[data-v-ce373d6c]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-ce373d6c]{flex:1 1 auto}.display-name[data-v-ce373d6c]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-ce373d6c]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-ce373d6c]{width:auto}.name-input[data-v-ce373d6c]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-ce373d6c]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;cursor:pointer}.mini-btn[data-v-ce373d6c]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-ce373d6c]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-ce373d6c]:disabled{opacity:.5;cursor:not-allowed}@media(max-width:720px){.user-info-extra[data-v-ce373d6c]{padding-left:0;padding-top:1rem;margin-top:1rem;border-left:none;border-top:1px solid var(--color-border)}}dialog[data-v-2ebcbb0a]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto;position:fixed;inset:0;margin:auto;height:fit-content}dialog[data-v-2ebcbb0a]::backdrop{background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7)}dialog[data-v-2ebcbb0a] .modal-title,dialog[data-v-2ebcbb0a] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}dialog[data-v-2ebcbb0a] form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}dialog[data-v-2ebcbb0a] .modal-form input,dialog[data-v-2ebcbb0a] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}dialog[data-v-2ebcbb0a] .modal-form input:focus,dialog[data-v-2ebcbb0a] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}dialog[data-v-2ebcbb0a] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-b73321cf]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-b73321cf]{color:var(--color-danger-text)}.small[data-v-b73321cf]{font-size:.9rem}.qr-display[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.75rem}.qr-section[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.5rem}.qr-link[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;text-decoration:none;color:inherit;border-radius:var(--radius-sm, 6px);overflow:hidden}.qr-code[data-v-727427c4]{display:block;width:200px;height:200px;max-width:100%;object-fit:contain;border-radius:var(--radius-sm, 6px);background:#fff;cursor:pointer}.link-text[data-v-727427c4]{padding:.5rem;font-size:.75rem;color:var(--color-text-muted);font-family:monospace;word-break:break-all;line-height:1.2;transition:color .2s ease}.qr-link:hover .link-text[data-v-727427c4]{color:var(--color-text)}dialog[data-v-e04dd463]{border:none;background:transparent;padding:0;max-width:none;width:fit-content;height:fit-content;position:fixed;inset:0;margin:auto}dialog[data-v-e04dd463]::backdrop{-webkit-backdrop-filter:blur(.2rem) brightness(.5);backdrop-filter:blur(.2rem) brightness(.5)}.icon-btn[data-v-e04dd463]{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6}.icon-btn[data-v-e04dd463]:hover{opacity:1}.reg-header-row[data-v-e04dd463]{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.75rem}.reg-title[data-v-e04dd463]{margin:0;font-size:1.25rem;font-weight:600}.device-dialog[data-v-e04dd463]{background:var(--color-surface);padding:1.25rem 1.25rem 1rem;border-radius:var(--radius-md);max-width:480px;width:100%;box-shadow:0 6px 28px #00000040}.reg-help[data-v-e04dd463]{margin:.5rem 0 .75rem;font-size:.85rem;line-height:1.4;text-align:center;color:var(--color-text-muted)}.reg-actions[data-v-e04dd463]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}.expiry-note[data-v-e04dd463]{font-size:.75rem;color:var(--color-text-muted);text-align:center;margin-top:.75rem}.loading-container[data-v-130f5abf]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:1rem}.loading-spinner[data-v-130f5abf]{width:40px;height:40px;border:4px solid var(--color-border);border-top:4px solid var(--color-primary);border-radius:50%;animation:spin-130f5abf 1s linear infinite}@keyframes spin-130f5abf{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-container p[data-v-130f5abf]{color:var(--color-text-muted);margin:0}.message-container[data-v-744305d5]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:2rem}.message-content[data-v-744305d5]{text-align:center;max-width:480px}.message-content h2[data-v-744305d5]{margin:0 0 1.5rem;color:var(--color-heading)}.message-content .button-row[data-v-744305d5]{display:flex;gap:.75rem;justify-content:center}
1
+ .breadcrumbs[data-v-6344dbb8]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-6344dbb8]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-6344dbb8]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-6344dbb8]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs .sep[data-v-6344dbb8]{color:var(--color-text-muted);margin:0}.btn-card-delete{display:none}.credential-item:focus .btn-card-delete{display:block}.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr 2fr;grid-template-areas:"heading heading extra" "org org extra" "label1 value1 extra" "label2 value2 extra" "label3 value3 extra"}.user-info[data-v-ce373d6c]:not(.has-extra){grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3"}@media(max-width:720px){.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3" "extra extra"}}.user-name-heading[data-v-ce373d6c]{grid-area:heading;display:flex;align-items:center;flex-wrap:wrap;margin:0 0 .25rem}.org-role-sub[data-v-ce373d6c]{grid-area:org;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-ce373d6c]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-ce373d6c]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.info-label[data-v-ce373d6c]:nth-of-type(1){grid-area:label1}.info-value[data-v-ce373d6c]:nth-of-type(2){grid-area:value1}.info-label[data-v-ce373d6c]:nth-of-type(3){grid-area:label2}.info-value[data-v-ce373d6c]:nth-of-type(4){grid-area:value2}.info-label[data-v-ce373d6c]:nth-of-type(5){grid-area:label3}.info-value[data-v-ce373d6c]:nth-of-type(6){grid-area:value3}.user-info-extra[data-v-ce373d6c]{grid-area:extra;padding-left:2rem;border-left:1px solid var(--color-border)}.user-name-row[data-v-ce373d6c]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-ce373d6c]{flex:1 1 auto}.display-name[data-v-ce373d6c]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-ce373d6c]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-ce373d6c]{width:auto}.name-input[data-v-ce373d6c]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-ce373d6c]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;cursor:pointer}.mini-btn[data-v-ce373d6c]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-ce373d6c]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-ce373d6c]:disabled{opacity:.5;cursor:not-allowed}@media(max-width:720px){.user-info-extra[data-v-ce373d6c]{padding-left:0;padding-top:1rem;margin-top:1rem;border-left:none;border-top:1px solid var(--color-border)}}dialog[data-v-2ebcbb0a]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto;position:fixed;inset:0;margin:auto;height:fit-content}dialog[data-v-2ebcbb0a]::backdrop{background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7)}dialog[data-v-2ebcbb0a] .modal-title,dialog[data-v-2ebcbb0a] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}dialog[data-v-2ebcbb0a] form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}dialog[data-v-2ebcbb0a] .modal-form input,dialog[data-v-2ebcbb0a] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}dialog[data-v-2ebcbb0a] .modal-form input:focus,dialog[data-v-2ebcbb0a] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}dialog[data-v-2ebcbb0a] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-b73321cf]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-b73321cf]{color:var(--color-danger-text)}.small[data-v-b73321cf]{font-size:.9rem}.qr-display[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.75rem}.qr-section[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.5rem}.qr-link[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;text-decoration:none;color:inherit;border-radius:var(--radius-sm, 6px);overflow:hidden}.qr-code[data-v-727427c4]{display:block;width:200px;height:200px;max-width:100%;object-fit:contain;border-radius:var(--radius-sm, 6px);background:#fff;cursor:pointer}.link-text[data-v-727427c4]{padding:.5rem;font-size:.75rem;color:var(--color-text-muted);font-family:monospace;word-break:break-all;line-height:1.2;transition:color .2s ease}.qr-link:hover .link-text[data-v-727427c4]{color:var(--color-text)}dialog[data-v-e04dd463]{border:none;background:transparent;padding:0;max-width:none;width:fit-content;height:fit-content;position:fixed;inset:0;margin:auto}dialog[data-v-e04dd463]::backdrop{-webkit-backdrop-filter:blur(.2rem) brightness(.5);backdrop-filter:blur(.2rem) brightness(.5)}.icon-btn[data-v-e04dd463]{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6}.icon-btn[data-v-e04dd463]:hover{opacity:1}.reg-header-row[data-v-e04dd463]{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.75rem}.reg-title[data-v-e04dd463]{margin:0;font-size:1.25rem;font-weight:600}.device-dialog[data-v-e04dd463]{background:var(--color-surface);padding:1.25rem 1.25rem 1rem;border-radius:var(--radius-md);max-width:480px;width:100%;box-shadow:0 6px 28px #00000040}.reg-help[data-v-e04dd463]{margin:.5rem 0 .75rem;font-size:.85rem;line-height:1.4;text-align:center;color:var(--color-text-muted)}.reg-actions[data-v-e04dd463]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}.expiry-note[data-v-e04dd463]{font-size:.75rem;color:var(--color-text-muted);text-align:center;margin-top:.75rem}.loading-container[data-v-130f5abf]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:1rem}.loading-spinner[data-v-130f5abf]{width:40px;height:40px;border:4px solid var(--color-border);border-top:4px solid var(--color-primary);border-radius:50%;animation:spin-130f5abf 1s linear infinite}@keyframes spin-130f5abf{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-container p[data-v-130f5abf]{color:var(--color-text-muted);margin:0}.message-container[data-v-a7b258e7]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:2rem}.message-content[data-v-a7b258e7]{text-align:center;max-width:480px}.message-content h2[data-v-a7b258e7]{margin:0 0 1rem;color:var(--color-heading)}.message-content .error-detail[data-v-a7b258e7]{margin:0 0 1.5rem;color:var(--color-text-muted)}.message-content .button-row[data-v-a7b258e7]{display:flex;gap:.75rem;justify-content:center}