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:
@@ -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:
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uvx paskia init --rp-id example.com
|
uvx paskia init example.com
|
||||||
uvx paskia
|
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).
|
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).
|
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
|
```text
|
||||||
paskia init [options] # one-time bootstrap
|
paskia init [rp-id] [rp-name] [options] # one-time bootstrap; with an existing
|
||||||
paskia migrate [--rp-id] # convert a legacy {rp-id}.paskiadb database
|
# database, adds the rp-id (or renames it)
|
||||||
|
paskia migrate [rp-id] # convert a legacy {rp-id}.paskiadb database
|
||||||
paskia [-l endpoint] # serve
|
paskia [-l endpoint] # serve
|
||||||
```
|
```
|
||||||
|
|
||||||
| init option | Description | Default |
|
| init option | Description | Default |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* (stored in the database) | **localhost:4401** |
|
| -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-id* (positional) | Main/top domain for passkeys | **localhost** |
|
||||||
| --rp-name *"text"* | Branding name of the first domain (passkey auth, login dialog) | Same as rp-id |
|
| *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.
|
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
|
## 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.
|
For a real deployment, bootstrap Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uvx paskia init --rp-id=example.com --rp-name="Example Corp"
|
uvx paskia init example.com "Example Corp"
|
||||||
uvx paskia
|
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
|
### Step 2: Set Up Caddy
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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/*
|
### WebSockets: /auth/ws/*
|
||||||
|
|
||||||
|
|||||||
+36
-27
@@ -167,14 +167,18 @@ class Config(msgspec.Struct, omit_defaults=True):
|
|||||||
URL, background jobs), the single configured domain is used, and with
|
URL, background jobs), the single configured domain is used, and with
|
||||||
several domains the first one sorted by rp-id — never for dispatch.
|
several domains the first one sorted by rp-id — never for dispatch.
|
||||||
- Origin keys are bare hosts (`app.example.com`), wildcard patterns
|
- Origin keys are bare hosts (`app.example.com`), wildcard patterns
|
||||||
(`*.example.com`), or full origins when not https
|
(`*.example.com`), full origins when not https (`http://localhost:8080`),
|
||||||
(`http://localhost:8080`) — `https://` is omitted as the common case.
|
or the bare `*` — `https://` is omitted as the common case. A dict value
|
||||||
A dict value of `true` means presence only; an object carries extra
|
of `true` means presence only; an object carries extra properties
|
||||||
properties (currently just `auth_host`). Ordering carries no meaning —
|
(currently just `auth_host`). Ordering carries no meaning — display
|
||||||
display order is a UI affair.
|
order is a UI affair.
|
||||||
- An empty `origins` dict means the rp-id and all its subdomains may
|
- 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 (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
|
- **Origin validation** — two separate concerns: `origins` entries must
|
||||||
be within the rp-id domain. `related` entries must be outside it, are
|
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
|
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
|
they can never mix with runtime configuration of an already-configured
|
||||||
instance:
|
instance:
|
||||||
|
|
||||||
- **`paskia init`** — creates `paskia.kantadb` in CWD and seeds it:
|
- **`paskia init [rp-id] [rp-name]`** — creates `paskia.kantadb` in CWD
|
||||||
- `--rp-id`: repeatable/comma-separated, default `["localhost"]`.
|
and seeds it:
|
||||||
Multiple values create multiple domains at once (useful for
|
- `rp-id` (positional, default `localhost`) and `rp-name` (positional,
|
||||||
devserver/e2e).
|
default same as rp-id) are the only bootstrap-time domain
|
||||||
- `--rp-name`: applies to the **first** rp-id only. Its purpose is that
|
configuration; the rp-name exists so the very first admin
|
||||||
the very first admin registration ceremony already shows the correct
|
registration ceremony already shows the correct name. Everything else
|
||||||
RP name; everything else (origins, auth hosts, related domains) is
|
(origins, auth hosts, related domains) is set up via the admin
|
||||||
set up via the admin interface afterwards.
|
interface.
|
||||||
- `--listen`: stored into `Config.listen` (process-global).
|
- `--listen`: stored into `Config.listen` (process-global).
|
||||||
- Seeds the admin user + registration reset link and prints the link.
|
- Seeds the admin user + registration reset link and prints the link.
|
||||||
Refuses to run if `paskia.kantadb` already exists, or if an
|
Refuses to run if an unconverted legacy `*.paskiadb` is present
|
||||||
unconverted legacy `*.paskiadb` is present (`paskia migrate` converts
|
(`paskia migrate` converts it first).
|
||||||
it first).
|
- **With an existing `paskia.kantadb`**, init instead adds the given
|
||||||
- **`paskia migrate`** — converts a legacy `<rp-id>.paskiadb` database
|
rp-id as a new domain (seeding its OIDC provider), or updates the
|
||||||
(§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id`
|
rp-name of an existing one — a convenience for what the admin
|
||||||
selects `<rp-id>.paskiadb` by name; the others are left in place.
|
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`
|
- **`paskia`** — serve. Takes **no domain options**; only `--listen`
|
||||||
(per-run override of stored `Config.listen`, never persisted). Startup:
|
(per-run override of stored `Config.listen`, never persisted). Startup:
|
||||||
open `paskia.kantadb` → sanitize the stored domain set best-effort →
|
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/`,
|
file) becomes `paskia.kantadb`, `users/` becomes `paskia.data/users/`,
|
||||||
and the old directory is renamed aside to `<name>.converted-bak`. A
|
and the old directory is renamed aside to `<name>.converted-bak`. A
|
||||||
lone candidate converts without options; with several candidates
|
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
|
left in place (e.g. a `*.bak.paskiadb` backup does not block
|
||||||
conversion). Empty directories are ignored. Conversion is an explicit
|
conversion). Empty directories are ignored. Conversion is an explicit
|
||||||
operator action, never a serve side effect — read-only opens never
|
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
|
## 12. Development
|
||||||
|
|
||||||
- `scripts/devserver.py`: bootstraps via one-shot `paskia init` when no
|
- `scripts/devserver.py`: bootstraps via one-shot `paskia init` per rp-id
|
||||||
database exists (multi `--rp-id`, `--rp-name` for the first domain),
|
when no database exists (rp-name for the first domain), then runs
|
||||||
then runs plain `paskia` serve. Caddy dev origins iterate all bootstrap
|
plain `paskia` serve. Caddy dev origins iterate all bootstrap rp-ids.
|
||||||
rp-ids.
|
|
||||||
- `PASKIA_AUTH_HOST` (consumed by `frontend/vite.config.js`) is a
|
- `PASKIA_AUTH_HOST` (consumed by `frontend/vite.config.js`) is a
|
||||||
comma-separated list of bare hostnames; the vite dev proxy forwards
|
comma-separated list of bare hostnames; the vite dev proxy forwards
|
||||||
`/.well-known/openid-configuration` and `/.well-known/webauthn` to the
|
`/.well-known/openid-configuration` and `/.well-known/webauthn` to the
|
||||||
backend.
|
backend.
|
||||||
- The example `caddy/auth/setup` snippet forwards both well-known paths
|
- The example `caddy/auth/setup` snippet forwards both well-known paths
|
||||||
to paskia so a static `/.well-known/*` handler does not shadow them.
|
to paskia so a static `/.well-known/*` handler does not shadow them.
|
||||||
- E2E: `e2e/tests/global-setup.ts` runs `paskia init --rp-id
|
- E2E: `e2e/tests/global-setup.ts` runs `paskia init localhost` and
|
||||||
localhost,test.localhost` in the test-data dir (which doubles as the
|
`paskia init test.localhost` in the test-data dir (which doubles as the
|
||||||
server CWD) and serves; `e2e/tests/50-multidomain.spec.ts` exercises
|
server CWD) and serves; `e2e/tests/50-multidomain.spec.ts` exercises
|
||||||
host dispatch, the well-known endpoint via the admin domain API, and a
|
host dispatch, the well-known endpoint via the admin domain API, and a
|
||||||
cross-domain remote login (request at test.localhost, permit at
|
cross-domain remote login (request at test.localhost, permit at
|
||||||
|
|||||||
@@ -44,14 +44,13 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
const state: TestState = {}
|
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...')
|
console.log(' Bootstrapping database with paskia init...')
|
||||||
const initResult = spawnSync(
|
const initResult = spawnSync(
|
||||||
'uv',
|
'uv',
|
||||||
[
|
[
|
||||||
'run', '--project', projectRoot,
|
'run', '--project', projectRoot,
|
||||||
'paskia', 'init', '-l', 'localhost:4404',
|
'paskia', 'init', '-l', 'localhost:4404', 'localhost',
|
||||||
'--rp-id', 'localhost,test.localhost',
|
|
||||||
],
|
],
|
||||||
{ cwd: testDataDir, encoding: 'utf-8' }
|
{ cwd: testDataDir, encoding: 'utf-8' }
|
||||||
)
|
)
|
||||||
@@ -60,6 +59,15 @@ export default async function globalSetup() {
|
|||||||
if (initResult.status !== 0) {
|
if (initResult.status !== 0) {
|
||||||
throw new Error(`paskia init failed with exit code ${initResult.status}`)
|
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
|
// Parse the reset token from init output
|
||||||
// Format: http://localhost:4404/auth/{token} where token is dot-separated words
|
// Format: http://localhost:4404/auth/{token} where token is dot-separated words
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ function createDomain() {
|
|||||||
function openDomain(domain) {
|
function openDomain(domain) {
|
||||||
// One combined list for editing, in display order: in-domain sites and
|
// One combined list for editing, in display order: in-domain sites and
|
||||||
// related origins, classified by hostname. The default is always shown
|
// 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)
|
const rows = originDisplayEntries(domain)
|
||||||
openDialog('domain-edit', {
|
openDialog('domain-edit', {
|
||||||
isNew: false,
|
isNew: false,
|
||||||
@@ -951,6 +951,10 @@ async function submitDialog() {
|
|||||||
const related = {}
|
const related = {}
|
||||||
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
|
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
|
||||||
const key = keyOf(o)
|
const key = keyOf(o)
|
||||||
|
if (key === '*') {
|
||||||
|
origins['*'] = true // anything in-domain, any scheme/port
|
||||||
|
continue
|
||||||
|
}
|
||||||
let hn = null
|
let hn = null
|
||||||
if (key.startsWith('*.')) {
|
if (key.startsWith('*.')) {
|
||||||
hn = key.slice(2).replace(/\.+$/, '')
|
hn = key.slice(2).replace(/\.+$/, '')
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function isWellFormedDomain(value) {
|
|||||||
|
|
||||||
function originHostname(origin) {
|
function originHostname(origin) {
|
||||||
if (!origin.trim()) return null
|
if (!origin.trim()) return null
|
||||||
|
if (origin.trim() === '*') return '*'
|
||||||
if (origin.trim().startsWith('*.')) {
|
if (origin.trim().startsWith('*.')) {
|
||||||
const base = origin.trim().slice(2).replace(/\.+$/, '')
|
const base = origin.trim().slice(2).replace(/\.+$/, '')
|
||||||
return isWellFormedDomain(base) ? base : null
|
return isWellFormedDomain(base) ? base : null
|
||||||
@@ -126,6 +127,7 @@ function originHostname(origin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWithinDomain(origin, rpId) {
|
function isWithinDomain(origin, rpId) {
|
||||||
|
if (origin.trim() === '*') return true
|
||||||
const hostname = originHostname(origin)
|
const hostname = originHostname(origin)
|
||||||
if (!hostname) return false
|
if (!hostname) return false
|
||||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||||
@@ -167,7 +169,7 @@ function validateOrigin(i) {
|
|||||||
d.originValidation[i] = 'invalid'
|
d.originValidation[i] = 'invalid'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (value.trim().startsWith('*.')) {
|
if (value.trim() === '*' || value.trim().startsWith('*.')) {
|
||||||
d.originValidation[i] = null // wildcards have no concrete site to probe
|
d.originValidation[i] = null // wildcards have no concrete site to probe
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,15 +205,15 @@ async function testWellKnown() {
|
|||||||
}
|
}
|
||||||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||||||
|
|
||||||
// Seed the default wildcard entry for a new domain once its rp-id is known,
|
// Seed the default '*' entry for a new domain once its rp-id is known,
|
||||||
// so the list always shows what is allowed ('*.example.com' = the domain and
|
// so the list always shows what is allowed ('*' = the domain and all its
|
||||||
// all its subdomains). Removing the last in-domain entry is blocked in the
|
// subdomains, any scheme/port). Removing the last in-domain entry is
|
||||||
// row menu, so the list never becomes empty afterwards.
|
// blocked in the row menu, so the list never becomes empty afterwards.
|
||||||
watch(dialogRpId, rp => {
|
watch(dialogRpId, rp => {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||||||
if (!d.origins.length && isWellFormedDomain(rp)) {
|
if (!d.origins.length && isWellFormedDomain(rp)) {
|
||||||
d.origins.push('*.' + rp)
|
d.origins.push('*')
|
||||||
d.originValidation.push(null)
|
d.originValidation.push(null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -236,9 +238,10 @@ function setAuthHost(i) {
|
|||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d) return
|
if (!d) return
|
||||||
let key = entryKey(d.origins[i])
|
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
|
// 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)) {
|
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||||
d.origins.push(key)
|
d.origins.push(key)
|
||||||
d.originValidation.push(null)
|
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>
|
<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>
|
</div>
|
||||||
<p class="small muted">
|
<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 ⋮).
|
Entries on other domain names become related origins (WebAuthn ROR). The 🔑 site hosts the account and admin interface (set via ⋮).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export const hostIP = ip => {
|
|||||||
// Display-time ordering of a domain's configured origins (the stored
|
// Display-time ordering of a domain's configured origins (the stored
|
||||||
// objects are unordered): the auth host first (flagged), then in-domain
|
// objects are unordered): the auth host first (flagged), then in-domain
|
||||||
// entries (exact rp-id, then alphabetical), then related domains
|
// 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) {
|
export function originDisplayEntries(domain) {
|
||||||
const origins = domain.origins || {}
|
const origins = domain.origins || {}
|
||||||
const keys = Object.keys(origins)
|
const keys = Object.keys(origins)
|
||||||
@@ -58,7 +59,7 @@ export function originDisplayEntries(domain) {
|
|||||||
const rows = []
|
const rows = []
|
||||||
if (authKey) rows.push({ key: authKey, auth: true })
|
if (authKey) rows.push({ key: authKey, auth: true })
|
||||||
for (const k of inDomain) rows.push({ key: k, auth: false })
|
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()) {
|
for (const k of Object.keys(domain.related || {}).sort()) {
|
||||||
rows.push({ key: k, auth: false, related: true })
|
rows.push({ key: k, auth: false, related: true })
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-32
@@ -12,18 +12,19 @@ from kanta import Kanta
|
|||||||
from paskia.db import legacy
|
from paskia.db import legacy
|
||||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||||
from paskia.db.paths import db_file_path
|
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 build as build_registry
|
||||||
from paskia.domains import configure as configure_domains
|
from paskia.domains import configure as configure_domains
|
||||||
from paskia.domains import validate_config
|
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.constants import DEFAULT_PORT, DEVMODE
|
||||||
|
from paskia.util.crypto import secret_key
|
||||||
from paskia.util.runtime import ServeConfig
|
from paskia.util.runtime import ServeConfig
|
||||||
|
|
||||||
EPILOG = """\
|
EPILOG = """\
|
||||||
Examples:
|
Examples:
|
||||||
paskia init --rp-id example.com --rp-name "Example Corporation"
|
paskia init example.com "Example Corporation"
|
||||||
paskia migrate --rp-id example.com
|
paskia migrate example.com
|
||||||
paskia
|
paskia
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -72,14 +73,61 @@ def _load_stored_config(db_path: Path) -> Config:
|
|||||||
raise SystemExit(f"{e}") from e
|
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:
|
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()
|
db_path = db_file_path()
|
||||||
if db_path.exists():
|
if db_path.exists():
|
||||||
raise SystemExit(
|
_init_add_domain(db_path, rp_id, rp_name, listen)
|
||||||
f"Database {db_path} already exists — domain configuration is "
|
return
|
||||||
"managed via the admin interface, not 'paskia init'."
|
|
||||||
)
|
|
||||||
if found := legacy.find_legacy_databases():
|
if found := legacy.find_legacy_databases():
|
||||||
names = ", ".join(str(p) for p in found)
|
names = ", ".join(str(p) for p in found)
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@@ -87,21 +135,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
"convert, not 'paskia init'."
|
"convert, not 'paskia init'."
|
||||||
)
|
)
|
||||||
|
|
||||||
rp_ids = _split_multi(args.rp_id) or ["localhost"]
|
|
||||||
|
|
||||||
# Only rp-id and rp-name are bootstrap-time configuration; everything
|
# Only rp-id and rp-name are bootstrap-time configuration; everything
|
||||||
# else (origins, auth host, related domains) is set up afterwards via
|
# else (origins, auth host, related domains) is set up afterwards via
|
||||||
# the admin interface.
|
# the admin interface. The bootstrap rp-name exists so the very first
|
||||||
domains = {}
|
# admin registration ceremony already shows the correct name.
|
||||||
for i, rp_id in enumerate(rp_ids):
|
config = Config(domains={rp_id: DomainConfig(rp_name=rp_name)}, listen=listen)
|
||||||
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)
|
|
||||||
try:
|
try:
|
||||||
validate_config(config)
|
validate_config(config)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -132,7 +170,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
registry = build_registry(config)
|
registry = build_registry(config)
|
||||||
startupbox.print_startup_config(registry, listen=config.listen)
|
startupbox.print_startup_config(registry, listen=config.listen)
|
||||||
log_reset_link(
|
log_reset_link(
|
||||||
registry.get(rp_ids[0]).reset_link_url(result["passphrase"]),
|
registry.get(rp_id).reset_link_url(result["passphrase"]),
|
||||||
"✅ Bootstrap completed!",
|
"✅ Bootstrap completed!",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,20 +243,23 @@ def main():
|
|||||||
|
|
||||||
init_parser = argparse.ArgumentParser(
|
init_parser = argparse.ArgumentParser(
|
||||||
prog="paskia init",
|
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,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=EPILOG,
|
epilog=EPILOG,
|
||||||
)
|
)
|
||||||
init_parser.add_argument(
|
init_parser.add_argument(
|
||||||
"--rp-id",
|
"rp_id",
|
||||||
action="append",
|
nargs="?",
|
||||||
help="Relying Party ID of the initial domain(s) (default: localhost). "
|
help="Relying Party ID of the initial domain (default: localhost). "
|
||||||
"Repeatable and comma-separated. Further domains, origins and auth "
|
"Further domains, origins and auth hosts are added via the admin "
|
||||||
"hosts are added via the admin interface.",
|
"interface — or with another 'paskia init <rp-id>'.",
|
||||||
)
|
)
|
||||||
init_parser.add_argument(
|
init_parser.add_argument(
|
||||||
"--rp-name",
|
"rp_name",
|
||||||
help="Relying Party name of the first domain (default: same as rp-id). "
|
nargs="?",
|
||||||
|
help="Relying Party name of the domain (default: same as rp-id). "
|
||||||
"Used by the initial admin registration; editable later via admin UI.",
|
"Used by the initial admin registration; editable later via admin UI.",
|
||||||
)
|
)
|
||||||
_add_listen_option(init_parser, help_extra=" (stored in the database)")
|
_add_listen_option(init_parser, help_extra=" (stored in the database)")
|
||||||
@@ -229,7 +270,8 @@ def main():
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
migrate_parser.add_argument(
|
migrate_parser.add_argument(
|
||||||
"--rp-id",
|
"rp_id",
|
||||||
|
nargs="?",
|
||||||
help="rp-id of the legacy database to convert, selecting "
|
help="rp-id of the legacy database to convert, selecting "
|
||||||
"<rp-id>.paskiadb when several legacy candidates exist.",
|
"<rp-id>.paskiadb when several legacy candidates exist.",
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-2
@@ -116,7 +116,12 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
|||||||
|
|
||||||
origins: dict[str, bool | OriginEntry] = {}
|
origins: dict[str, bool | OriginEntry] = {}
|
||||||
for origin in old.config.origins or []:
|
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:
|
if old.config.auth_host:
|
||||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
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)
|
names = ", ".join(str(c) for c in candidates)
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"Multiple legacy databases found ({names}) — select one with "
|
f"Multiple legacy databases found ({names}) — select one with "
|
||||||
"'paskia migrate --rp-id <rp-id>'."
|
"'paskia migrate <rp-id>'."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
src = candidates[0]
|
src = candidates[0]
|
||||||
|
|||||||
+23
-2
@@ -29,7 +29,11 @@ DEFAULT_RELATED_ORIGIN_CAP = 5
|
|||||||
|
|
||||||
def origin_url(key: str) -> str:
|
def origin_url(key: str) -> str:
|
||||||
"""Full origin URL for an origins-dict key (https:// is implied)."""
|
"""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 key
|
||||||
return f"https://{key}"
|
return f"https://{key}"
|
||||||
|
|
||||||
@@ -181,6 +185,10 @@ def validate_config(
|
|||||||
|
|
||||||
for key, props in domain.origins.items():
|
for key, props in domain.origins.items():
|
||||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
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):
|
if hostutil.is_wildcard_pattern(key):
|
||||||
base = key[2:].rstrip(".")
|
base = key[2:].rstrip(".")
|
||||||
if not base or not hostutil.is_subdomain(base, rp_id):
|
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)
|
related: dict[str, bool] = dict(domain.related)
|
||||||
for key, props in domain.origins.items():
|
for key, props in domain.origins.items():
|
||||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
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):
|
if hostutil.is_wildcard_pattern(key):
|
||||||
base = key[2:].rstrip(".")
|
base = key[2:].rstrip(".")
|
||||||
if not base:
|
if not base:
|
||||||
@@ -421,7 +438,11 @@ def _derive_site(
|
|||||||
return auth, "/"
|
return auth, "/"
|
||||||
if rp_id in domain.origins:
|
if rp_id in domain.origins:
|
||||||
return origin_url(rp_id), "/auth/"
|
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:
|
if concrete:
|
||||||
return origin_url(concrete[0]), "/auth/"
|
return origin_url(concrete[0]), "/auth/"
|
||||||
if rp_id == "localhost":
|
if rp_id == "localhost":
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]
|
|||||||
key = raw_key.strip()
|
key = raw_key.strip()
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
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))
|
key = domains.origin_key(hostutil.normalize_origin(key))
|
||||||
is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host"))
|
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
|
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()
|
key = raw_key.strip()
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
if hostutil.is_wildcard_pattern(key):
|
if hostutil.is_any_pattern(key) or hostutil.is_wildcard_pattern(key):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Related origin '{key}' is a wildcard — related origins "
|
f"Related origin '{key}' is a wildcard — related origins "
|
||||||
"(ROR) must be listed individually"
|
"(ROR) must be listed individually"
|
||||||
|
|||||||
+15
-7
@@ -56,10 +56,13 @@ class Passkey:
|
|||||||
rp_id: Your security domain (e.g. "example.com")
|
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.
|
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
|
origins: Allow-list of sign-in site origins within the rp-id domain
|
||||||
(e.g. ["https://app.example.com"]); wildcard patterns like
|
(e.g. ["https://app.example.com"]); the bare entry "*"
|
||||||
"*.example.com" match the base domain and its subdomains.
|
allows the whole rp-id domain on any scheme and port,
|
||||||
If not provided, the rp-id and any subdomain of it may
|
while wildcard patterns like "*.example.com" match the
|
||||||
authenticate.
|
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
|
related_origins: Origins on unrelated domains that may assert this
|
||||||
rp-id (WebAuthn Related Origin Requests). Always additive.
|
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).
|
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:
|
if origins:
|
||||||
# Validate and deduplicate origins into a set for O(1) lookups
|
# Validate and deduplicate origins into a set for O(1) lookups
|
||||||
for o in origins:
|
for o in origins:
|
||||||
|
if hostutil.is_any_pattern(o):
|
||||||
|
continue # anything in-domain, any scheme/port
|
||||||
self._validate_origin_url(o)
|
self._validate_origin_url(o)
|
||||||
hostname = hostutil.origin_hostname(o)
|
hostname = hostutil.origin_hostname(o)
|
||||||
if not hostutil.is_subdomain(hostname, rp_id):
|
if not hostutil.is_subdomain(hostname, rp_id):
|
||||||
@@ -119,11 +124,14 @@ class Passkey:
|
|||||||
def _allowlisted(self, origin: str) -> bool:
|
def _allowlisted(self, origin: str) -> bool:
|
||||||
"""Check an in-domain origin against the allow-list.
|
"""Check an in-domain origin against the allow-list.
|
||||||
|
|
||||||
An entry matches exactly, or as a wildcard pattern ('*.example.com'
|
An entry matches exactly, '*' matches anything in-domain (any
|
||||||
matches the base domain and any subdomain of it).
|
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
|
return True
|
||||||
|
if not origin.startswith("https://"):
|
||||||
|
return False # Wildcard patterns match https origins only
|
||||||
hostname = hostutil.origin_hostname(origin)
|
hostname = hostutil.origin_hostname(origin)
|
||||||
return any(
|
return any(
|
||||||
hostutil.is_wildcard_pattern(entry)
|
hostutil.is_wildcard_pattern(entry)
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ def is_wildcard_pattern(value: str) -> bool:
|
|||||||
return value.startswith("*.")
|
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:
|
def normalize_origin(origin: str) -> str:
|
||||||
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
|
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
|
||||||
|
|
||||||
|
|||||||
@@ -162,13 +162,13 @@ def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) ->
|
|||||||
"convert it before starting the dev server."
|
"convert it before starting the dev server."
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"]
|
for i, rp_id in enumerate(rp_ids):
|
||||||
for rp_id in rp_ids:
|
cmd = [sys.executable, "-m", "paskia", "init", rp_id]
|
||||||
cmd.extend(["--rp-id", rp_id])
|
if i == 0:
|
||||||
if args.rp_name:
|
if args.rp_name:
|
||||||
cmd.extend(["--rp-name", args.rp_name])
|
cmd.append(args.rp_name)
|
||||||
|
cmd.append(f"--listen={listen}")
|
||||||
logger.info(">>> paskia init (first run)")
|
logger.info(">>> paskia init %s", rp_id)
|
||||||
proc = subprocess.run(cmd, check=False) # noqa: S603
|
proc = subprocess.run(cmd, check=False) # noqa: S603
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise SystemExit(proc.returncode)
|
raise SystemExit(proc.returncode)
|
||||||
|
|||||||
+36
-19
@@ -20,7 +20,7 @@ from kanta import Kanta
|
|||||||
|
|
||||||
from paskia.__main__ import _load_stored_config, main
|
from paskia.__main__ import _load_stored_config, main
|
||||||
from paskia.db import legacy
|
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
|
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):
|
def test_init_full_options(run_cli, tmp_path):
|
||||||
run_cli(
|
run_cli("init", "example.com", "Example Corp", "--listen", "4402")
|
||||||
"init",
|
|
||||||
"--rp-id",
|
|
||||||
"example.com",
|
|
||||||
"--rp-name",
|
|
||||||
"Example Corp",
|
|
||||||
"--listen",
|
|
||||||
"4402",
|
|
||||||
)
|
|
||||||
|
|
||||||
config = stored_config(tmp_path)
|
config = stored_config(tmp_path)
|
||||||
domain = config.domains["example.com"]
|
domain = config.domains["example.com"]
|
||||||
@@ -109,16 +101,41 @@ def test_init_full_options(run_cli, tmp_path):
|
|||||||
assert config.listen == ["4402"]
|
assert config.listen == ["4402"]
|
||||||
|
|
||||||
|
|
||||||
def test_init_multiple_rp_ids(run_cli, tmp_path):
|
def test_init_adds_domains_to_existing_database(run_cli, tmp_path):
|
||||||
run_cli("init", "--rp-id", "company.com,app.com", "--rp-id", "pro.com")
|
"""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)
|
config = stored_config(tmp_path)
|
||||||
assert list(config.domains) == ["company.com", "app.com", "pro.com"]
|
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")
|
run_cli("init")
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit, match="already configured"):
|
||||||
run_cli("init")
|
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):
|
def test_init_rejects_removed_options(run_cli):
|
||||||
"""Origins and auth hosts are admin-interface configuration, not init's."""
|
"""Origins and auth hosts are admin-interface configuration, not init's."""
|
||||||
with pytest.raises(SystemExit):
|
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):
|
with pytest.raises(SystemExit):
|
||||||
run_cli("init", "--origin", "https://app.example.com")
|
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):
|
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()
|
calls = run_cli()
|
||||||
|
|
||||||
assert calls["app"] == "paskia.fastapi.mainapp:app"
|
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):
|
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="one.com"))
|
||||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.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")
|
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="one.com"))
|
||||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.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)
|
config = stored_config(tmp_path)
|
||||||
assert list(config.domains) == ["two.com"]
|
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):
|
def test_migrate_unknown_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="one.com"))
|
||||||
with pytest.raises(SystemExit, match="nope.com.paskiadb"):
|
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):
|
def test_migrate_refuses_existing_database(run_cli):
|
||||||
|
|||||||
+42
-2
@@ -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):
|
def test_auth_host_collision(self):
|
||||||
with pytest.raises(ValueError, match="collides with a related origin"):
|
with pytest.raises(ValueError, match="collides with a related origin"):
|
||||||
domains.validate_config(
|
domains.validate_config(
|
||||||
@@ -417,6 +432,30 @@ class TestOriginValidation:
|
|||||||
with pytest.raises(ValueError, match="not allowed"):
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
p.validate_origin("https://other.com")
|
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):
|
def test_sub_wildcard_matches_only_its_subtree(self):
|
||||||
p = Passkey(rp_id="example.com", origins=["*.app.example.com"])
|
p = Passkey(rp_id="example.com", origins=["*.app.example.com"])
|
||||||
assert p.validate_origin("https://app.example.com")
|
assert p.validate_origin("https://app.example.com")
|
||||||
@@ -643,7 +682,7 @@ class TestLegacyConversion:
|
|||||||
kanta.data.config = LegacyConfig(
|
kanta.data.config = LegacyConfig(
|
||||||
rp_id="example.com",
|
rp_id="example.com",
|
||||||
rp_name="Example",
|
rp_name="Example",
|
||||||
origins=["https://app.example.com"],
|
origins=["https://app.example.com", "*.example.com"],
|
||||||
)
|
)
|
||||||
kanta.data.credentials[cred_uuid] = LegacyCredential(
|
kanta.data.credentials[cred_uuid] = LegacyCredential(
|
||||||
credential_id=b"credential-id",
|
credential_id=b"credential-id",
|
||||||
@@ -668,7 +707,8 @@ class TestLegacyConversion:
|
|||||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||||
domain = config.domains["example.com"]
|
domain = config.domains["example.com"]
|
||||||
assert domain.rp_name == "Example"
|
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")
|
converted = _read_db(tmp_path / "paskia.kantadb")
|
||||||
assert converted.credentials[cred_uuid].rp_id == "example.com"
|
assert converted.credentials[cred_uuid].rp_id == "example.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user