From 7ff8869e1dfbb06c8d97e3037be810c69ea4e705 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 03:07:38 +0000 Subject: [PATCH] CLI: positional rp-id/rp-name; init adds domains to an existing database - 'paskia init [rp-id] [rp-name]' and 'paskia migrate [rp-id]' are now positional; comma separation and the --rp-id/--rp-name flags are gone. - With an existing paskia.kantadb, init adds the rp-id as a new domain (seeding its OIDC provider) or updates an existing domain's rp-name. - Origin allow-list semantics clarified: the bare '*' entry allows anything within the rp-id domain on any scheme and port (also the empty-list default and its display in the admin UI, replacing the synthetic '*.rp-id' row); '*.x' wildcards are https-only; exact entries match scheme, host and port. Legacy '*.rp-id' wildcards migrate to '*' to preserve their any-scheme meaning. --- README.md | 21 +++--- docs/API.md | 2 +- docs/MultiSite.md | 63 ++++++++++------- e2e/tests/global-setup.ts | 14 +++- frontend/auth/admin/AdminApp.vue | 6 +- frontend/src/admin/AdminDialogs.vue | 21 +++--- frontend/src/utils/helpers.js | 5 +- paskia/__main__.py | 106 +++++++++++++++++++--------- paskia/db/legacy.py | 9 ++- paskia/domains.py | 25 ++++++- paskia/fastapi/admin/domains.py | 4 +- paskia/sansio.py | 22 ++++-- paskia/util/hostutil.py | 5 ++ scripts/devserver.py | 20 +++--- tests/test_cli.py | 55 ++++++++++----- tests/test_domains.py | 44 +++++++++++- 16 files changed, 293 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 44d8d83..47e4aa1 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ Paskia includes set of login, reauthentication and forbidden dialogs that it can Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: ```sh -uvx paskia init --rp-id example.com +uvx paskia init example.com uvx paskia ``` -The first command bootstraps the database and prints a registration link for the Admin. The second starts the server on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id` (defaults to `localhost`). +The first command bootstraps the database and prints a registration link for the Admin. The second starts the server on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out the rp-id (defaults to `localhost`). 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). @@ -55,20 +55,21 @@ uv tool install paskia 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 -paskia migrate [--rp-id] # convert a legacy {rp-id}.paskiadb database -paskia [-l endpoint] # serve +paskia init [rp-id] [rp-name] [options] # one-time bootstrap; with an existing + # database, adds the rp-id (or renames it) +paskia migrate [rp-id] # convert a legacy {rp-id}.paskiadb database +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 domains | **localhost** | -| --rp-name *"text"* | Branding name of the first domain (passkey auth, login dialog) | Same as rp-id | +| *rp-id* (positional) | Main/top domain for passkeys | **localhost** | +| *rp-name* (positional) | Branding name of the 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. +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, the positional rp-id selects one by name and the rest are left in place. ## Tutorial: From Local Testing to Production @@ -79,11 +80,11 @@ This section walks you through a complete example, from running Paskia locally t For a real deployment, bootstrap Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. ```sh -uvx paskia init --rp-id=example.com --rp-name="Example Corp" +uvx paskia init example.com "Example Corp" uvx paskia ``` -This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). Init prints a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name. +This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The rp-name is the branding shown in UI and registered with passkeys for everything on your domain (rp id). Init prints a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name. ### Step 2: Set Up Caddy diff --git a/docs/API.md b/docs/API.md index fe58881..c93fd86 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,7 +79,7 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately. -`origins` is an object keyed by sign-in sites *within* the domain (bare hosts, `*.` wildcards, or full origins when not https); an empty object means the rp-id and all subdomains may authenticate. A value of `true` marks presence; `{"auth_host": true}` additionally marks the entry as the domain's authentication host. `related` is an object keyed by *other* domains that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5); those are published at `/.well-known/webauthn` on the rp-id host. Entries filed under the wrong map are rejected: cross-domain entries in `origins`, in-domain entries in `related`. +`origins` is an object keyed by sign-in sites *within* the domain (bare hosts, `*.` wildcards matching the base domain and subdomains over https only, the bare `*` for anything in-domain on any scheme/port, or full origins when not https); an empty object means the rp-id and all subdomains may authenticate on any scheme. A value of `true` marks presence; `{"auth_host": true}` additionally marks the entry as the domain's authentication host. `related` is an object keyed by *other* domains that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5); those are published at `/.well-known/webauthn` on the rp-id host. Entries filed under the wrong map are rejected: cross-domain entries in `origins`, in-domain entries in `related`. ### WebSockets: /auth/ws/* diff --git a/docs/MultiSite.md b/docs/MultiSite.md index 0b1a297..a0e5b17 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -167,14 +167,18 @@ class Config(msgspec.Struct, omit_defaults=True): URL, background jobs), the single configured domain is used, and with several domains the first one sorted by rp-id — never for dispatch. - Origin keys are bare hosts (`app.example.com`), wildcard patterns - (`*.example.com`), or full origins when not https - (`http://localhost:8080`) — `https://` is omitted as the common case. - A dict value of `true` means presence only; an object carries extra - properties (currently just `auth_host`). Ordering carries no meaning — - display order is a UI affair. + (`*.example.com`), full origins when not https (`http://localhost:8080`), + or the bare `*` — `https://` is omitted as the common case. A dict value + of `true` means presence only; an object carries extra properties + (currently just `auth_host`). Ordering carries no meaning — display + order is a UI affair. - An empty `origins` dict means the rp-id and all its subdomains may sign in (the default). A non-empty dict is an allow-list of in-domain - sign-in sites; one entry may be marked `auth_host` (never a wildcard). + sign-in sites; matching semantics per entry kind: + - `*` — anything within the rp-id domain, any scheme and port; + - `*.example.com` — the base domain and its subdomains, **https only**; + - anything else — exact match on scheme, host and port. + One entry may be marked `auth_host` (never a wildcard or `*`). - **Origin validation** — two separate concerns: `origins` entries must be within the rp-id domain. `related` entries must be outside it, are capped (default 5), must not be wildcards, and must not collide with @@ -190,22 +194,28 @@ The CLI is split so that domain options exist only at bootstrap time — they can never mix with runtime configuration of an already-configured instance: -- **`paskia init`** — creates `paskia.kantadb` in CWD and seeds it: - - `--rp-id`: repeatable/comma-separated, default `["localhost"]`. - Multiple values create multiple domains at once (useful for - devserver/e2e). - - `--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. +- **`paskia init [rp-id] [rp-name]`** — creates `paskia.kantadb` in CWD + and seeds it: + - `rp-id` (positional, default `localhost`) and `rp-name` (positional, + default same as rp-id) are the only bootstrap-time domain + configuration; the rp-name exists so the very first admin + registration ceremony already shows the correct name. Everything else + (origins, auth hosts, related domains) is set up via the admin + interface. - `--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 - unconverted legacy `*.paskiadb` is present (`paskia migrate` converts - it first). -- **`paskia migrate`** — converts a legacy `.paskiadb` database - (§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id` - selects `.paskiadb` by name; the others are left in place. + Refuses to run if an unconverted legacy `*.paskiadb` is present + (`paskia migrate` converts it first). + - **With an existing `paskia.kantadb`**, init instead adds the given + rp-id as a new domain (seeding its OIDC provider), or updates the + rp-name of an existing one — a convenience for what the admin + interface also does. +- **`paskia migrate [rp-id]`** — converts a legacy `.paskiadb` + database (§10) to `paskia.kantadb`. With several legacy candidates, the + positional rp-id selects `.paskiadb` by name; the others are + left in place. A legacy wildcard origin over the rp-id itself + (`*.example.com`) converts to the bare `*` entry, preserving its + any-scheme meaning. - **`paskia`** — serve. Takes **no domain options**; only `--listen` (per-run override of stored `Config.listen`, never persisted). Startup: open `paskia.kantadb` → sanitize the stored domain set best-effort → @@ -437,7 +447,7 @@ effective_auth_host(domain) = domain's own auth host or first configured one or file) becomes `paskia.kantadb`, `users/` becomes `paskia.data/users/`, and the old directory is renamed aside to `.converted-bak`. A lone candidate converts without options; with several candidates - `--rp-id ` selects `.paskiadb` by name and the rest are + a positional rp-id selects `.paskiadb` by name and the rest are left in place (e.g. a `*.bak.paskiadb` backup does not block conversion). Empty directories are ignored. Conversion is an explicit operator action, never a serve side effect — read-only opens never @@ -465,18 +475,17 @@ 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`, `--rp-name` for the first domain), - then runs plain `paskia` serve. Caddy dev origins iterate all bootstrap - rp-ids. +- `scripts/devserver.py`: bootstraps via one-shot `paskia init` per rp-id + when no database exists (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 backend. - The example `caddy/auth/setup` snippet forwards both well-known paths to paskia so a static `/.well-known/*` handler does not shadow them. -- E2E: `e2e/tests/global-setup.ts` runs `paskia init --rp-id - localhost,test.localhost` in the test-data dir (which doubles as the +- E2E: `e2e/tests/global-setup.ts` runs `paskia init localhost` and + `paskia init test.localhost` in the test-data dir (which doubles as the server CWD) and serves; `e2e/tests/50-multidomain.spec.ts` exercises host dispatch, the well-known endpoint via the admin domain API, and a cross-domain remote login (request at test.localhost, permit at diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index a6cf33b..b832620 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -44,14 +44,13 @@ export default async function globalSetup() { const state: TestState = {} - // Bootstrap the database: two domains, localhost (default) and test.localhost + // Bootstrap the database: two domains, localhost and test.localhost console.log(' Bootstrapping database with paskia init...') const initResult = spawnSync( 'uv', [ 'run', '--project', projectRoot, - 'paskia', 'init', '-l', 'localhost:4404', - '--rp-id', 'localhost,test.localhost', + 'paskia', 'init', '-l', 'localhost:4404', 'localhost', ], { cwd: testDataDir, encoding: 'utf-8' } ) @@ -60,6 +59,15 @@ export default async function globalSetup() { if (initResult.status !== 0) { throw new Error(`paskia init failed with exit code ${initResult.status}`) } + const addResult = spawnSync( + 'uv', + ['run', '--project', projectRoot, 'paskia', 'init', 'test.localhost'], + { cwd: testDataDir, encoding: 'utf-8' } + ) + process.stdout.write(`${addResult.stdout}${addResult.stderr}`) + if (addResult.status !== 0) { + throw new Error(`paskia init test.localhost failed with exit code ${addResult.status}`) + } // Parse the reset token from init output // Format: http://localhost:4404/auth/{token} where token is dot-separated words diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index dd41b58..f763e66 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -490,7 +490,7 @@ function createDomain() { function openDomain(domain) { // One combined list for editing, in display order: in-domain sites and // related origins, classified by hostname. The default is always shown - // explicitly as the '*.rp_id' wildcard entry. + // explicitly as the '*' entry. const rows = originDisplayEntries(domain) openDialog('domain-edit', { isNew: false, @@ -951,6 +951,10 @@ async function submitDialog() { const related = {} for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) { const key = keyOf(o) + if (key === '*') { + origins['*'] = true // anything in-domain, any scheme/port + continue + } let hn = null if (key.startsWith('*.')) { hn = key.slice(2).replace(/\.+$/, '') diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 2ec625e..9c8e0be 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -113,6 +113,7 @@ function isWellFormedDomain(value) { function originHostname(origin) { if (!origin.trim()) return null + if (origin.trim() === '*') return '*' if (origin.trim().startsWith('*.')) { const base = origin.trim().slice(2).replace(/\.+$/, '') return isWellFormedDomain(base) ? base : null @@ -126,6 +127,7 @@ function originHostname(origin) { } function isWithinDomain(origin, rpId) { + if (origin.trim() === '*') return true const hostname = originHostname(origin) if (!hostname) return false return hostname === rpId || hostname.endsWith('.' + rpId) @@ -167,7 +169,7 @@ function validateOrigin(i) { d.originValidation[i] = 'invalid' return } - if (value.trim().startsWith('*.')) { + if (value.trim() === '*' || value.trim().startsWith('*.')) { d.originValidation[i] = null // wildcards have no concrete site to probe return } @@ -203,15 +205,15 @@ async function testWellKnown() { } watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true }) -// Seed the default wildcard entry for a new domain once its rp-id is known, -// so the list always shows what is allowed ('*.example.com' = the domain and -// all its subdomains). Removing the last in-domain entry is blocked in the -// row menu, so the list never becomes empty afterwards. +// Seed the default '*' entry for a new domain once its rp-id is known, +// so the list always shows what is allowed ('*' = the domain and all its +// subdomains, any scheme/port). Removing the last in-domain entry is +// blocked in the row menu, so the list never becomes empty afterwards. watch(dialogRpId, rp => { const d = props.dialog?.data if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return if (!d.origins.length && isWellFormedDomain(rp)) { - d.origins.push('*.' + rp) + d.origins.push('*') d.originValidation.push(null) } }) @@ -236,9 +238,10 @@ function setAuthHost(i) { const d = props.dialog?.data if (!d) return let key = entryKey(d.origins[i]) - if (key.startsWith('*.')) { + if (key === '*' || key.startsWith('*.')) { // A wildcard cannot be the auth host — create a concrete auth. entry - key = 'auth.' + key.slice(2) + const base = key === '*' ? dialogRpId.value : key.slice(2) + key = 'auth.' + base if (!d.origins.some(o => entryKey(o) === key)) { d.origins.push(key) d.originValidation.push(null) @@ -387,7 +390,7 @@ function onRemoveOrigin(i) {

Some sites are reachable but do not serve this domain.

- Only the listed sites may sign in with this domain's passkeys — *.{{ dialog.data.rp_id }} means the domain and all its subdomains. + Only the listed sites may sign in with this domain's passkeys — * means the domain and all its subdomains on any scheme and port; *.{{ dialog.data.rp_id }} restricts that to https. Entries on other domain names become related origins (WebAuthn ROR). The 🔑 site hosts the account and admin interface (set via ⋮).

diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js index 903853a..0584693 100644 --- a/frontend/src/utils/helpers.js +++ b/frontend/src/utils/helpers.js @@ -45,7 +45,8 @@ export const hostIP = ip => { // Display-time ordering of a domain's configured origins (the stored // objects are unordered): the auth host first (flagged), then in-domain // entries (exact rp-id, then alphabetical), then related domains -// alphabetically. An empty origins object shows as the '*.rp_id' default. +// alphabetically. An empty origins object shows as the '*' default +// (anything within the rp-id domain, any scheme/port). export function originDisplayEntries(domain) { const origins = domain.origins || {} const keys = Object.keys(origins) @@ -58,7 +59,7 @@ export function originDisplayEntries(domain) { const rows = [] if (authKey) rows.push({ key: authKey, auth: true }) for (const k of inDomain) rows.push({ key: k, auth: false }) - if (!keys.length) rows.push({ key: '*.' + domain.rp_id, auth: false }) + if (!keys.length) rows.push({ key: '*', auth: false }) for (const k of Object.keys(domain.related || {}).sort()) { rows.push({ key: k, auth: false, related: true }) } diff --git a/paskia/__main__.py b/paskia/__main__.py index ecdff4f..3dde1c1 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -12,18 +12,19 @@ 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 +from paskia.db.structs import DB, OIDC, Config, DomainConfig from paskia.domains import build as build_registry from paskia.domains import configure as configure_domains from paskia.domains import validate_config -from paskia.util import startupbox +from paskia.util import hostutil, startupbox from paskia.util.constants import DEFAULT_PORT, DEVMODE +from paskia.util.crypto import secret_key from paskia.util.runtime import ServeConfig EPILOG = """\ Examples: - paskia init --rp-id example.com --rp-name "Example Corporation" - paskia migrate --rp-id example.com + paskia init example.com "Example Corporation" + paskia migrate example.com paskia """ @@ -72,14 +73,61 @@ def _load_stored_config(db_path: Path) -> Config: raise SystemExit(f"{e}") from e +def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) -> None: + """Add a domain to an existing database, or update an existing one's + rp-name. Seeds an OIDC provider entry for a new domain.""" + new_db = DB() + kanta = Kanta(str(db_path), new_db) + + async def _update() -> str: + await kanta.open() + try: + data = kanta.data + if rp_id in data.config.domains: + if rp_name is None and listen is None: + raise SystemExit(f"Domain {rp_id} is already configured.") + with kanta.transaction("init:update_domain"): + if rp_name is not None: + data.config.domains[rp_id].rp_name = rp_name + if listen is not None: + data.config.listen = listen + return f"Updated domain {rp_id}" + new = DomainConfig(rp_name=rp_name) + try: + validate_config( + Config( + domains={**data.config.domains, rp_id: new}, + listen=data.config.listen, + ) + ) + except ValueError as e: + raise SystemExit(str(e)) from e + with kanta.transaction("init:add_domain"): + data.config.domains[rp_id] = new + data.oidc[rp_id] = OIDC(key=secret_key()) + if listen is not None: + data.config.listen = listen + return f"Added domain {rp_id}" + finally: + await kanta.close() + + print(f"✅ {asyncio.run(_update())}") + + def cmd_init(args: argparse.Namespace) -> None: - """Bootstrap a new paskia.kantadb database with the initial domain(s).""" + """Bootstrap a new paskia.kantadb, or add a domain to an existing one.""" + rp_id = (args.rp_id or "localhost").strip().lower() + rp_name = args.rp_name or None + listen = _split_multi(args.listen) or None + try: + hostutil.validate_rp_id(rp_id) + except ValueError as e: + raise SystemExit(str(e)) from e + db_path = db_file_path() if db_path.exists(): - raise SystemExit( - f"Database {db_path} already exists — domain configuration is " - "managed via the admin interface, not 'paskia init'." - ) + _init_add_domain(db_path, rp_id, rp_name, listen) + return if found := legacy.find_legacy_databases(): names = ", ".join(str(p) for p in found) raise SystemExit( @@ -87,21 +135,11 @@ def cmd_init(args: argparse.Namespace) -> None: "convert, not 'paskia init'." ) - 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: - # 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 - domains[rp_id] = domain - - config = Config(domains=domains, listen=_split_multi(args.listen) or None) + # the admin interface. The bootstrap rp-name exists so the very first + # admin registration ceremony already shows the correct name. + config = Config(domains={rp_id: DomainConfig(rp_name=rp_name)}, listen=listen) try: validate_config(config) except ValueError as e: @@ -132,7 +170,7 @@ def cmd_init(args: argparse.Namespace) -> None: registry = build_registry(config) startupbox.print_startup_config(registry, listen=config.listen) log_reset_link( - registry.get(rp_ids[0]).reset_link_url(result["passphrase"]), + registry.get(rp_id).reset_link_url(result["passphrase"]), "✅ Bootstrap completed!", ) @@ -205,20 +243,23 @@ def main(): init_parser = argparse.ArgumentParser( prog="paskia init", - description="Bootstrap a new paskia.kantadb database in the current directory", + description="Bootstrap a new paskia.kantadb database in the current " + "directory. With an existing database, adds the domain to it instead " + "(or updates its rp-name).", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG, ) init_parser.add_argument( - "--rp-id", - action="append", - help="Relying Party ID of the initial domain(s) (default: localhost). " - "Repeatable and comma-separated. Further domains, origins and auth " - "hosts are added via the admin interface.", + "rp_id", + nargs="?", + help="Relying Party ID of the initial domain (default: localhost). " + "Further domains, origins and auth hosts are added via the admin " + "interface — or with another 'paskia init '.", ) init_parser.add_argument( - "--rp-name", - help="Relying Party name of the first domain (default: same as rp-id). " + "rp_name", + nargs="?", + help="Relying Party name of the domain (default: same as rp-id). " "Used by the initial admin registration; editable later via admin UI.", ) _add_listen_option(init_parser, help_extra=" (stored in the database)") @@ -229,7 +270,8 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, ) migrate_parser.add_argument( - "--rp-id", + "rp_id", + nargs="?", help="rp-id of the legacy database to convert, selecting " ".paskiadb when several legacy candidates exist.", ) diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index c44514b..58b7105 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -116,7 +116,12 @@ def convert_legacy_database(src: Path, dst: Path) -> Config: origins: dict[str, bool | OriginEntry] = {} for origin in old.config.origins or []: - origins[origin_key(origin)] = True + key = origin_key(origin) + # A legacy wildcard over the rp-id itself matched any scheme; the + # bare '*' keeps that meaning ('*.x' is now https-only). + if key == f"*.{rp_id}": + key = "*" + origins[key] = True if old.config.auth_host: origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True) @@ -237,7 +242,7 @@ def migrate_legacy_database(rp_id: str | None = None) -> str: names = ", ".join(str(c) for c in candidates) raise SystemExit( f"Multiple legacy databases found ({names}) — select one with " - "'paskia migrate --rp-id '." + "'paskia migrate '." ) else: src = candidates[0] diff --git a/paskia/domains.py b/paskia/domains.py index f63dcaa..86d68bf 100644 --- a/paskia/domains.py +++ b/paskia/domains.py @@ -29,7 +29,11 @@ DEFAULT_RELATED_ORIGIN_CAP = 5 def origin_url(key: str) -> str: """Full origin URL for an origins-dict key (https:// is implied).""" - if hostutil.is_wildcard_pattern(key) or "://" in key: + if ( + hostutil.is_any_pattern(key) + or hostutil.is_wildcard_pattern(key) + or "://" in key + ): return key return f"https://{key}" @@ -181,6 +185,10 @@ def validate_config( for key, props in domain.origins.items(): is_auth = isinstance(props, OriginEntry) and props.auth_host + if hostutil.is_any_pattern(key): + if is_auth: + raise ValueError("Origin '*' cannot be the auth host") + continue if hostutil.is_wildcard_pattern(key): base = key[2:].rstrip(".") if not base or not hostutil.is_subdomain(base, rp_id): @@ -285,6 +293,15 @@ def sanitize_config( related: dict[str, bool] = dict(domain.related) for key, props in domain.origins.items(): is_auth = isinstance(props, OriginEntry) and props.auth_host + if hostutil.is_any_pattern(key): + if is_auth: + warn( + f"Domain '{rp_id}': origin '*' cannot be the " + "auth host — mark cleared" + ) + props = True + origins[key] = props + continue if hostutil.is_wildcard_pattern(key): base = key[2:].rstrip(".") if not base: @@ -421,7 +438,11 @@ def _derive_site( return auth, "/" if rp_id in domain.origins: return origin_url(rp_id), "/auth/" - concrete = sorted(k for k in domain.origins if not hostutil.is_wildcard_pattern(k)) + concrete = sorted( + k + for k in domain.origins + if not hostutil.is_any_pattern(k) and not hostutil.is_wildcard_pattern(k) + ) if concrete: return origin_url(concrete[0]), "/auth/" if rp_id == "localhost": diff --git a/paskia/fastapi/admin/domains.py b/paskia/fastapi/admin/domains.py index 1938eeb..a60c23e 100644 --- a/paskia/fastapi/admin/domains.py +++ b/paskia/fastapi/admin/domains.py @@ -50,7 +50,7 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry] key = raw_key.strip() if not key: continue - if not hostutil.is_wildcard_pattern(key): + if not hostutil.is_any_pattern(key) and not hostutil.is_wildcard_pattern(key): key = domains.origin_key(hostutil.normalize_origin(key)) is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host")) out[key] = OriginEntry(auth_host=True) if is_auth else True @@ -64,7 +64,7 @@ def _normalize_related_map(values: dict | None) -> dict[str, bool]: key = raw_key.strip() if not key: continue - if hostutil.is_wildcard_pattern(key): + if hostutil.is_any_pattern(key) or hostutil.is_wildcard_pattern(key): raise ValueError( f"Related origin '{key}' is a wildcard — related origins " "(ROR) must be listed individually" diff --git a/paskia/sansio.py b/paskia/sansio.py index 5c93bc3..7cc3fea 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -56,10 +56,13 @@ class Passkey: rp_id: Your security domain (e.g. "example.com") rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. origins: Allow-list of sign-in site origins within the rp-id domain - (e.g. ["https://app.example.com"]); wildcard patterns like - "*.example.com" match the base domain and its subdomains. - If not provided, the rp-id and any subdomain of it may - authenticate. + (e.g. ["https://app.example.com"]); the bare entry "*" + allows the whole rp-id domain on any scheme and port, + while wildcard patterns like "*.example.com" match the + base domain and its subdomains over https only. Exact + entries match scheme, host and port. If not provided, the + rp-id and any subdomain of it may authenticate (same as + listing "*"). related_origins: Origins on unrelated domains that may assert this rp-id (WebAuthn Related Origin Requests). Always additive. supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). @@ -76,6 +79,8 @@ class Passkey: if origins: # Validate and deduplicate origins into a set for O(1) lookups for o in origins: + if hostutil.is_any_pattern(o): + continue # anything in-domain, any scheme/port self._validate_origin_url(o) hostname = hostutil.origin_hostname(o) if not hostutil.is_subdomain(hostname, rp_id): @@ -119,11 +124,14 @@ class Passkey: def _allowlisted(self, origin: str) -> bool: """Check an in-domain origin against the allow-list. - An entry matches exactly, or as a wildcard pattern ('*.example.com' - matches the base domain and any subdomain of it). + An entry matches exactly, '*' matches anything in-domain (any + scheme/port), and a wildcard pattern ('*.example.com') matches the + base domain and any subdomain of it over https only. """ - if origin in self.allowed_origins: + if "*" in self.allowed_origins or origin in self.allowed_origins: return True + if not origin.startswith("https://"): + return False # Wildcard patterns match https origins only hostname = hostutil.origin_hostname(origin) return any( hostutil.is_wildcard_pattern(entry) diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 1deaff4..689d533 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -23,6 +23,11 @@ def is_wildcard_pattern(value: str) -> bool: return value.startswith("*.") +def is_any_pattern(value: str) -> bool: + """Check whether an origins entry is the bare '*' (anything in-domain).""" + return value == "*" + + def normalize_origin(origin: str) -> str: """Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes. diff --git a/scripts/devserver.py b/scripts/devserver.py index 489fb67..71a8adb 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -162,16 +162,16 @@ def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) -> "convert it before starting the dev server." ) - cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"] - for rp_id in rp_ids: - cmd.extend(["--rp-id", rp_id]) - if args.rp_name: - cmd.extend(["--rp-name", args.rp_name]) - - logger.info(">>> paskia init (first run)") - proc = subprocess.run(cmd, check=False) # noqa: S603 - if proc.returncode != 0: - raise SystemExit(proc.returncode) + for i, rp_id in enumerate(rp_ids): + cmd = [sys.executable, "-m", "paskia", "init", rp_id] + if i == 0: + if args.rp_name: + cmd.append(args.rp_name) + cmd.append(f"--listen={listen}") + logger.info(">>> paskia init %s", rp_id) + proc = subprocess.run(cmd, check=False) # noqa: S603 + if proc.returncode != 0: + raise SystemExit(proc.returncode) async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index daea0eb..dc29b68 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 +from paskia.db.structs import DB, Config from paskia.util.runtime import ServeConfig, clear_cache @@ -92,15 +92,7 @@ def test_init_defaults(run_cli, tmp_path): def test_init_full_options(run_cli, tmp_path): - run_cli( - "init", - "--rp-id", - "example.com", - "--rp-name", - "Example Corp", - "--listen", - "4402", - ) + run_cli("init", "example.com", "Example Corp", "--listen", "4402") config = stored_config(tmp_path) domain = config.domains["example.com"] @@ -109,16 +101,41 @@ def test_init_full_options(run_cli, tmp_path): assert config.listen == ["4402"] -def test_init_multiple_rp_ids(run_cli, tmp_path): - run_cli("init", "--rp-id", "company.com,app.com", "--rp-id", "pro.com") +def test_init_adds_domains_to_existing_database(run_cli, tmp_path): + """Further rp-ids are added by repeating init; no comma separation.""" + run_cli("init", "company.com") + run_cli("init", "app.com") + run_cli("init", "pro.com", "Pro Corp") config = stored_config(tmp_path) assert list(config.domains) == ["company.com", "app.com", "pro.com"] + assert config.domains["pro.com"].rp_name == "Pro Corp" + # OIDC providers seeded for the added domains + assert set(converted_oidc(tmp_path)) == {"company.com", "app.com", "pro.com"} -def test_init_refuses_existing_database(run_cli): +def converted_oidc(tmp_path): + async def _read(): + new_db = DB() + kanta = Kanta(str(tmp_path / "paskia.kantadb"), new_db) + await kanta.open(readonly=True) + try: + return set(kanta.data.oidc) + finally: + await kanta.close() + + return asyncio.run(_read()) + + +def test_init_updates_rp_name_of_existing_domain(run_cli, tmp_path): + run_cli("init", "example.com", "Old Name") + run_cli("init", "example.com", "New Name") + assert stored_config(tmp_path).domains["example.com"].rp_name == "New Name" + + +def test_init_noop_on_existing_domain(run_cli): run_cli("init") - with pytest.raises(SystemExit): + with pytest.raises(SystemExit, match="already configured"): run_cli("init") @@ -131,7 +148,7 @@ def test_init_refuses_legacy_database(run_cli, tmp_path): 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", "auth.example.com") + run_cli("init", "example.com", "--auth-host", "auth.example.com") with pytest.raises(SystemExit): run_cli("init", "--origin", "https://app.example.com") @@ -142,7 +159,7 @@ def test_serve_requires_database(run_cli): def test_serve_uses_stored_config(run_cli, tmp_path): - run_cli("init", "--rp-id", "example.com", "--rp-name", "Stored Name") + run_cli("init", "example.com", "Stored Name") calls = run_cli() assert calls["app"] == "paskia.fastapi.mainapp:app" @@ -197,7 +214,7 @@ def test_migrate_converts_legacy_database(run_cli, tmp_path): def test_migrate_multiple_legacy_databases_require_rp_id(run_cli, tmp_path): write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com")) - with pytest.raises(SystemExit, match="--rp-id"): + with pytest.raises(SystemExit, match="paskia migrate"): run_cli("migrate") @@ -205,7 +222,7 @@ def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path): write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com")) - run_cli("migrate", "--rp-id", "two.com") + run_cli("migrate", "two.com") config = stored_config(tmp_path) assert list(config.domains) == ["two.com"] @@ -217,7 +234,7 @@ def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path): def test_migrate_unknown_rp_id(run_cli, tmp_path): write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) with pytest.raises(SystemExit, match="nope.com.paskiadb"): - run_cli("migrate", "--rp-id", "nope.com") + run_cli("migrate", "nope.com") def test_migrate_refuses_existing_database(run_cli): diff --git a/tests/test_domains.py b/tests/test_domains.py index ab14b50..6675164 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -227,6 +227,21 @@ class TestValidateConfig: ) ) + def test_star_origin_accepted_not_auth_host(self): + domains.validate_config( + Config(domains={"a.com": DomainConfig(origins={"*": True})}) + ) + with pytest.raises(ValueError, match="cannot be the auth host"): + domains.validate_config( + Config( + domains={ + "a.com": DomainConfig( + origins={"*": OriginEntry(auth_host=True)} + ) + } + ) + ) + def test_auth_host_collision(self): with pytest.raises(ValueError, match="collides with a related origin"): domains.validate_config( @@ -417,6 +432,30 @@ class TestOriginValidation: with pytest.raises(ValueError, match="not allowed"): p.validate_origin("https://other.com") + def test_wildcard_is_https_only(self): + """A '*.example.com' entry does not fall back to other schemes.""" + p = Passkey(rp_id="example.com", origins=["*.example.com"]) + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("http://example.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("http://app.example.com:8080") + + def test_star_entry_matches_any_scheme_and_port(self): + """The bare '*' entry allows anything within the rp-id domain.""" + p = Passkey(rp_id="example.com", origins=["*"]) + assert p.validate_origin("https://example.com") + assert p.validate_origin("http://app.example.com:8080") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://other.com") + + def test_exact_entry_matches_scheme_and_port(self): + p = Passkey(rp_id="localhost", origins=["http://localhost:4403"]) + assert p.validate_origin("http://localhost:4403") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://localhost:4403") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("http://localhost:4404") + def test_sub_wildcard_matches_only_its_subtree(self): p = Passkey(rp_id="example.com", origins=["*.app.example.com"]) assert p.validate_origin("https://app.example.com") @@ -643,7 +682,7 @@ class TestLegacyConversion: kanta.data.config = LegacyConfig( rp_id="example.com", rp_name="Example", - origins=["https://app.example.com"], + origins=["https://app.example.com", "*.example.com"], ) kanta.data.credentials[cred_uuid] = LegacyCredential( credential_id=b"credential-id", @@ -668,7 +707,8 @@ class TestLegacyConversion: config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb") domain = config.domains["example.com"] assert domain.rp_name == "Example" - assert domain.origins == {"app.example.com": True} + # A legacy wildcard over the rp-id itself becomes the bare '*' + assert domain.origins == {"app.example.com": True, "*": True} converted = _read_db(tmp_path / "paskia.kantadb") assert converted.credentials[cred_uuid].rp_id == "example.com"