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.
- paskia/_version.py +2 -2
- paskia/aaguid/__init__.py +5 -4
- paskia/authsession.py +15 -43
- paskia/bootstrap.py +31 -103
- paskia/db/__init__.py +27 -55
- paskia/db/background.py +20 -40
- paskia/db/jsonl.py +196 -46
- paskia/db/logging.py +233 -0
- paskia/db/migrations.py +33 -0
- paskia/db/operations.py +409 -825
- paskia/db/structs.py +408 -94
- paskia/fastapi/__main__.py +25 -28
- paskia/fastapi/admin.py +147 -329
- paskia/fastapi/api.py +68 -110
- paskia/fastapi/logging.py +218 -0
- paskia/fastapi/mainapp.py +25 -8
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -19
- paskia/fastapi/response.py +22 -0
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +24 -30
- paskia/fastapi/ws.py +25 -60
- paskia/fastapi/wschat.py +62 -0
- paskia/fastapi/wsutil.py +15 -2
- paskia/frontend-build/auth/admin/index.html +5 -5
- paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
- paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
- paskia/frontend-build/auth/index.html +5 -5
- paskia/frontend-build/auth/restricted/index.html +4 -4
- paskia/frontend-build/int/forward/index.html +4 -4
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/globals.py +2 -2
- paskia/migrate/__init__.py +67 -60
- paskia/migrate/sql.py +94 -37
- paskia/remoteauth.py +7 -8
- paskia/sansio.py +6 -12
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
- paskia-0.9.1.dist-info/RECORD +60 -0
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- paskia-0.8.1.dist-info/RECORD +0 -55
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
- {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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
"user_agent": request.headers.get("user-agent", "")[:500]
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
)
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
)
|
|
91
|
+
)
|
|
93
92
|
|
|
94
|
-
target_session = db.
|
|
95
|
-
if not target_session or target_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
|
-
|
|
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=
|
|
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(
|
|
144
|
+
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
151
145
|
if expiry.tzinfo
|
|
152
|
-
else expiry.replace(tzinfo=
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
88
|
-
user_agent=metadata
|
|
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
|
-
"
|
|
96
|
-
"
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
session_user_uuid =
|
|
116
|
-
credential_ids = db.
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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=
|
|
154
|
-
|
|
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
|
|
157
|
-
user_agent=metadata
|
|
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
|
-
"
|
|
128
|
+
"user": str(cred.user_uuid),
|
|
164
129
|
"session_token": token,
|
|
165
130
|
}
|
|
166
131
|
)
|
paskia/fastapi/wschat.py
ADDED
|
@@ -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
|
-
|
|
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-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
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-
|
|
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-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/admin-
|
|
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-
|
|
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}
|