Simplify paskia init to rp-id/rp-name only

Only rp-id and rp-name are essential bootstrap-time configuration;
origins and auth hosts are set up afterwards via the admin interface.
Removes --origin/--auth-host from init and from devserver, and the
now-unused hostutil.validate_auth_host.
This commit is contained in:
2026-09-07 02:17:22 +00:00
parent d767012d80
commit 476ce996ad
9 changed files with 37 additions and 109 deletions
+9 -9
View File
@@ -52,7 +52,7 @@ uv tool install paskia
## Configuration
Bootstrapping is done once with `paskia init`; after that, `paskia` serves all configured realms from the database `paskia.kantadb` in the current directory. Realm configuration (rp-name, auth host, origins) is managed via the admin web interface, including adding further realms (rp-ids).
Bootstrapping is done once with `paskia init`; after that, `paskia` serves all configured domains from the database `paskia.kantadb` in the current directory. Domain configuration (rp-name, auth host, origins) is managed via the admin web interface, including adding further domains (rp-ids).
```text
paskia init [options] # one-time bootstrap
@@ -63,10 +63,10 @@ paskia [-l endpoint] # serve
| init option | Description | Default |
|--------|-------------|---------|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* (stored in the database) | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys. Repeatable and comma-separated for multiple realms; the first is the default realm | **localhost** |
| --rp-name *"text"* | Branding name of the default realm (passkey auth, login dialog) | Same as rp-id |
| --origin *url* | Only sites listed can login on the default realm (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --rp-id *domain* | Main/top domain for passkeys. Repeatable and comma-separated for multiple domains | **localhost** |
| --rp-name *"text"* | Branding name of the first domain (passkey auth, login dialog) | Same as rp-id |
Origins, auth hosts and related domains are configured afterwards in the admin panel's Domains section.
The `paskia` serve command accepts only `--listen` (overriding the stored value) and never converts databases: with no `paskia.kantadb` it tells you to run `paskia init`, or `paskia migrate` when a legacy `{rp-id}.paskiadb` database is present. `paskia migrate` converts the legacy database; with several candidates, `--rp-id` selects one by name and the rest are left in place.
@@ -217,13 +217,13 @@ auth.example.com {
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
Set the auth host in the admin panel's Realms section, or pass `--auth-host=auth.example.com` to `paskia init` when bootstrapping.
Set the auth host in the admin panel's Domains section.
## Multiple Realms and Related Origins
## Multiple Domains and Related Origins
One Paskia instance can serve several realms (rp-ids) from the same database: users, orgs and permissions are shared, while passkeys are registered per realm. The master admin adds realms in the admin panel's Realms section; no restart is needed.
One Paskia instance can serve several domains (rp-ids) from the same database: users, orgs and permissions are shared, while passkeys are registered per domain. The master admin adds domains in the admin panel's Domains section; no restart is needed.
A realm can also let *other* domain names use its passkeys via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — add them as related domains in the realm settings, and paskia serves the required `/.well-known/webauthn` declaration on the realm's domain. This is separate from the allowed-sign-in-sites list, which only restricts which subdomains of the realm's own domain may authenticate (empty = the domain and all subdomains).
A domain can also let *other* domain names use its passkeys via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — add them as related domains in the domain settings, and paskia serves the required `/.well-known/webauthn` declaration on the domain's main site. This is separate from the allowed-sign-in-sites list, which only restricts which subdomains of the domain's own rp-id may authenticate (empty = the rp-id and all subdomains).
See [Multi-Site documentation](docs/MultiSite.md) for details.
+1 -1
View File
@@ -94,7 +94,7 @@ These are for internal use only, but are documented here because they are the co
### Auth host mode (dedicated auth site)
A domain may configure a dedicated authentication host (auth-host, a subdomain of the rp-id), either at bootstrap (`paskia init --auth-host`) or via the Domains admin panel.
A domain may configure a dedicated authentication host (auth-host, a subdomain of the rp-id) via the Domains admin panel.
#### On the auth host:
- The Web UI is served at site root instead of /auth/* (that redirects to root paths)
+7 -8
View File
@@ -194,10 +194,10 @@ instance:
- `--rp-id`: repeatable/comma-separated, default `["localhost"]`.
Multiple values create multiple domains at once (useful for
devserver/e2e).
- `--rp-name`, `--auth-host`, `--origin`: apply to the **first**
rp-id only. Their purpose is that the very first admin registration
ceremony already shows the correct RP name; everything is editable
via the admin interface afterwards.
- `--rp-name`: applies to the **first** rp-id only. Its purpose is that
the very first admin registration ceremony already shows the correct
RP name; everything else (origins, auth hosts, related domains) is
set up via the admin interface afterwards.
- `--listen`: stored into `Config.listen` (process-global).
- Seeds the admin user + registration reset link and prints the link.
Refuses to run if `paskia.kantadb` already exists, or if an
@@ -466,10 +466,9 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
## 12. Development
- `scripts/devserver.py`: bootstraps via one-shot `paskia init` when no
database exists (multi `--rp-id`, and `--rp-name`/`--auth-host`/
`--origin` for the first domain), then runs plain `paskia` serve.
Caddy dev origins iterate all bootstrap rp-ids plus the auth host and
explicit origins.
database exists (multi `--rp-id`, `--rp-name` for the first domain),
then runs plain `paskia` serve. Caddy dev origins iterate all bootstrap
rp-ids.
- `PASKIA_AUTH_HOST` (consumed by `frontend/vite.config.js`) is a
comma-separated list of bare hostnames; the vite dev proxy forwards
`/.well-known/openid-configuration` and `/.well-known/webauthn` to the
+1 -1
View File
@@ -81,7 +81,7 @@ auth.example.com {
}
```
Remember to set the auth host for the domain — either `paskia init --auth-host auth.example.com` at bootstrap or in the admin panel's Domains section to restrict the authentication services to this domain.
Remember to set the auth host for the domain in the admin panel's Domains section to restrict the authentication services to this domain.
Note that we still reserve `/auth/` on each site for logout page and any APIs your application may require, while full user profile and global options are only available on the auth host.
+1 -1
View File
@@ -92,7 +92,7 @@ authResponseHeaders:
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and set the domain's auth host (`paskia init --auth-host auth.example.com` at bootstrap, or the admin panel's Domains section).
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and set the domain's auth host in the admin panel's Domains section.
## Adjusting requirements
+10 -38
View File
@@ -12,21 +12,17 @@ from kanta import Kanta
from paskia.db import legacy
from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path
from paskia.db.structs import DB, Config, DomainConfig, OriginEntry
from paskia.db.structs import DB, Config, DomainConfig
from paskia.domains import build as build_registry
from paskia.domains import configure as configure_domains
from paskia.domains import origin_key, validate_config
from paskia.domains import validate_config
from paskia.util import startupbox
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import (
normalize_origin,
validate_auth_host,
)
from paskia.util.runtime import ServeConfig
EPILOG = """\
Examples:
paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
paskia init --rp-id example.com --rp-name "Example Corporation"
paskia migrate --rp-id example.com
paskia
"""
@@ -93,27 +89,16 @@ def cmd_init(args: argparse.Namespace) -> None:
rp_ids = _split_multi(args.rp_id) or ["localhost"]
# Only rp-id and rp-name are bootstrap-time configuration; everything
# else (origins, auth host, related domains) is set up afterwards via
# the admin interface.
domains = {}
for i, rp_id in enumerate(rp_ids):
domain = DomainConfig()
if i == 0:
# Bootstrap-time naming and hosts apply to the first domain;
# everything is editable via the admin interface afterwards.
# The bootstrap rp-name exists so the very first admin
# registration ceremony already shows the correct name.
domain.rp_name = args.rp_name or None
origins = {
origin_key(normalize_origin(o)): True
for o in _split_multi(args.origins)
}
auth_host = args.auth_host or None
if auth_host:
try:
validate_auth_host(auth_host, rp_id)
except ValueError as e:
raise SystemExit(str(e)) from e
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
origins[origin_key(auth_host)] = OriginEntry(auth_host=True)
domain.origins = origins
domains[rp_id] = domain
config = Config(domains=domains, listen=_split_multi(args.listen) or None)
@@ -228,27 +213,14 @@ def main():
"--rp-id",
action="append",
help="Relying Party ID of the initial domain(s) (default: localhost). "
"Repeatable and comma-separated; bootstrap options apply to the first. "
"Further domains are added via the admin interface.",
"Repeatable and comma-separated. Further domains, origins and auth "
"hosts are added via the admin interface.",
)
init_parser.add_argument(
"--rp-name",
help="Relying Party name of the first domain (default: same as rp-id). "
"Used by the initial admin registration; editable later via admin UI.",
)
init_parser.add_argument(
"--origin",
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s) for the first domain. May be specified "
"multiple times; comma-separated values accepted.",
)
init_parser.add_argument(
"--auth-host",
help="Dedicated authentication site for the first domain "
"(optionally with scheme/port)",
)
_add_listen_option(init_parser, help_extra=" (stored in the database)")
migrate_parser = argparse.ArgumentParser(
-15
View File
@@ -55,21 +55,6 @@ def is_subdomain(sub: str, domain: str) -> bool:
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id.
Raises ValueError on invalid auth_host.
"""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise ValueError(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise ValueError(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def auth_host_netloc(auth_host: str) -> str | None:
"""Return the host[:port] part of a configured auth host URL."""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
+1 -25
View File
@@ -167,10 +167,6 @@ def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) ->
cmd.extend(["--rp-id", rp_id])
if args.rp_name:
cmd.extend(["--rp-name", args.rp_name])
if args.auth_host:
cmd.extend(["--auth-host", args.auth_host])
for origin in _split_multi(args.origins):
cmd.extend(["--origin", origin])
logger.info(">>> paskia init (first run)")
proc = subprocess.run(cmd, check=False) # noqa: S603
@@ -199,24 +195,13 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
os.environ["PASKIA_VITE_URL"] = viteurl
os.environ["PASKIA_BACKEND_URL"] = backurl
os.environ["PASKIA_DEV"] = "1"
if args.auth_host:
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
async with ProcessGroup() as pg:
# Start Caddy first if requested (needs to bind ports)
if args.caddy:
caddy_origins = []
if args.auth_host:
auth_host = args.auth_host
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
caddy_origins.append(auth_host)
for rp_id in rp_ids:
caddy_origins.append(f"https://{rp_id}")
for origin in _split_multi(args.origins):
if "://" not in origin:
origin = f"https://{origin}"
caddy_origins.append(origin)
seen: set = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
@@ -251,20 +236,11 @@ def main():
"--rp-id",
action="append",
help="Relying Party ID(s) for first-run bootstrap (default: localhost). "
"Repeatable and comma-separated; the bootstrap name/host options apply to the first.",
"Repeatable and comma-separated.",
)
parser.add_argument(
"--rp-name", help="Relying Party name of the first domain (bootstrap only)"
)
parser.add_argument(
"--origin",
action="append",
dest="origins",
help="Allowed origin(s), bootstrap only",
)
parser.add_argument(
"--auth-host", help="Dedicated auth host for the first domain (bootstrap only)"
)
args, remaining = parser.parse_known_args()
with suppress(KeyboardInterrupt):
+7 -11
View File
@@ -20,7 +20,7 @@ from kanta import Kanta
from paskia.__main__ import _load_stored_config, main
from paskia.db import legacy
from paskia.db.structs import Config, OriginEntry
from paskia.db.structs import Config
from paskia.util.runtime import ServeConfig, clear_cache
@@ -98,10 +98,6 @@ def test_init_full_options(run_cli, tmp_path):
"example.com",
"--rp-name",
"Example Corp",
"--auth-host",
"auth.example.com",
"--origin",
"https://app.example.com",
"--listen",
"4402",
)
@@ -109,10 +105,7 @@ def test_init_full_options(run_cli, tmp_path):
config = stored_config(tmp_path)
domain = config.domains["example.com"]
assert domain.rp_name == "Example Corp"
assert domain.origins == {
"app.example.com": True,
"auth.example.com": OriginEntry(auth_host=True),
}
assert domain.origins == {}
assert config.listen == ["4402"]
@@ -135,9 +128,12 @@ def test_init_refuses_legacy_database(run_cli, tmp_path):
run_cli("init")
def test_init_invalid_auth_host(run_cli):
def test_init_rejects_removed_options(run_cli):
"""Origins and auth hosts are admin-interface configuration, not init's."""
with pytest.raises(SystemExit):
run_cli("init", "--rp-id", "example.com", "--auth-host", "notsub.example.org")
run_cli("init", "--rp-id", "example.com", "--auth-host", "auth.example.com")
with pytest.raises(SystemExit):
run_cli("init", "--origin", "https://app.example.com")
def test_serve_requires_database(run_cli):