CLI: positional rp-id/rp-name; init adds domains to an existing database

- 'paskia init [rp-id] [rp-name]' and 'paskia migrate [rp-id]' are now
  positional; comma separation and the --rp-id/--rp-name flags are gone.
- With an existing paskia.kantadb, init adds the rp-id as a new domain
  (seeding its OIDC provider) or updates an existing domain's rp-name.
- Origin allow-list semantics clarified: the bare '*' entry allows
  anything within the rp-id domain on any scheme and port (also the
  empty-list default and its display in the admin UI, replacing the
  synthetic '*.rp-id' row); '*.x' wildcards are https-only; exact entries
  match scheme, host and port. Legacy '*.rp-id' wildcards migrate to '*'
  to preserve their any-scheme meaning.
This commit is contained in:
2026-09-07 03:07:38 +00:00
parent 476ce996ad
commit 7ff8869e1d
16 changed files with 293 additions and 129 deletions
+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 })
}