Compare commits

...
2 Commits
6 changed files with 58 additions and 45 deletions
+11 -10
View File
@@ -29,12 +29,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish
uvx paskia serve --rp-id example.com
uvx paskia --rp-id example.com
```
On the first run it downloads the software and prints a registration link for the Admin. 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.
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--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 (see documentation below).
For production you 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 (see documentation below).
For a permanent install of `paskia` CLI command, not needing `uvx`:
@@ -44,19 +44,20 @@ uv tool install paskia
## Configuration
There is no config file. Pass only the options on CLI:
There is no config file. All settings are passed as CLI options:
```text
paskia serve [options]
paskia [options]
paskia reset [user] # Generate passkey reset link
```
| Option | Description | Default |
|--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
## Further Documentation
+8 -7
View File
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
logger = logging.getLogger(__name__)
# Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\
%s
ADMIN_RESET_MESSAGE = """
👤 Admin %s
- Use this link to register a Passkey for the admin user!
"""
def _log_reset_link(message: str, passphrase: str) -> str:
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
if message:
logger.info(message)
logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
reset_passphrase = db.bootstrap()
# Log the reset link (this is separate from the transaction log)
_log_reset_link("✅ Bootstrap completed!", reset_passphrase)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def check_admin_credentials() -> bool:
@@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool:
if not db.get_user_credential_ids(admin_user.uuid):
# Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate()
expiry = authsession.reset_expires()
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
expiry=expiry,
token_type="admin registration",
)
_log_reset_link("⚠️ Admin user has no credentials!", token)
_log_reset_link(token)
return True
return False
+5 -8
View File
@@ -74,21 +74,18 @@ async def start_background():
_logger.debug("Background task in different event loop, restarting")
_background_task = None
else:
# Task is running in the same event loop - this is an error
raise RuntimeError(
"Background task is already running. "
"start_background() must not be called multiple times in the same event loop."
# Task is already running in same loop - idempotent, just return
# This happens with dual IPv4+IPv6 endpoints sharing the same process
_logger.debug(
"Background task already running in same loop, skipping"
)
except RuntimeError:
raise # Re-raise RuntimeError from above
return
except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None
if _background_task is None:
_background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background():
+21 -13
View File
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
EPILOG = """\
Examples:
paskia # localhost:4401
paskia :8080 # All interfaces, port 8080
paskia unix:/tmp/paskia.sock
paskia reset [user] # Generate passkey reset link
paskia # localhost:4401
paskia -l :8080 # All interfaces, port 8080
paskia -l /tmp/paskia.sock # Unix socket
paskia reset [user] # Generate passkey reset link
"""
@@ -81,32 +81,40 @@ def main():
epilog=EPILOG,
)
# Primary argument: either host:port or "reset" subcommand
# Subcommand for reset
parser.add_argument(
"hostport",
"command",
nargs="?",
help=(
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
help="Command: 'reset' for credential reset, or omit to run server",
)
parser.add_argument(
"reset_query",
nargs="?",
help="For 'reset' command: user UUID or substring of display name",
)
parser.add_argument(
"-l",
"--listen",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
),
)
add_common_options(parser)
args = parser.parse_args()
# Detect "reset" subcommand (first positional is "reset")
is_reset = args.hostport == "reset"
# Detect "reset" subcommand
is_reset = args.command == "reset"
if is_reset:
endpoints = []
else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
# Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {}
View File
+13 -7
View File
@@ -6,9 +6,9 @@ not from the installed package. It starts both the Vite frontend dev server
and the FastAPI backend with auto-reload enabled.
Usage:
uv run scripts/devserver.py [host:port] [options...]
uv run scripts/devserver.py [-l host:port] [options...]
The optional host:port argument sets where the Vite frontend listens.
The optional -l/--listen argument sets where the Vite frontend listens.
All other options are forwarded to `paskia`.
Backend always listens on localhost:4402.
@@ -376,9 +376,15 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
def main():
# Parse optional hostport argument for Vite frontend
# Parse optional listen argument for Vite frontend
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("hostport", nargs="?", default=None)
parser.add_argument(
"-l",
"--listen",
metavar="ENDPOINT",
default=None,
help="Vite frontend endpoint (default: localhost:4403)",
)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument(
@@ -389,7 +395,7 @@ def main():
# Parse Vite endpoint
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
args.hostport, DEFAULT_VITE_PORT
args.listen, DEFAULT_VITE_PORT
)
if vite_uds:
@@ -446,8 +452,8 @@ def main():
# Start Vite dev server
run_vite(vite_url, vite_host, vite_port, env, args.auth_host)
# Build command with origin args (no serve subcommand, host:port is first arg)
cmd = ["paskia", f"localhost:{BACKEND_PORT}"]
# Build command with origin args
cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
# Pass through rp-id (always pass, has default)
cmd.extend(["--rp-id", args.rp_id])