diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue
index bc933d0..bb242fd 100644
--- a/frontend/auth/admin/AdminApp.vue
+++ b/frontend/auth/admin/AdminApp.vue
@@ -462,6 +462,23 @@ function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
}
+async function openServerConfig() {
+ try {
+ const config = await apiJson('/auth/api/admin/server-config')
+ // Strip https:// scheme from stored origins and auth_host for editing
+ const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
+ const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
+ openDialog('server-config', {
+ rp_name: config.rp_name || '',
+ auth_host,
+ origins,
+ originValidation: origins.map(() => null),
+ })
+ } catch (e) {
+ authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
+ }
+}
+
function deleteOidcClient(client) {
openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
@@ -886,6 +903,27 @@ async function submitDialog() {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
})
return // Don't call closeDialog() again
+ } else if (t === 'server-config') {
+ const rp_name = dialog.value.data.rp_name?.trim() || ''
+ const auth_host = dialog.value.data.auth_host?.trim() || ''
+ // Origins are stored as-is (hostnames); backend normalizes with https://
+ const origins = dialog.value.data.origins
+ .map(o => o.trim())
+ .filter(o => o)
+
+ closeDialog()
+ apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
+ .then(() => {
+ authStore.showMessage('Server configuration updated.', 'success', 2500)
+ // Reload settings to reflect rp_name changes
+ authStore.loadSettings().then(() => {
+ if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
+ })
+ })
+ .catch(e => {
+ authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
+ })
+ return // Don't call closeDialog() again
} else if (t === 'confirm') {
const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage)
@@ -951,6 +989,7 @@ async function submitDialog() {
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
+ @open-server-config="openServerConfig"
@navigate-out="handlePanelNavigateOut"
/>
diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue
index bcce288..fa588c1 100644
--- a/frontend/src/admin/AdminDialogs.vue
+++ b/frontend/src/admin/AdminDialogs.vue
@@ -17,6 +17,21 @@ const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
+// Initialize validation properties
+if (props.dialog?.data && props.dialog.type === 'server-config') {
+ if (!('authHostValidation' in props.dialog.data)) {
+ props.dialog.data.authHostValidation = null
+ }
+}
+
+const isValidationInvalid = computed(() => {
+ if (props.dialog?.type !== 'server-config') return false
+ const d = props.dialog.data
+ if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
+ if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
+ return false
+})
+
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
@@ -24,6 +39,135 @@ function copyText(value, label) {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
+
+function addOrigin() {
+ const d = props.dialog?.data
+ if (d) {
+ d.origins.push(rpId.value)
+ d.originValidation.push(null)
+ validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
+ }
+}
+function removeOrigin(i) {
+ const d = props.dialog?.data
+ if (d) {
+ d.origins.splice(i, 1)
+ d.originValidation.splice(i, 1)
+ }
+}
+function stripScheme(val, i) {
+ const d = props.dialog?.data
+ if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
+}
+function stripSchemeAuthHost() {
+ const d = props.dialog?.data
+ if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
+}
+function focusOriginStart(e) {
+ e.target.setSelectionRange(0, 0)
+}
+
+function validateOriginDomain(origin, rpId) {
+ if (!origin.trim()) return false
+ try {
+ const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
+ const hostname = url.hostname
+ return hostname === rpId || hostname.endsWith('.' + rpId)
+ } catch {
+ return false
+ }
+}
+
+async function validateOriginConnectivity(origin, i) {
+ const d = props.dialog?.data
+ if (!d) return
+
+ d.originValidation[i] = 'validating'
+ try {
+ const cleanOrigin = origin.replace(/\/+$/, '')
+ const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
+ const response = await fetch(testUrl + '/auth/api/settings', {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' }
+ })
+ if (response.ok) {
+ const data = await response.json()
+ // Check if it returns valid settings (has rp_id and matches current rp_id)
+ const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
+ // Only update if the origin hasn't changed
+ if (d.origins[i] === origin) {
+ d.originValidation[i] = result
+ }
+ } else {
+ if (d.origins[i] === origin) {
+ d.originValidation[i] = 'invalid'
+ }
+ }
+ } catch (e) {
+ if (d.origins[i] === origin) {
+ d.originValidation[i] = 'invalid'
+ }
+ }
+}
+
+function validateOrigin(origin, i) {
+ const d = props.dialog?.data
+ if (!d) return
+
+ const id = rpId.value
+ if (validateOriginDomain(origin, id)) {
+ validateOriginConnectivity(origin, i)
+ } else {
+ d.originValidation[i] = 'invalid'
+ }
+}
+
+async function validateAuthHostConnectivity(authHost) {
+ const d = props.dialog?.data
+ if (!d) return
+
+ d.authHostValidation = 'validating'
+ try {
+ const cleanAuthHost = authHost.replace(/\/+$/, '')
+ const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
+ const response = await fetch(testUrl + '/auth/api/settings', {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' }
+ })
+ if (response.ok) {
+ const data = await response.json()
+ // Check if it returns valid settings (has rp_id and matches current rp_id)
+ const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
+ // Only update if the auth_host hasn't changed
+ if (d.auth_host === authHost) {
+ d.authHostValidation = result
+ }
+ } else {
+ if (d.auth_host === authHost) {
+ d.authHostValidation = 'invalid-connectivity'
+ }
+ }
+ } catch (e) {
+ if (d.auth_host === authHost) {
+ d.authHostValidation = 'invalid-connectivity'
+ }
+ }
+}
+
+function validateAuthHost() {
+ const d = props.dialog?.data
+ if (!d || !d.auth_host?.trim()) {
+ d.authHostValidation = null // Allow empty
+ return
+ }
+
+ const id = rpId.value
+ if (validateOriginDomain(d.auth_host, id)) {
+ validateAuthHostConnectivity(d.auth_host)
+ } else {
+ d.authHostValidation = 'invalid-domain'
+ }
+}
@@ -37,6 +181,7 @@ function copyText(value, label) {
Edit User Name
{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}
{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}
+ Server Options
Confirm
+
+
+
+ Validating...
+ Valid
+ Invalid domain
+ Well-formed but unreachable
+ Invalid configuration
+ Enter {{ rpId }} or any subdomain of it.
+
+ Allowed Origins
+
+
+
+
+ { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
+ @focus="focusOriginStart"
+ class="origin-input"
+ :class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
+ />
+
+
+
+ {{ rpId }} and all subdomains allowed.
+ Only the above sites are allowed to authenticate.
+
{{ dialog.data.message }}
@@ -112,7 +289,7 @@ function copyText(value, label) {
@@ -141,4 +318,17 @@ function copyText(value, label) {
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
+
+/* Server config origins */
+.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
+.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
+.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
+.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
+.origin-row .delete-icon { flex-shrink: 0; }
+.origin-add-btn { font-size: 1.2rem; }
+
+.input-error {
+ border-color: var(--color-error);
+ background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
+}
diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue
index 840bad2..e4a0169 100644
--- a/frontend/src/admin/AdminOverview.vue
+++ b/frontend/src/admin/AdminOverview.vue
@@ -12,7 +12,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
-const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
+const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
@@ -431,6 +431,16 @@ defineExpose({ focusFirstElement })
+
+
+
+
+
diff --git a/paskia/__main__.py b/paskia/__main__.py
index f7b2081..331999d 100644
--- a/paskia/__main__.py
+++ b/paskia/__main__.py
@@ -1,7 +1,6 @@
import argparse
import logging
import os
-from urllib.parse import urlparse
import msgspec
from fastapi_vue import server
@@ -9,7 +8,11 @@ from fastapi_vue.hostutil import parse_endpoints
from paskia.db.jsonl import load_readonly
from paskia.util import startupbox
-from paskia.util.hostutil import normalize_origin
+from paskia.util.hostutil import (
+ normalize_auth_host_and_origins,
+ normalize_origin,
+ validate_auth_host,
+)
from paskia.util.runtime import RuntimeConfig
DEFAULT_PORT = 4401
@@ -21,27 +24,6 @@ Example:
"""
-def is_subdomain(sub: str, domain: str) -> bool:
- """Check if sub is a subdomain of domain (or equal)."""
- sub_parts = sub.lower().split(".")
- domain_parts = domain.lower().split(".")
- if len(sub_parts) < len(domain_parts):
- return False
- return sub_parts[-len(domain_parts) :] == domain_parts
-
-
-def validate_auth_host(auth_host: str, rp_id: str) -> None:
- """Validate that auth_host is a subdomain of rp_id."""
- parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
- host = parsed.hostname or parsed.path
- if not host:
- raise SystemExit(f"Invalid auth-host: '{auth_host}'")
- if not is_subdomain(host, rp_id):
- raise SystemExit(
- f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
- )
-
-
def add_common_options(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
@@ -104,18 +86,16 @@ def main():
if args.listen is not None:
config.listen = None if args.listen == [""] else args.listen
- # Process and normalize auth_host
- if config.auth_host:
- if "://" not in config.auth_host:
- config.auth_host = f"https://{config.auth_host}"
- config.auth_host = config.auth_host.rstrip("/")
- validate_auth_host(config.auth_host, config.rp_id)
- if config.origins:
- config.origins.insert(0, config.auth_host) # Ensure first in origins
-
- # Normalize and deduplicate while preserving order
+ # Process and normalize auth_host and origins
+ try:
+ validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
+ except ValueError as e:
+ raise SystemExit(str(e))
if config.origins:
- config.origins = list({normalize_origin(o): ... for o in config.origins})
+ config.origins = [normalize_origin(o) for o in config.origins]
+ config.auth_host, config.origins = normalize_auth_host_and_origins(
+ config.auth_host, config.origins
+ )
# Parse first endpoint for site_url fallback
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py
index c55506f..363ea48 100644
--- a/paskia/fastapi/admin.py
+++ b/paskia/fastapi/admin.py
@@ -12,12 +12,13 @@ from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.db.operations import _UNSET
-from paskia.db.structs import Client
+from paskia.db.structs import Client, Config
from paskia.fastapi import authz
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
+from paskia.sansio import Passkey
from paskia.util import (
hostutil,
permutil,
@@ -1176,3 +1177,79 @@ async def admin_delete_oidc_client(
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
+
+
+# -------------------- Server Configuration --------------------
+
+
+@app.get("/server-config")
+async def admin_get_server_config(
+ request: Request,
+ auth=AUTH_COOKIE,
+):
+ """Get current server configuration (master admin only)."""
+ await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
+ pk = passkey.instance
+ config = db.data().config
+ return {
+ "rp_name": pk.rp_name,
+ "auth_host": config.auth_host or "",
+ "origins": list(pk.allowed_origins) if pk.allowed_origins else [],
+ }
+
+
+@app.patch("/server-config")
+async def admin_update_server_config(
+ request: Request,
+ payload: dict = Body(...),
+ auth=AUTH_COOKIE,
+):
+ """Update server configuration (master admin only).
+
+ Updates rp_name, auth_host, and origins in both the runtime Passkey
+ instance and the persisted database config.
+ """
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
+ )
+ config = db.data().config
+ pk = passkey.instance
+
+ rp_name = payload.get("rp_name", "").strip() or None
+ auth_host = payload.get("auth_host", "").strip() or None
+ raw_origins = payload.get("origins", [])
+ origins = [
+ hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
+ ] or None
+
+ # Normalize auth_host and origins (matching CLI startup behavior)
+ if auth_host:
+ try:
+ hostutil.validate_auth_host(auth_host, config.rp_id)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
+
+ # Validate origins against the current rp_id
+ if origins:
+ for o in origins:
+ Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
+
+ # Update runtime Passkey instance
+ pk.rp_name = rp_name or config.rp_id
+ pk.allowed_origins = set(origins) if origins else None
+
+ # Persist to database
+ new_config = Config(
+ rp_id=config.rp_id,
+ rp_name=rp_name,
+ auth_host=auth_host,
+ origins=origins,
+ listen=config.listen,
+ )
+ await db.update_config(new_config)
+
+ # Reload hostutil cached config so auth_host changes take effect
+ hostutil.reload_config()
+
+ return {"status": "ok"}
diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py
index 4a7782a..46f5008 100644
--- a/paskia/fastapi/api.py
+++ b/paskia/fastapi/api.py
@@ -185,7 +185,8 @@ async def get_settings():
auth_site_url=hostutil.auth_site_url(),
session_cookie=AUTH_COOKIE_NAME,
version=__version__,
- )
+ ),
+ headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
)
diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py
index c34e9b6..1150ca9 100644
--- a/paskia/util/hostutil.py
+++ b/paskia/util/hostutil.py
@@ -49,6 +49,51 @@ def normalize_origin(origin: str) -> str:
return origin.rstrip("/")
+def is_subdomain(sub: str, domain: str) -> bool:
+ """Check if sub is a subdomain of domain (or equal)."""
+ sub_parts = sub.lower().split(".")
+ domain_parts = domain.lower().split(".")
+ if len(sub_parts) < len(domain_parts):
+ return False
+ return sub_parts[-len(domain_parts) :] == domain_parts
+
+
+def validate_auth_host(auth_host: str, rp_id: str) -> None:
+ """Validate that auth_host is a subdomain of rp_id.
+
+ Raises ValueError on invalid auth_host.
+ """
+ parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
+ host = parsed.hostname or parsed.path
+ if not host:
+ raise ValueError(f"Invalid auth-host: '{auth_host}'")
+ if not is_subdomain(host, rp_id):
+ raise ValueError(
+ f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
+ )
+
+
+def normalize_auth_host_and_origins(
+ auth_host: str | None, origins: list[str] | None
+) -> tuple[str | None, list[str] | None]:
+ """Normalize auth_host and origins, matching CLI startup behavior.
+
+ - Adds https:// to auth_host if no scheme present, strips trailing slashes
+ - Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host)
+ - Inserts auth_host as first origin if both are specified and not already present
+ - Deduplicates origins while preserving order
+ """
+ if auth_host:
+ if "://" not in auth_host:
+ auth_host = f"https://{auth_host}"
+ auth_host = auth_host.rstrip("/")
+ if origins is not None and auth_host not in origins:
+ origins.insert(0, auth_host)
+ if origins:
+ origins = list(dict.fromkeys(origins))
+ return auth_host, origins
+
+
def reload_config() -> None:
_load_config.cache_clear()
diff --git a/scripts/devserver.py b/scripts/devserver.py
index 55594b1..ac5fda2 100755
--- a/scripts/devserver.py
+++ b/scripts/devserver.py
@@ -186,7 +186,9 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
await check_ports_free(viteurl, backurl)
await pg.spawn(*paskia)
- await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py"))
+ await pg.wait(
+ npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
+ )
await pg.spawn(*vite, cwd=frontend_path)