Domains: object-keyed config format, drop default domain, rename realm→domain throughout backend
This commit is contained in:
+33
-30
@@ -12,14 +12,13 @@ from kanta import Kanta
|
||||
from paskia.db import legacy
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB, Config, RealmConfig
|
||||
from paskia.realms import build as build_registry
|
||||
from paskia.realms import configure as configure_realms
|
||||
from paskia.realms import validate_config
|
||||
from paskia.db.structs import DB, Config, DomainConfig, OriginEntry
|
||||
from paskia.domains import build as build_registry
|
||||
from paskia.domains import configure as configure_domains
|
||||
from paskia.domains import origin_key, validate_config
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import (
|
||||
normalize_auth_host_and_origins,
|
||||
normalize_origin,
|
||||
validate_auth_host,
|
||||
)
|
||||
@@ -78,11 +77,11 @@ def _load_stored_config(db_path: Path) -> Config:
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
"""Bootstrap a new paskia.kantadb database with the initial realm(s)."""
|
||||
"""Bootstrap a new paskia.kantadb database with the initial domain(s)."""
|
||||
db_path = db_file_path()
|
||||
if db_path.exists():
|
||||
raise SystemExit(
|
||||
f"Database {db_path} already exists — realm configuration is "
|
||||
f"Database {db_path} already exists — domain configuration is "
|
||||
"managed via the admin interface, not 'paskia init'."
|
||||
)
|
||||
if found := legacy.find_legacy_databases():
|
||||
@@ -94,33 +93,37 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
|
||||
rp_ids = _split_multi(args.rp_id) or ["localhost"]
|
||||
|
||||
realms = []
|
||||
domains = {}
|
||||
for i, rp_id in enumerate(rp_ids):
|
||||
realm = RealmConfig(rp_id=rp_id)
|
||||
domain = DomainConfig()
|
||||
if i == 0:
|
||||
# Bootstrap-time naming and hosts apply to the default realm;
|
||||
# Bootstrap-time naming and hosts apply to the first domain;
|
||||
# everything is editable via the admin interface afterwards.
|
||||
realm.rp_name = args.rp_name or None
|
||||
origins = [normalize_origin(o) for o in _split_multi(args.origins)] or None
|
||||
domain.rp_name = args.rp_name or None
|
||||
origins = {
|
||||
origin_key(normalize_origin(o)): True
|
||||
for o in _split_multi(args.origins)
|
||||
}
|
||||
auth_host = args.auth_host or None
|
||||
if auth_host:
|
||||
try:
|
||||
validate_auth_host(auth_host, rp_id)
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e)) from e
|
||||
realm.auth_host, realm.origins = normalize_auth_host_and_origins(
|
||||
auth_host, origins
|
||||
)
|
||||
realms.append(realm)
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
origins[origin_key(auth_host)] = OriginEntry(auth_host=True)
|
||||
domain.origins = origins
|
||||
domains[rp_id] = domain
|
||||
|
||||
config = Config(realms=realms, listen=_split_multi(args.listen) or None)
|
||||
config = Config(domains=domains, listen=_split_multi(args.listen) or None)
|
||||
try:
|
||||
validate_config(config)
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e)) from e
|
||||
|
||||
# Create the database; the kanta bootstrap callback seeds it (admin
|
||||
# user, org, permissions, reset token, per-realm OIDC keys).
|
||||
# user, org, permissions, reset token, per-domain OIDC keys).
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(db_path), new_db)
|
||||
result = {}
|
||||
@@ -140,11 +143,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
db_path.unlink(missing_ok=True)
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
configure_realms(listen=config.listen)
|
||||
configure_domains(listen=config.listen)
|
||||
registry = build_registry(config)
|
||||
startupbox.print_startup_config(registry, listen=config.listen)
|
||||
log_reset_link(
|
||||
registry.default.reset_link_url(result["passphrase"]),
|
||||
registry.get(rp_ids[0]).reset_link_url(result["passphrase"]),
|
||||
"✅ Bootstrap completed!",
|
||||
)
|
||||
|
||||
@@ -152,11 +155,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
def cmd_migrate(args: argparse.Namespace) -> None:
|
||||
"""Convert a legacy <rp-id>.paskiadb database to paskia.kantadb."""
|
||||
rp_id = legacy.migrate_legacy_database(args.rp_id)
|
||||
print(f"✅ Converted legacy database to {db_file_path()} (realm: {rp_id})")
|
||||
print(f"✅ Converted legacy database to {db_file_path()} (domain: {rp_id})")
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> None:
|
||||
"""Open the combined database and serve all configured realms."""
|
||||
"""Open the combined database and serve all configured domains."""
|
||||
db_path = db_file_path()
|
||||
if not db_path.exists():
|
||||
if found := legacy.find_legacy_databases():
|
||||
@@ -170,14 +173,14 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
||||
config = _load_stored_config(db_path)
|
||||
|
||||
listen = _split_multi(args.listen) or config.listen
|
||||
configure_realms(listen=listen)
|
||||
configure_domains(listen=listen)
|
||||
try:
|
||||
registry = build_registry(config)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid stored configuration: {e}") from e
|
||||
for warning in registry.warnings:
|
||||
# Serving is best-effort; fixing the stored config is the admin's
|
||||
# job via the admin interface on any working realm.
|
||||
# job via the admin interface on any working domain.
|
||||
print(
|
||||
f"⚠️ Config problem (fix via the admin interface): {warning}",
|
||||
file=sys.stderr,
|
||||
@@ -224,13 +227,13 @@ def main():
|
||||
init_parser.add_argument(
|
||||
"--rp-id",
|
||||
action="append",
|
||||
help="Relying Party ID of the initial realm(s) (default: localhost). "
|
||||
"Repeatable and comma-separated; the first is the default realm. "
|
||||
"Further realms are added via the admin interface.",
|
||||
help="Relying Party ID of the initial domain(s) (default: localhost). "
|
||||
"Repeatable and comma-separated; the first is the default domain. "
|
||||
"Further domains are added via the admin interface.",
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"--rp-name",
|
||||
help="Relying Party name of the default realm (default: same as rp-id). "
|
||||
help="Relying Party name of the default domain (default: same as rp-id). "
|
||||
"Used by the initial admin registration; editable later via admin UI.",
|
||||
)
|
||||
init_parser.add_argument(
|
||||
@@ -238,12 +241,12 @@ def main():
|
||||
action="append",
|
||||
dest="origins",
|
||||
metavar="URL",
|
||||
help="Allowed origin URL(s) for the default realm. May be specified "
|
||||
help="Allowed origin URL(s) for the default domain. May be specified "
|
||||
"multiple times; comma-separated values accepted.",
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"--auth-host",
|
||||
help="Dedicated authentication site for the default realm "
|
||||
help="Dedicated authentication site for the default domain "
|
||||
"(optionally with scheme/port)",
|
||||
)
|
||||
_add_listen_option(init_parser, help_extra=" (stored in the database)")
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ class OIDCCode(msgspec.Struct):
|
||||
"""An OIDC authorization code pending token exchange.
|
||||
|
||||
PKCE uses S256 only when provided (verified at token exchange).
|
||||
rp_id binds the code to the realm it was issued in; the token
|
||||
rp_id binds the code to the domain it was issued in; the token
|
||||
endpoint (dispatched by Host) must match.
|
||||
"""
|
||||
|
||||
@@ -40,10 +40,10 @@ class OIDCCode(msgspec.Struct):
|
||||
class CookieCode(msgspec.Struct):
|
||||
"""A cookie exchange code for setting session cookie after WebSocket auth.
|
||||
|
||||
rp_id binds the code to the realm it was issued in; the redemption
|
||||
rp_id binds the code to the domain it was issued in; the redemption
|
||||
endpoint (dispatched by Host) must match. This is what allows a
|
||||
remote-auth approver on one realm to mint a code for the requesting
|
||||
device's realm without the code being usable on the wrong realm.
|
||||
remote-auth approver on one domain to mint a code for the requesting
|
||||
device's domain without the code being usable on the wrong domain.
|
||||
"""
|
||||
|
||||
session_key: str
|
||||
|
||||
+14
-11
@@ -5,12 +5,12 @@ The initial database seeding (admin user, organization, permissions,
|
||||
registration reset token) is performed by ``paskia init`` via
|
||||
:func:`paskia.db.bootstrap.bootstrap`. This module provides the serve-time
|
||||
check that re-prints a registration link when the admin user still has no
|
||||
passkey under the default realm.
|
||||
passkey under the default domain.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from paskia import authsession, db, realms
|
||||
from paskia import authsession, db, domains
|
||||
from paskia.db.bootstrap import log_reset_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,9 +33,10 @@ async def check_admin_credentials() -> bool:
|
||||
"""
|
||||
Check if the admin user needs credentials and create a reset link if needed.
|
||||
|
||||
With global users, the admin may hold passkeys under other realms only —
|
||||
the check tests for a credential under the **default realm's** rp-id, so
|
||||
the printed link (which points at the default realm) is usable.
|
||||
With global users, the admin may hold passkeys under any configured
|
||||
domain — the check passes if the admin has a credential for at least
|
||||
one of them. Otherwise a reset link is printed for the first domain
|
||||
(sorted by rp-id).
|
||||
|
||||
Returns:
|
||||
bool: True if a reset link was created, False if admin already has credentials
|
||||
@@ -63,13 +64,15 @@ async def check_admin_credentials() -> bool:
|
||||
if not admin_users:
|
||||
return False
|
||||
|
||||
# Check first admin user for credentials under the default realm
|
||||
# Check first admin user for credentials on any configured domain
|
||||
admin_user = admin_users[0]
|
||||
default = realms.registry().default
|
||||
reg = domains.registry()
|
||||
configured = sorted(d.rp_id for d in reg.domains)
|
||||
|
||||
if not admin_user.credential_ids_for(default.rp_id):
|
||||
# Admin exists but has no credential on the default realm
|
||||
logger.info("⚠️ Admin user has no credentials on %s!", default.rp_id)
|
||||
if not any(admin_user.credential_ids_for(rp_id) for rp_id in configured):
|
||||
# Admin exists but has no credential on any domain
|
||||
target = reg.get(configured[0])
|
||||
logger.info("⚠️ Admin user has no credentials on %s!", target.rp_id)
|
||||
|
||||
expiry = authsession.reset_expires()
|
||||
token = db.create_reset_token(
|
||||
@@ -77,7 +80,7 @@ async def check_admin_credentials() -> bool:
|
||||
expiry=expiry,
|
||||
token_type="admin registration",
|
||||
)
|
||||
log_reset_link(default.reset_link_url(token))
|
||||
log_reset_link(target.reset_link_url(token))
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -28,7 +28,7 @@ from paskia.db.operations import (
|
||||
create_oid_client,
|
||||
create_org,
|
||||
create_permission,
|
||||
create_realm,
|
||||
create_domain,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_user,
|
||||
@@ -36,7 +36,7 @@ from paskia.db.operations import (
|
||||
delete_oid_client,
|
||||
delete_org,
|
||||
delete_permission,
|
||||
delete_realm,
|
||||
delete_domain,
|
||||
delete_reset_token,
|
||||
delete_role,
|
||||
delete_session,
|
||||
@@ -54,7 +54,7 @@ from paskia.db.operations import (
|
||||
update_oid_client,
|
||||
update_org_name,
|
||||
update_permission,
|
||||
update_realm,
|
||||
update_domain,
|
||||
update_role_name,
|
||||
update_session,
|
||||
update_user_display_name,
|
||||
@@ -69,7 +69,7 @@ from paskia.db.structs import (
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
RealmConfig,
|
||||
DomainConfig,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
@@ -92,7 +92,7 @@ __all__ = [
|
||||
"OIDC",
|
||||
"Org",
|
||||
"Permission",
|
||||
"RealmConfig",
|
||||
"DomainConfig",
|
||||
"ResetToken",
|
||||
"Role",
|
||||
"Session",
|
||||
@@ -109,14 +109,14 @@ __all__ = [
|
||||
"create_credential_session",
|
||||
"create_org",
|
||||
"create_permission",
|
||||
"create_realm",
|
||||
"create_domain",
|
||||
"create_reset_token",
|
||||
"create_role",
|
||||
"create_user",
|
||||
"delete_credential",
|
||||
"delete_org",
|
||||
"delete_permission",
|
||||
"delete_realm",
|
||||
"delete_domain",
|
||||
"delete_reset_token",
|
||||
"delete_role",
|
||||
"delete_session",
|
||||
@@ -131,7 +131,7 @@ __all__ = [
|
||||
"update_credential_sign_count",
|
||||
"update_org_name",
|
||||
"update_permission",
|
||||
"update_realm",
|
||||
"update_domain",
|
||||
"update_role_name",
|
||||
"update_session",
|
||||
"update_user_display_name",
|
||||
|
||||
@@ -145,9 +145,8 @@ def bootstrap(
|
||||
if config is not None:
|
||||
data.config = config
|
||||
|
||||
# Generate an OIDC signing key for each realm
|
||||
rp_ids = [r.rp_id for r in data.config.realms]
|
||||
data.oidc = {rp_id: OIDC(key=secret_key()) for rp_id in rp_ids}
|
||||
# Generate an OIDC signing key for each domain
|
||||
data.oidc = {rp_id: OIDC(key=secret_key()) for rp_id in data.config.domains}
|
||||
|
||||
# Store all bootstrapped objects in the live data object
|
||||
data.permissions[perm_admin_uuid] = perm_admin
|
||||
|
||||
+14
-12
@@ -29,8 +29,9 @@ from paskia.db.structs import (
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RealmConfig,
|
||||
DomainConfig,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
@@ -39,7 +40,7 @@ from paskia.db.structs import (
|
||||
|
||||
|
||||
class LegacyConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Pre-realms stored configuration (single rp-id per database)."""
|
||||
"""Pre-domains stored configuration (single rp-id per database)."""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None = None
|
||||
@@ -111,15 +112,16 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
||||
old = _read_legacy(src)
|
||||
rp_id = old.config.rp_id
|
||||
|
||||
from paskia.domains import origin_key # noqa: PLC0415 (import cycle)
|
||||
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
for origin in old.config.origins or []:
|
||||
origins[origin_key(origin)] = True
|
||||
if old.config.auth_host:
|
||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
||||
|
||||
new_config = Config(
|
||||
realms=[
|
||||
RealmConfig(
|
||||
rp_id=rp_id,
|
||||
rp_name=old.config.rp_name,
|
||||
auth_host=old.config.auth_host,
|
||||
origins=old.config.origins,
|
||||
)
|
||||
],
|
||||
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
|
||||
listen=old.config.listen,
|
||||
)
|
||||
|
||||
@@ -209,7 +211,7 @@ def migrate_legacy_database(rp_id: str | None = None) -> str:
|
||||
|
||||
With ``rp_id``, selects the ``<rp-id>.paskiadb`` candidate by name;
|
||||
without it, exactly one candidate must exist. Returns the migrated
|
||||
realm's rp-id. The converted legacy directory/file is renamed aside
|
||||
domain's rp-id. The converted legacy directory/file is renamed aside
|
||||
to ``<name>.converted-bak`` rather than deleted.
|
||||
|
||||
Raises SystemExit when ``paskia.kantadb`` already exists, when no
|
||||
@@ -250,4 +252,4 @@ def migrate_legacy_database(rp_id: str | None = None) -> str:
|
||||
shutil.move(str(child), str(target_users / child.name))
|
||||
|
||||
shutil.move(str(src), str(src.with_name(src.name + ".converted-bak")))
|
||||
return config.default_realm.rp_id
|
||||
return next(iter(config.domains))
|
||||
|
||||
@@ -42,7 +42,7 @@ def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||
return display_name
|
||||
|
||||
# OIDC clients use "name" instead of "display_name"; providers are
|
||||
# nested per realm rp-id.
|
||||
# nested per domain rp-id.
|
||||
for provider in state.get("oidc", {}).values():
|
||||
if not isinstance(provider, dict):
|
||||
continue
|
||||
|
||||
+37
-37
@@ -22,8 +22,9 @@ from paskia.db.structs import (
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RealmConfig,
|
||||
DomainConfig,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
@@ -712,69 +713,68 @@ def create_credential_session(
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Realm operations
|
||||
# Domain operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _oidc_provider(rp_id: str) -> OIDC:
|
||||
"""Return the OIDC provider entry for a realm, raising if missing."""
|
||||
"""Return the OIDC provider entry for a domain, raising if missing."""
|
||||
provider = _db.oidc.get(rp_id)
|
||||
if provider is None:
|
||||
raise ValueError(f"Realm {rp_id} not found")
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
return provider
|
||||
|
||||
|
||||
def create_realm(realm: RealmConfig, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Add a new realm (rp-id) to the stored configuration.
|
||||
def create_domain(
|
||||
rp_id: str, domain: DomainConfig, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Add a new domain (rp-id) to the stored configuration.
|
||||
|
||||
Seeds an OIDC provider entry (with a fresh signing key) for the realm.
|
||||
Seeds an OIDC provider entry (with a fresh signing key) for the domain.
|
||||
The caller must validate the resulting combined configuration.
|
||||
"""
|
||||
if _db.config.find_realm(realm.rp_id) is not None:
|
||||
raise ValueError(f"Realm {realm.rp_id} already exists")
|
||||
with _transaction("admin:create_realm", ctx):
|
||||
_db.config.realms.append(realm)
|
||||
_db.oidc[realm.rp_id] = OIDC(key=secret_key())
|
||||
if rp_id in _db.config.domains:
|
||||
raise ValueError(f"Domain {rp_id} already exists")
|
||||
with _transaction("admin:create_domain", ctx):
|
||||
_db.config.domains[rp_id] = domain
|
||||
_db.oidc[rp_id] = OIDC(key=secret_key())
|
||||
|
||||
|
||||
def update_realm(
|
||||
def update_domain(
|
||||
rp_id: str,
|
||||
*,
|
||||
rp_name: str | None = None,
|
||||
auth_host: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
related_origins: list[str] | None = None,
|
||||
origins: dict[str, bool | OriginEntry] | None = None,
|
||||
related: dict[str, bool] | None = None,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update a realm's rp_name, auth_host, origins and related origins.
|
||||
"""Update a domain's rp_name, origins and related origins.
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it, so
|
||||
changing it would orphan them — delete and recreate the realm instead.
|
||||
changing it would orphan them — delete and recreate the domain instead.
|
||||
The caller must validate the resulting combined configuration.
|
||||
"""
|
||||
realm = _db.config.find_realm(rp_id)
|
||||
if realm is None:
|
||||
raise ValueError(f"Realm {rp_id} not found")
|
||||
with _transaction("admin:update_realm", ctx):
|
||||
realm.rp_name = rp_name
|
||||
realm.auth_host = auth_host
|
||||
realm.origins = origins
|
||||
realm.related_origins = related_origins
|
||||
domain = _db.config.domains.get(rp_id)
|
||||
if domain is None:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
with _transaction("admin:update_domain", ctx):
|
||||
domain.rp_name = rp_name
|
||||
domain.origins = origins or {}
|
||||
domain.related = related or {}
|
||||
|
||||
|
||||
def delete_realm(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a realm. Refused for the last realm or while credentials remain."""
|
||||
realm = _db.config.find_realm(rp_id)
|
||||
if realm is None:
|
||||
raise ValueError(f"Realm {rp_id} not found")
|
||||
if len(_db.config.realms) <= 1:
|
||||
raise ValueError("Cannot delete the last remaining realm")
|
||||
def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a domain. Refused for the last domain or while credentials remain."""
|
||||
if rp_id not in _db.config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
if len(_db.config.domains) <= 1:
|
||||
raise ValueError("Cannot delete the last remaining domain")
|
||||
if any(c.rp_id == rp_id for c in _db.credentials.values()):
|
||||
raise ValueError(
|
||||
f"Cannot delete realm {rp_id}: credentials still registered under it"
|
||||
f"Cannot delete domain {rp_id}: credentials still registered under it"
|
||||
)
|
||||
with _transaction("admin:delete_realm", ctx):
|
||||
_db.config.realms.remove(realm)
|
||||
with _transaction("admin:delete_domain", ctx):
|
||||
del _db.config.domains[rp_id]
|
||||
_db.oidc.pop(rp_id, None)
|
||||
|
||||
|
||||
@@ -786,7 +786,7 @@ def delete_realm(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
def create_oid_client(
|
||||
rp_id: str, client: Client, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Create a new OIDC client under a realm."""
|
||||
"""Create a new OIDC client under a domain."""
|
||||
provider = _oidc_provider(rp_id)
|
||||
if client.uuid in provider.clients:
|
||||
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||
|
||||
+34
-32
@@ -238,7 +238,7 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
|
||||
return [c.credential_id for c in self.credentials]
|
||||
|
||||
def credential_ids_for(self, rp_id: str) -> list[bytes]:
|
||||
"""Get credential IDs registered under a specific realm's rp-id."""
|
||||
"""Get credential IDs registered under a specific domain's rp-id."""
|
||||
return [c.credential_id for c in self.credentials if c.rp_id == rp_id]
|
||||
|
||||
@property
|
||||
@@ -297,8 +297,8 @@ class Credential(msgspec.Struct, dict=True):
|
||||
Immutable fields: credential_id, user, aaguid, public_key, created_at, rp_id
|
||||
uuid is derived from created_at using uuid7.
|
||||
|
||||
rp_id is the realm the passkey was registered under. With Related Origin
|
||||
Requests it is always the realm's canonical rp-id, regardless of which
|
||||
rp_id is the domain the passkey was registered under. With Related Origin
|
||||
Requests it is always the domain's canonical rp-id, regardless of which
|
||||
origin the registration ceremony ran on.
|
||||
"""
|
||||
|
||||
@@ -391,7 +391,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
user_agent: str
|
||||
validated: datetime
|
||||
client_uuid: UUID | None = msgspec.field(name="client", default=None)
|
||||
rp_id: str | None = None # Owning realm (needed when no request context)
|
||||
rp_id: str | None = None # Owning domain (needed when no request context)
|
||||
issuer: str | None = None # OIDC issuer URL this session was created under
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -449,7 +449,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
|
||||
Args:
|
||||
key: The hashed session key (derived from secret via hash_secret)
|
||||
rp_id: Owning realm's rp-id (used when no request context exists)
|
||||
rp_id: Owning domain's rp-id (used when no request context exists)
|
||||
issuer: OIDC issuer URL (scheme + host) for OIDC sessions
|
||||
|
||||
Returns:
|
||||
@@ -620,45 +620,47 @@ class OIDC(msgspec.Struct, dict=True):
|
||||
key: bytes | None = None
|
||||
|
||||
|
||||
class RealmConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Configuration for one authentication realm (one WebAuthn rp-id).
|
||||
class OriginEntry(msgspec.Struct, omit_defaults=True):
|
||||
"""Extra properties of one allowed origin within a domain.
|
||||
|
||||
A realm is one rp-id with its associated hosts. ``origins`` restricts
|
||||
which sites *within* the rp-id domain may authenticate (unset = the
|
||||
rp-id and all its subdomains); ``related_origins`` lists *other*
|
||||
domains that may assert this rp-id (WebAuthn Related Origin Requests).
|
||||
Stored as the dict value for an origin key; plain ``True`` instead of an
|
||||
object means presence only, nothing more to store.
|
||||
"""
|
||||
|
||||
auth_host: bool = False # This site hosts the account/admin interface
|
||||
|
||||
|
||||
class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Configuration for one domain (one WebAuthn rp-id).
|
||||
|
||||
``origins`` maps sign-in sites within the rp-id domain to their
|
||||
properties. Keys are hosts without the https:// scheme
|
||||
("app.example.com"), wildcard patterns ("*.example.com"), or full
|
||||
origins when not https ("http://localhost:8080"). An empty dict means
|
||||
the rp-id and all its subdomains may sign in (the default). Ordering
|
||||
carries no meaning — display order is decided by the UI.
|
||||
|
||||
``related`` lists other domain names that may assert this rp-id
|
||||
(WebAuthn Related Origin Requests), with the same key rule.
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None = None
|
||||
auth_host: str | None = None # This realm's dedicated auth host (URL)
|
||||
origins: list[str] | None = None # Allow-list of in-domain sign-in sites
|
||||
related_origins: list[str] | None = None # Cross-domain ROR origins
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
related: dict[str, bool] = {}
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
"""Stored configuration for the instance.
|
||||
|
||||
Realms are shared by the whole administrative instance: organizations and
|
||||
users are global across rp-ids. The first realm is the default realm,
|
||||
used only where a default is genuinely needed (bootstrap reset-link URL,
|
||||
startup display) — never for request dispatch.
|
||||
Domains are keyed by rp-id and shared by the whole administrative
|
||||
instance: organizations and users are global across rp-ids.
|
||||
"""
|
||||
|
||||
realms: list[RealmConfig] = msgspec.field(
|
||||
default_factory=lambda: [RealmConfig(rp_id="localhost")]
|
||||
domains: dict[str, DomainConfig] = msgspec.field(
|
||||
default_factory=lambda: {"localhost": DomainConfig()}
|
||||
)
|
||||
listen: list[str] | None = None # Process-global listen endpoints
|
||||
|
||||
@property
|
||||
def default_realm(self) -> RealmConfig:
|
||||
"""The first configured realm."""
|
||||
return self.realms[0]
|
||||
|
||||
def find_realm(self, rp_id: str) -> RealmConfig | None:
|
||||
"""Find a realm configuration by rp-id."""
|
||||
return next((r for r in self.realms if r.rp_id == rp_id), None)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Database storage structure
|
||||
@@ -676,7 +678,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
# OIDC provider data, keyed by realm rp-id: each realm is an independent
|
||||
# OIDC provider data, keyed by rp-id: each domain is an independent
|
||||
# issuer with its own signing key and clients.
|
||||
oidc: dict[str, OIDC] = {}
|
||||
|
||||
@@ -704,7 +706,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
client.uuid = uuid
|
||||
|
||||
def oidc_for(self, rp_id: str) -> OIDC | None:
|
||||
"""Get the OIDC provider data for a realm, if it exists."""
|
||||
"""Get the OIDC provider data for a domain, if it exists."""
|
||||
return self.oidc.get(rp_id)
|
||||
|
||||
def session_ctx(
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""Domain registry: per-rp-id runtime state and host resolution.
|
||||
|
||||
A **domain** is one rp-id with its associated hosts and origins. The
|
||||
registry is built from the stored combined ``Config`` at startup and
|
||||
rebuilt on admin domain changes; request dispatch resolves hosts to
|
||||
domains through it. The database itself is global — only the *current
|
||||
domain* (passkey, site URLs, OIDC view) varies per request, tracked via a
|
||||
contextvar set by the dispatch middleware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.constants import DEFAULT_PORT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of related (non-subdomain) origins per domain. WebAuthn
|
||||
# Related Origin Requests require browsers to support at least 5 labels.
|
||||
DEFAULT_RELATED_ORIGIN_CAP = 5
|
||||
|
||||
|
||||
def origin_url(key: str) -> str:
|
||||
"""Full origin URL for an origins-dict key (https:// is implied)."""
|
||||
if hostutil.is_wildcard_pattern(key) or "://" in key:
|
||||
return key
|
||||
return f"https://{key}"
|
||||
|
||||
|
||||
def origin_key(origin: str) -> str:
|
||||
"""Origins-dict key for a full origin URL (https:// omitted)."""
|
||||
return origin.removeprefix("https://").rstrip("/")
|
||||
|
||||
|
||||
def auth_host_url(domain: DomainConfig) -> str | None:
|
||||
"""Full URL of the domain's auth host origin, if one is marked."""
|
||||
for key, props in domain.origins.items():
|
||||
if isinstance(props, OriginEntry) and props.auth_host:
|
||||
return origin_url(key)
|
||||
return None
|
||||
|
||||
|
||||
class Domain:
|
||||
"""Runtime view of one domain: stored config plus derived values."""
|
||||
|
||||
def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str):
|
||||
# Lazy import: paskia.sansio depends on paskia.db, which (via
|
||||
# paskia.db.operations → paskia.oidc_notify) depends on this module.
|
||||
from paskia.sansio import Passkey # noqa: PLC0415
|
||||
|
||||
self.rp_id = rp_id
|
||||
self.config = config
|
||||
self.site_url = site_url
|
||||
self.site_path = site_path
|
||||
self.passkey = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=[origin_url(k) for k in config.origins] or None,
|
||||
related_origins=[origin_url(k) for k in config.related],
|
||||
)
|
||||
|
||||
@property
|
||||
def rp_name(self) -> str:
|
||||
return self.passkey.rp_name
|
||||
|
||||
@property
|
||||
def own_auth_host(self) -> str | None:
|
||||
"""This domain's own auth host as host[:port], if configured."""
|
||||
url = auth_host_url(self.config)
|
||||
return hostutil.auth_host_netloc(url) if url else None
|
||||
|
||||
@property
|
||||
def related_origins(self) -> list[str]:
|
||||
"""Configured related (cross-domain) origins for ROR, as URLs."""
|
||||
return [origin_url(k) for k in self.config.related]
|
||||
|
||||
@property
|
||||
def is_root_mode(self) -> bool:
|
||||
"""Whether this domain's UI lives at the site root (own auth host)."""
|
||||
return auth_host_url(self.config) is not None
|
||||
|
||||
@property
|
||||
def ui_base_path(self) -> str:
|
||||
return "/" if self.is_root_mode else "/auth/"
|
||||
|
||||
@property
|
||||
def auth_site_url(self) -> str:
|
||||
"""Base URL of this domain's auth site UI."""
|
||||
return self.site_url + self.site_path
|
||||
|
||||
def api_url(self, path: str = "") -> str:
|
||||
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||
if not path:
|
||||
return f"{self.site_url}/auth/api/"
|
||||
return f"{self.site_url}/auth/api/{path.lstrip('/')}"
|
||||
|
||||
def reset_link_url(self, token: str) -> str:
|
||||
"""Generate a reset link URL for the given token on this domain."""
|
||||
return f"{self.auth_site_url}{token}"
|
||||
|
||||
|
||||
class DomainRegistry:
|
||||
"""Resolved domains and host lookup tables."""
|
||||
|
||||
def __init__(self, domains: list[Domain]):
|
||||
self._by_rp_id = {d.rp_id: d for d in domains}
|
||||
self._auth_hosts: dict[str, Domain] = {}
|
||||
self._related_hosts: dict[str, Domain] = {}
|
||||
self.warnings: list[str] = []
|
||||
for domain in domains:
|
||||
if own := domain.own_auth_host:
|
||||
self._auth_hosts[hostutil.normalize_host(own) or own] = domain
|
||||
for origin in domain.related_origins:
|
||||
if hostname := hostutil.origin_hostname(origin):
|
||||
self._related_hosts[hostname] = domain
|
||||
|
||||
@property
|
||||
def domains(self) -> list[Domain]:
|
||||
"""All domains (unordered — ordering is a display-time affair)."""
|
||||
return list(self._by_rp_id.values())
|
||||
|
||||
def get(self, rp_id: str) -> Domain | None:
|
||||
return self._by_rp_id.get(rp_id)
|
||||
|
||||
def effective_auth_host(self, domain: Domain) -> str | None:
|
||||
"""Auth host serving WS/restricted APIs for a domain: its own, or
|
||||
another domain's as a shared fallback.
|
||||
|
||||
Returns host[:port] suitable for URL building, or None.
|
||||
"""
|
||||
if domain.own_auth_host:
|
||||
return domain.own_auth_host
|
||||
for candidate in self._by_rp_id.values():
|
||||
if candidate.own_auth_host:
|
||||
return candidate.own_auth_host
|
||||
return None
|
||||
|
||||
def resolve(self, host: str | None) -> Domain | None:
|
||||
"""Resolve a request Host header to a domain.
|
||||
|
||||
Order: exact rp-id → exact auth host → exact related-origin
|
||||
hostname → longest-suffix rp-id. Unknown hosts return None.
|
||||
"""
|
||||
h = hostutil.normalize_host(host)
|
||||
if not h:
|
||||
return None
|
||||
if domain := self._by_rp_id.get(h):
|
||||
return domain
|
||||
if domain := self._auth_hosts.get(h):
|
||||
return domain
|
||||
if domain := self._related_hosts.get(h):
|
||||
return domain
|
||||
best = None
|
||||
for rp_id, domain in self._by_rp_id.items():
|
||||
if h.endswith(f".{rp_id}") and (
|
||||
best is None or len(rp_id) > len(best.rp_id)
|
||||
):
|
||||
best = domain
|
||||
return best
|
||||
|
||||
|
||||
def validate_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
) -> None:
|
||||
"""Validate a combined configuration cross-domain. Raises ValueError."""
|
||||
if not config.domains:
|
||||
raise ValueError("At least one domain (rp-id) is required")
|
||||
|
||||
auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id
|
||||
related_hosts: dict[str, str] = {} # hostname -> owning rp_id
|
||||
|
||||
for rp_id, domain in config.domains.items():
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = key[2:].rstrip(".")
|
||||
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{key}' is outside the rp-id domain "
|
||||
f"'{rp_id}' — configure it as a related origin instead"
|
||||
)
|
||||
if is_auth:
|
||||
raise ValueError(
|
||||
f"Wildcard origin '{key}' cannot be the auth host"
|
||||
)
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
raise ValueError(f"Invalid origin: '{key}'")
|
||||
if not hostutil.is_subdomain(hn, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{key}' is outside the rp-id domain "
|
||||
f"'{rp_id}' — configure it as a related origin instead"
|
||||
)
|
||||
if is_auth:
|
||||
ah = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(origin_url(key)) or ""
|
||||
)
|
||||
if ah in auth_hosts:
|
||||
raise ValueError(
|
||||
f"auth-host '{ah}' is configured for both "
|
||||
f"'{auth_hosts[ah]}' and '{rp_id}'"
|
||||
)
|
||||
auth_hosts[ah] = rp_id
|
||||
|
||||
if len(domain.related) > related_origin_cap:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' has {len(domain.related)} "
|
||||
f"related origins (maximum {related_origin_cap})"
|
||||
)
|
||||
for key in domain.related:
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is a wildcard — related "
|
||||
"origins (ROR) must be listed individually"
|
||||
)
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
raise ValueError(f"Invalid related origin: '{key}'")
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is within the rp-id domain "
|
||||
f"'{rp_id}' — subdomains need no related origin entry"
|
||||
)
|
||||
if hn in related_hosts:
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' is configured for both "
|
||||
f"'{related_hosts[hn]}' and '{rp_id}'"
|
||||
)
|
||||
related_hosts[hn] = rp_id
|
||||
|
||||
rp_ids = set(config.domains)
|
||||
for hn, owner in auth_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"auth-host '{hn}' collides with an rp-id")
|
||||
if hn in related_hosts:
|
||||
raise ValueError(
|
||||
f"auth-host '{hn}' collides with a related origin of "
|
||||
f"domain '{related_hosts[hn]}'"
|
||||
)
|
||||
|
||||
for hn, owner in related_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"Related origin host '{hn}' collides with an rp-id")
|
||||
for other in rp_ids:
|
||||
if other != owner and hostutil.is_subdomain(hn, other):
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' of domain '{owner}' "
|
||||
f"falls inside domain '{other}'"
|
||||
)
|
||||
|
||||
|
||||
def sanitize_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
) -> tuple[Config, list[str]]:
|
||||
"""Best-effort repair of a stored configuration for serving.
|
||||
|
||||
Serving must never fail because of stored domain config: fixing it is
|
||||
the admin's job via the admin UI, which is reachable only on a running
|
||||
server. Returns a sanitized copy (the stored config is left untouched)
|
||||
plus a warning for every degradation made. The result always passes
|
||||
``validate_config``.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
warnings.append(msg)
|
||||
|
||||
domains: dict[str, DomainConfig] = {}
|
||||
for rp_id, domain in config.domains.items():
|
||||
try:
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
except ValueError as e:
|
||||
warn(f"Domain dropped: {e}")
|
||||
continue
|
||||
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
related: dict[str, bool] = dict(domain.related)
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = key[2:].rstrip(".")
|
||||
if not base:
|
||||
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
|
||||
continue
|
||||
if not hostutil.is_subdomain(base, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '{key}' is outside the "
|
||||
"rp-id domain — dropped (wildcards cannot be "
|
||||
"related origins)"
|
||||
)
|
||||
continue
|
||||
if is_auth:
|
||||
warn(
|
||||
f"Domain '{rp_id}': wildcard '{key}' cannot be the "
|
||||
"auth host — mark cleared"
|
||||
)
|
||||
props = True
|
||||
origins[key] = props
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
|
||||
continue
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
origins[key] = props
|
||||
else:
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '{key}' is outside the rp-id "
|
||||
"domain — treating it as a related origin; fix the "
|
||||
"lists in the admin interface"
|
||||
)
|
||||
related[origin_key(origin_url(key))] = True
|
||||
|
||||
related_ok: dict[str, bool] = {}
|
||||
for key in related:
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is a "
|
||||
"wildcard — dropped (ROR entries must be individual)"
|
||||
)
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
warn(f"Domain '{rp_id}': invalid related origin '{key}' dropped")
|
||||
continue
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is within the "
|
||||
"rp-id domain — dropped (subdomains need no related entry)"
|
||||
)
|
||||
continue
|
||||
related_ok[key] = True
|
||||
if len(related_ok) > related_origin_cap:
|
||||
warn(
|
||||
f"Domain '{rp_id}': {len(related_ok)} related origins exceed "
|
||||
f"the maximum of {related_origin_cap} — extras dropped"
|
||||
)
|
||||
related_ok = dict(sorted(related_ok.items())[:related_origin_cap])
|
||||
|
||||
domains[rp_id] = DomainConfig(
|
||||
rp_name=domain.rp_name, origins=origins, related=related_ok
|
||||
)
|
||||
|
||||
if not domains:
|
||||
raise ValueError("No servable domain in the stored configuration")
|
||||
|
||||
# Cross-domain collisions: keep the first configured claimant, drop the
|
||||
# rest with a warning so dispatch stays deterministic.
|
||||
rp_ids = set(domains)
|
||||
seen_auth_hosts: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
for key, props in domain.origins.items():
|
||||
if not isinstance(props, OriginEntry) or not props.auth_host:
|
||||
continue
|
||||
hn = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(origin_url(key)) or ""
|
||||
)
|
||||
if hn in rp_ids or hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Domain '{rp_id}': auth host '{hn}' collides with "
|
||||
"another domain — mark cleared"
|
||||
)
|
||||
domain.origins[key] = True
|
||||
else:
|
||||
seen_auth_hosts[hn] = rp_id
|
||||
|
||||
seen_related: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
keep: dict[str, bool] = {}
|
||||
for key in domain.related:
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if hn in rp_ids:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' collides "
|
||||
"with an rp-id — dropped"
|
||||
)
|
||||
elif other := next(
|
||||
(o for o in rp_ids if o != rp_id and hostutil.is_subdomain(hn, o)),
|
||||
None,
|
||||
):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' falls "
|
||||
f"inside domain '{other}' — dropped"
|
||||
)
|
||||
elif hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is the "
|
||||
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
|
||||
)
|
||||
elif hn in seen_related:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is also "
|
||||
f"used by '{seen_related[hn]}' — dropped (first domain wins)"
|
||||
)
|
||||
else:
|
||||
seen_related[hn] = rp_id
|
||||
keep[key] = True
|
||||
domain.related = keep
|
||||
|
||||
return Config(domains=domains, listen=config.listen), warnings
|
||||
|
||||
|
||||
def _derive_site(
|
||||
rp_id: str, domain: DomainConfig, *, listen_port: int | None, vite_url: str | None
|
||||
) -> tuple[str, str]:
|
||||
"""Compute a domain's site_url and site_path.
|
||||
|
||||
Priority: auth host > exact rp-id origin key > first concrete origin
|
||||
key (sorted) > PASKIA_VITE_URL (localhost domain only) >
|
||||
http://localhost:port (localhost domain) > https://rp-id.
|
||||
"""
|
||||
if auth := auth_host_url(domain):
|
||||
return auth, "/"
|
||||
if rp_id in domain.origins:
|
||||
return origin_url(rp_id), "/auth/"
|
||||
concrete = sorted(
|
||||
k for k in domain.origins if not hostutil.is_wildcard_pattern(k)
|
||||
)
|
||||
if concrete:
|
||||
return origin_url(concrete[0]), "/auth/"
|
||||
if rp_id == "localhost":
|
||||
if vite_url:
|
||||
return vite_url.rstrip("/"), "/auth/"
|
||||
if listen_port:
|
||||
return f"http://localhost:{listen_port}", "/auth/"
|
||||
return f"https://{rp_id}", "/auth/"
|
||||
|
||||
|
||||
_registry: DomainRegistry | None = None
|
||||
_listen: list[str] | None = None
|
||||
|
||||
|
||||
def configure(*, listen: list[str] | None = None) -> None:
|
||||
"""Record process-global serve parameters for site URL derivation."""
|
||||
global _listen
|
||||
_listen = listen
|
||||
|
||||
|
||||
def build(config: Config) -> DomainRegistry:
|
||||
"""Build a registry from a stored configuration.
|
||||
|
||||
The config is sanitized best-effort (serving must not fail on stored
|
||||
config problems — the admin UI fixes them on a running server);
|
||||
warnings are logged and exposed on the registry.
|
||||
"""
|
||||
config, warnings = sanitize_config(config)
|
||||
validate_config(config) # sanitize guarantees this; a raise means a bug
|
||||
endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {})
|
||||
vite_url = os.environ.get("PASKIA_VITE_URL")
|
||||
domains = [
|
||||
Domain(
|
||||
rp_id,
|
||||
dc,
|
||||
*_derive_site(
|
||||
rp_id, dc, listen_port=endpoint.get("port"), vite_url=vite_url
|
||||
),
|
||||
)
|
||||
for rp_id, dc in config.domains.items()
|
||||
]
|
||||
registry = DomainRegistry(domains)
|
||||
registry.warnings = warnings
|
||||
for warning in warnings:
|
||||
logger.warning("Config: %s", warning)
|
||||
return registry
|
||||
|
||||
|
||||
def init_registry(config: Config) -> DomainRegistry:
|
||||
"""Build and install the global registry from a combined configuration."""
|
||||
global _registry
|
||||
_registry = build(config)
|
||||
return _registry
|
||||
|
||||
|
||||
def registry() -> DomainRegistry:
|
||||
"""Return the global registry (must be initialized)."""
|
||||
if _registry is None:
|
||||
raise RuntimeError("Domain registry is not initialized")
|
||||
return _registry
|
||||
|
||||
|
||||
_current_domain: contextvars.ContextVar[Domain | None] = contextvars.ContextVar(
|
||||
"paskia_current_domain", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_current_domain(domain: Domain | None) -> contextvars.Token:
|
||||
return _current_domain.set(domain)
|
||||
|
||||
|
||||
def reset_current_domain(token: contextvars.Token) -> None:
|
||||
_current_domain.reset(token)
|
||||
|
||||
|
||||
def current_domain() -> Domain:
|
||||
"""Return the request's domain.
|
||||
|
||||
Without request context (background jobs, CLI), the single configured
|
||||
domain is returned; with several domains a request context is required.
|
||||
"""
|
||||
domain = _current_domain.get()
|
||||
if domain is not None:
|
||||
return domain
|
||||
reg = registry()
|
||||
if len(reg.domains) == 1:
|
||||
return reg.domains[0]
|
||||
raise RuntimeError("No current domain: request context required")
|
||||
@@ -5,20 +5,18 @@ from fastapi import FastAPI, Request
|
||||
from paskia import db
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin import (
|
||||
domains,
|
||||
oidc_clients,
|
||||
orgs,
|
||||
permissions,
|
||||
roles,
|
||||
users,
|
||||
)
|
||||
from paskia.fastapi.admin import (
|
||||
realms as realms_admin,
|
||||
)
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import (
|
||||
avatar,
|
||||
permutil,
|
||||
@@ -41,7 +39,7 @@ app.mount("/orgs", orgs.app)
|
||||
app.mount("/roles", roles.app)
|
||||
app.mount("/users", users.app)
|
||||
app.mount("/permissions", permissions.app)
|
||||
app.mount("/realms", realms_admin.app)
|
||||
app.mount("/domains", domains.app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
@@ -97,10 +95,10 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
|
||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
|
||||
|
||||
# OIDC Clients (master admin only) — the current realm's provider
|
||||
# OIDC Clients (master admin only) — the current domain's provider
|
||||
oidc_clients_dict = {}
|
||||
if master_admin(ctx):
|
||||
provider = db.data().oidc_for(current_realm().rp_id)
|
||||
provider = db.data().oidc_for(current_domain().rp_id)
|
||||
clients = (
|
||||
sorted(provider.clients.values(), key=lambda c: c.uuid) if provider else []
|
||||
)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Domain (rp-id) management API — master admin only.
|
||||
|
||||
Each domain is one rp-id with its own rp-name, allowed in-domain sign-in
|
||||
sites (origins, one of which may be marked as the auth host), and optional
|
||||
related origins on unrelated domains (WebAuthn Related Origin Requests).
|
||||
All changes are validated cross-domain before being persisted, and the
|
||||
runtime domain registry is rebuilt after each change so it takes effect
|
||||
immediately.
|
||||
"""
|
||||
|
||||
from fastapi import Body, FastAPI, Request
|
||||
|
||||
from paskia import db, domains
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil, oidjwt
|
||||
from paskia.util.apistructs import ApiDomain
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def _domain_to_api(domain: domains.Domain, registry: domains.DomainRegistry) -> ApiDomain:
|
||||
return ApiDomain(
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
origins=domain.config.origins,
|
||||
related=domain.config.related,
|
||||
site_url=domain.site_url,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
effective_auth_host=registry.effective_auth_host(domain),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]:
|
||||
"""Normalize an origins object from the admin UI (raises on malformed).
|
||||
|
||||
Keys arrive as bare hosts, wildcard patterns, or full origins; they are
|
||||
stored as origins-dict keys (https:// omitted).
|
||||
"""
|
||||
out: dict[str, bool | OriginEntry] = {}
|
||||
for raw_key, raw_props in (values or {}).items():
|
||||
key = raw_key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if not hostutil.is_wildcard_pattern(key):
|
||||
key = domains.origin_key(hostutil.normalize_origin(key))
|
||||
is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host"))
|
||||
out[key] = OriginEntry(auth_host=True) if is_auth else True
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_related_map(values: dict | None) -> dict[str, bool]:
|
||||
"""Normalize a related-origins object from the admin UI."""
|
||||
out: dict[str, bool] = {}
|
||||
for raw_key in (values or {}):
|
||||
key = raw_key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is a wildcard — related origins "
|
||||
"(ROR) must be listed individually"
|
||||
)
|
||||
out[domains.origin_key(hostutil.normalize_origin(key))] = True
|
||||
return out
|
||||
|
||||
|
||||
def _rebuild_registry() -> None:
|
||||
"""Rebuild the runtime domain registry from the stored configuration."""
|
||||
domains.init_registry(db.data().config)
|
||||
|
||||
|
||||
def _check_not_locking_self_out(
|
||||
request: Request,
|
||||
rp_id: str,
|
||||
domain: DomainConfig,
|
||||
) -> None:
|
||||
"""Refuse domain changes that lock the admin out of their current host.
|
||||
|
||||
Applies when the admin edits the domain they are currently using and the
|
||||
new config has no auth host (with an auth host, ceremonies move there
|
||||
and it is always allowed). The admin's current host must remain able to
|
||||
run passkey ceremonies under the new config.
|
||||
"""
|
||||
current: domains.Domain = request.state.domain
|
||||
if rp_id != current.rp_id or domains.auth_host_url(domain):
|
||||
return
|
||||
raw_host = (request.headers.get("host") or "").rstrip(".")
|
||||
if not raw_host:
|
||||
return
|
||||
probe = Passkey(
|
||||
rp_id=rp_id,
|
||||
origins=[domains.origin_url(k) for k in domain.origins] or None,
|
||||
related_origins=[domains.origin_url(k) for k in domain.related],
|
||||
)
|
||||
for scheme in ("https", "http"):
|
||||
try:
|
||||
probe.validate_origin(f"{scheme}://{raw_host}")
|
||||
return # Current host still works — no lockout
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"This change would lock you out: '{raw_host}' could no longer "
|
||||
f"run passkey ceremonies for domain '{rp_id}'. Add it to the "
|
||||
"allowed origins (or mark an auth host) before saving."
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def admin_list_domains(request: Request, auth=AUTH_COOKIE):
|
||||
"""List all domains with derived URLs (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
registry = domains.registry()
|
||||
return MsgspecResponse(
|
||||
[_domain_to_api(domain, registry) for domain in registry.domains]
|
||||
)
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def admin_create_domain(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Add a new domain (master admin only, recent authentication required)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
|
||||
rp_id = (payload.get("rp_id") or "").strip().lower()
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id is required")
|
||||
new = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
related=_normalize_related_map(payload.get("related")),
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
# Validate the would-be combined configuration before persisting
|
||||
domains.validate_config(
|
||||
Config(domains={**config.domains, rp_id: new}, listen=config.listen)
|
||||
)
|
||||
|
||||
db.create_domain(rp_id, new, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/{rp_id}")
|
||||
async def admin_update_domain(
|
||||
rp_id: str,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update a domain's rp_name, origins and related origins (replaced
|
||||
wholesale).
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it.
|
||||
"""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
if rp_id not in config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
|
||||
updated = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
related=_normalize_related_map(payload.get("related")),
|
||||
)
|
||||
would_be = Config(
|
||||
domains={k: updated if k == rp_id else v for k, v in config.domains.items()},
|
||||
listen=config.listen,
|
||||
)
|
||||
domains.validate_config(would_be)
|
||||
_check_not_locking_self_out(request, rp_id, updated)
|
||||
|
||||
db.update_domain(
|
||||
rp_id,
|
||||
rp_name=updated.rp_name,
|
||||
origins=updated.origins,
|
||||
related=updated.related,
|
||||
ctx=ctx,
|
||||
)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{rp_id}")
|
||||
async def admin_delete_domain(
|
||||
rp_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete a domain (refused for the last domain or while credentials remain)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
current: domains.Domain = request.state.domain
|
||||
if rp_id == current.rp_id:
|
||||
raise ValueError(
|
||||
"Cannot delete the domain you are currently using — authenticate "
|
||||
"on another domain first"
|
||||
)
|
||||
db.delete_domain(rp_id, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
oidjwt.clear_key(rp_id)
|
||||
return {"status": "ok"}
|
||||
@@ -8,7 +8,7 @@ from paskia.db.structs import Client
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import permutil
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -84,7 +84,7 @@ async def admin_create_oidc_client(
|
||||
)
|
||||
client.uuid = client_uuid
|
||||
|
||||
db.create_oid_client(current_realm().rp_id, client, ctx=ctx)
|
||||
db.create_oid_client(current_domain().rp_id, client, ctx=ctx)
|
||||
|
||||
return {"status": "ok", "client_id": str(client.uuid)}
|
||||
|
||||
@@ -153,7 +153,7 @@ async def admin_update_oidc_client(
|
||||
|
||||
try:
|
||||
db.update_oid_client(
|
||||
current_realm().rp_id,
|
||||
current_domain().rp_id,
|
||||
client_uuid,
|
||||
name=name,
|
||||
redirect_uris=redirect_uris,
|
||||
@@ -204,7 +204,7 @@ async def admin_reset_oidc_client_secret(
|
||||
|
||||
try:
|
||||
db.reset_oid_client_secret(
|
||||
current_realm().rp_id, client_uuid, secret_hash, ctx=ctx
|
||||
current_domain().rp_id, client_uuid, secret_hash, ctx=ctx
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -234,7 +234,7 @@ async def admin_delete_oidc_client(
|
||||
)
|
||||
|
||||
try:
|
||||
db.delete_oid_client(current_realm().rp_id, client_uuid, ctx=ctx)
|
||||
db.delete_oid_client(current_domain().rp_id, client_uuid, ctx=ctx)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from paskia.db import Permission as PermDC
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.realms import registry
|
||||
from paskia.domains import registry
|
||||
from paskia.util import hostutil, permutil, querysafe
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -16,10 +16,10 @@ install_error_handlers(app)
|
||||
|
||||
|
||||
def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is a configured realm host or an OIDC client UUID.
|
||||
"""Validate that domain is a configured domain host or an OIDC client UUID.
|
||||
|
||||
Accepted: any realm's rp-id or its subdomain, a related-origin hostname
|
||||
of any realm, or the UUID of any realm's OIDC client (used for the
|
||||
Accepted: any domain's rp-id or its subdomain, a related-origin hostname
|
||||
of any domain, or the UUID of any domain's OIDC client (used for the
|
||||
groups claim).
|
||||
"""
|
||||
if domain is None:
|
||||
@@ -37,7 +37,7 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
if reg.resolve(domain) is not None:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Domain '{domain}' must belong to a configured realm or be an OIDC client UUID"
|
||||
f"Domain '{domain}' must belong to a configured domain or be an OIDC client UUID"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Realm (rp-id) management API — master admin only.
|
||||
|
||||
Realms replace the old single-site server configuration: each realm is one
|
||||
rp-id with its own rp-name, optional dedicated auth host, an optional
|
||||
allow-list of in-domain sign-in sites (origins), and optional related
|
||||
origins on unrelated domains (WebAuthn Related Origin Requests). All
|
||||
changes are validated cross-realm before being persisted, and the runtime
|
||||
realm registry is rebuilt after each change so it takes effect immediately.
|
||||
"""
|
||||
|
||||
from fastapi import Body, FastAPI, Request
|
||||
|
||||
from paskia import db, realms
|
||||
from paskia.db.structs import Config, RealmConfig
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil, oidjwt
|
||||
from paskia.util.apistructs import ApiRealm
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def _realm_to_api(realm: realms.Realm, registry: realms.RealmRegistry) -> ApiRealm:
|
||||
return ApiRealm(
|
||||
rp_id=realm.rp_id,
|
||||
rp_name=realm.rp_name,
|
||||
auth_host=realm.config.auth_host,
|
||||
origins=list(realm.config.origins or []),
|
||||
related_origins=realm.related_origins,
|
||||
site_url=realm.site_url,
|
||||
auth_site_url=realm.auth_site_url,
|
||||
effective_auth_host=registry.effective_auth_host(realm),
|
||||
is_default=realm is registry.default,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_origins(values: list[str] | None) -> list[str] | None:
|
||||
"""Normalize a list of origin URLs (raises ValueError on malformed)."""
|
||||
return [
|
||||
hostutil.normalize_origin(o.strip()) for o in values or [] if o.strip()
|
||||
] or None
|
||||
|
||||
|
||||
def _normalize_realm_fields(
|
||||
rp_id: str,
|
||||
auth_host: str | None,
|
||||
origins: list[str] | None,
|
||||
related_origins: list[str] | None,
|
||||
) -> tuple[str | None, list[str] | None, list[str] | None]:
|
||||
"""Normalize and validate auth_host/origins for a realm (raises ValueError)."""
|
||||
if auth_host:
|
||||
hostutil.validate_auth_host(auth_host, rp_id)
|
||||
auth_host, origins = hostutil.normalize_auth_host_and_origins(
|
||||
auth_host, _normalize_origins(origins)
|
||||
)
|
||||
return auth_host, origins, _normalize_origins(related_origins)
|
||||
|
||||
|
||||
def _rebuild_registry() -> None:
|
||||
"""Rebuild the runtime realm registry from the stored configuration."""
|
||||
realms.init_registry(db.data().config)
|
||||
|
||||
|
||||
def _check_not_locking_self_out(
|
||||
request: Request,
|
||||
realm: RealmConfig,
|
||||
) -> None:
|
||||
"""Refuse realm changes that lock the admin out of their current host.
|
||||
|
||||
Applies when the admin edits the realm they are currently using and the
|
||||
new config has no auth host (with an auth host, ceremonies move there
|
||||
and it is always allowed). The admin's current host must remain able to
|
||||
run passkey ceremonies under the new config.
|
||||
"""
|
||||
current: realms.Realm = request.state.realm
|
||||
if realm.rp_id != current.rp_id or realm.auth_host:
|
||||
return
|
||||
raw_host = (request.headers.get("host") or "").rstrip(".")
|
||||
if not raw_host:
|
||||
return
|
||||
probe = Passkey(
|
||||
rp_id=realm.rp_id,
|
||||
origins=realm.origins,
|
||||
related_origins=realm.related_origins,
|
||||
)
|
||||
for scheme in ("https", "http"):
|
||||
try:
|
||||
probe.validate_origin(f"{scheme}://{raw_host}")
|
||||
return # Current host still works — no lockout
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"This change would lock you out: '{raw_host}' could no longer "
|
||||
f"run passkey ceremonies for realm '{realm.rp_id}'. Add it to the "
|
||||
"allowed sign-in sites (or configure an auth host) before saving."
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def admin_list_realms(request: Request, auth=AUTH_COOKIE):
|
||||
"""List all realms with derived URLs (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
registry = realms.registry()
|
||||
return MsgspecResponse(
|
||||
[_realm_to_api(realm, registry) for realm in registry.realms]
|
||||
)
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def admin_create_realm(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Add a new realm (master admin only, recent authentication required)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
|
||||
rp_id = (payload.get("rp_id") or "").strip().lower()
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id is required")
|
||||
rp_name = (payload.get("rp_name") or "").strip() or None
|
||||
auth_host = (payload.get("auth_host") or "").strip() or None
|
||||
auth_host, origins, related_origins = _normalize_realm_fields(
|
||||
rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
new_realm = RealmConfig(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
auth_host=auth_host,
|
||||
origins=origins,
|
||||
related_origins=related_origins,
|
||||
)
|
||||
# Validate the would-be combined configuration before persisting
|
||||
realms.validate_config(
|
||||
Config(realms=[*config.realms, new_realm], listen=config.listen)
|
||||
)
|
||||
|
||||
db.create_realm(new_realm, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/{rp_id}")
|
||||
async def admin_update_realm(
|
||||
rp_id: str,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update a realm's rp_name, auth_host, origins and related origins
|
||||
(lists are replaced wholesale).
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it.
|
||||
"""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
realm = config.find_realm(rp_id)
|
||||
if realm is None:
|
||||
raise ValueError(f"Realm {rp_id} not found")
|
||||
|
||||
rp_name = (payload.get("rp_name") or "").strip() or None
|
||||
auth_host = (payload.get("auth_host") or "").strip() or None
|
||||
auth_host, origins, related_origins = _normalize_realm_fields(
|
||||
rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
|
||||
)
|
||||
|
||||
updated = RealmConfig(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
auth_host=auth_host,
|
||||
origins=origins,
|
||||
related_origins=related_origins,
|
||||
)
|
||||
would_be = Config(
|
||||
realms=[updated if r.rp_id == rp_id else r for r in config.realms],
|
||||
listen=config.listen,
|
||||
)
|
||||
realms.validate_config(would_be)
|
||||
_check_not_locking_self_out(request, updated)
|
||||
|
||||
db.update_realm(
|
||||
rp_id,
|
||||
rp_name=rp_name,
|
||||
auth_host=auth_host,
|
||||
origins=origins,
|
||||
related_origins=related_origins,
|
||||
ctx=ctx,
|
||||
)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{rp_id}")
|
||||
async def admin_delete_realm(
|
||||
rp_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete a realm (refused for the last realm or while credentials remain)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
current: realms.Realm = request.state.realm
|
||||
if rp_id == current.rp_id:
|
||||
raise ValueError(
|
||||
"Cannot delete the realm you are currently using — authenticate "
|
||||
"on another realm first"
|
||||
)
|
||||
db.delete_realm(rp_id, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
oidjwt.clear_key(rp_id)
|
||||
return {"status": "ok"}
|
||||
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import avatar, hostutil, permutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
@@ -123,7 +123,7 @@ async def admin_create_user_registration_link(
|
||||
token_type=token_type,
|
||||
ctx=ctx,
|
||||
)
|
||||
url = current_realm().reset_link_url(token)
|
||||
url = current_domain().reset_link_url(token)
|
||||
return MsgspecResponse(
|
||||
ApiCreateLinkResponse(
|
||||
url=url,
|
||||
|
||||
+10
-10
@@ -20,7 +20,7 @@ from paskia.authsession import EXPIRES, get_reset, session_ctx
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.realms import current_realm, registry
|
||||
from paskia.domains import current_domain, registry
|
||||
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||
from paskia.util.apistructs import (
|
||||
ApiCheckUserResponse,
|
||||
@@ -300,15 +300,15 @@ async def forward_authentication(
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
realm = current_realm()
|
||||
domain = current_domain()
|
||||
return MsgspecResponse(
|
||||
ApiSettings(
|
||||
rp_id=realm.rp_id,
|
||||
rp_name=realm.rp_name,
|
||||
ui_base_path=realm.ui_base_path,
|
||||
auth_host=registry().effective_auth_host(realm),
|
||||
own_auth_host=realm.own_auth_host,
|
||||
auth_site_url=realm.auth_site_url,
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
ui_base_path=domain.ui_base_path,
|
||||
auth_host=registry().effective_auth_host(domain),
|
||||
own_auth_host=domain.own_auth_host,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
session_cookie=AUTH_COOKIE_NAME,
|
||||
version=__version__,
|
||||
),
|
||||
@@ -407,8 +407,8 @@ async def api_set_session(
|
||||
a = authcode.consume_cookie(auth.credentials)
|
||||
if not a:
|
||||
raise HTTPException(401, "Code expired or already used")
|
||||
if a.rp_id != current_realm().rp_id:
|
||||
raise HTTPException(401, "Code was issued for a different realm")
|
||||
if a.rp_id != current_domain().rp_id:
|
||||
raise HTTPException(401, "Code was issued for a different domain")
|
||||
|
||||
secret = a.session_key
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
@@ -75,12 +75,12 @@ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Resp
|
||||
async def redirect_middleware(request: Request, call_next):
|
||||
"""Middleware to handle auth host redirects.
|
||||
|
||||
Only the current realm's *own* auth host triggers redirects; a realm
|
||||
without one serves its UI under /auth/ on its own hosts. Realms
|
||||
Only the current domain's *own* auth host triggers redirects; a domain
|
||||
without one serves its UI under /auth/ on its own hosts. Domains
|
||||
relying on a shared (fallback) auth host use it for WS/restricted
|
||||
API calls, not for redirects.
|
||||
"""
|
||||
cfg = current_realm().own_auth_host
|
||||
cfg = current_domain().own_auth_host
|
||||
if not cfg:
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
+30
-30
@@ -1,10 +1,10 @@
|
||||
"""ASGI dispatch middleware: resolve the request Host to a realm.
|
||||
"""ASGI dispatch middleware: resolve the request Host to a domain.
|
||||
|
||||
Every HTTP request and WebSocket connection is dispatched to exactly one
|
||||
realm, resolved from the Host header via the realm registry. The resolved
|
||||
realm is exposed as ``request.state.realm`` and through the
|
||||
:func:`paskia.realms.current_realm` contextvar, which endpoint code uses
|
||||
for all realm-dependent behavior (passkey configuration, OIDC provider,
|
||||
domain, resolved from the Host header via the domain registry. The resolved
|
||||
domain is exposed as ``request.state.domain`` and through the
|
||||
:func:`paskia.domains.current_domain` contextvar, which endpoint code uses
|
||||
for all domain-dependent behavior (passkey configuration, OIDC provider,
|
||||
site URLs).
|
||||
|
||||
Unknown hosts are rejected before routing:
|
||||
@@ -12,18 +12,18 @@ Unknown hosts are rejected before routing:
|
||||
- HTTP: ``421 Misdirected Request``
|
||||
- WebSocket: closed pre-accept with code 1008
|
||||
|
||||
For WebSocket connections the Origin header selects the realm when it
|
||||
belongs to a different realm than the Host — a related-origin page using
|
||||
the realm's auth host, or a realm without its own auth host using the
|
||||
shared one. A cross-realm connection is only allowed when the Host is the
|
||||
origin realm's effective auth host; otherwise the connection is closed
|
||||
pre-accept. When the Origin is missing or unknown the Host realm applies
|
||||
For WebSocket connections the Origin header selects the domain when it
|
||||
belongs to a different domain than the Host — a related-origin page using
|
||||
the domain's auth host, or a domain without its own auth host using the
|
||||
shared one. A cross-domain connection is only allowed when the Host is the
|
||||
origin domain's effective auth host; otherwise the connection is closed
|
||||
pre-accept. When the Origin is missing or unknown the Host domain applies
|
||||
and endpoint-side origin validation decides.
|
||||
"""
|
||||
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from paskia import realms
|
||||
from paskia import domains
|
||||
from paskia.util import hostutil
|
||||
|
||||
_WS_CLOSE_POLICY_VIOLATION = 1008
|
||||
@@ -39,7 +39,7 @@ def _header(scope: dict, name: str) -> str | None:
|
||||
|
||||
|
||||
class DispatchMiddleware:
|
||||
"""Pure ASGI middleware dispatching each connection to its realm."""
|
||||
"""Pure ASGI middleware dispatching each connection to its domain."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
@@ -53,28 +53,28 @@ class DispatchMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
async def _http(self, scope, receive, send):
|
||||
realm = realms.registry().resolve(_header(scope, "host"))
|
||||
if realm is None:
|
||||
domain = domains.registry().resolve(_header(scope, "host"))
|
||||
if domain is None:
|
||||
response = PlainTextResponse("Unknown host", status_code=421)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self._dispatch(scope, receive, send, realm)
|
||||
await self._dispatch(scope, receive, send, domain)
|
||||
|
||||
async def _websocket(self, scope, receive, send):
|
||||
registry = realms.registry()
|
||||
registry = domains.registry()
|
||||
host = _header(scope, "host")
|
||||
host_realm = registry.resolve(host)
|
||||
if host_realm is None:
|
||||
host_domain = registry.resolve(host)
|
||||
if host_domain is None:
|
||||
await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION})
|
||||
return
|
||||
|
||||
realm = host_realm
|
||||
domain = host_domain
|
||||
origin = _header(scope, "origin")
|
||||
origin_host = hostutil.origin_hostname(origin) if origin else None
|
||||
origin_realm = registry.resolve(origin_host) if origin_host else None
|
||||
if origin_realm is not None and origin_realm is not host_realm:
|
||||
# Cross-realm connection: only via the origin realm's auth host.
|
||||
effective = registry.effective_auth_host(origin_realm)
|
||||
origin_domain = registry.resolve(origin_host) if origin_host else None
|
||||
if origin_domain is not None and origin_domain is not host_domain:
|
||||
# Cross-domain connection: only via the origin domain's auth host.
|
||||
effective = registry.effective_auth_host(origin_domain)
|
||||
if not effective or hostutil.normalize_host(
|
||||
host
|
||||
) != hostutil.normalize_host(effective):
|
||||
@@ -82,13 +82,13 @@ class DispatchMiddleware:
|
||||
{"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION}
|
||||
)
|
||||
return
|
||||
realm = origin_realm
|
||||
await self._dispatch(scope, receive, send, realm)
|
||||
domain = origin_domain
|
||||
await self._dispatch(scope, receive, send, domain)
|
||||
|
||||
async def _dispatch(self, scope, receive, send, realm: realms.Realm):
|
||||
scope.setdefault("state", {})["realm"] = realm
|
||||
token = realms.set_current_realm(realm)
|
||||
async def _dispatch(self, scope, receive, send, domain: domains.Domain):
|
||||
scope.setdefault("state", {})["domain"] = domain
|
||||
token = domains.set_current_domain(domain)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
realms.reset_current_realm(token)
|
||||
domains.reset_current_domain(token)
|
||||
|
||||
+11
-11
@@ -7,7 +7,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from kanta.logging import configure_logging as configure_kanta_logging
|
||||
|
||||
from paskia import authcode, db, realms, remoteauth
|
||||
from paskia import authcode, db, domains, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.db.background import start_background, stop_background
|
||||
from paskia.db.lifecycle import kanta
|
||||
@@ -31,22 +31,22 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""Application lifespan: open the combined database and build the realm registry.
|
||||
"""Application lifespan: open the combined database and build the domain registry.
|
||||
|
||||
Process-global serve parameters (listen endpoints) are passed via the
|
||||
PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so that
|
||||
uvicorn reload / multiprocess workers derive site URLs the same way.
|
||||
Realm configuration is read from the database.
|
||||
Domain configuration is read from the database.
|
||||
"""
|
||||
cfg = serve_config()
|
||||
realms.configure(listen=cfg.listen if cfg else None)
|
||||
domains.configure(listen=cfg.listen if cfg else None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||
)
|
||||
async with kanta:
|
||||
try:
|
||||
realms.init_registry(db.data().config)
|
||||
domains.init_registry(db.data().config)
|
||||
await remoteauth.init()
|
||||
await authcode.start()
|
||||
except ValueError as e:
|
||||
@@ -77,8 +77,8 @@ app = FastAPI(
|
||||
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||
app.middleware("http")(auth_host.redirect_middleware)
|
||||
|
||||
# Realm dispatch must be the outermost application middleware: everything
|
||||
# below it (including the auth-host redirects) uses the current realm.
|
||||
# Domain dispatch must be the outermost application middleware: everything
|
||||
# below it (including the auth-host redirects) uses the current domain.
|
||||
app.add_middleware(DispatchMiddleware)
|
||||
|
||||
app.mount("/auth/api/admin/", admin.app)
|
||||
@@ -130,11 +130,11 @@ async def openid_configuration(request: Request):
|
||||
async def webauthn_related_origins(request: Request):
|
||||
"""WebAuthn Related Origin Requests discovery document.
|
||||
|
||||
Served on the realm's rp-id site; lists the realm's related origins
|
||||
(other domains) that may assert this rp-id. 404 when the realm has no
|
||||
Served on the domain's rp-id site; lists the domain's related origins
|
||||
(other domains) that may assert this rp-id. 404 when the domain has no
|
||||
related origins.
|
||||
"""
|
||||
related = request.state.realm.related_origins
|
||||
related = request.state.domain.related_origins
|
||||
if not related:
|
||||
raise HTTPException(status_code=404)
|
||||
return {"origins": related}
|
||||
@@ -166,7 +166,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
@app.get("/auth/admin", include_in_schema=False)
|
||||
async def admin_root_redirect():
|
||||
return RedirectResponse(
|
||||
f"{realms.current_realm().ui_base_path}admin/", status_code=307
|
||||
f"{domains.current_domain().ui_base_path}admin/", status_code=307
|
||||
)
|
||||
|
||||
|
||||
|
||||
+13
-13
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.db.structs import OIDC, Session
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -32,17 +32,17 @@ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def _provider() -> OIDC:
|
||||
"""Return the OIDC provider state of the current request's realm."""
|
||||
provider = db.data().oidc_for(current_realm().rp_id)
|
||||
if provider is None: # pragma: no cover - invariant: realms always seed OIDC
|
||||
raise RuntimeError(f"No OIDC provider for realm {current_realm().rp_id}")
|
||||
"""Return the OIDC provider state of the current request's domain."""
|
||||
provider = db.data().oidc_for(current_domain().rp_id)
|
||||
if provider is None: # pragma: no cover - invariant: domains always seed OIDC
|
||||
raise RuntimeError(f"No OIDC provider for domain {current_domain().rp_id}")
|
||||
return provider
|
||||
|
||||
|
||||
@app.get("/keys")
|
||||
async def keys():
|
||||
"""JSON Web Key Set for token verification."""
|
||||
return oidjwt.get_jwks(current_realm().rp_id)
|
||||
return oidjwt.get_jwks(current_domain().rp_id)
|
||||
|
||||
|
||||
def _oidc_session_by_token(
|
||||
@@ -197,12 +197,12 @@ async def _handle_authorization_code(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# The code is bound to the realm it was issued in (dispatched by Host)
|
||||
if oidc_code.rp_id != current_realm().rp_id:
|
||||
# The code is bound to the domain it was issued in (dispatched by Host)
|
||||
if oidc_code.rp_id != current_domain().rp_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Code was issued for a different realm",
|
||||
"error_description": "Code was issued for a different domain",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
@@ -347,7 +347,7 @@ def _build_token_response(
|
||||
credential_uuid: UUID | None = None,
|
||||
):
|
||||
"""Build the token response with access_token, id_token, and refresh_token."""
|
||||
rp_id = current_realm().rp_id
|
||||
rp_id = current_domain().rp_id
|
||||
issuer = _get_issuer(request)
|
||||
|
||||
# Get user's permissions scoped to this OIDC client (domain == client UUID)
|
||||
@@ -424,7 +424,7 @@ async def userinfo(
|
||||
if not credentials:
|
||||
raise HTTPException(401, "Bearer token required")
|
||||
|
||||
rp_id = current_realm().rp_id
|
||||
rp_id = current_domain().rp_id
|
||||
issuer = _get_issuer(request)
|
||||
payload = oidjwt.decode_access_token(rp_id, credentials.credentials, issuer)
|
||||
if not payload:
|
||||
@@ -510,7 +510,7 @@ async def backchannel_logout(
|
||||
)
|
||||
|
||||
# Decode and verify the logout token
|
||||
rp_id = current_realm().rp_id
|
||||
rp_id = current_domain().rp_id
|
||||
issuer = _get_issuer(request)
|
||||
payload = oidjwt.decode_access_token(rp_id, logout_token, issuer)
|
||||
if not payload:
|
||||
@@ -581,7 +581,7 @@ async def backchannel_logout(
|
||||
{"error": "invalid_request", "error_description": "Invalid sub claim"},
|
||||
status_code=400,
|
||||
)
|
||||
# Find and delete matching sessions (this realm's OIDC sessions only)
|
||||
# Find and delete matching sessions (this domain's OIDC sessions only)
|
||||
sessions_to_delete = [
|
||||
s
|
||||
for s in db.data().sessions.values()
|
||||
|
||||
@@ -22,7 +22,7 @@ from paskia.authsession import expires
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.realms import current_realm, registry
|
||||
from paskia.domains import current_domain, registry
|
||||
from paskia.util import pow, useragent
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
@@ -95,7 +95,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
rp_id=current_realm().rp_id,
|
||||
rp_id=current_domain().rp_id,
|
||||
action=action,
|
||||
)
|
||||
|
||||
@@ -335,8 +335,8 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
)
|
||||
|
||||
# Create exchange code for the session (don't expose raw secret)
|
||||
# Stamped with the *requesting* device's realm: it redeems the
|
||||
# code on its own host, which dispatches to that realm.
|
||||
# Stamped with the *requesting* device's domain: it redeems the
|
||||
# code on its own host, which dispatches to that domain.
|
||||
exchange_code = authcode.store_cookie(
|
||||
CookieCode(
|
||||
session_key=secret,
|
||||
@@ -446,15 +446,15 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
request.action = locked_action # Update local copy with locked value
|
||||
|
||||
# Send device info to the authenticating device, including the
|
||||
# requesting device's realm (may differ from the approver's)
|
||||
requesting_realm = registry().get(request.rp_id)
|
||||
# requesting device's domain (may differ from the approver's)
|
||||
requesting_domain = registry().get(request.rp_id)
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "found",
|
||||
"host": request.host,
|
||||
"rp_id": request.rp_id,
|
||||
"rp_name": (
|
||||
requesting_realm.rp_name if requesting_realm else request.rp_id
|
||||
requesting_domain.rp_name if requesting_domain else request.rp_id
|
||||
),
|
||||
"user_agent_pretty": useragent.compact_user_agent(
|
||||
request.user_agent
|
||||
|
||||
@@ -20,7 +20,7 @@ from paskia.authsession import (
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import avatar
|
||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||
|
||||
@@ -292,7 +292,7 @@ async def api_create_link(
|
||||
token_type="device addition",
|
||||
ctx=ctx,
|
||||
)
|
||||
url = current_realm().reset_link_url(token)
|
||||
url = current_domain().reset_link_url(token)
|
||||
return MsgspecResponse(
|
||||
ApiCreateLinkResponse(
|
||||
message="Registration link generated successfully",
|
||||
|
||||
+11
-11
@@ -17,7 +17,7 @@ from paskia.fastapi.wschat import (
|
||||
register_chat,
|
||||
)
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -28,7 +28,7 @@ def create_exchange_code(session_key: str) -> str:
|
||||
cookie_code = CookieCode(
|
||||
session_key=session_key,
|
||||
created=now,
|
||||
rp_id=current_realm().rp_id,
|
||||
rp_id=current_domain().rp_id,
|
||||
)
|
||||
return authcode.store_cookie(cookie_code)
|
||||
|
||||
@@ -56,11 +56,11 @@ async def websocket_register_add(
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
host = hostutil.normalize_host(origin.split("://", 1)[1])
|
||||
realm = current_realm()
|
||||
domain = current_domain()
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError(
|
||||
f"The reset link for {realm.rp_name} is invalid or has expired"
|
||||
f"The reset link for {domain.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
@@ -77,7 +77,7 @@ async def websocket_register_add(
|
||||
stripped = name.strip()
|
||||
if stripped:
|
||||
user_name = stripped
|
||||
credential_ids = user.credential_ids_for(realm.rp_id) or None
|
||||
credential_ids = user.credential_ids_for(domain.rp_id) or None
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
|
||||
@@ -125,7 +125,7 @@ async def websocket_authenticate(
|
||||
):
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
realm = current_realm()
|
||||
domain = current_domain()
|
||||
|
||||
# OIDC mode: validate client before auth
|
||||
oidc_client = None
|
||||
@@ -136,7 +136,7 @@ async def websocket_authenticate(
|
||||
await ws.send_json({"status": 400, "detail": "Invalid client_id"})
|
||||
return
|
||||
|
||||
oidc_client = db.data().oidc_for(realm.rp_id).clients.get(client_uuid)
|
||||
oidc_client = db.data().oidc_for(domain.rp_id).clients.get(client_uuid)
|
||||
if not oidc_client:
|
||||
await ws.send_json({"status": 400, "detail": "Unknown client_id"})
|
||||
return
|
||||
@@ -148,9 +148,9 @@ async def websocket_authenticate(
|
||||
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
|
||||
return
|
||||
# Store as the only allowed redirect URI
|
||||
db.update_oid_client(realm.rp_id, client_uuid, redirect_uris=[redirect_uri])
|
||||
db.update_oid_client(domain.rp_id, client_uuid, redirect_uris=[redirect_uri])
|
||||
# Reload client to get updated redirect_uris
|
||||
oidc_client = db.data().oidc_for(realm.rp_id).clients.get(client_uuid)
|
||||
oidc_client = db.data().oidc_for(domain.rp_id).clients.get(client_uuid)
|
||||
elif redirect_uri not in oidc_client.redirect_uris:
|
||||
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
|
||||
return
|
||||
@@ -224,7 +224,7 @@ async def websocket_authenticate(
|
||||
user_agent=metadata["user_agent"],
|
||||
validated=now,
|
||||
client=oidc_client.uuid,
|
||||
rp_id=realm.rp_id,
|
||||
rp_id=domain.rp_id,
|
||||
issuer=origin,
|
||||
)
|
||||
db.oidc_login(
|
||||
@@ -238,7 +238,7 @@ async def websocket_authenticate(
|
||||
created=now,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
rp_id=realm.rp_id,
|
||||
rp_id=domain.rp_id,
|
||||
nonce=nonce,
|
||||
code_challenge=code_challenge,
|
||||
)
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@ from paskia.authsession import session_ctx
|
||||
from paskia.db import Credential, SessionContext
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wsutil import validate_origin
|
||||
from paskia.realms import current_realm, registry
|
||||
from paskia.domains import current_domain, registry
|
||||
from paskia.util import hostutil
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ async def register_chat(
|
||||
credential_ids: list[bytes] | None = None,
|
||||
):
|
||||
"""Run WebAuthn registration flow and return the verified credential."""
|
||||
passkey = current_realm().passkey
|
||||
passkey = current_domain().passkey
|
||||
options, challenge = passkey.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
@@ -43,8 +43,8 @@ async def authenticate_chat(
|
||||
Returns:
|
||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||
"""
|
||||
realm = current_realm()
|
||||
passkey = realm.passkey
|
||||
domain = current_domain()
|
||||
passkey = domain.passkey
|
||||
origin = validate_origin(ws)
|
||||
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
@@ -54,7 +54,7 @@ async def authenticate_chat(
|
||||
(
|
||||
c
|
||||
for c in db.data().credentials.values()
|
||||
if c.credential_id == authcred.raw_id and c.rp_id == realm.rp_id
|
||||
if c.credential_id == authcred.raw_id and c.rp_id == domain.rp_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -81,14 +81,14 @@ async def authenticate_and_login(
|
||||
ws: The WebSocket connection (used for WebAuthn and origin validation)
|
||||
auth: Existing session cookie for re-auth credential restriction
|
||||
session_host: Override host for the new session (defaults to ws origin);
|
||||
must belong to a configured realm
|
||||
must belong to a configured domain
|
||||
session_ip: Override IP for the new session (defaults to ws client IP)
|
||||
session_user_agent: Override user-agent for the new session (defaults to ws headers)
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionContext for the authenticated session, session secret)
|
||||
"""
|
||||
realm = current_realm()
|
||||
domain = current_domain()
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
@@ -101,7 +101,7 @@ async def authenticate_and_login(
|
||||
if auth:
|
||||
existing_ctx = session_ctx(auth, host)
|
||||
if existing_ctx:
|
||||
credential_ids = existing_ctx.user.credential_ids_for(realm.rp_id) or None
|
||||
credential_ids = existing_ctx.user.credential_ids_for(domain.rp_id) or None
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||
|
||||
@@ -114,7 +114,7 @@ async def authenticate_and_login(
|
||||
if not login_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
if session_host is not None and registry().resolve(login_host) is None:
|
||||
raise ValueError(f"Host '{login_host}' does not belong to a configured realm")
|
||||
raise ValueError(f"Host '{login_host}' does not belong to a configured domain")
|
||||
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
||||
login_user_agent = (
|
||||
session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
||||
@@ -128,7 +128,7 @@ async def authenticate_and_login(
|
||||
host=login_host,
|
||||
ip=login_ip,
|
||||
user_agent=login_user_agent,
|
||||
rp_id=realm.rp_id,
|
||||
rp_id=domain.rp_id,
|
||||
)
|
||||
|
||||
# Fetch and return the full session context (using the same host the session was created with)
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from paskia.fastapi import authz
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import pow
|
||||
|
||||
|
||||
@@ -83,9 +83,9 @@ def validate_origin(ws: WebSocket) -> str:
|
||||
"""Extract and validate origin from WebSocket request headers.
|
||||
|
||||
Raises:
|
||||
ValueError: If origin header is missing or not allowed in the current realm
|
||||
ValueError: If origin header is missing or not allowed in the current domain
|
||||
"""
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return current_realm().passkey.validate_origin(origin)
|
||||
return current_domain().passkey.validate_origin(origin)
|
||||
|
||||
+13
-16
@@ -4,8 +4,8 @@ OIDC Back-Channel Logout notifications.
|
||||
When sessions are deleted (logout, admin, expiry), this module notifies
|
||||
any OIDC clients that have a backchannel_logout_uri configured.
|
||||
|
||||
Notifications run without request context, so the realm and issuer come
|
||||
from the session itself: ``Session.rp_id`` selects the realm's signing key
|
||||
Notifications run without request context, so the domain and issuer come
|
||||
from the session itself: ``Session.rp_id`` selects the domain's signing key
|
||||
and ``Session.issuer`` (stamped at session creation/refresh) is the `iss`.
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,7 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from paskia import db, realms
|
||||
from paskia import db, domains
|
||||
from paskia.util import oidjwt
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -24,17 +24,14 @@ _logger = logging.getLogger(__name__)
|
||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
|
||||
def _session_realm(rp_id: str | None):
|
||||
"""Resolve a session's realm, falling back to the default realm."""
|
||||
def _session_domain(rp_id: str | None):
|
||||
"""Resolve a session's domain by its rp-id stamp (None if unknown)."""
|
||||
if not rp_id:
|
||||
return None
|
||||
try:
|
||||
reg = realms.registry()
|
||||
return domains.registry().get(rp_id)
|
||||
except RuntimeError:
|
||||
return None
|
||||
if rp_id:
|
||||
realm = reg.get(rp_id)
|
||||
if realm is not None:
|
||||
return realm
|
||||
return reg.default
|
||||
|
||||
|
||||
def _collect_oidc_sessions(
|
||||
@@ -51,18 +48,18 @@ def _collect_oidc_sessions(
|
||||
session = data.sessions.get(key)
|
||||
if not session or session.client_uuid is None:
|
||||
continue
|
||||
realm = _session_realm(session.rp_id)
|
||||
if realm is None:
|
||||
domain = _session_domain(session.rp_id)
|
||||
if domain is None:
|
||||
continue
|
||||
provider = data.oidc.get(realm.rp_id)
|
||||
provider = data.oidc.get(domain.rp_id)
|
||||
client = provider.clients.get(session.client_uuid) if provider else None
|
||||
if not client or not client.backchannel_logout_uri:
|
||||
continue
|
||||
issuer = session.issuer or realm.site_url
|
||||
issuer = session.issuer or domain.site_url
|
||||
notifications.append(
|
||||
(
|
||||
client.backchannel_logout_uri,
|
||||
realm.rp_id,
|
||||
domain.rp_id,
|
||||
issuer,
|
||||
session.key,
|
||||
session.client_uuid,
|
||||
|
||||
@@ -1,493 +0,0 @@
|
||||
"""Realm registry: per-rp-id runtime state and host resolution.
|
||||
|
||||
A **realm** is one rp-id with its associated hosts and origins. The
|
||||
registry is built from the stored combined ``Config`` at startup and
|
||||
rebuilt on admin realm changes; request dispatch resolves hosts to realms
|
||||
through it. The database itself is global — only the *current realm*
|
||||
(passkey, site URLs, OIDC view) varies per request, tracked via a
|
||||
contextvar set by the dispatch middleware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia.db.structs import Config, RealmConfig
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.constants import DEFAULT_PORT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of related (non-subdomain) origins per realm. WebAuthn
|
||||
# Related Origin Requests require browsers to support at least 5 labels.
|
||||
DEFAULT_RELATED_ORIGIN_CAP = 5
|
||||
|
||||
|
||||
class Realm:
|
||||
"""Runtime view of one realm: stored config plus derived values."""
|
||||
|
||||
def __init__(self, config: RealmConfig, site_url: str, site_path: str):
|
||||
# Lazy import: paskia.sansio depends on paskia.db, which (via
|
||||
# paskia.db.operations → paskia.oidc_notify) depends on this module.
|
||||
from paskia.sansio import Passkey # noqa: PLC0415
|
||||
|
||||
self.config = config
|
||||
self.site_url = site_url
|
||||
self.site_path = site_path
|
||||
self.passkey = Passkey(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
related_origins=config.related_origins,
|
||||
)
|
||||
|
||||
@property
|
||||
def rp_id(self) -> str:
|
||||
return self.config.rp_id
|
||||
|
||||
@property
|
||||
def rp_name(self) -> str:
|
||||
return self.passkey.rp_name
|
||||
|
||||
@property
|
||||
def own_auth_host(self) -> str | None:
|
||||
"""This realm's own auth host as host[:port], if configured."""
|
||||
if not self.config.auth_host:
|
||||
return None
|
||||
return hostutil.auth_host_netloc(self.config.auth_host)
|
||||
|
||||
@property
|
||||
def related_origins(self) -> list[str]:
|
||||
"""Configured related (cross-domain) origins for ROR."""
|
||||
return list(self.config.related_origins or [])
|
||||
|
||||
@property
|
||||
def is_root_mode(self) -> bool:
|
||||
"""Whether this realm's UI lives at the site root (own auth host)."""
|
||||
return self.config.auth_host is not None
|
||||
|
||||
@property
|
||||
def ui_base_path(self) -> str:
|
||||
return "/" if self.is_root_mode else "/auth/"
|
||||
|
||||
@property
|
||||
def auth_site_url(self) -> str:
|
||||
"""Base URL of this realm's auth site UI."""
|
||||
return self.site_url + self.site_path
|
||||
|
||||
def api_url(self, path: str = "") -> str:
|
||||
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||
if not path:
|
||||
return f"{self.site_url}/auth/api/"
|
||||
return f"{self.site_url}/auth/api/{path.lstrip('/')}"
|
||||
|
||||
def reset_link_url(self, token: str) -> str:
|
||||
"""Generate a reset link URL for the given token on this realm."""
|
||||
return f"{self.auth_site_url}{token}"
|
||||
|
||||
|
||||
class RealmRegistry:
|
||||
"""Resolved realms and host lookup tables."""
|
||||
|
||||
def __init__(self, realms: list[Realm]):
|
||||
self._by_rp_id = {r.rp_id: r for r in realms}
|
||||
self._auth_hosts: dict[str, Realm] = {}
|
||||
self._related_hosts: dict[str, Realm] = {}
|
||||
self.warnings: list[str] = []
|
||||
for realm in realms:
|
||||
if own := realm.own_auth_host:
|
||||
self._auth_hosts[hostutil.normalize_host(own) or own] = realm
|
||||
for origin in realm.related_origins:
|
||||
if hostname := hostutil.origin_hostname(origin):
|
||||
self._related_hosts[hostname] = realm
|
||||
|
||||
@property
|
||||
def realms(self) -> list[Realm]:
|
||||
"""All realms, in configuration order (first is the default)."""
|
||||
return list(self._by_rp_id.values())
|
||||
|
||||
@property
|
||||
def default(self) -> Realm:
|
||||
"""The default realm (first in configuration order)."""
|
||||
return next(iter(self._by_rp_id.values()))
|
||||
|
||||
def get(self, rp_id: str) -> Realm | None:
|
||||
return self._by_rp_id.get(rp_id)
|
||||
|
||||
def effective_auth_host(self, realm: Realm) -> str | None:
|
||||
"""Auth host serving WS/restricted APIs for a realm: its own, or the
|
||||
first configured auth host (in realm order) as a shared fallback.
|
||||
|
||||
Returns host[:port] suitable for URL building, or None.
|
||||
"""
|
||||
if realm.own_auth_host:
|
||||
return realm.own_auth_host
|
||||
for candidate in self._by_rp_id.values():
|
||||
if candidate.own_auth_host:
|
||||
return candidate.own_auth_host
|
||||
return None
|
||||
|
||||
def resolve(self, host: str | None) -> Realm | None:
|
||||
"""Resolve a request Host header to a realm.
|
||||
|
||||
Order: exact rp-id → exact auth host → exact related-origin
|
||||
hostname → longest-suffix rp-id. Unknown hosts return None.
|
||||
"""
|
||||
h = hostutil.normalize_host(host)
|
||||
if not h:
|
||||
return None
|
||||
if realm := self._by_rp_id.get(h):
|
||||
return realm
|
||||
if realm := self._auth_hosts.get(h):
|
||||
return realm
|
||||
if realm := self._related_hosts.get(h):
|
||||
return realm
|
||||
best = None
|
||||
for rp_id, realm in self._by_rp_id.items():
|
||||
if h.endswith(f".{rp_id}") and (
|
||||
best is None or len(rp_id) > len(best.rp_id)
|
||||
):
|
||||
best = realm
|
||||
return best
|
||||
|
||||
|
||||
def validate_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
) -> None:
|
||||
"""Validate a combined configuration cross-realm. Raises ValueError."""
|
||||
if not config.realms:
|
||||
raise ValueError("At least one realm (rp-id) is required")
|
||||
|
||||
rp_ids: set[str] = set()
|
||||
auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id
|
||||
related_hosts: dict[str, str] = {} # hostname -> owning rp_id
|
||||
|
||||
for realm in config.realms:
|
||||
hostutil.validate_rp_id(realm.rp_id)
|
||||
if realm.rp_id in rp_ids:
|
||||
raise ValueError(f"Duplicate rp-id '{realm.rp_id}'")
|
||||
rp_ids.add(realm.rp_id)
|
||||
|
||||
if realm.auth_host:
|
||||
hostutil.validate_auth_host(realm.auth_host, realm.rp_id)
|
||||
hn = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(realm.auth_host) or ""
|
||||
)
|
||||
if hn:
|
||||
if hn in auth_hosts:
|
||||
raise ValueError(
|
||||
f"auth-host '{hn}' is configured for both "
|
||||
f"'{auth_hosts[hn]}' and '{realm.rp_id}'"
|
||||
)
|
||||
auth_hosts[hn] = realm.rp_id
|
||||
|
||||
for origin in realm.origins or []:
|
||||
hn = hostutil.origin_hostname(origin)
|
||||
if not hn:
|
||||
raise ValueError(f"Invalid origin URL: '{origin}'")
|
||||
if not hostutil.is_subdomain(hn, realm.rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{origin}' is outside the rp-id domain "
|
||||
f"'{realm.rp_id}' — configure it as a related origin instead"
|
||||
)
|
||||
|
||||
if len(realm.related_origins or []) > related_origin_cap:
|
||||
raise ValueError(
|
||||
f"Realm '{realm.rp_id}' has {len(realm.related_origins or [])} "
|
||||
f"related origins (maximum {related_origin_cap})"
|
||||
)
|
||||
for origin in realm.related_origins or []:
|
||||
if hostutil.is_wildcard_pattern(origin):
|
||||
raise ValueError(
|
||||
f"Related origin '{origin}' is a wildcard — related "
|
||||
"origins (ROR) must be listed individually"
|
||||
)
|
||||
hn = hostutil.origin_hostname(origin)
|
||||
if not hn:
|
||||
raise ValueError(f"Invalid related origin URL: '{origin}'")
|
||||
if hostutil.is_subdomain(hn, realm.rp_id):
|
||||
raise ValueError(
|
||||
f"Related origin '{origin}' is within the rp-id domain "
|
||||
f"'{realm.rp_id}' — subdomains need no related origin entry"
|
||||
)
|
||||
if hn in related_hosts:
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' is configured for both "
|
||||
f"'{related_hosts[hn]}' and '{realm.rp_id}'"
|
||||
)
|
||||
related_hosts[hn] = realm.rp_id
|
||||
|
||||
for hn, owner in auth_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"auth-host '{hn}' collides with an rp-id")
|
||||
if hn in related_hosts:
|
||||
raise ValueError(
|
||||
f"auth-host '{hn}' collides with a related origin of "
|
||||
f"realm '{related_hosts[hn]}'"
|
||||
)
|
||||
|
||||
for hn, owner in related_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"Related origin host '{hn}' collides with an rp-id")
|
||||
for other in rp_ids:
|
||||
if other != owner and hostutil.is_subdomain(hn, other):
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' of realm '{owner}' "
|
||||
f"falls inside realm '{other}'"
|
||||
)
|
||||
|
||||
|
||||
def sanitize_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
) -> tuple[Config, list[str]]:
|
||||
"""Best-effort repair of a stored configuration for serving.
|
||||
|
||||
Serving must never fail because of stored realm config: fixing it is
|
||||
the admin's job via the admin UI, which is reachable only on a running
|
||||
server. Returns a sanitized copy (the stored config is left untouched)
|
||||
plus a warning for every degradation made. The result always passes
|
||||
``validate_config``.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
if not config.realms:
|
||||
raise ValueError("At least one realm (rp-id) is required")
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
warnings.append(msg)
|
||||
|
||||
realms: list[RealmConfig] = []
|
||||
seen_rp_ids: set[str] = set()
|
||||
for realm in config.realms:
|
||||
rp_id = realm.rp_id
|
||||
try:
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
except ValueError as e:
|
||||
warn(f"Realm dropped: {e}")
|
||||
continue
|
||||
if rp_id in seen_rp_ids:
|
||||
warn(f"Duplicate realm '{rp_id}' dropped (first entry kept)")
|
||||
continue
|
||||
seen_rp_ids.add(rp_id)
|
||||
|
||||
auth_host = realm.auth_host
|
||||
if auth_host:
|
||||
try:
|
||||
hostutil.validate_auth_host(auth_host, rp_id)
|
||||
except ValueError as e:
|
||||
warn(f"Realm '{rp_id}': {e} — auth host ignored")
|
||||
auth_host = None
|
||||
|
||||
origins = []
|
||||
related = list(realm.related_origins or [])
|
||||
for origin in realm.origins or []:
|
||||
hn = hostutil.origin_hostname(origin)
|
||||
if not hn:
|
||||
warn(f"Realm '{rp_id}': invalid origin '{origin}' dropped")
|
||||
continue
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
origins.append(origin)
|
||||
else:
|
||||
warn(
|
||||
f"Realm '{rp_id}': origin '{origin}' is outside the rp-id "
|
||||
"domain — treating it as a related origin; fix the lists "
|
||||
"in the admin interface"
|
||||
)
|
||||
related.append(origin)
|
||||
|
||||
related_ok = []
|
||||
for origin in related:
|
||||
if hostutil.is_wildcard_pattern(origin):
|
||||
warn(
|
||||
f"Realm '{rp_id}': related origin '{origin}' is a "
|
||||
"wildcard — dropped (ROR entries must be individual)"
|
||||
)
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin)
|
||||
if not hn:
|
||||
warn(f"Realm '{rp_id}': invalid related origin '{origin}' dropped")
|
||||
continue
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
warn(
|
||||
f"Realm '{rp_id}': related origin '{origin}' is within the "
|
||||
"rp-id domain — dropped (subdomains need no related entry)"
|
||||
)
|
||||
continue
|
||||
related_ok.append(origin)
|
||||
related_ok = list(dict.fromkeys(related_ok))
|
||||
if len(related_ok) > related_origin_cap:
|
||||
warn(
|
||||
f"Realm '{rp_id}': {len(related_ok)} related origins exceed the "
|
||||
f"maximum of {related_origin_cap} — extras dropped"
|
||||
)
|
||||
del related_ok[related_origin_cap:]
|
||||
|
||||
realms.append(
|
||||
RealmConfig(
|
||||
rp_id=rp_id,
|
||||
rp_name=realm.rp_name,
|
||||
auth_host=auth_host,
|
||||
origins=list(dict.fromkeys(origins)) or None,
|
||||
related_origins=related_ok or None,
|
||||
)
|
||||
)
|
||||
|
||||
if not realms:
|
||||
raise ValueError("No servable realm in the stored configuration")
|
||||
|
||||
# Cross-realm collisions: keep the first configured claimant, drop the
|
||||
# rest with a warning so dispatch stays deterministic.
|
||||
rp_ids = {r.rp_id for r in realms}
|
||||
seen_auth_hosts: dict[str, str] = {}
|
||||
for realm in realms:
|
||||
if not realm.auth_host:
|
||||
continue
|
||||
hn = hostutil.normalize_host(hostutil.auth_host_netloc(realm.auth_host))
|
||||
if hn in rp_ids:
|
||||
warn(
|
||||
f"Realm '{realm.rp_id}': auth host '{hn}' collides with an "
|
||||
"rp-id — auth host ignored"
|
||||
)
|
||||
realm.auth_host = None
|
||||
elif hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Realm '{realm.rp_id}': auth host '{hn}' is also used by "
|
||||
f"'{seen_auth_hosts[hn]}' — auth host ignored"
|
||||
)
|
||||
realm.auth_host = None
|
||||
else:
|
||||
seen_auth_hosts[hn] = realm.rp_id
|
||||
|
||||
seen_related: dict[str, str] = {}
|
||||
for realm in realms:
|
||||
keep = []
|
||||
for origin in realm.related_origins or []:
|
||||
hn = hostutil.origin_hostname(origin)
|
||||
if hn in rp_ids:
|
||||
warn(
|
||||
f"Realm '{realm.rp_id}': related origin '{origin}' "
|
||||
"collides with an rp-id — dropped"
|
||||
)
|
||||
elif other := next(
|
||||
(
|
||||
o
|
||||
for o in rp_ids
|
||||
if o != realm.rp_id and hostutil.is_subdomain(hn, o)
|
||||
),
|
||||
None,
|
||||
):
|
||||
warn(
|
||||
f"Realm '{realm.rp_id}': related origin '{origin}' falls "
|
||||
f"inside realm '{other}' — dropped"
|
||||
)
|
||||
elif hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Realm '{realm.rp_id}': related origin '{origin}' is the "
|
||||
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
|
||||
)
|
||||
elif hn in seen_related:
|
||||
warn(
|
||||
f"Realm '{realm.rp_id}': related origin '{origin}' is also "
|
||||
f"used by '{seen_related[hn]}' — dropped (first realm wins)"
|
||||
)
|
||||
else:
|
||||
seen_related[hn] = realm.rp_id
|
||||
keep.append(origin)
|
||||
realm.related_origins = keep or None
|
||||
|
||||
return Config(realms=realms, listen=config.listen), warnings
|
||||
|
||||
|
||||
def _derive_site(
|
||||
realm: RealmConfig, *, listen_port: int | None, vite_url: str | None
|
||||
) -> tuple[str, str]:
|
||||
"""Compute a realm's site_url and site_path.
|
||||
|
||||
Priority: auth_host > origins[0] > PASKIA_VITE_URL (localhost realm
|
||||
only) > http://localhost:port (localhost realm) > https://rp-id.
|
||||
"""
|
||||
if realm.auth_host:
|
||||
return realm.auth_host, "/"
|
||||
if realm.origins:
|
||||
for origin in realm.origins:
|
||||
if not hostutil.is_wildcard_pattern(origin):
|
||||
return origin, "/auth/"
|
||||
if realm.rp_id == "localhost":
|
||||
if vite_url:
|
||||
return vite_url.rstrip("/"), "/auth/"
|
||||
if listen_port:
|
||||
return f"http://localhost:{listen_port}", "/auth/"
|
||||
return f"https://{realm.rp_id}", "/auth/"
|
||||
|
||||
|
||||
_registry: RealmRegistry | None = None
|
||||
_listen: list[str] | None = None
|
||||
|
||||
|
||||
def configure(*, listen: list[str] | None = None) -> None:
|
||||
"""Record process-global serve parameters for site URL derivation."""
|
||||
global _listen
|
||||
_listen = listen
|
||||
|
||||
|
||||
def build(config: Config) -> RealmRegistry:
|
||||
"""Build a registry from a stored configuration.
|
||||
|
||||
The config is sanitized best-effort (serving must not fail on stored
|
||||
config problems — the admin UI fixes them on a running server);
|
||||
warnings are logged and exposed on the registry.
|
||||
"""
|
||||
config, warnings = sanitize_config(config)
|
||||
validate_config(config) # sanitize guarantees this; a raise means a bug
|
||||
endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {})
|
||||
vite_url = os.environ.get("PASKIA_VITE_URL")
|
||||
realms = [
|
||||
Realm(
|
||||
rc,
|
||||
*_derive_site(rc, listen_port=endpoint.get("port"), vite_url=vite_url),
|
||||
)
|
||||
for rc in config.realms
|
||||
]
|
||||
registry = RealmRegistry(realms)
|
||||
registry.warnings = warnings
|
||||
for warning in warnings:
|
||||
logger.warning("Config: %s", warning)
|
||||
return registry
|
||||
|
||||
|
||||
def init_registry(config: Config) -> RealmRegistry:
|
||||
"""Build and install the global registry from a combined configuration."""
|
||||
global _registry
|
||||
_registry = build(config)
|
||||
return _registry
|
||||
|
||||
|
||||
def registry() -> RealmRegistry:
|
||||
"""Return the global registry (must be initialized)."""
|
||||
if _registry is None:
|
||||
raise RuntimeError("Realm registry is not initialized")
|
||||
return _registry
|
||||
|
||||
|
||||
_current_realm: contextvars.ContextVar[Realm | None] = contextvars.ContextVar(
|
||||
"paskia_current_realm", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_current_realm(realm: Realm | None) -> contextvars.Token:
|
||||
return _current_realm.set(realm)
|
||||
|
||||
|
||||
def reset_current_realm(token: contextvars.Token) -> None:
|
||||
_current_realm.reset(token)
|
||||
|
||||
|
||||
def current_realm() -> Realm:
|
||||
"""Return the request's realm, or the default realm without request context."""
|
||||
realm = _current_realm.get()
|
||||
if realm is not None:
|
||||
return realm
|
||||
return registry().default
|
||||
@@ -39,7 +39,7 @@ class RemoteAuthRequest:
|
||||
host: str # The host where the session should be created
|
||||
ip: str # IP of the requesting device
|
||||
user_agent: str # User agent of the requesting device
|
||||
rp_id: str # Realm of the requesting device (session/exchange codes are stamped with it)
|
||||
rp_id: str # Domain of the requesting device (session/exchange codes are stamped with it)
|
||||
action: str = "login" # "login" or "register"
|
||||
locked: bool = False # True once the authenticating device has entered the code
|
||||
# Callback to notify the requesting device when auth completes
|
||||
|
||||
+13
-10
@@ -12,7 +12,7 @@ from uuid import UUID
|
||||
import msgspec
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Credential, Org, Permission, Role, User
|
||||
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
||||
from paskia.util import useragent
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -161,10 +161,10 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||
|
||||
|
||||
class ApiSettings(msgspec.Struct):
|
||||
"""Settings response struct (per the realm the request was dispatched to).
|
||||
"""Settings response struct (per the domain the request was dispatched to).
|
||||
|
||||
auth_host is the realm's effective auth host (its own, or the shared
|
||||
fallback of another realm); own_auth_host is set only when this realm
|
||||
auth_host is the domain's effective auth host (its own, or the shared
|
||||
fallback of another domain); own_auth_host is set only when this domain
|
||||
has its own dedicated auth host.
|
||||
"""
|
||||
|
||||
@@ -178,18 +178,21 @@ class ApiSettings(msgspec.Struct):
|
||||
version: str
|
||||
|
||||
|
||||
class ApiRealm(msgspec.Struct):
|
||||
"""Realm entry in the admin realm list response."""
|
||||
class ApiDomain(msgspec.Struct):
|
||||
"""Domain entry in the admin domain list response.
|
||||
|
||||
origins/related mirror the stored configuration: objects keyed by host
|
||||
or wildcard pattern (https:// omitted), values True or an object with
|
||||
extra properties (auth_host).
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str
|
||||
auth_host: str | None
|
||||
origins: list[str]
|
||||
related_origins: list[str]
|
||||
origins: dict[str, bool | OriginEntry]
|
||||
related: dict[str, bool]
|
||||
site_url: str
|
||||
auth_site_url: str
|
||||
effective_auth_host: str | None
|
||||
is_default: bool
|
||||
|
||||
|
||||
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
@@ -10,7 +10,7 @@ from uuid import UUID
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from paskia.db.paths import users_root_path
|
||||
from paskia.realms import current_realm
|
||||
from paskia.domains import current_domain
|
||||
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
@@ -46,7 +46,7 @@ def avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the absolute public avatar URL for a user, or None."""
|
||||
if not avatar_path(user_uuid).is_file():
|
||||
return None
|
||||
return current_realm().api_url(f"user/{user_uuid}/profile.webp")
|
||||
return current_domain().api_url(f"user/{user_uuid}/profile.webp")
|
||||
|
||||
|
||||
def current_avatar_url(user_uuid: UUID) -> str | None:
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
OIDC JWT utilities for signing ID tokens and serving JWKS.
|
||||
|
||||
Each realm is an independent OIDC provider with its own signing key;
|
||||
Each domain is an independent OIDC provider with its own signing key;
|
||||
keys are cached per rp-id.
|
||||
"""
|
||||
|
||||
@@ -21,16 +21,16 @@ from paskia.util.crypto import (
|
||||
secret_key,
|
||||
)
|
||||
|
||||
# JWT signing keys (loaded on first use), keyed by realm rp-id
|
||||
# JWT signing keys (loaded on first use), keyed by domain rp-id
|
||||
_keys: dict[str, tuple[object, object, str]] = {}
|
||||
|
||||
|
||||
def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
|
||||
"""Load a realm's Ed25519 key or generate and store a new one."""
|
||||
"""Load a domain's Ed25519 key or generate and store a new one."""
|
||||
data = db.data()
|
||||
provider = data.oidc.get(rp_id)
|
||||
if provider is None:
|
||||
raise RuntimeError(f"No OIDC provider for realm {rp_id}")
|
||||
raise RuntimeError(f"No OIDC provider for domain {rp_id}")
|
||||
store = data._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
@@ -49,14 +49,14 @@ def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
|
||||
|
||||
|
||||
def _ensure_key(rp_id: str) -> tuple[object, object, str]:
|
||||
"""Ensure a realm's key is loaded and return (private, public, kid)."""
|
||||
"""Ensure a domain's key is loaded and return (private, public, kid)."""
|
||||
if rp_id not in _keys:
|
||||
_keys[rp_id] = _load_or_generate_key(rp_id)
|
||||
return _keys[rp_id]
|
||||
|
||||
|
||||
def clear_key(rp_id: str) -> None:
|
||||
"""Drop a realm's cached key (realm deleted or key rotated)."""
|
||||
"""Drop a domain's cached key (domain deleted or key rotated)."""
|
||||
_keys.pop(rp_id, None)
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ def create_id_token(
|
||||
"""Create a signed ID token (JWT).
|
||||
|
||||
Args:
|
||||
rp_id: Realm whose signing key to use
|
||||
rp_id: Domain whose signing key to use
|
||||
issuer: Token issuer (site URL)
|
||||
subject: User UUID (sub claim)
|
||||
audience: Client ID (aud claim)
|
||||
@@ -154,7 +154,7 @@ def create_access_token(
|
||||
"""Create a signed access token (JWT) for userinfo endpoint.
|
||||
|
||||
Args:
|
||||
rp_id: Realm whose signing key to use
|
||||
rp_id: Domain whose signing key to use
|
||||
issuer: Token issuer (site URL)
|
||||
subject: User UUID
|
||||
audience: Client ID
|
||||
@@ -183,7 +183,7 @@ def decode_access_token(
|
||||
"""Decode and verify an access token.
|
||||
|
||||
Args:
|
||||
rp_id: Realm whose key to verify with
|
||||
rp_id: Domain whose key to verify with
|
||||
token: JWT string
|
||||
issuer: Expected issuer
|
||||
audience: Optional expected audience (client_id). If provided, aud claim must match.
|
||||
@@ -226,7 +226,7 @@ def create_logout_token(
|
||||
either sid (session) or sub (user), or both.
|
||||
|
||||
Args:
|
||||
rp_id: Realm whose signing key to use
|
||||
rp_id: Domain whose signing key to use
|
||||
issuer: Token issuer (site URL)
|
||||
audience: Client ID (aud claim)
|
||||
sid: Session ID (base64url-encoded)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Runtime serve configuration (process-global parameters only).
|
||||
|
||||
Realm configuration lives in the database (``Config.realms``); the
|
||||
Domain configuration lives in the database (``Config.domains``); the
|
||||
``PASKIA_CONFIG`` environment variable only carries the effective listen
|
||||
endpoints so that child processes (uvicorn reload / workers) can derive
|
||||
site URLs the same way the parent did.
|
||||
|
||||
+23
-24
@@ -14,7 +14,9 @@ from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.realms import RealmRegistry
|
||||
from paskia.domains import DomainRegistry
|
||||
|
||||
from paskia.domains import origin_url
|
||||
|
||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
@@ -49,16 +51,16 @@ def bottom() -> str:
|
||||
|
||||
|
||||
def print_startup_config(
|
||||
registry: RealmRegistry, listen: list[str] | None = None
|
||||
registry: DomainRegistry, listen: list[str] | None = None
|
||||
) -> None:
|
||||
"""Print server configuration on startup (one section per realm)."""
|
||||
"""Print server configuration on startup (one section per domain)."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
y = YELLOW # Bright golden yellow for main body
|
||||
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||
w = BRIGHT_WHITE # Bold white for URL
|
||||
r = RESET
|
||||
|
||||
default = registry.default
|
||||
domains = sorted(registry.domains, key=lambda d: d.rp_id)
|
||||
|
||||
lines = [top()]
|
||||
lines.append(line(f" {b}▄▄▄▄▄{r}"))
|
||||
@@ -67,8 +69,8 @@ def print_startup_config(
|
||||
lines.append(
|
||||
line(
|
||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
||||
+ default.site_url
|
||||
+ default.site_path
|
||||
+ domains[0].site_url
|
||||
+ domains[0].site_path
|
||||
+ r
|
||||
)
|
||||
)
|
||||
@@ -86,24 +88,21 @@ def print_startup_config(
|
||||
parts = [format_endpoint(ep) for ep in endpoints]
|
||||
lines.append(line(f"Backend: {' '.join(parts)}"))
|
||||
|
||||
realms = registry.realms
|
||||
for realm in realms:
|
||||
# Realm line (omit name if same as id); mark the default realm
|
||||
rp_name = realm.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != realm.rp_id else ""
|
||||
header = "Realm: " if len(realms) > 1 else "Relying Party: "
|
||||
lines.append(line(f"{header}{realm.rp_id}{suffix}"))
|
||||
if len(realms) > 1:
|
||||
lines.append(line(f" URL: {realm.site_url}{realm.site_path}"))
|
||||
if realm.config.auth_host:
|
||||
lines.append(line(f" Auth Host: {realm.config.auth_host}"))
|
||||
if realm.config.origins:
|
||||
for origin in sorted(realm.config.origins):
|
||||
lines.append(line(f" Origin: {origin}"))
|
||||
else:
|
||||
lines.append(line(f" Origin: {realm.rp_id} and subdomains"))
|
||||
for origin in sorted(realm.config.related_origins or []):
|
||||
lines.append(line(f" Related: {origin}"))
|
||||
for domain in domains:
|
||||
# Domain line (omit name if same as id)
|
||||
rp_name = domain.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
|
||||
header = "Domain: " if len(domains) > 1 else "Relying Party: "
|
||||
lines.append(line(f"{header}{domain.rp_id}{suffix}"))
|
||||
if len(domains) > 1:
|
||||
lines.append(line(f" URL: {domain.site_url}{domain.site_path}"))
|
||||
for key, props in sorted(domain.config.origins.items()):
|
||||
marker = " (auth host)" if props is not True and props.auth_host else ""
|
||||
lines.append(line(f" Origin: {origin_url(key)}{marker}"))
|
||||
if not domain.config.origins:
|
||||
lines.append(line(f" Origin: {domain.rp_id} and subdomains"))
|
||||
for key in sorted(domain.config.related):
|
||||
lines.append(line(f" Related: {origin_url(key)}"))
|
||||
|
||||
lines.append(bottom())
|
||||
stderr.write("".join(lines))
|
||||
|
||||
Reference in New Issue
Block a user