MultiSite: one instance serves authentication across many domains #4

Merged
LeoVasanko merged 48 commits from multihost into main 2026-09-07 22:02:06 +00:00
16 changed files with 293 additions and 129 deletions
Showing only changes of commit 7ff8869e1d - Show all commits
+11 -10
View File
@@ -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
+1 -1
View File
@@ -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/*
+36 -27
View File
@@ -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 `<rp-id>.paskiadb` database
(§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id`
selects `<rp-id>.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 `<rp-id>.paskiadb`
database (§10) to `paskia.kantadb`. With several legacy candidates, the
positional rp-id selects `<rp-id>.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 `<name>.converted-bak`. A
lone candidate converts without options; with several candidates
`--rp-id <rp-id>` selects `<rp-id>.paskiadb` by name and the rest are
a positional rp-id selects `<rp-id>.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
+11 -3
View File
@@ -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
+5 -1
View File
@@ -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(/\.+$/, '')
+12 -9
View File
@@ -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.<base> 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) {
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
</div>
<p class="small muted">
Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> means the domain and all its subdomains.
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> means the domain and all its subdomains on any scheme and port; <strong>*.{{ dialog.data.rp_id }}</strong> restricts that to https.
Entries on other domain names become related origins (WebAuthn ROR). The 🔑 site hosts the account and admin interface (set via ⋮).
</p>
+3 -2
View File
@@ -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 })
}
+74 -32
View File
@@ -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 <rp-id>'.",
)
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 "
"<rp-id>.paskiadb when several legacy candidates exist.",
)
+7 -2
View File
@@ -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 <rp-id>'."
"'paskia migrate <rp-id>'."
)
else:
src = candidates[0]
+23 -2
View File
@@ -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":
+2 -2
View File
@@ -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"
+15 -7
View File
@@ -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)
+5
View File
@@ -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.
+10 -10
View File
@@ -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:
+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"