Compare commits

..
3 Commits
7 changed files with 99 additions and 14 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ dist/
*.lock *.lock
package-lock.json package-lock.json
paskia.sqlite paskia.sqlite
paskia.jsonl *.paskiadb
/paskia/frontend-build /paskia/frontend-build
/paskia/_version.py /paskia/_version.py
coverage-html/ coverage-html/
+9 -6
View File
@@ -51,7 +51,7 @@ uv tool install paskia
## Configuration ## Configuration
All configuration is passed by CLI arguments, of which there are just a few. You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
```text ```text
paskia [options] paskia [options]
@@ -61,9 +61,12 @@ paskia [options]
|--------|-------------|---------| |--------|-------------|---------|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** | | -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-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id | | --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id | | --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site | | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## Tutorial: From Local Testing to Production ## Tutorial: From Local Testing to Production
@@ -84,10 +87,10 @@ This starts the server on [localhost:4401](http://localhost:4401) with passkeys
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```fish ```fish
paskia --rp-id example.com --rp-name "Example Corp" paskia --rp-id example.com --rp-name "Example Corp" --save
``` ```
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`.
### Step 3: Set Up Caddy ### Step 3: Set Up Caddy
@@ -187,7 +190,7 @@ Description=Paskia Authentication Server
Type=simple Type=simple
User=paskia User=paskia
WorkingDirectory=/srv/paskia WorkingDirectory=/srv/paskia
ExecStart=uvx paskia --rp-id example.com --rp-name "Example Corp" ExecStart=uvx paskia --rp-id=example.com
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+19 -5
View File
@@ -17,7 +17,6 @@ import uuid7
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import ( from paskia.db.jsonl import (
DB_PATH_DEFAULT,
JsonlStore, JsonlStore,
) )
from paskia.db.structs import ( from paskia.db.structs import (
@@ -42,15 +41,14 @@ _db._store = _store
_initialized = False _initialized = False
async def init(*args, **kwargs): async def init(rp_id: str = "localhost", *args, **kwargs):
"""Load database from JSONL file.""" """Load database from JSONL file."""
global _db, _initialized global _db, _initialized
if _initialized: if _initialized:
_logger.debug("Database already initialized, skipping reload") _logger.debug("Database already initialized, skipping reload")
return return
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) default_path = f"{rp_id}.paskiadb"
if db_path.startswith("json:"): db_path = os.environ.get("PASKIA_DB", default_path)
db_path = db_path[5:]
await _store.load(db_path) await _store.load(db_path)
_db = _store.db _db = _store.db
_initialized = True _initialized = True
@@ -823,3 +821,19 @@ def bootstrap(
_db.reset_tokens[reset_token.key] = reset_token _db.reset_tokens[reset_token.key] = reset_token
return reset_passphrase return reset_passphrase
# -------------------------------------------------------------------------
# Config operations
# -------------------------------------------------------------------------
def get_config() -> Config:
"""Get the stored configuration."""
return _db.config
async def set_config(config: Config) -> None:
"""Update the stored configuration."""
async with _db.transaction("update_config"):
_db.config = config
+11
View File
@@ -397,6 +397,16 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = [] permissions: list[Permission] = []
class Config(msgspec.Struct, dict=True, omit_defaults=True):
"""Stored configuration for the instance."""
rp_id: str | None = None
rp_name: str | None = None
origins: list[str] | None = None
auth_host: str | None = None
listen: str | None = None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Database storage structure # Database storage structure
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -412,6 +422,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {} credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {} sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {} reset_tokens: dict[bytes, ResetToken] = {}
config: Config = Config()
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Store reference for persistence (not serialized)
+40
View File
@@ -12,7 +12,10 @@ from uvicorn import run as uvicorn_run
from paskia import globals as _globals from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig from paskia.config import PaskiaConfig
from paskia.db import get_config, set_config
from paskia.db import init as db_init
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.structs import Config
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import normalize_origin
@@ -62,6 +65,11 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
"--auth-host", "--auth-host",
help=("Dedicated authentication site (optionally with scheme/port)"), help=("Dedicated authentication site (optionally with scheme/port)"),
) )
p.add_argument(
"--save",
action="store_true",
help="Save the CLI options to database for future runs.",
)
def main(): def main():
@@ -88,6 +96,28 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Handle clearing options
if getattr(args, "auth_host", None) == "":
args.auth_host = None
if getattr(args, "rp_name", None) == "":
args.rp_name = None
if getattr(args, "listen", None) == "":
args.listen = None
# Init db and load stored config
asyncio.run(db_init(rp_id=args.rp_id))
stored_config = get_config()
# Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None:
args.rp_name = stored_config.rp_name
if args.origins is None and stored_config.origins is not None:
args.origins = stored_config.origins
if args.auth_host is None and stored_config.auth_host is not None:
args.auth_host = stored_config.auth_host
if args.listen is None and stored_config.listen is not None:
args.listen = stored_config.listen
# Parse endpoint using fastapi_vue.hostutil # Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.listen, DEFAULT_PORT) endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
@@ -168,6 +198,16 @@ def main():
startupbox.print_startup_config(config) startupbox.print_startup_config(config)
if args.save:
new_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
asyncio.run(set_config(new_config))
run_kwargs: dict = { run_kwargs: dict = {
"log_level": "warning", # Suppress startup messages; we use custom logging "log_level": "warning", # Suppress startup messages; we use custom logging
"access_log": False, # We use custom AccessLogMiddleware instead "access_log": False, # We use custom AccessLogMiddleware instead
+2 -2
View File
@@ -42,7 +42,7 @@ async def init(
Database configuration: Database configuration:
Set PASKIA_DB environment variable to specify the JSONL database file path. Set PASKIA_DB environment variable to specify the JSONL database file path.
Default: paskia.jsonl Default: {rp_id}.paskiadb
""" """
# Initialize passkey instance with provided parameters # Initialize passkey instance with provided parameters
@@ -53,7 +53,7 @@ async def init(
) )
# Initialize database # Initialize database
await db.init() await db.init(rp_id=rp_id)
# Initialize remote auth manager # Initialize remote auth manager
await remoteauth.init() await remoteauth.init()
+17
View File
@@ -8,6 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
""" """
import json import json
import re
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
@@ -62,6 +63,7 @@ class Passkey:
ValueError: If any 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_id = rp_id
self._validate_rp_id(rp_id)
self.rp_name = rp_name or rp_id self.rp_name = rp_name or rp_id
self.allowed_origins: set[str] | None = None self.allowed_origins: set[str] | None = None
if origins: if origins:
@@ -75,6 +77,21 @@ class Passkey:
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
] ]
def _validate_rp_id(self, rp_id: str) -> None:
"""Validate that rp_id is a valid domain name."""
if not rp_id:
raise ValueError("rp_id cannot be empty")
# Allow localhost, or domain-like strings
if rp_id == "localhost":
return
# Regex for valid domain: letters, digits, hyphens, dots, but not starting/ending with hyphen, etc.
# Simplified: alphanumeric, dots, hyphens
if not re.match(
r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
rp_id,
):
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
def _validate_origin(self, origin: str, rp_id: str) -> None: def _validate_origin(self, origin: str, rp_id: str) -> None:
"""Validate an origin URL against the rp_id.""" """Validate an origin URL against the rp_id."""
hostname = urlparse(origin).hostname hostname = urlparse(origin).hostname