diff --git a/paskia/authcode.py b/paskia/authcode.py index 2031a4e..4d497a3 100644 --- a/paskia/authcode.py +++ b/paskia/authcode.py @@ -24,21 +24,31 @@ 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 + endpoint (dispatched by Host) must match. """ session_key: str created: datetime redirect_uri: str scope: str + rp_id: str nonce: str | None = None code_challenge: str | None = None class CookieCode(msgspec.Struct): - """A cookie exchange code for setting session cookie after WebSocket auth.""" + """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 + 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. + """ session_key: str created: datetime + rp_id: str # Separate stores for each code type diff --git a/paskia/db/operations.py b/paskia/db/operations.py index a0b363e..e5ac25b 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -464,6 +464,7 @@ def update_session( ip: str | None = None, user_agent: str | None = None, validated: datetime | None = None, + issuer: str | None = None, *, ctx: SessionContext | None = None, ) -> None: @@ -480,6 +481,8 @@ def update_session( s.user_agent = user_agent if validated is not None: s.validated = validated + if issuer is not None: + s.issuer = issuer def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None: @@ -576,6 +579,7 @@ def login( ip: str, user_agent: str, duration: timedelta = SESSION_LIFETIME, + rp_id: str | None = None, ) -> str: """Update user/credential on login and create session in a single transaction. @@ -583,7 +587,7 @@ def login( - user.last_seen, user.visits - credential.sign_count, credential.last_used Creates: - - new session + - new session (stamped with rp_id when provided) Returns the generated session token. """ @@ -606,6 +610,7 @@ def login( ip=ip, user_agent=user_agent, validated=now, + rp_id=rp_id, ) user_str = str(user_uuid) with _transaction("login", user=user_str): @@ -679,6 +684,7 @@ def create_credential_session( ip=ip, user_agent=user_agent, validated=now, + rp_id=credential.rp_id, ) user_str = str(user_uuid) with _transaction("create_credential_session", user=user_str): diff --git a/paskia/fastapi/admin/adminapp.py b/paskia/fastapi/admin/adminapp.py index c3dbe4a..eed8fde 100644 --- a/paskia/fastapi/admin/adminapp.py +++ b/paskia/fastapi/admin/adminapp.py @@ -8,14 +8,15 @@ from paskia.fastapi.admin import ( oidc_clients, orgs, permissions, + realms as realms_admin, roles, - server_config, users, ) 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.util import ( avatar, permutil, @@ -38,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("/server-config", server_config.app) +app.mount("/realms", realms_admin.app) def master_admin(ctx) -> bool: @@ -94,10 +95,11 @@ 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) + # OIDC Clients (master admin only) — the current realm's provider oidc_clients_dict = {} if master_admin(ctx): - clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid) + provider = db.data().oidc_for(current_realm().rp_id) + clients = sorted(provider.clients.values(), key=lambda c: c.uuid) if provider else [] sessions = db.data().sessions # Count active sessions per client client_session_counts = {} diff --git a/paskia/fastapi/admin/oidc_clients.py b/paskia/fastapi/admin/oidc_clients.py index 2061520..2430729 100644 --- a/paskia/fastapi/admin/oidc_clients.py +++ b/paskia/fastapi/admin/oidc_clients.py @@ -8,6 +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.util import permutil app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) @@ -83,7 +84,7 @@ async def admin_create_oidc_client( ) client.uuid = client_uuid - db.create_oid_client(client, ctx=ctx) + db.create_oid_client(current_realm().rp_id, client, ctx=ctx) return {"status": "ok", "client_id": str(client.uuid)} @@ -152,6 +153,7 @@ async def admin_update_oidc_client( try: db.update_oid_client( + current_realm().rp_id, client_uuid, name=name, redirect_uris=redirect_uris, @@ -201,7 +203,7 @@ async def admin_reset_oidc_client_secret( raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)") try: - db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx) + db.reset_oid_client_secret(current_realm().rp_id, client_uuid, secret_hash, ctx=ctx) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -230,7 +232,7 @@ async def admin_delete_oidc_client( ) try: - db.delete_oid_client(client_uuid, ctx=ctx) + db.delete_oid_client(current_realm().rp_id, client_uuid, ctx=ctx) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/paskia/fastapi/admin/permissions.py b/paskia/fastapi/admin/permissions.py index 434b34e..399978d 100644 --- a/paskia/fastapi/admin/permissions.py +++ b/paskia/fastapi/admin/permissions.py @@ -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.globals import passkey +from paskia.realms import registry from paskia.util import hostutil, permutil, querysafe app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) @@ -16,23 +16,32 @@ install_error_handlers(app) def _validate_permission_domain(domain: str | None) -> None: - """Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID.""" + """Validate that domain is a configured realm 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 + groups claim). + """ if domain is None: return # Allow OIDC client UUIDs (used for groups claim) try: client_uuid = UUID(domain) - if client_uuid in db.data().oidc.clients: + if any( + client_uuid in provider.clients + for provider in db.data().oidc.values() + ): return except ValueError: pass - rp_id = passkey.rp_id - if domain == rp_id or domain.endswith(f".{rp_id}"): + reg = registry() + if reg.resolve(domain) is not None: return raise ValueError( - f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID" + f"Domain '{domain}' must belong to a configured realm " + "or be an OIDC client UUID" ) diff --git a/paskia/fastapi/admin/realms.py b/paskia/fastapi/admin/realms.py new file mode 100644 index 0000000..78ce21b --- /dev/null +++ b/paskia/fastapi/admin/realms.py @@ -0,0 +1,154 @@ +"""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, and origins +(including Related Origin Requests origins on unrelated domains). 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.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_realm_fields( + rp_id: str, auth_host: str | None, origins: list[str] | None +) -> tuple[str | None, list[str] | None]: + """Normalize and validate auth_host/origins for a realm (raises ValueError).""" + normalized_origins = [ + hostutil.normalize_origin(o.strip()) for o in origins or [] if o.strip() + ] or None + if auth_host: + hostutil.validate_auth_host(auth_host, rp_id) + return hostutil.normalize_auth_host_and_origins(auth_host, normalized_origins) + + +def _rebuild_registry() -> None: + """Rebuild the runtime realm registry from the stored configuration.""" + realms.init_registry(db.data().config) + + +@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 = _normalize_realm_fields( + rp_id, auth_host, payload.get("origins") or [] + ) + + config = db.data().config + new_realm = RealmConfig( + rp_id=rp_id, rp_name=rp_name, auth_host=auth_host, origins=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 and 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 + 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 = _normalize_realm_fields( + rp_id, auth_host, payload.get("origins") or [] + ) + + updated = RealmConfig( + rp_id=rp_id, rp_name=rp_name, auth_host=auth_host, origins=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) + + db.update_realm(rp_id, rp_name=rp_name, auth_host=auth_host, origins=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" + ) + db.delete_realm(rp_id, ctx=ctx) + _rebuild_registry() + oidjwt.clear_key(rp_id) + return {"status": "ok"} diff --git a/paskia/fastapi/admin/server_config.py b/paskia/fastapi/admin/server_config.py deleted file mode 100644 index 79007a3..0000000 --- a/paskia/fastapi/admin/server_config.py +++ /dev/null @@ -1,85 +0,0 @@ -from fastapi import Body, FastAPI, HTTPException, Request - -from paskia import db -from paskia.db.structs import Config -from paskia.fastapi import authz -from paskia.fastapi.admin.errors import install_error_handlers -from paskia.fastapi.session import AUTH_COOKIE -from paskia.globals import passkey -from paskia.sansio import Passkey -from paskia.util import hostutil -from paskia.util.runtime import update_runtime_config - -app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) - -install_error_handlers(app) - - -@app.get("/") -async def admin_get_server_config( - request: Request, - auth=AUTH_COOKIE, -): - """Get current server configuration (master admin only).""" - await authz.verify(auth, ["auth:admin"], host=request.headers.get("host")) - pk = passkey - config = db.data().config - return { - "rp_name": pk.rp_name, - "auth_host": config.auth_host or "", - "origins": list(pk.allowed_origins) if pk.allowed_origins else [], - } - - -@app.patch("/") -async def admin_update_server_config( - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - """Update server configuration (master admin only). - - Updates rp_name, auth_host, and origins in both the runtime Passkey - instance and the persisted database config. - """ - await authz.verify( - auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m" - ) - config = db.data().config - pk = passkey - - rp_name = payload.get("rp_name", "").strip() or None - auth_host = payload.get("auth_host", "").strip() or None - raw_origins = payload.get("origins", []) - origins = [ - hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip() - ] or None - - # Normalize auth_host and origins (matching CLI startup behavior) - if auth_host: - try: - hostutil.validate_auth_host(auth_host, config.rp_id) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins) - - # Validate origins against the current rp_id - if origins: - for o in origins: - Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises - - # Update runtime Passkey instance - pk.rp_name = rp_name or config.rp_id - pk.allowed_origins = set(origins) if origins else None - - # Persist to database - new_config = Config( - rp_id=config.rp_id, - rp_name=rp_name, - auth_host=auth_host, - origins=origins, - listen=config.listen, - ) - db.update_config(new_config) - update_runtime_config(new_config) - return {"status": "ok"} diff --git a/paskia/fastapi/admin/users.py b/paskia/fastapi/admin/users.py index 7568146..06c2359 100644 --- a/paskia/fastapi/admin/users.py +++ b/paskia/fastapi/admin/users.py @@ -9,6 +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.util import avatar, hostutil, permutil from paskia.util.apistructs import ( ApiAaguidInfo, @@ -122,7 +123,7 @@ async def admin_create_user_registration_link( token_type=token_type, ctx=ctx, ) - url = hostutil.reset_link_url(token) + url = current_realm().reset_link_url(token) return MsgspecResponse( ApiCreateLinkResponse( url=url, diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 9e3c3ee..5b6a6a0 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -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.globals import passkey as global_passkey +from paskia.realms import current_realm, 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(): - pk = global_passkey - base_path = hostutil.ui_base_path() + realm = current_realm() return MsgspecResponse( ApiSettings( - rp_id=pk.rp_id, - rp_name=pk.rp_name, - ui_base_path=base_path, - auth_host=hostutil.dedicated_auth_host(), - auth_site_url=hostutil.auth_site_url(), + 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, session_cookie=AUTH_COOKIE_NAME, version=__version__, ), @@ -407,6 +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") secret = a.session_key diff --git a/paskia/fastapi/auth_host.py b/paskia/fastapi/auth_host.py index 1a8da01..6f62425 100644 --- a/paskia/fastapi/auth_host.py +++ b/paskia/fastapi/auth_host.py @@ -3,6 +3,7 @@ from fastapi import Request, Response from fastapi.responses import RedirectResponse +from paskia.realms import current_realm from paskia.util import hostutil, passphrase @@ -72,8 +73,14 @@ 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.""" - cfg = hostutil.dedicated_auth_host() + """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 + relying on a shared (fallback) auth host use it for WS/restricted + API calls, not for redirects. + """ + cfg = current_realm().own_auth_host if not cfg: return await call_next(request) diff --git a/paskia/fastapi/dispatch.py b/paskia/fastapi/dispatch.py new file mode 100644 index 0000000..0a384ee --- /dev/null +++ b/paskia/fastapi/dispatch.py @@ -0,0 +1,94 @@ +"""ASGI dispatch middleware: resolve the request Host to a realm. + +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, +site URLs). + +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 +and endpoint-side origin validation decides. +""" + +from fastapi.responses import PlainTextResponse + +from paskia import realms +from paskia.util import hostutil + +_WS_CLOSE_POLICY_VIOLATION = 1008 + + +def _header(scope: dict, name: str) -> str | None: + """Return the first value of a lowercased ASGI header name.""" + key = name.encode() + for header, value in scope.get("headers", []): + if header == key: + return value.decode() + return None + + +class DispatchMiddleware: + """Pure ASGI middleware dispatching each connection to its realm.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + await self._http(scope, receive, send) + elif scope["type"] == "websocket": + await self._websocket(scope, receive, send) + else: + await self.app(scope, receive, send) + + async def _http(self, scope, receive, send): + realm = realms.registry().resolve(_header(scope, "host")) + if realm is None: + response = PlainTextResponse("Unknown host", status_code=421) + await response(scope, receive, send) + return + await self._dispatch(scope, receive, send, realm) + + async def _websocket(self, scope, receive, send): + registry = realms.registry() + host = _header(scope, "host") + host_realm = registry.resolve(host) + if host_realm is None: + await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION}) + return + + realm = host_realm + 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) + if not effective or hostutil.normalize_host(host) != hostutil.normalize_host( + effective + ): + await send( + {"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION} + ) + return + realm = origin_realm + await self._dispatch(scope, receive, send, realm) + + async def _dispatch(self, scope, receive, send, realm: realms.Realm): + scope.setdefault("state", {})["realm"] = realm + token = realms.set_current_realm(realm) + try: + await self.app(scope, receive, send) + finally: + realms.reset_current_realm(token) diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 55494c1..367c3d2 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -1,27 +1,26 @@ import asyncio import logging -import os from contextlib import asynccontextmanager from pathlib import Path -import msgspec 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, remoteauth +from paskia import authcode, db, realms, remoteauth from paskia.bootstrap import bootstrap_if_needed from paskia.db.background import start_background, stop_background from paskia.db.lifecycle import kanta from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi.admin.adminapp import adminapp +from paskia.fastapi.dispatch import DispatchMiddleware # Import frontend instance from paskia.fastapi.front import frontend from paskia.fastapi.session import AUTH_COOKIE -from paskia.util import hostutil, passphrase, vitedev +from paskia.util import passphrase, vitedev from paskia.util.constants import DEVMODE -from paskia.util.runtime import RuntimeConfig +from paskia.util.runtime import serve_config # Configure custom logging configure_kanta_logging() @@ -32,19 +31,22 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples" @asynccontextmanager async def lifespan(app: FastAPI): # pragma: no cover - startup path - """Application lifespan to ensure globals (DB, passkey) are initialized in each process. + """Application lifespan: open the combined database and build the realm registry. - Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) - so that uvicorn reload / multiprocess workers inherit the settings. - All keys are guaranteed to exist; values are already normalized by __main__.py. + 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. """ - runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig) + cfg = serve_config() + realms.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) await remoteauth.init() await authcode.start() except ValueError as e: @@ -52,11 +54,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path # Re-raise to fail fast raise - # Bootstrap and persist config now that the full DB is loaded - await bootstrap_if_needed(config=runtime.config) - if runtime.save: - db.update_config(runtime.config) - + await bootstrap_if_needed() await frontend.load() await start_background() yield @@ -79,6 +77,10 @@ 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. +app.add_middleware(DispatchMiddleware) + app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) @@ -124,6 +126,20 @@ async def openid_configuration(request: Request): } +@app.get("/.well-known/webauthn") +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 + (non-subdomain) origins that may assert this rp-id. 404 when the + realm has no related origins. + """ + related = request.state.realm.related_origins + if not related: + raise HTTPException(status_code=404) + return {"origins": related} + + @app.get("/auth/restricted/iframe") @app.get("/auth/restricted/oidc") async def restricted_view(request: Request): @@ -149,7 +165,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): @app.get("/admin", include_in_schema=False) @app.get("/auth/admin", include_in_schema=False) async def admin_root_redirect(): - return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307) + return RedirectResponse(f"{realms.current_realm().ui_base_path}admin/", status_code=307) @app.get("/admin/", include_in_schema=False) diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 12c8303..e600e23 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -21,7 +21,8 @@ from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer from paskia import authcode, db -from paskia.db.structs import Session +from paskia.db.structs import OIDC, Session +from paskia.realms import current_realm from paskia.util import avatar, oidjwt from paskia.util.crypto import hash_secret @@ -30,10 +31,18 @@ _logger = logging.getLogger(__name__) 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 provider + + @app.get("/keys") async def keys(): """JSON Web Key Set for token verification.""" - return oidjwt.get_jwks() + return oidjwt.get_jwks(current_realm().rp_id) def _oidc_session_by_token( @@ -148,7 +157,7 @@ async def token( except ValueError: return JSONResponse({"error": "invalid_client"}, status_code=401) - client = db.data().oidc.clients.get(client_uuid) + client = _provider().clients.get(client_uuid) if not client or not client.verify_secret(client_secret): return JSONResponse({"error": "invalid_client"}, status_code=401) @@ -188,6 +197,16 @@ 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: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Code was issued for a different realm", + }, + status_code=400, + ) + # Look up the OIDC session by token session = _oidc_session_by_token(oidc_code.session_key, client.uuid) if not session: @@ -297,6 +316,7 @@ async def _handle_refresh_token( db.update_session( session.key, validated=now, + issuer=_get_issuer(request), ) _logger.info("OIDC session refreshed: %s", session.key) @@ -327,6 +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 issuer = _get_issuer(request) # Get user's permissions scoped to this OIDC client (domain == client UUID) @@ -353,6 +374,7 @@ def _build_token_response( # Create ID token id_token = oidjwt.create_id_token( + rp_id, issuer=issuer, subject=user.uuid, audience=client_id, @@ -368,6 +390,7 @@ def _build_token_response( # Create access token access_token = oidjwt.create_access_token( + rp_id, issuer=issuer, subject=user.uuid, audience=client_id, @@ -401,8 +424,9 @@ async def userinfo( if not credentials: raise HTTPException(401, "Bearer token required") + rp_id = current_realm().rp_id issuer = _get_issuer(request) - payload = oidjwt.decode_access_token(credentials.credentials, issuer) + payload = oidjwt.decode_access_token(rp_id, credentials.credentials, issuer) if not payload: raise HTTPException(401, "Invalid or expired token") @@ -416,7 +440,7 @@ async def userinfo( except ValueError: raise HTTPException(401, "Invalid token (invalid aud format)") - if not db.data().oidc.clients.get(client_uuid): + if not _provider().clients.get(client_uuid): raise HTTPException(401, "Invalid token (unknown client)") # Get user @@ -486,8 +510,9 @@ async def backchannel_logout( ) # Decode and verify the logout token + rp_id = current_realm().rp_id issuer = _get_issuer(request) - payload = oidjwt.decode_access_token(logout_token, issuer) + payload = oidjwt.decode_access_token(rp_id, logout_token, issuer) if not payload: return JSONResponse( {"error": "invalid_request", "error_description": "Invalid logout_token"}, @@ -504,7 +529,7 @@ async def backchannel_logout( if aud: try: client_uuid = UUID(aud) - if not db.data().oidc.clients.get(client_uuid): + if not _provider().clients.get(client_uuid): return JSONResponse( { "error": "invalid_request", @@ -556,12 +581,13 @@ async def backchannel_logout( {"error": "invalid_request", "error_description": "Invalid sub claim"}, status_code=400, ) - # Find and delete matching sessions + # Find and delete matching sessions (this realm's OIDC sessions only) sessions_to_delete = [ s for s in db.data().sessions.values() if s.user_uuid == user_uuid and s.client_uuid is not None + and s.rp_id == rp_id and (client_uuid is None or s.client_uuid == client_uuid) ] for session in sessions_to_delete: diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index 33c2f19..a797396 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -22,6 +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.util import pow, useragent # Create a FastAPI subapp for remote auth WebSocket endpoints @@ -94,6 +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, action=action, ) @@ -333,10 +335,13 @@ 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. exchange_code = authcode.store_cookie( CookieCode( session_key=secret, created=datetime.now(UTC), + rp_id=request.rp_id, ) ) @@ -440,11 +445,17 @@ 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 + # 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) 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 + ), "user_agent_pretty": useragent.compact_user_agent( request.user_agent ), diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 24b8521..f696abc 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -20,6 +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.util import avatar, hostutil from paskia.util.apistructs import ApiCreateLinkResponse @@ -291,7 +292,7 @@ async def api_create_link( token_type="device addition", ctx=ctx, ) - url = hostutil.reset_link_url(token) + url = current_realm().reset_link_url(token) return MsgspecResponse( ApiCreateLinkResponse( message="Registration link generated successfully", diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 7fe010a..a164435 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -17,7 +17,7 @@ from paskia.fastapi.wschat import ( register_chat, ) from paskia.fastapi.wsutil import validate_origin, websocket_error_handler -from paskia.globals import passkey +from paskia.realms import current_realm from paskia.util import hostutil, passphrase from paskia.util.crypto import hash_secret @@ -28,6 +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, ) return authcode.store_cookie(cookie_code) @@ -55,10 +56,11 @@ async def websocket_register_add( """ origin = validate_origin(ws) host = hostutil.normalize_host(origin.split("://", 1)[1]) + realm = current_realm() if reset is not None: if not passphrase.is_well_formed(reset): raise ValueError( - f"The reset link for {passkey.rp_name} is invalid or has expired" + f"The reset link for {realm.rp_name} is invalid or has expired" ) s = get_reset(reset) user_uuid = s.user_uuid @@ -75,7 +77,7 @@ async def websocket_register_add( stripped = name.strip() if stripped: user_name = stripped - credential_ids = user.credential_ids or None + credential_ids = user.credential_ids_for(realm.rp_id) or None # WebAuthn registration credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) @@ -123,6 +125,7 @@ async def websocket_authenticate( ): origin = validate_origin(ws) host = origin.split("://", 1)[1] + realm = current_realm() # OIDC mode: validate client before auth oidc_client = None @@ -133,7 +136,7 @@ async def websocket_authenticate( await ws.send_json({"status": 400, "detail": "Invalid client_id"}) return - oidc_client = db.data().oidc.clients.get(client_uuid) + oidc_client = db.data().oidc_for(realm.rp_id).clients.get(client_uuid) if not oidc_client: await ws.send_json({"status": 400, "detail": "Unknown client_id"}) return @@ -145,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(client_uuid, redirect_uris=[redirect_uri]) + db.update_oid_client(realm.rp_id, client_uuid, redirect_uris=[redirect_uri]) # Reload client to get updated redirect_uris - oidc_client = db.data().oidc.clients.get(client_uuid) + oidc_client = db.data().oidc_for(realm.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 @@ -204,8 +207,6 @@ async def websocket_authenticate( cred, new_sign_count = await authenticate_chat(ws) # Get metadata for session - origin = validate_origin(ws) - host = origin.split("://", 1)[1] normalized_host = hostutil.normalize_host(host) metadata = infodict(ws, "oidc_auth") @@ -223,6 +224,8 @@ async def websocket_authenticate( user_agent=metadata["user_agent"], validated=now, client=oidc_client.uuid, + rp_id=realm.rp_id, + issuer=origin, ) db.oidc_login( session=session, @@ -235,6 +238,7 @@ async def websocket_authenticate( created=now, redirect_uri=redirect_uri, scope=scope, + rp_id=realm.rp_id, nonce=nonce, code_challenge=code_challenge, ) diff --git a/paskia/fastapi/wschat.py b/paskia/fastapi/wschat.py index 1f9ab04..9e3409f 100644 --- a/paskia/fastapi/wschat.py +++ b/paskia/fastapi/wschat.py @@ -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.globals import passkey +from paskia.realms import current_realm, registry from paskia.util import hostutil @@ -23,6 +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 options, challenge = passkey.reg_generate_options( user_id=user_uuid, user_name=user_name, @@ -42,6 +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 origin = validate_origin(ws) options, challenge = passkey.auth_generate_options(credential_ids=credential_ids) await ws.send_json({"optionsJSON": options}) @@ -51,7 +54,7 @@ async def authenticate_chat( ( c for c in db.data().credentials.values() - if c.credential_id == authcred.raw_id + if c.credential_id == authcred.raw_id and c.rp_id == realm.rp_id ), None, ) @@ -77,22 +80,20 @@ async def authenticate_and_login( Args: 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) + session_host: Override host for the new session (defaults to ws origin); + must belong to a configured realm 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() origin = validate_origin(ws) host = origin.split("://", 1)[1] normalized_host = hostutil.normalize_host(host) if not normalized_host: raise ValueError("Host required for session creation") - hostname = normalized_host.split(":")[0] - rp_id = passkey.rp_id - if not (hostname == rp_id or hostname.endswith(f".{rp_id}")): - raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") metadata = infodict(ws, "auth") # Get credential IDs if restricting to a user's credentials @@ -100,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 or None + credential_ids = existing_ctx.user.credential_ids_for(realm.rp_id) or None cred, new_sign_count = await authenticate_chat(ws, credential_ids) @@ -112,6 +113,8 @@ 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") 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"] @@ -125,6 +128,7 @@ async def authenticate_and_login( host=login_host, ip=login_ip, user_agent=login_user_agent, + rp_id=realm.rp_id, ) # Fetch and return the full session context (using the same host the session was created with) diff --git a/paskia/fastapi/wsutil.py b/paskia/fastapi/wsutil.py index d547915..bc5ad6b 100644 --- a/paskia/fastapi/wsutil.py +++ b/paskia/fastapi/wsutil.py @@ -10,7 +10,7 @@ from fastapi import WebSocket, WebSocketDisconnect from webauthn.helpers.exceptions import InvalidAuthenticationResponse from paskia.fastapi import authz -from paskia.globals import passkey +from paskia.realms import current_realm 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 in allowed list + ValueError: If origin header is missing or not allowed in the current realm """ origin = ws.headers.get("origin") if not origin: raise ValueError("Origin header is required for WebSocket connections") - return passkey.validate_origin(origin) + return current_realm().passkey.validate_origin(origin) diff --git a/paskia/globals.py b/paskia/globals.py deleted file mode 100644 index c852698..0000000 --- a/paskia/globals.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Global Passkey instance configured from PASKIA_CONFIG. - -The Passkey instance is created at import time using the runtime configuration -passed via the ``PASKIA_CONFIG`` environment variable. Other runtime setup -(remote auth, auth codes, bootstrap checks) is performed explicitly by the -FastAPI lifespan once the database is open. -""" - -from paskia.sansio import Passkey -from paskia.util import runtime - -runtime = runtime.config() -if runtime is None: - raise RuntimeError("PASKIA_CONFIG must be defined before importing paskia.globals") - -passkey = Passkey( - rp_id=runtime.config.rp_id, - rp_name=runtime.config.rp_name, - origins=runtime.config.origins, -) diff --git a/paskia/remoteauth.py b/paskia/remoteauth.py index 143d004..7dd0d45 100644 --- a/paskia/remoteauth.py +++ b/paskia/remoteauth.py @@ -39,6 +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) 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 @@ -113,6 +114,7 @@ class RemoteAuthManager: host: str, ip: str, user_agent: str, + rp_id: str, action: str = "login", ) -> tuple[str, datetime]: """Create a new remote auth request. @@ -143,6 +145,7 @@ class RemoteAuthManager: host=host, ip=ip, user_agent=user_agent, + rp_id=rp_id, action=action, ) diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py index 5ce0ca0..1d952be 100644 --- a/paskia/util/apistructs.py +++ b/paskia/util/apistructs.py @@ -161,17 +161,37 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True): class ApiSettings(msgspec.Struct): - """Settings response struct.""" + """Settings response struct (per the realm 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 + has its own dedicated auth host. + """ rp_id: str rp_name: str ui_base_path: str auth_host: str | None + own_auth_host: str | None auth_site_url: str session_cookie: str version: str +class ApiRealm(msgspec.Struct): + """Realm entry in the admin realm list response.""" + + rp_id: str + rp_name: str + auth_host: str | None + origins: list[str] + related_origins: list[str] + site_url: str + auth_site_url: str + effective_auth_host: str | None + is_default: bool + + class ApiTokenInfo(msgspec.Struct, omit_defaults=True): """Token info response struct.""" diff --git a/paskia/util/avatar.py b/paskia/util/avatar.py index 4c765d1..de80dca 100644 --- a/paskia/util/avatar.py +++ b/paskia/util/avatar.py @@ -46,7 +46,10 @@ 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 hostutil.api_url(f"user/{user_uuid}/profile.webp") + # Lazy import: paskia.realms pulls in paskia.db, which is circular here. + from paskia.realms import current_realm + + return current_realm().api_url(f"user/{user_uuid}/profile.webp") def current_avatar_url(user_uuid: UUID) -> str | None: