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/api.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from contextlib import suppress
3
- from datetime import datetime, timedelta, timezone
3
+ from datetime import UTC, datetime, timedelta
4
4
 
5
5
  from fastapi import (
6
6
  Depends,
@@ -14,20 +14,16 @@ from fastapi.responses import JSONResponse
14
14
  from fastapi.security import HTTPBearer
15
15
 
16
16
  from paskia import db
17
- from paskia.authsession import (
18
- EXPIRES,
19
- get_reset,
20
- get_session,
21
- refresh_session_token,
22
- )
17
+ from paskia.authsession import EXPIRES, expires, get_reset
23
18
  from paskia.fastapi import authz, session, user
19
+ from paskia.fastapi.response import MsgspecResponse
24
20
  from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
25
21
  from paskia.globals import passkey as global_passkey
26
22
  from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
27
23
 
28
24
  bearer_auth = HTTPBearer(auto_error=True)
29
25
 
30
- app = FastAPI()
26
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
31
27
 
32
28
  app.mount("/user", user.app)
33
29
 
@@ -78,12 +74,7 @@ async def validate_token(
78
74
  max_age: str | None = Query(None),
79
75
  auth=AUTH_COOKIE,
80
76
  ):
81
- """Validate the current session and extend its expiry.
82
-
83
- Always refreshes the session (sliding expiration) and re-sets the cookie with a
84
- renewed max-age. This keeps active users logged in without needing a separate
85
- refresh endpoint.
86
- """
77
+ """Validate session and return context. Refreshes session expiry."""
87
78
  try:
88
79
  ctx = await authz.verify(
89
80
  auth,
@@ -96,26 +87,23 @@ async def validate_token(
96
87
  raise
97
88
  renewed = False
98
89
  if auth:
99
- consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc))
90
+ consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
100
91
  if not timedelta(0) < consumed < _REFRESH_INTERVAL:
101
- try:
102
- await refresh_session_token(
103
- auth,
104
- ip=request.client.host if request.client else "",
105
- user_agent=request.headers.get("user-agent") or "",
106
- )
107
- session.set_session_cookie(response, auth)
108
- renewed = True
109
- except ValueError:
110
- # Session disappeared, e.g. due to concurrent logout; global handler will clear
111
- raise authz.AuthException(
112
- status_code=401, detail="Session expired", mode="login"
113
- )
114
- return {
115
- "valid": True,
116
- "user_uuid": str(ctx.session.user_uuid),
117
- "renewed": renewed,
118
- }
92
+ db.update_session(
93
+ auth,
94
+ ip=request.client.host if request.client else "",
95
+ user_agent=request.headers.get("user-agent") or "",
96
+ expiry=expires(),
97
+ )
98
+ session.set_session_cookie(response, auth)
99
+ renewed = True
100
+ return MsgspecResponse(
101
+ {
102
+ "valid": True,
103
+ "renewed": renewed,
104
+ "ctx": userinfo.build_session_context(ctx),
105
+ }
106
+ )
119
107
 
120
108
 
121
109
  @app.get("/forward")
@@ -144,9 +132,10 @@ async def forward_authentication(
144
132
  ctx = await authz.verify(
145
133
  auth, perm, host=request.headers.get("host"), max_age=max_age
146
134
  )
147
- role_permissions = set(ctx.role.permissions or [])
148
- if ctx.permissions:
149
- role_permissions.update(permission.scope for permission in ctx.permissions)
135
+ # Build permission scopes for Remote-Groups header
136
+ role_permissions = (
137
+ {p.scope for p in ctx.permissions} if ctx.permissions else set()
138
+ )
150
139
 
151
140
  remote_headers: dict[str, str] = {
152
141
  "Remote-User": str(ctx.user.uuid),
@@ -157,15 +146,13 @@ async def forward_authentication(
157
146
  "Remote-Role": str(ctx.role.uuid),
158
147
  "Remote-Role-Name": ctx.role.display_name,
159
148
  "Remote-Session-Expires": (
160
- ctx.session.expiry.astimezone(timezone.utc)
161
- .isoformat()
162
- .replace("+00:00", "Z")
149
+ ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
163
150
  if ctx.session.expiry.tzinfo
164
- else ctx.session.expiry.replace(tzinfo=timezone.utc)
151
+ else ctx.session.expiry.replace(tzinfo=UTC)
165
152
  .isoformat()
166
153
  .replace("+00:00", "Z")
167
154
  ),
168
- "Remote-Credential": str(ctx.session.credential_uuid),
155
+ "Remote-Credential": str(ctx.session.credential),
169
156
  }
170
157
  return Response(status_code=204, headers=remote_headers)
171
158
  except authz.AuthException as e:
@@ -207,92 +194,61 @@ async def get_settings():
207
194
  }
208
195
 
209
196
 
210
- @app.get("/token-info")
211
- async def api_token_info(token: str):
212
- """Get information about a reset token.
213
-
214
- Returns:
215
- - type: "reset"
216
- - user_name: display name of the user
217
- - token_type: type of reset token
218
- """
219
- if not passphrase.is_well_formed(token):
220
- raise HTTPException(status_code=404, detail="Invalid token")
221
-
222
- # Check if this is a reset token
223
- try:
224
- reset_token = await get_reset(token)
225
- user = db.get_user_by_uuid(reset_token.user_uuid)
226
- return {
227
- "type": "reset",
228
- "user_name": user.display_name,
229
- "token_type": reset_token.token_type,
230
- }
231
- except (ValueError, Exception):
232
- raise HTTPException(status_code=404, detail="Token not found or expired")
233
-
234
-
235
197
  @app.post("/user-info")
236
198
  async def api_user_info(
237
199
  request: Request,
238
200
  response: Response,
239
- reset: str | None = None,
240
201
  auth=AUTH_COOKIE,
241
202
  ):
242
- """Get user information including credentials, sessions, and permissions.
203
+ """Get full user profile including credentials and sessions."""
204
+ if auth is None:
205
+ raise authz.AuthException(
206
+ status_code=401,
207
+ detail="Authentication required",
208
+ mode="login",
209
+ )
210
+ ctx = db.data().session_ctx(auth, request.headers.get("host"))
211
+ if not ctx:
212
+ raise HTTPException(401, "Session expired")
213
+
214
+ return MsgspecResponse(
215
+ await userinfo.build_user_info(
216
+ user_uuid=ctx.user.uuid,
217
+ auth=auth,
218
+ session_record=ctx.session,
219
+ request_host=request.headers.get("host"),
220
+ )
221
+ )
243
222
 
244
- Can be called with either:
245
- - A session cookie (auth) for authenticated users
246
- - A reset token for users in password reset flow
247
- """
248
- authenticated = False
249
- session_record = None
250
- reset_token = None
223
+
224
+ @app.get("/token-info")
225
+ async def token_info(credentials=Depends(bearer_auth)):
226
+ """Get reset/device-add token info. Pass token via Bearer header."""
227
+ token = credentials.credentials
228
+ if not passphrase.is_well_formed(token):
229
+ raise HTTPException(400, "Invalid token format")
251
230
  try:
252
- if reset:
253
- if not passphrase.is_well_formed(reset):
254
- raise ValueError("Invalid reset token")
255
- reset_token = await get_reset(reset)
256
- target_user_uuid = reset_token.user_uuid
257
- else:
258
- if auth is None:
259
- raise authz.AuthException(
260
- status_code=401,
261
- detail="Authentication required",
262
- mode="login",
263
- )
264
- session_record = await get_session(auth, host=request.headers.get("host"))
265
- authenticated = True
266
- target_user_uuid = session_record.user_uuid
231
+ reset_token = get_reset(token)
267
232
  except ValueError as e:
268
233
  raise HTTPException(401, str(e))
269
234
 
270
- # Return minimal response for reset tokens
271
- if not authenticated and reset_token:
272
- return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
273
-
274
- # Return full user info for authenticated users
275
- assert auth is not None
276
- assert session_record is not None
277
-
278
- return await userinfo.format_user_info(
279
- user_uuid=target_user_uuid,
280
- auth=auth,
281
- session_record=session_record,
282
- request_host=request.headers.get("host"),
283
- )
235
+ u = reset_token.user
236
+ return {
237
+ "token_type": reset_token.token_type,
238
+ "display_name": u.display_name,
239
+ }
284
240
 
285
241
 
286
242
  @app.post("/logout")
287
243
  async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
288
244
  if not auth:
289
245
  return {"message": "Already logged out"}
290
- try:
291
- _s = await get_session(auth, host=request.headers.get("host"))
292
- except ValueError:
246
+ host = request.headers.get("host")
247
+ ctx = db.data().session_ctx(auth, host)
248
+ if not ctx:
293
249
  return {"message": "Already logged out"}
294
250
  with suppress(Exception):
295
- db.delete_session(auth)
251
+ db.delete_session(auth, ctx=ctx)
296
252
  session.clear_session_cookie(response)
297
253
  return {"message": "Logged out successfully"}
298
254
 
@@ -301,9 +257,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
301
257
  async def api_set_session(
302
258
  request: Request, response: Response, auth=Depends(bearer_auth)
303
259
  ):
304
- user = await get_session(auth.credentials, host=request.headers.get("host"))
260
+ ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
261
+ if not ctx:
262
+ raise HTTPException(401, "Session expired")
305
263
  session.set_session_cookie(response, auth.credentials)
306
264
  return {
307
265
  "message": "Session cookie set successfully",
308
- "user_uuid": str(user.user_uuid),
266
+ "user": str(ctx.user.uuid),
309
267
  }
@@ -0,0 +1,218 @@
1
+ """Custom access logging middleware for FastAPI/Uvicorn."""
2
+
3
+ import logging
4
+ import sys
5
+ import time
6
+ from ipaddress import IPv6Address
7
+
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+
12
+ logger = logging.getLogger("paskia.access")
13
+
14
+ _RESET = "\033[0m"
15
+ _STATUS_INFO = "\033[32m" # 1xx (green)
16
+ _STATUS_OK = "\033[92m" # 2xx (bright green)
17
+ _STATUS_REDIRECT = "\033[32m" # 3xx (green)
18
+ _STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
19
+ _STATUS_SERVER_ERR = "\033[1;31m" # 5xx (bright red)
20
+ _METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
21
+ _METHOD_WRITE = "\033[1;34m" # POST, PUT, DELETE, PATCH (bright blue)
22
+ _HOST = "\033[1;30m" # hostname (dark grey)
23
+ _PATH = "\033[0m" # path (default)
24
+ _TIMING = "\033[2m" # timing (dim)
25
+ _WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow)
26
+ _WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow)
27
+ _WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey)
28
+
29
+
30
+ def format_ipv6_network(ip: str) -> str:
31
+ """Format IPv6 address to show only network part (first 64 bits)."""
32
+ try:
33
+ addr = IPv6Address(ip)
34
+ # Get the integer representation and mask to first 64 bits
35
+ network_int = int(addr) >> 64
36
+ # Format as IPv6 with trailing ::
37
+ # Split into 4 groups of 16 bits
38
+ groups = []
39
+ for _ in range(4):
40
+ groups.insert(0, format(network_int & 0xFFFF, "x"))
41
+ network_int >>= 16
42
+ # Compress consecutive zero groups
43
+ result = ":".join(groups) + "::"
44
+ # Simplify leading zeros in groups and compress
45
+ return str(IPv6Address(result + "0"))
46
+ except Exception:
47
+ return ip
48
+
49
+
50
+ def format_client_ip(ip: str) -> str:
51
+ """Format client IP, compressing IPv6 to network part only."""
52
+ if not ip or ip == "-":
53
+ return "-"
54
+ if ":" in ip:
55
+ return format_ipv6_network(ip)
56
+ return ip
57
+
58
+
59
+ def status_color(status: int) -> str:
60
+ """Return color code based on HTTP status."""
61
+ if status < 200:
62
+ return _STATUS_INFO
63
+ if status < 300:
64
+ return _STATUS_OK
65
+ if status < 400:
66
+ return _STATUS_REDIRECT
67
+ if status < 500:
68
+ return _STATUS_CLIENT_ERR
69
+ return _STATUS_SERVER_ERR
70
+
71
+
72
+ def method_color(method: str) -> str:
73
+ """Return color code based on HTTP method."""
74
+ if method in ("GET", "HEAD", "OPTIONS"):
75
+ return _METHOD_READ
76
+ return _METHOD_WRITE
77
+
78
+
79
+ def format_access_log(
80
+ client: str, status: int, method: str, host: str, path: str, duration_ms: float
81
+ ) -> str:
82
+ """Format access log line with colors and aligned fields."""
83
+ use_color = sys.stderr.isatty()
84
+
85
+ # Format components with fixed widths for alignment
86
+ ip = format_client_ip(client).ljust(15) # IPv4 max 15 chars
87
+ timing = f"{duration_ms:.0f}ms"
88
+ method_padded = method.ljust(7) # Longest method is OPTIONS (7)
89
+
90
+ if use_color:
91
+ status_str = f"{status_color(status)}{status}{_RESET}"
92
+ timing_str = f"{_TIMING}{timing}{_RESET}"
93
+ method_str = f"{method_color(method)}{method_padded}{_RESET}"
94
+ host_str = f"{_HOST}{host}{_RESET}"
95
+ path_str = f"{_PATH}{path}{_RESET}"
96
+ else:
97
+ status_str = str(status)
98
+ timing_str = timing
99
+ method_str = method_padded
100
+ host_str = host
101
+ path_str = path
102
+
103
+ # Format: "IP STATUS METHOD host path TIMING"
104
+ return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
105
+
106
+
107
+ # WebSocket connection counter (mod 100)
108
+ _ws_counter = 0
109
+
110
+
111
+ def _next_ws_id() -> int:
112
+ """Get next WebSocket connection ID (0-99)."""
113
+ global _ws_counter
114
+ ws_id = _ws_counter
115
+ _ws_counter = (_ws_counter + 1) % 100
116
+ return ws_id
117
+
118
+
119
+ def log_ws_open(client: str, host: str, path: str) -> int:
120
+ """Log WebSocket connection open. Returns connection ID for use in close."""
121
+ use_color = sys.stderr.isatty()
122
+ ws_id = _next_ws_id()
123
+
124
+ ip = format_client_ip(client).ljust(15)
125
+ id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
126
+
127
+ if use_color:
128
+ # 🔌 aligned with status (takes ~2 char width), ID aligned with method
129
+ prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
130
+ host_str = f"{_HOST}{host}{_RESET}"
131
+ path_str = f"{_PATH}{path}{_RESET}"
132
+ else:
133
+ prefix = f"WS+ {id_str}"
134
+ host_str = host
135
+ path_str = path
136
+
137
+ logger.info(f"{ip} {prefix} {host_str}{path_str}")
138
+ return ws_id
139
+
140
+
141
+ # WebSocket close codes to human-readable status
142
+ WS_CLOSE_CODES = {
143
+ 1000: "ok",
144
+ 1001: "going away",
145
+ 1002: "protocol error",
146
+ 1003: "unsupported",
147
+ 1005: "no status",
148
+ 1006: "abnormal",
149
+ 1007: "invalid data",
150
+ 1008: "policy violation",
151
+ 1009: "too large",
152
+ 1010: "extension required",
153
+ 1011: "server error",
154
+ 1012: "restarting",
155
+ 1013: "try again",
156
+ 1014: "bad gateway",
157
+ 1015: "tls error",
158
+ }
159
+
160
+
161
+ def log_ws_close(
162
+ client: str, ws_id: int, close_code: int | None, duration_ms: float
163
+ ) -> None:
164
+ """Log WebSocket connection close with duration and status."""
165
+ use_color = sys.stderr.isatty()
166
+
167
+ ip = format_client_ip(client).ljust(15)
168
+ id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
169
+ timing = f"{duration_ms:.0f}ms"
170
+
171
+ # Convert close code to status text
172
+ if close_code is None:
173
+ status = "closed"
174
+ else:
175
+ status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
176
+
177
+ if use_color:
178
+ # 🔌 aligned with status, ID aligned with method
179
+ prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
180
+ status_str = f"{_WS_STATUS}{status}{_RESET}"
181
+ timing_str = f"{_TIMING}{timing}{_RESET}"
182
+ else:
183
+ prefix = f"WS- {id_str}"
184
+ status_str = status
185
+ timing_str = timing
186
+
187
+ logger.info(f"{ip} {prefix} {status_str} {timing_str}")
188
+
189
+
190
+ class AccessLogMiddleware(BaseHTTPMiddleware):
191
+ """Middleware that logs HTTP requests with custom format."""
192
+
193
+ async def dispatch(self, request: Request, call_next) -> Response:
194
+ start = time.perf_counter()
195
+ response = await call_next(request)
196
+ duration_ms = (time.perf_counter() - start) * 1000
197
+
198
+ client = request.client.host if request.client else "-"
199
+ host = request.headers.get("host", "-")
200
+ method = request.method
201
+ path = request.url.path
202
+ if request.url.query:
203
+ path = f"{path}?{request.url.query}"
204
+ status = response.status_code
205
+
206
+ line = format_access_log(client, status, method, host, path, duration_ms)
207
+ logger.info(line)
208
+
209
+ return response
210
+
211
+
212
+ def configure_access_logging():
213
+ """Configure the access logger to output to stderr."""
214
+ handler = logging.StreamHandler(sys.stderr)
215
+ handler.setFormatter(logging.Formatter("%(message)s"))
216
+ logger.addHandler(handler)
217
+ logger.setLevel(logging.INFO)
218
+ logger.propagate = False
paskia/fastapi/mainapp.py CHANGED
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  import os
3
4
  from contextlib import asynccontextmanager
@@ -7,13 +8,21 @@ from fastapi import FastAPI, HTTPException, Request, Response
7
8
  from fastapi.responses import FileResponse, RedirectResponse
8
9
  from fastapi_vue import Frontend
9
10
 
11
+ from paskia import globals
12
+ from paskia.db import start_background, stop_background
13
+ from paskia.db.logging import configure_db_logging
10
14
  from paskia.fastapi import admin, api, auth_host, ws
15
+ from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
11
16
  from paskia.fastapi.session import AUTH_COOKIE
12
17
  from paskia.util import hostutil, passphrase, vitedev
13
18
 
19
+ # Configure custom logging
20
+ configure_access_logging()
21
+ configure_db_logging()
22
+
14
23
  # Vue Frontend static files
15
24
  frontend = Frontend(
16
- Path(__file__).with_name("frontend-build"),
25
+ Path(__file__).parent.parent / "frontend-build",
17
26
  cached=["/auth/assets/"],
18
27
  )
19
28
 
@@ -30,10 +39,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
30
39
  so that uvicorn reload / multiprocess workers inherit the settings.
31
40
  All keys are guaranteed to exist; values are already normalized by __main__.py.
32
41
  """
33
- import json
34
-
35
- from paskia import globals
36
-
37
42
  config = json.loads(os.environ["PASKIA_CONFIG"])
38
43
 
39
44
  try:
@@ -49,16 +54,28 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
49
54
  # Re-raise to fail fast
50
55
  raise
51
56
 
52
- # Restore info level logging after startup (suppressed during uvicorn init in dev mode)
57
+ # Restore uvicorn info logging (suppressed during startup in dev mode)
58
+ # Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
53
59
  if frontend.devmode:
54
60
  logging.getLogger("uvicorn").setLevel(logging.INFO)
55
- logging.getLogger("uvicorn.access").setLevel(logging.INFO)
61
+ logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
56
62
 
57
63
  await frontend.load()
64
+ await start_background()
58
65
  yield
66
+ await stop_background()
59
67
 
60
68
 
61
- app = FastAPI(lifespan=lifespan, redirect_slashes=False)
69
+ app = FastAPI(
70
+ lifespan=lifespan,
71
+ redirect_slashes=False,
72
+ docs_url=None,
73
+ redoc_url=None,
74
+ openapi_url=None,
75
+ )
76
+
77
+ # Custom access logging (uvicorn's access_log is disabled)
78
+ app.add_middleware(AccessLogMiddleware)
62
79
 
63
80
  # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
64
81
  app.middleware("http")(auth_host.redirect_middleware)
paskia/fastapi/remote.py CHANGED
@@ -16,13 +16,14 @@ import base64url
16
16
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
17
17
 
18
18
  from paskia import db, remoteauth
19
+ from paskia.authsession import expires
19
20
  from paskia.fastapi.session import infodict
21
+ from paskia.fastapi.wschat import authenticate_chat
20
22
  from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
21
- from paskia.globals import passkey
22
- from paskia.util import passphrase, pow
23
+ from paskia.util import hostutil, passphrase, pow, useragent
23
24
 
24
25
  # Create a FastAPI subapp for remote auth WebSocket endpoints
25
- app = FastAPI()
26
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
26
27
 
27
28
 
28
29
  @app.websocket("/request")
@@ -179,7 +180,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
179
180
  ):
180
181
  response = {
181
182
  "status": "authenticated",
182
- "user_uuid": str(result_data["user_uuid"]),
183
+ "user": str(result_data["user_uuid"]),
183
184
  }
184
185
  if result_data.get("session_token"):
185
186
  response["session_token"] = result_data["session_token"]
@@ -268,7 +269,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
268
269
  6. Client sends WebAuthn response
269
270
  7. Server sends {status: "success", message: "..."}
270
271
  """
271
- from paskia.util import useragent
272
272
 
273
273
  origin = validate_origin(ws)
274
274
 
@@ -289,7 +289,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
289
289
  )
290
290
 
291
291
  request = None
292
- webauthn_challenge = None
293
292
  explicitly_denied = False
294
293
 
295
294
  try:
@@ -311,43 +310,21 @@ async def websocket_remote_auth_permit(ws: WebSocket):
311
310
 
312
311
  # Handle authenticate request (no PoW needed - already validated during lookup)
313
312
  if msg.get("authenticate") and request is not None:
314
- # Generate authentication options
315
- options, webauthn_challenge = passkey.instance.auth_generate_options(
316
- credential_ids=None
317
- )
318
- await ws.send_json({"optionsJSON": options})
319
-
320
- # Wait for WebAuthn response
321
- credential = passkey.instance.auth_parse(await ws.receive_json())
322
-
323
- # Fetch and verify credential
324
- try:
325
- stored_cred = db.get_credential_by_id(credential.raw_id)
326
- except ValueError:
327
- raise ValueError(
328
- f"This passkey is no longer registered with {passkey.instance.rp_name}"
329
- )
330
-
331
- # Verify the credential
332
- passkey.instance.auth_verify(
333
- credential, webauthn_challenge, stored_cred, origin
334
- )
313
+ cred, new_sign_count = await authenticate_chat(ws, origin)
335
314
 
336
315
  # Create a session for the REQUESTING device
337
- assert stored_cred.uuid is not None
316
+ assert cred.uuid is not None
338
317
 
339
318
  session_token = None
340
319
  reset_token = None
341
320
 
342
321
  if request.action == "register":
343
322
  # For registration, create a reset token for device addition
344
- from paskia.authsession import expires
345
- from paskia.util import hostutil
346
323
 
347
324
  token_str = passphrase.generate()
348
325
  expiry = expires()
349
326
  db.create_reset_token(
350
- user_uuid=stored_cred.user_uuid,
327
+ user_uuid=cred.user_uuid,
351
328
  passphrase=token_str,
352
329
  expiry=expiry,
353
330
  token_type="device addition",
@@ -356,8 +333,9 @@ async def websocket_remote_auth_permit(ws: WebSocket):
356
333
  # Also create a session so the device is logged in
357
334
  normalized_host = hostutil.normalize_host(request.host)
358
335
  session_token = db.login(
359
- user_uuid=stored_cred.user_uuid,
360
- credential=stored_cred,
336
+ user_uuid=cred.user_uuid,
337
+ credential_uuid=cred.uuid,
338
+ sign_count=new_sign_count,
361
339
  host=normalized_host,
362
340
  ip=request.ip,
363
341
  user_agent=request.user_agent,
@@ -365,13 +343,12 @@ async def websocket_remote_auth_permit(ws: WebSocket):
365
343
  )
366
344
  else:
367
345
  # Default login action
368
- from paskia.authsession import expires
369
- from paskia.util import hostutil
370
346
 
371
347
  normalized_host = hostutil.normalize_host(request.host)
372
348
  session_token = db.login(
373
- user_uuid=stored_cred.user_uuid,
374
- credential=stored_cred,
349
+ user_uuid=cred.user_uuid,
350
+ credential_uuid=cred.uuid,
351
+ sign_count=new_sign_count,
375
352
  host=normalized_host,
376
353
  ip=request.ip,
377
354
  user_agent=request.user_agent,
@@ -382,8 +359,8 @@ async def websocket_remote_auth_permit(ws: WebSocket):
382
359
  completed = await remoteauth.instance.complete_request(
383
360
  token=request.key,
384
361
  session_token=session_token,
385
- user_uuid=stored_cred.user_uuid,
386
- credential_uuid=stored_cred.uuid,
362
+ user_uuid=cred.user_uuid,
363
+ credential_uuid=cred.uuid,
387
364
  reset_token=reset_token,
388
365
  )
389
366