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' + } +} + @@ -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 }) + +
+
+

Server

+

+ Configure core server settings such as the display name, authentication host, and allowed origins. +

+
+ +
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)