diff --git a/README.md b/README.md index f389d4f..44d8d83 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/API.md b/docs/API.md index 25db48c..fe58881 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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) diff --git a/docs/MultiSite.md b/docs/MultiSite.md index d1ee310..0b1a297 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -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 diff --git a/docs/proxy/caddy.md b/docs/proxy/caddy.md index 64aae65..4361f2d 100644 --- a/docs/proxy/caddy.md +++ b/docs/proxy/caddy.md @@ -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. diff --git a/docs/proxy/traefik.md b/docs/proxy/traefik.md index e2681ee..0ec971a 100644 --- a/docs/proxy/traefik.md +++ b/docs/proxy/traefik.md @@ -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 diff --git a/paskia/__main__.py b/paskia/__main__.py index 7601c9c..ecdff4f 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -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( diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 1dea746..1deaff4 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -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}") diff --git a/scripts/devserver.py b/scripts/devserver.py index 78214d0..489fb67 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -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): diff --git a/tests/test_cli.py b/tests/test_cli.py index 74d19e8..daea0eb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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):