paskia 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. paskia/__init__.py +3 -0
  2. paskia/_version.py +34 -0
  3. paskia/aaguid/__init__.py +32 -0
  4. paskia/aaguid/combined_aaguid.json +1 -0
  5. paskia/authsession.py +112 -0
  6. paskia/bootstrap.py +190 -0
  7. paskia/config.py +25 -0
  8. paskia/db/__init__.py +415 -0
  9. paskia/db/sql.py +1424 -0
  10. paskia/fastapi/__init__.py +3 -0
  11. paskia/fastapi/__main__.py +335 -0
  12. paskia/fastapi/admin.py +850 -0
  13. paskia/fastapi/api.py +308 -0
  14. paskia/fastapi/auth_host.py +97 -0
  15. paskia/fastapi/authz.py +110 -0
  16. paskia/fastapi/mainapp.py +130 -0
  17. paskia/fastapi/remote.py +504 -0
  18. paskia/fastapi/reset.py +101 -0
  19. paskia/fastapi/session.py +52 -0
  20. paskia/fastapi/user.py +162 -0
  21. paskia/fastapi/ws.py +163 -0
  22. paskia/fastapi/wsutil.py +91 -0
  23. paskia/frontend-build/auth/admin/index.html +18 -0
  24. paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
  25. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
  26. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
  27. paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
  28. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
  30. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
  32. paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
  33. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
  34. paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
  35. paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
  36. paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
  37. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
  38. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
  39. paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
  40. paskia/frontend-build/auth/index.html +19 -0
  41. paskia/frontend-build/auth/restricted/index.html +16 -0
  42. paskia/frontend-build/int/forward/index.html +18 -0
  43. paskia/frontend-build/int/reset/index.html +15 -0
  44. paskia/globals.py +71 -0
  45. paskia/remoteauth.py +359 -0
  46. paskia/sansio.py +263 -0
  47. paskia/util/frontend.py +75 -0
  48. paskia/util/hostutil.py +76 -0
  49. paskia/util/htmlutil.py +47 -0
  50. paskia/util/passphrase.py +20 -0
  51. paskia/util/permutil.py +32 -0
  52. paskia/util/pow.py +45 -0
  53. paskia/util/querysafe.py +11 -0
  54. paskia/util/sessionutil.py +37 -0
  55. paskia/util/startupbox.py +75 -0
  56. paskia/util/timeutil.py +47 -0
  57. paskia/util/tokens.py +44 -0
  58. paskia/util/useragent.py +10 -0
  59. paskia/util/userinfo.py +159 -0
  60. paskia/util/wordlist.py +54 -0
  61. paskia-0.7.1.dist-info/METADATA +22 -0
  62. paskia-0.7.1.dist-info/RECORD +64 -0
  63. paskia-0.7.1.dist-info/WHEEL +4 -0
  64. paskia-0.7.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,3 @@
1
+ from paskia.fastapi.mainapp import app
2
+
3
+ __all__ = ["app"]
@@ -0,0 +1,335 @@
1
+ import argparse
2
+ import asyncio
3
+ import ipaddress
4
+ import logging
5
+ import os
6
+ from urllib.parse import urlparse
7
+
8
+ import uvicorn
9
+
10
+ from paskia.util.hostutil import normalize_origin
11
+
12
+ DEFAULT_HOST = "localhost"
13
+ DEFAULT_SERVE_PORT = 4401
14
+
15
+
16
+ def is_subdomain(sub: str, domain: str) -> bool:
17
+ """Check if sub is a subdomain of domain (or equal)."""
18
+ sub_parts = sub.lower().split(".")
19
+ domain_parts = domain.lower().split(".")
20
+ if len(sub_parts) < len(domain_parts):
21
+ return False
22
+ return sub_parts[-len(domain_parts) :] == domain_parts
23
+
24
+
25
+ def validate_auth_host(auth_host: str, rp_id: str) -> None:
26
+ """Validate that auth_host is a subdomain of rp_id."""
27
+ parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
28
+ host = parsed.hostname or parsed.path
29
+ if not host:
30
+ raise SystemExit(f"Invalid auth-host: '{auth_host}'")
31
+ if not is_subdomain(host, rp_id):
32
+ raise SystemExit(
33
+ f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
34
+ )
35
+
36
+
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
+ def add_common_options(p: argparse.ArgumentParser) -> None:
112
+ p.add_argument(
113
+ "--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
114
+ )
115
+ p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
116
+ p.add_argument(
117
+ "--origin",
118
+ action="append",
119
+ dest="origins",
120
+ metavar="URL",
121
+ help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
122
+ )
123
+ p.add_argument(
124
+ "--auth-host",
125
+ help=(
126
+ "Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
127
+ " e.g. auth.example.com or https://auth.example.com"
128
+ ),
129
+ )
130
+
131
+
132
+ def main():
133
+ # Configure logging to remove the "ERROR:root:" prefix
134
+ logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
135
+
136
+ parser = argparse.ArgumentParser(
137
+ prog="paskia", description="Paskia authentication server"
138
+ )
139
+ sub = parser.add_subparsers(dest="command", required=True)
140
+
141
+ # serve subcommand
142
+ serve = sub.add_parser(
143
+ "serve", help="Run the server (production style, no auto-reload)"
144
+ )
145
+ serve.add_argument(
146
+ "hostport",
147
+ nargs="?",
148
+ help=(
149
+ "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)."
161
+ ),
162
+ )
163
+ reset.add_argument(
164
+ "query",
165
+ nargs="?",
166
+ help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.",
167
+ )
168
+ add_common_options(reset)
169
+
170
+ args = parser.parse_args()
171
+
172
+ if args.command == "serve":
173
+ host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
174
+ else:
175
+ host = port = uds = all_ifaces = None # type: ignore
176
+
177
+ # Collect and normalize origins, handle auth_host
178
+ origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
179
+ if args.auth_host:
180
+ # Normalize auth_host with scheme
181
+ if "://" not in args.auth_host:
182
+ args.auth_host = f"https://{args.auth_host}"
183
+
184
+ validate_auth_host(args.auth_host, args.rp_id)
185
+
186
+ # If origins are configured, ensure auth_host is included at top
187
+ if origins:
188
+ # Insert auth_host at the beginning
189
+ origins.insert(0, args.auth_host)
190
+
191
+ # Remove duplicates while preserving order
192
+ seen = set()
193
+ origins = [x for x in origins if not (x in seen or seen.add(x))]
194
+
195
+ # 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:
198
+ site_url = args.auth_host.rstrip("/")
199
+ site_path = "/"
200
+ elif origins:
201
+ # Find localhost origin if rp_id is localhost, else use first origin
202
+ localhost_origin = (
203
+ next((o for o in origins if "://localhost" in o), None)
204
+ if args.rp_id == "localhost"
205
+ else None
206
+ )
207
+ site_url = (localhost_origin or origins[0]).rstrip("/")
208
+ site_path = "/auth/"
209
+ elif args.rp_id == "localhost" and port:
210
+ # Dev mode: use http with port
211
+ site_url = f"http://localhost:{port}"
212
+ site_path = "/auth/"
213
+ else:
214
+ site_url = f"https://{args.rp_id}"
215
+ site_path = "/auth/"
216
+
217
+ # Build runtime configuration
218
+ from paskia.config import PaskiaConfig
219
+
220
+ config = PaskiaConfig(
221
+ rp_id=args.rp_id,
222
+ rp_name=args.rp_name or None,
223
+ origins=origins or None,
224
+ auth_host=args.auth_host or None,
225
+ site_url=site_url,
226
+ site_path=site_path,
227
+ host=host,
228
+ port=port,
229
+ uds=uds,
230
+ )
231
+
232
+ # Export configuration via single JSON env variable for worker processes
233
+ import json
234
+
235
+ config_json = {
236
+ "rp_id": config.rp_id,
237
+ "rp_name": config.rp_name,
238
+ "origins": config.origins,
239
+ "auth_host": config.auth_host,
240
+ "site_url": config.site_url,
241
+ "site_path": config.site_path,
242
+ }
243
+ os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
244
+
245
+ # Initialize globals (without bootstrap yet)
246
+ from paskia import globals as _globals # local import
247
+
248
+ asyncio.run(
249
+ _globals.init(
250
+ rp_id=config.rp_id,
251
+ rp_name=config.rp_name,
252
+ origins=config.origins,
253
+ bootstrap=False,
254
+ )
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())
330
+ else:
331
+ uvicorn.run("paskia.fastapi:app", **run_kwargs)
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()