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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +14 -27
  3. paskia/bootstrap.py +31 -103
  4. paskia/config.py +0 -1
  5. paskia/db/__init__.py +26 -51
  6. paskia/db/background.py +17 -37
  7. paskia/db/jsonl.py +168 -6
  8. paskia/db/migrations.py +34 -0
  9. paskia/db/operations.py +400 -723
  10. paskia/db/structs.py +214 -90
  11. paskia/fastapi/__main__.py +89 -189
  12. paskia/fastapi/admin.py +103 -162
  13. paskia/fastapi/api.py +49 -85
  14. paskia/fastapi/mainapp.py +30 -19
  15. paskia/fastapi/remote.py +16 -39
  16. paskia/fastapi/reset.py +27 -17
  17. paskia/fastapi/session.py +2 -2
  18. paskia/fastapi/user.py +21 -27
  19. paskia/fastapi/ws.py +27 -62
  20. paskia/fastapi/wschat.py +62 -0
  21. paskia/frontend-build/auth/admin/index.html +5 -5
  22. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  23. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  24. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  25. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  26. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  27. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  28. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  29. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  30. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  31. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  32. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  33. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  34. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  35. paskia/frontend-build/auth/index.html +5 -5
  36. paskia/frontend-build/auth/restricted/index.html +4 -4
  37. paskia/frontend-build/int/forward/index.html +4 -4
  38. paskia/frontend-build/int/reset/index.html +3 -3
  39. paskia/globals.py +2 -2
  40. paskia/migrate/__init__.py +62 -55
  41. paskia/migrate/sql.py +72 -22
  42. paskia/remoteauth.py +1 -2
  43. paskia/sansio.py +6 -12
  44. {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/METADATA +3 -2
  45. paskia-0.9.0.dist-info/RECORD +57 -0
  46. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  47. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  48. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  49. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  50. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  51. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  52. paskia/util/frontend.py +0 -75
  53. paskia/util/hostutil.py +0 -76
  54. paskia/util/htmlutil.py +0 -47
  55. paskia/util/passphrase.py +0 -20
  56. paskia/util/permutil.py +0 -43
  57. paskia/util/pow.py +0 -45
  58. paskia/util/querysafe.py +0 -11
  59. paskia/util/sessionutil.py +0 -38
  60. paskia/util/startupbox.py +0 -75
  61. paskia/util/timeutil.py +0 -47
  62. paskia/util/useragent.py +0 -10
  63. paskia/util/userinfo.py +0 -145
  64. paskia/util/wordlist.py +0 -54
  65. paskia-0.8.0.dist-info/RECORD +0 -68
  66. {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
  67. {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
@@ -1,16 +1,31 @@
1
1
  import argparse
2
2
  import asyncio
3
- import ipaddress
3
+ import json
4
4
  import logging
5
5
  import os
6
6
  from urllib.parse import urlparse
7
7
 
8
- import uvicorn
8
+ from fastapi_vue.hostutil import parse_endpoint
9
+ from uvicorn import Config, Server
9
10
 
11
+ from paskia import globals as _globals
12
+ from paskia.bootstrap import bootstrap_if_needed
13
+ from paskia.config import PaskiaConfig
14
+ from paskia.db.background import flush
15
+ from paskia.fastapi import app as fastapi_app
16
+ from paskia.fastapi import reset as reset_cmd
17
+ from paskia.util import startupbox
10
18
  from paskia.util.hostutil import normalize_origin
11
19
 
12
- DEFAULT_HOST = "localhost"
13
- DEFAULT_SERVE_PORT = 4401
20
+ DEFAULT_PORT = 4401
21
+
22
+ EPILOG = """\
23
+ Examples:
24
+ paskia # localhost:4401
25
+ paskia :8080 # All interfaces, port 8080
26
+ paskia unix:/tmp/paskia.sock
27
+ paskia reset [user] # Generate passkey reset link
28
+ """
14
29
 
15
30
 
16
31
  def is_subdomain(sub: str, domain: str) -> bool:
@@ -34,80 +49,6 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None:
34
49
  )
35
50
 
36
51
 
37
- def parse_endpoint(
38
- value: str | None, default_port: int
39
- ) -> tuple[str | None, int | None, str | None, bool]:
40
- """Parse an endpoint using stdlib (urllib.parse, ipaddress).
41
-
42
- Returns (host, port, uds_path). If uds_path is not None, host/port are None.
43
-
44
- Supported forms:
45
- - host[:port]
46
- - :port (uses default host)
47
- - [ipv6][:port] (bracketed for port usage)
48
- - ipv6 (unbracketed, no port allowed -> default port)
49
- - unix:/path/to/socket.sock
50
- - None -> defaults (localhost:4401)
51
-
52
- Notes:
53
- - For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080)
54
- - Unbracketed IPv6 like ::1 implies the default port.
55
- """
56
- if not value:
57
- return DEFAULT_HOST, default_port, None, False
58
-
59
- # Port only (numeric) -> localhost:port
60
- if value.isdigit():
61
- try:
62
- port_only = int(value)
63
- except ValueError: # pragma: no cover (isdigit guards)
64
- raise SystemExit(f"Invalid port '{value}'")
65
- return DEFAULT_HOST, port_only, None, False
66
-
67
- # Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
68
- if value.startswith(":") and value != ":":
69
- port_part = value[1:]
70
- if not port_part.isdigit():
71
- raise SystemExit(f"Invalid port in '{value}'")
72
- return None, int(port_part), None, True
73
-
74
- # UNIX domain socket
75
- if value.startswith("unix:"):
76
- uds_path = value[5:] or None
77
- if uds_path is None:
78
- raise SystemExit("unix: path must not be empty")
79
- return None, None, uds_path, False
80
-
81
- # Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
82
- if value.count(":") > 1 and not value.startswith("["):
83
- try:
84
- ipaddress.IPv6Address(value)
85
- except ValueError as e: # pragma: no cover
86
- raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
87
- return value, default_port, None, False
88
-
89
- # Use urllib.parse for everything else (host[:port], :port, [ipv6][:port])
90
- parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
91
- host = parsed.hostname
92
- port = parsed.port
93
-
94
- # Host may be None if empty (e.g. ':5500')
95
- if not host:
96
- host = DEFAULT_HOST
97
- if port is None:
98
- port = default_port
99
-
100
- # Validate IP literals (optional; hostname passes through)
101
- try:
102
- # Strip brackets if somehow present (urlparse removes them already)
103
- ipaddress.ip_address(host)
104
- except ValueError:
105
- # Not an IP address -> treat as hostname; no action
106
- pass
107
-
108
- return host, port, None, False
109
-
110
-
111
52
  def add_common_options(p: argparse.ArgumentParser) -> None:
112
53
  p.add_argument(
113
54
  "--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
@@ -134,45 +75,44 @@ def main():
134
75
  logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
135
76
 
136
77
  parser = argparse.ArgumentParser(
137
- prog="paskia", description="Paskia authentication server"
78
+ prog="paskia",
79
+ description="Paskia authentication server",
80
+ formatter_class=argparse.RawDescriptionHelpFormatter,
81
+ epilog=EPILOG,
138
82
  )
139
- sub = parser.add_subparsers(dest="command", required=True)
140
83
 
141
- # serve subcommand
142
- serve = sub.add_parser(
143
- "serve", help="Run the server (production style, no auto-reload)"
144
- )
145
- serve.add_argument(
84
+ # Primary argument: either host:port or "reset" subcommand
85
+ parser.add_argument(
146
86
  "hostport",
147
87
  nargs="?",
148
88
  help=(
149
89
  "Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
150
- "[ipv6][:port] | ipv6 | unix:/path.sock"
151
- ),
152
- )
153
- add_common_options(serve)
154
-
155
- # reset subcommand
156
- reset = sub.add_parser(
157
- "reset",
158
- help=(
159
- "Create a credential reset link for a user. Provide part of the display name or UUID. "
160
- "If omitted, targets the master admin (first Administration role user in an auth:admin org)."
90
+ "[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
161
91
  ),
162
92
  )
163
- reset.add_argument(
164
- "query",
93
+ parser.add_argument(
94
+ "reset_query",
165
95
  nargs="?",
166
- help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.",
96
+ help="For 'reset' command: user UUID or substring of display name",
167
97
  )
168
- add_common_options(reset)
98
+ add_common_options(parser)
169
99
 
170
100
  args = parser.parse_args()
171
101
 
172
- if args.command == "serve":
173
- host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
102
+ # Detect "reset" subcommand (first positional is "reset")
103
+ is_reset = args.hostport == "reset"
104
+
105
+ if is_reset:
106
+ endpoints = []
174
107
  else:
175
- host = port = uds = all_ifaces = None # type: ignore
108
+ # Parse endpoint using fastapi_vue.hostutil
109
+ endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
110
+
111
+ # Extract host/port/uds from first endpoint for config display and site_url
112
+ ep = endpoints[0] if endpoints else {}
113
+ host = ep.get("host")
114
+ port = ep.get("port")
115
+ uds = ep.get("uds")
176
116
 
177
117
  # Collect and normalize origins, handle auth_host
178
118
  origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
@@ -193,8 +133,13 @@ def main():
193
133
  origins = [x for x in origins if not (x in seen or seen.add(x))]
194
134
 
195
135
  # Compute site_url and site_path for reset links
196
- # Priority: auth_host > first origin with localhost > http://localhost:port
197
- if args.auth_host:
136
+ # Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
137
+ explicit_site_url = os.environ.get("PASKIA_SITE_URL")
138
+ if explicit_site_url:
139
+ # Explicit site URL from devserver or deployment config
140
+ site_url = explicit_site_url.rstrip("/")
141
+ site_path = "/" if args.auth_host else "/auth/"
142
+ elif args.auth_host:
198
143
  site_url = args.auth_host.rstrip("/")
199
144
  site_path = "/"
200
145
  elif origins:
@@ -215,8 +160,6 @@ def main():
215
160
  site_path = "/auth/"
216
161
 
217
162
  # Build runtime configuration
218
- from paskia.config import PaskiaConfig
219
-
220
163
  config = PaskiaConfig(
221
164
  rp_id=args.rp_id,
222
165
  rp_name=args.rp_name or None,
@@ -230,8 +173,6 @@ def main():
230
173
  )
231
174
 
232
175
  # Export configuration via single JSON env variable for worker processes
233
- import json
234
-
235
176
  config_json = {
236
177
  "rp_id": config.rp_id,
237
178
  "rp_name": config.rp_name,
@@ -242,93 +183,52 @@ def main():
242
183
  }
243
184
  os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
244
185
 
245
- # Initialize globals (without bootstrap yet)
246
- from paskia import globals as _globals # local import
186
+ startupbox.print_startup_config(config)
187
+
188
+ devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
247
189
 
248
- asyncio.run(
249
- _globals.init(
190
+ run_kwargs: dict = {
191
+ "log_level": "info",
192
+ }
193
+
194
+ if devmode:
195
+ # Security: dev mode must run on localhost:4402 to prevent
196
+ # accidental public exposure of the Vite dev server
197
+ if host != "localhost" or port != 4402:
198
+ raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
199
+ run_kwargs["reload"] = True
200
+ run_kwargs["reload_dirs"] = ["paskia"]
201
+ # Suppress uvicorn startup messages in dev mode
202
+ run_kwargs["log_level"] = "warning"
203
+
204
+ async def async_main():
205
+ await _globals.init(
250
206
  rp_id=config.rp_id,
251
207
  rp_name=config.rp_name,
252
208
  origins=config.origins,
253
209
  bootstrap=False,
254
210
  )
255
- )
256
-
257
- # Print startup configuration
258
- from paskia.util import startupbox
259
-
260
- startupbox.print_startup_config(config)
261
-
262
- # Bootstrap after startup box is printed
263
- from paskia.bootstrap import bootstrap_if_needed
264
-
265
- asyncio.run(bootstrap_if_needed())
266
-
267
- # Handle recover-admin command (no server start)
268
- if args.command == "reset":
269
- from paskia.fastapi import reset as reset_cmd # local import
270
-
271
- exit_code = reset_cmd.run(getattr(args, "query", None))
272
- raise SystemExit(exit_code)
273
-
274
- if args.command == "serve":
275
- run_kwargs: dict = {
276
- "log_level": "info",
277
- }
278
-
279
- # Dev mode: enable reload when PASKIA_DEVMODE is set
280
- devmode = bool(os.environ.get("PASKIA_DEVMODE"))
281
- if devmode:
282
- # Security: dev mode must run on localhost:4402 to prevent
283
- # accidental public exposure of the Vite dev server
284
- if host != "localhost" or port != 4402:
285
- raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
286
- run_kwargs["reload"] = True
287
- run_kwargs["reload_dirs"] = ["paskia"]
288
- # Suppress uvicorn startup messages in dev mode
289
- run_kwargs["log_level"] = "warning"
290
-
291
- if uds:
292
- run_kwargs["uds"] = uds
293
- else:
294
- if not all_ifaces:
295
- run_kwargs["host"] = host
296
- run_kwargs["port"] = port
297
-
298
- if all_ifaces and not uds:
299
- # Dev mode with all interfaces: use simple single-server approach
300
- if devmode:
301
- run_kwargs["host"] = "::"
302
- run_kwargs["port"] = port
303
- uvicorn.run("paskia.fastapi:app", **run_kwargs)
304
- else:
305
- # Production: run separate servers for IPv4 and IPv6
306
- from uvicorn import Config, Server # noqa: E402 local import
307
-
308
- from paskia.fastapi import (
309
- app as fastapi_app, # noqa: E402 local import
310
- )
311
-
312
- async def serve_both():
313
- servers = []
314
- assert port is not None
315
- for h in ("0.0.0.0", "::"):
316
- try:
317
- cfg = Config(
318
- app=fastapi_app,
319
- host=h,
320
- port=port,
321
- log_level="info",
322
- )
323
- servers.append(Server(cfg))
324
- except Exception as e: # pragma: no cover
325
- logging.warning(f"Failed to configure server for {h}: {e}")
326
- tasks = [asyncio.create_task(s.serve()) for s in servers]
327
- await asyncio.gather(*tasks)
328
-
329
- asyncio.run(serve_both())
211
+ await bootstrap_if_needed()
212
+ await flush()
213
+
214
+ if is_reset:
215
+ exit_code = reset_cmd.run(args.reset_query)
216
+ raise SystemExit(exit_code)
217
+
218
+ if len(endpoints) > 1:
219
+ async with asyncio.TaskGroup() as tg:
220
+ for ep in endpoints:
221
+ tg.create_task(
222
+ Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
223
+ )
330
224
  else:
331
- uvicorn.run("paskia.fastapi:app", **run_kwargs)
225
+ server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
226
+ await server.serve()
227
+
228
+ try:
229
+ asyncio.run(async_main())
230
+ except KeyboardInterrupt:
231
+ pass
332
232
 
333
233
 
334
234
  if __name__ == "__main__":