More robust server startup, startup logo and info screen, renewed devmode script.
This commit is contained in:
@@ -30,11 +30,22 @@ Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||
uvx paskia serve --rp-id example.com
|
||||
```
|
||||
|
||||
On the first run it downloads the software and prints a registration link for the Admin. Consider `uv tool install paskia` for a permanent install of `paskia` CLI.
|
||||
On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
|
||||
|
||||
The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
|
||||
|
||||
If you are going to be connecting `localhost` directly, for testing, leave out the rp-id. Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
|
||||
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
|
||||
|
||||
A quick example without any config file:
|
||||
```fish
|
||||
sudo caddy reverse-proxy --from example.com --to :4401
|
||||
```
|
||||
|
||||
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||
|
||||
```fish
|
||||
uv tool install paskia
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -44,12 +55,12 @@ There is no config file. Pass only the options on CLI:
|
||||
paskia serve [options]
|
||||
```
|
||||
|
||||
Options (all optional):
|
||||
Optional options:
|
||||
|
||||
- Listen address (one of):
|
||||
* `[host]:port`: Address and port (default: `localhost:4401`)
|
||||
* `unix:/path.sock`: Unix socket
|
||||
- `--rp-id <domain>`: Domain name for authentication (required for production)
|
||||
- `--rp-id <domain>`: Main domain (required for production)
|
||||
- `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
|
||||
- `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
|
||||
- `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
|
||||
<div class="section">
|
||||
<h2>Browser Mode (full page)</h2>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nxinx):</p>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p>
|
||||
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
|
||||
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
|
||||
+44
-15
@@ -111,7 +111,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||
)
|
||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
||||
p.add_argument(
|
||||
"--origin",
|
||||
action="append",
|
||||
dest="origins",
|
||||
metavar="URL",
|
||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--auth-host",
|
||||
help=(
|
||||
@@ -166,39 +172,62 @@ def main():
|
||||
else:
|
||||
host = port = uds = all_ifaces = None # type: ignore
|
||||
|
||||
# Export configuration via environment for lifespan initialization in each process
|
||||
os.environ.setdefault("PASKIA_RP_ID", args.rp_id)
|
||||
if args.rp_name:
|
||||
os.environ["PASKIA_RP_NAME"] = args.rp_name
|
||||
if args.origin:
|
||||
os.environ["PASKIA_ORIGIN"] = args.origin
|
||||
if getattr(args, "auth_host", None):
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
else:
|
||||
# Collect origins and handle auth_host
|
||||
origins = getattr(args, "origins", None) or []
|
||||
if not getattr(args, "auth_host", None):
|
||||
# Preserve pre-set env variable if CLI option omitted
|
||||
args.auth_host = os.environ.get("PASKIA_AUTH_HOST")
|
||||
|
||||
if args.auth_host:
|
||||
# Normalize auth_host with scheme
|
||||
if "://" not in args.auth_host:
|
||||
args.auth_host = f"https://{args.auth_host}"
|
||||
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
from paskia.util import hostutil as _hostutil # local import
|
||||
|
||||
_hostutil.reload_config()
|
||||
|
||||
# If origins are configured, ensure auth_host is included at top
|
||||
if origins:
|
||||
# Insert auth_host at the beginning (Passkey.__init__ will normalize/dedupe)
|
||||
origins.insert(0, args.auth_host)
|
||||
|
||||
# Export configuration via single JSON env variable for worker processes
|
||||
# (PASKIA_DEVMODE is kept separate as it's externally defined)
|
||||
# All keys are always present; None is used where no value is configured
|
||||
import json
|
||||
|
||||
config = {
|
||||
"rp_id": args.rp_id,
|
||||
"rp_name": args.rp_name or None,
|
||||
"origins": origins or None,
|
||||
"auth_host": args.auth_host or None,
|
||||
"default_admin": os.getenv("PASKIA_DEFAULT_ADMIN") or None,
|
||||
"default_org": os.getenv("PASKIA_DEFAULT_ORG") or None,
|
||||
}
|
||||
os.environ["PASKIA_CONFIG"] = json.dumps(config)
|
||||
|
||||
# One-time initialization + bootstrap before starting any server processes.
|
||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
||||
from paskia import globals as _globals # local import
|
||||
|
||||
asyncio.run(
|
||||
_globals.init(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origin=args.origin,
|
||||
default_admin=os.getenv("PASKIA_DEFAULT_ADMIN") or None,
|
||||
default_org=os.getenv("PASKIA_DEFAULT_ORG") or None,
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
default_admin=config["default_admin"],
|
||||
default_org=config["default_org"],
|
||||
bootstrap=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Print startup configuration
|
||||
from paskia.util import startupbox
|
||||
|
||||
startupbox.print_startup_config(_globals.passkey.instance, args, host, port, uds)
|
||||
|
||||
# Handle recover-admin command (no server start)
|
||||
if args.command == "reset":
|
||||
from paskia.fastapi import reset as reset_cmd # local import
|
||||
|
||||
+11
-13
@@ -15,26 +15,24 @@ from paskia.util import frontend, hostutil, passphrase
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
||||
|
||||
We populate configuration from environment variables (set by the CLI entrypoint)
|
||||
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||
"""
|
||||
import json
|
||||
|
||||
from paskia import globals
|
||||
|
||||
rp_id = os.getenv("PASKIA_RP_ID", "localhost")
|
||||
rp_name = os.getenv("PASKIA_RP_NAME") or None
|
||||
origin = os.getenv("PASKIA_ORIGIN") or None
|
||||
default_admin = (
|
||||
os.getenv("PASKIA_DEFAULT_ADMIN") or None
|
||||
) # still passed for context
|
||||
default_org = os.getenv("PASKIA_DEFAULT_ORG") or None
|
||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||
|
||||
try:
|
||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||
await globals.init(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
origin=origin,
|
||||
default_admin=default_admin,
|
||||
default_org=default_org,
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
default_admin=config["default_admin"],
|
||||
default_org=config["default_org"],
|
||||
bootstrap=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
+17
-6
@@ -42,19 +42,30 @@ def websocket_error_handler(func):
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def _validate_origin(ws: WebSocket) -> str:
|
||||
"""Extract and validate origin from WebSocket request headers.
|
||||
|
||||
Raises:
|
||||
ValueError: If origin header is missing or not in allowed list
|
||||
"""
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.instance.validate_origin(origin)
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: str,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
origin: str | None = None,
|
||||
):
|
||||
"""Generate registration options and send them to the client."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
origin=origin,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
@@ -75,7 +86,7 @@ async def websocket_register_add(
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
origin = _validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
@@ -100,7 +111,7 @@ async def websocket_register_add(
|
||||
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
@@ -131,7 +142,7 @@ async def websocket_register_add(
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
origin = _validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
@@ -166,7 +177,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ class Manager(Generic[T]):
|
||||
async def init(
|
||||
rp_id: str = "localhost",
|
||||
rp_name: str | None = None,
|
||||
origin: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
default_admin: str | None = None,
|
||||
default_org: str | None = None,
|
||||
*,
|
||||
@@ -45,7 +45,7 @@ async def init(
|
||||
passkey.instance = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name or rp_id,
|
||||
origin=origin,
|
||||
origins=origins,
|
||||
)
|
||||
|
||||
# Test if we have a database already initialized, otherwise use SQL
|
||||
|
||||
+39
-16
@@ -47,7 +47,7 @@ class Passkey:
|
||||
self,
|
||||
rp_id: str,
|
||||
rp_name: str | None = None,
|
||||
origin: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||
):
|
||||
"""
|
||||
@@ -56,27 +56,30 @@ class Passkey:
|
||||
Args:
|
||||
rp_id: Your security domain (e.g. "example.com")
|
||||
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
||||
origin: The origin URL of the application (e.g. "https://app.example.com").
|
||||
If no scheme is provided, "https://" will be prepended.
|
||||
Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
||||
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
||||
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||
|
||||
Raises:
|
||||
ValueError: If the origin domain doesn't match or isn't a subdomain of rp_id.
|
||||
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
||||
"""
|
||||
self.rp_id = rp_id
|
||||
self.rp_name = rp_name or rp_id
|
||||
self.origin = self._normalize_and_validate_origin(origin, rp_id)
|
||||
self.allowed_origins: set[str] | None = None
|
||||
if origins:
|
||||
# Normalize and deduplicate origins into a set for O(1) lookups
|
||||
self.allowed_origins = {
|
||||
self._normalize_and_validate_origin(o, rp_id) for o in origins
|
||||
}
|
||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||
COSEAlgorithmIdentifier.EDDSA,
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
]
|
||||
|
||||
def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str:
|
||||
if origin is None:
|
||||
origin = f"https://{rp_id}"
|
||||
elif "://" not in origin:
|
||||
def _normalize_and_validate_origin(self, origin: str, rp_id: str) -> str:
|
||||
"""Normalize and validate an origin URL against the rp_id."""
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
|
||||
hostname = urlparse(origin).hostname
|
||||
@@ -90,6 +93,24 @@ class Passkey:
|
||||
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
||||
)
|
||||
|
||||
def validate_origin(self, origin: str) -> str:
|
||||
"""Validate that origin is allowed and return the normalized form.
|
||||
|
||||
Args:
|
||||
origin: The origin URL to validate (from WebSocket request header)
|
||||
|
||||
Returns:
|
||||
The validated origin URL
|
||||
|
||||
Raises:
|
||||
ValueError: If origin is not in the allowed list (when origins are configured)
|
||||
or if origin is not a valid subdomain of rp_id
|
||||
"""
|
||||
normalized = self._normalize_and_validate_origin(origin, self.rp_id)
|
||||
if self.allowed_origins is not None and normalized not in self.allowed_origins:
|
||||
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
||||
return normalized
|
||||
|
||||
### Registration Methods ###
|
||||
|
||||
def reg_generate_options(
|
||||
@@ -137,14 +158,16 @@ class Passkey:
|
||||
response_json: dict | str,
|
||||
expected_challenge: bytes,
|
||||
user_uuid: UUID,
|
||||
origin: str | None = None,
|
||||
origin: str,
|
||||
) -> Credential:
|
||||
"""
|
||||
Verify registration response.
|
||||
|
||||
Args:
|
||||
credential: The credential response from the client
|
||||
response_json: The credential response from the client
|
||||
expected_challenge: The expected challenge bytes
|
||||
user_uuid: The user's UUID
|
||||
origin: The origin URL (required, must be pre-validated)
|
||||
|
||||
Returns:
|
||||
Registration verification result
|
||||
@@ -153,7 +176,7 @@ class Passkey:
|
||||
registration = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=origin or self.origin,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
)
|
||||
return Credential(
|
||||
@@ -206,7 +229,7 @@ class Passkey:
|
||||
credential: AuthenticationCredential,
|
||||
expected_challenge: bytes,
|
||||
stored_cred: Credential,
|
||||
origin: str | None = None,
|
||||
origin: str,
|
||||
) -> VerifiedAuthentication:
|
||||
"""
|
||||
Verify authentication response against locally stored credential data.
|
||||
@@ -215,13 +238,13 @@ class Passkey:
|
||||
credential: The authentication credential response from the client
|
||||
expected_challenge: The earlier generated challenge bytes
|
||||
stored_cred: The server stored credential record (modified by this function)
|
||||
origin: The origin URL (required, must be pre-validated)
|
||||
"""
|
||||
expected_origin = origin or self.origin
|
||||
# Verify the authentication response
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=expected_origin,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
credential_public_key=stored_cred.public_key,
|
||||
credential_current_sign_count=stored_cred.sign_count,
|
||||
|
||||
+27
-11
@@ -1,28 +1,38 @@
|
||||
"""Utilities for determining the auth UI host and base URLs."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
from paskia.globals import passkey as global_passkey
|
||||
|
||||
_AUTH_HOST_ENV = "PASKIA_AUTH_HOST"
|
||||
|
||||
|
||||
def _default_origin_scheme() -> str:
|
||||
origin_url = urlparse(global_passkey.instance.origin)
|
||||
return origin_url.scheme or "https"
|
||||
"""Get the default scheme from configured origins, or fallback to https."""
|
||||
allowed = global_passkey.instance.allowed_origins
|
||||
if allowed:
|
||||
# Pick any origin from the set
|
||||
origin_url = urlparse(next(iter(allowed)))
|
||||
return origin_url.scheme or "https"
|
||||
return "https"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_config() -> tuple[str | None, str] | None:
|
||||
raw = os.getenv(_AUTH_HOST_ENV)
|
||||
"""Load auth_host from PASKIA_CONFIG JSON, falling back to PASKIA_AUTH_HOST."""
|
||||
# Try PASKIA_CONFIG first (set by CLI)
|
||||
config_json = os.getenv("PASKIA_CONFIG")
|
||||
if config_json:
|
||||
config = json.loads(config_json)
|
||||
raw = config["auth_host"] # Always present, may be None
|
||||
else:
|
||||
# Fallback for external usage (e.g., PASKIA_AUTH_HOST set directly)
|
||||
raw = os.getenv("PASKIA_AUTH_HOST")
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
candidate = raw.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
|
||||
parsed = urlparse(raw if "://" in raw else f"//{raw}")
|
||||
netloc = parsed.netloc or parsed.path
|
||||
if not netloc:
|
||||
return None
|
||||
@@ -53,8 +63,14 @@ def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> st
|
||||
scheme_to_use = scheme or _default_origin_scheme()
|
||||
netloc = host.strip("/")
|
||||
else:
|
||||
origin = global_passkey.instance.origin.rstrip("/")
|
||||
return f"{origin}{ui_base_path()}"
|
||||
# Use the first allowed origin, or fallback to rp_id
|
||||
allowed = global_passkey.instance.allowed_origins
|
||||
if allowed:
|
||||
origin = allowed[0].rstrip("/")
|
||||
return f"{origin}{ui_base_path()}"
|
||||
# Fallback: construct from rp_id
|
||||
rp_id = global_passkey.instance.rp_id
|
||||
return f"https://{rp_id}{ui_base_path()}"
|
||||
|
||||
base = f"{scheme_to_use}://{netloc}".rstrip("/")
|
||||
path = ui_base_path().lstrip("/")
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Startup configuration box formatting utilities."""
|
||||
|
||||
import os
|
||||
|
||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
|
||||
def line(text: str = "") -> str:
|
||||
"""Format a line inside the box with proper padding, truncating if needed."""
|
||||
if len(text) > BOX_WIDTH:
|
||||
text = text[: BOX_WIDTH - 1] + "…"
|
||||
return f"┃ {text:<{BOX_WIDTH}} ┃\n"
|
||||
|
||||
|
||||
def top() -> str:
|
||||
return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n"
|
||||
|
||||
|
||||
def bottom() -> str:
|
||||
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
||||
|
||||
|
||||
def print_startup_config(passkey_instance, args, host, port, uds) -> None:
|
||||
"""Print server configuration on startup."""
|
||||
from sys import stderr
|
||||
|
||||
from paskia._version import __version__
|
||||
|
||||
lines = [top()]
|
||||
lines.append(line(" ▄▄▄▄▄"))
|
||||
lines.append(line("█ █ Paskia " + __version__))
|
||||
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
|
||||
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█"))
|
||||
lines.append(line(" ▀▀▀▀▀"))
|
||||
|
||||
# Format auth host section
|
||||
auth_host = getattr(args, "auth_host", None)
|
||||
if auth_host:
|
||||
lines.append(line(f"Auth Host: {auth_host}"))
|
||||
|
||||
# Show frontend URL if in dev mode
|
||||
devmode = os.environ.get("PASKIA_DEVMODE")
|
||||
if devmode:
|
||||
lines.append(line(f"Dev Frontend: {devmode}"))
|
||||
|
||||
# Format listen address with scheme
|
||||
if uds:
|
||||
listen = f"unix:{uds}"
|
||||
elif host:
|
||||
listen = f"http://{host}:{port}"
|
||||
else:
|
||||
listen = f"http://0.0.0.0:{port} + [::]:{port}"
|
||||
lines.append(line(f"Backend: {listen}"))
|
||||
|
||||
# Relying Party line (omit name if same as id)
|
||||
rp_id = passkey_instance.rp_id
|
||||
rp_name = passkey_instance.rp_name
|
||||
if rp_name and rp_name != rp_id:
|
||||
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
|
||||
else:
|
||||
lines.append(line(f"Relying Party: {rp_id}"))
|
||||
|
||||
# Format origins section
|
||||
allowed = passkey_instance.allowed_origins
|
||||
if allowed:
|
||||
lines.append(line("Permitted Origins:"))
|
||||
for origin in sorted(allowed):
|
||||
lines.append(line(f" - {origin}"))
|
||||
else:
|
||||
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
|
||||
|
||||
lines.append(bottom())
|
||||
stderr.write("".join(lines))
|
||||
Regular → Executable
+58
-31
@@ -1,16 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Development server script for Paskia.
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
|
||||
This script is only available when running from the git repository source,
|
||||
not from the installed package. It starts both the Vite frontend dev server
|
||||
and the FastAPI backend with auto-reload enabled.
|
||||
|
||||
Usage:
|
||||
python scripts/dev.py [options...]
|
||||
uv run scripts/dev.py [host:port] [options...]
|
||||
|
||||
All options are forwarded to `paskia serve`.
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
All other options are forwarded to `paskia serve`.
|
||||
Backend always listens on localhost:4402.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
@@ -21,14 +24,10 @@ from pathlib import Path
|
||||
from sys import stderr
|
||||
from threading import Thread
|
||||
|
||||
# Set dev mode environment variable BEFORE importing anything from paskia
|
||||
os.environ["PASKIA_DEVMODE"] = "1"
|
||||
from paskia.fastapi.__main__ import parse_endpoint
|
||||
|
||||
# Ensure the package is importable when running from repo root
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
DEFAULT_DEV_PORT = 4402
|
||||
DEV_SERVER = "http://localhost:4403"
|
||||
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
|
||||
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
|
||||
|
||||
NO_FRONTEND_TOOL = """\
|
||||
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
|
||||
@@ -48,17 +47,19 @@ BUN_BUG = """\
|
||||
|
||||
NO_FRONTEND = """\
|
||||
┃
|
||||
┃ Note: only static build of the frontend is served at localhost:4402.
|
||||
┃ The page will not update with frontend code changes.
|
||||
┃ The backend will still try reaching Vite at {vite_url}
|
||||
┃ for various frontend assets, so make sure to start it manually.
|
||||
"""
|
||||
|
||||
|
||||
def run_vite():
|
||||
def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
|
||||
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
||||
devpath = Path(__file__).parent.parent / "frontend"
|
||||
if not (devpath / "package.json").exists():
|
||||
stderr.write(f"┃ ⚠️ Frontend source not found at {devpath}\n")
|
||||
stderr.write(NO_FRONTEND)
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Frontend source not found at {devpath}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
return
|
||||
|
||||
options = [
|
||||
@@ -74,24 +75,31 @@ def run_vite():
|
||||
tool_name = option[0]
|
||||
break
|
||||
|
||||
# Add Vite CLI args for host/port
|
||||
vite_args = ["--port", str(vite_port)]
|
||||
if vite_host:
|
||||
vite_args.extend(["--host", vite_host])
|
||||
|
||||
vite_process = None
|
||||
|
||||
def start_vite():
|
||||
nonlocal vite_process
|
||||
if cmd is None:
|
||||
stderr.write(NO_FRONTEND_TOOL)
|
||||
stderr.write(NO_FRONTEND)
|
||||
stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url))
|
||||
return
|
||||
assert tool_name is not None
|
||||
try:
|
||||
if tool_name == "bunx":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n")
|
||||
vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False)
|
||||
full_cmd = cmd + vite_args
|
||||
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
|
||||
vite_process = subprocess.Popen(full_cmd, cwd=str(devpath), shell=False)
|
||||
except Exception as e:
|
||||
stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n")
|
||||
stderr.write(NO_FRONTEND)
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Vite couldn't start: {e}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
|
||||
def cleanup():
|
||||
if vite_process:
|
||||
@@ -108,20 +116,39 @@ def run_vite():
|
||||
|
||||
|
||||
def main():
|
||||
# Start Vite dev server first
|
||||
run_vite()
|
||||
# Parse optional hostport argument for Vite frontend
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("hostport", nargs="?", default=None)
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
# Set default origin for Vite if not specified
|
||||
if "--origin" not in sys.argv:
|
||||
os.environ.setdefault("PASKIA_ORIGIN", DEV_SERVER)
|
||||
# Parse Vite endpoint
|
||||
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
|
||||
args.hostport, DEFAULT_VITE_PORT
|
||||
)
|
||||
|
||||
# Build argv for the main CLI
|
||||
# Dev mode always listens on localhost:4402 (security: prevents public exposure)
|
||||
# User args come after, allowing overrides of other options
|
||||
sys.argv = ["paskia", "serve", f"localhost:{DEFAULT_DEV_PORT}"] + sys.argv[1:]
|
||||
if vite_uds:
|
||||
raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend")
|
||||
|
||||
# Handle all-interfaces case (:port syntax)
|
||||
# Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev)
|
||||
if all_ifaces:
|
||||
vite_host = "0.0.0.0"
|
||||
|
||||
# Build Vite URL for PASKIA_DEVMODE (always use localhost for URL)
|
||||
vite_url = f"http://localhost:{vite_port}"
|
||||
|
||||
# Start Vite dev server
|
||||
run_vite(vite_url, vite_host, vite_port)
|
||||
|
||||
# Set dev mode with Vite URL
|
||||
os.environ["PASKIA_DEVMODE"] = vite_url
|
||||
|
||||
# Import CLI after environment is set up
|
||||
from paskia.fastapi.__main__ import main as cli_main
|
||||
|
||||
# Build argv for the main CLI in Dev mode
|
||||
# Backend always listens on localhost only (Vite proxies API requests)
|
||||
sys.argv = ["paskia", "serve", f"localhost:{BACKEND_PORT}"] + remaining
|
||||
cli_main()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user