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.
This commit is contained in:
2026-09-07 03:07:38 +00:00
parent 476ce996ad
commit 7ff8869e1d
16 changed files with 293 additions and 129 deletions
+36 -19
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
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):
+42 -2
View File
@@ -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"