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