Realm dispatch, per-realm OIDC, realm-scoped credentials and admin realm API

- DispatchMiddleware (outermost app middleware) resolves Host to a realm:
  HTTP 421 for unknown hosts, WS closed pre-accept (1008); cross-realm WS
  only via the origin realm's effective auth host. Current realm exposed
  via request.state.realm and the current_realm() contextvar.
- Credentials and sessions are scoped by realm rp_id: authentication only
  matches credentials of the dispatched realm; sessions record rp_id.
- Auth codes (OIDC and cookie exchange) are stamped with the issuing
  realm and verified at redemption; remote-auth permits mint the exchange
  code for the *requesting* device's realm.
- OIDC provider state (clients, signing keys) is per realm; token,
  userinfo, keys and backchannel-logout endpoints use the dispatched
  realm; refresh re-stamps the session issuer.
- /.well-known/webauthn serves the realm's related origins (ROR).
- Admin /server-config replaced by /realms CRUD (validated cross-realm,
  registry rebuilt on change); permission domains may reference any
  realm's hosts or clients; /settings reports the realm's own vs
  effective auth host.
- paskia.globals and the runtime-backed hostutil helpers are gone.
This commit is contained in:
2026-09-06 04:11:22 +00:00
parent 4591a023dd
commit f44bcc9dea
22 changed files with 448 additions and 178 deletions
+11 -1
View File
@@ -24,21 +24,31 @@ class OIDCCode(msgspec.Struct):
"""An OIDC authorization code pending token exchange. """An OIDC authorization code pending token exchange.
PKCE uses S256 only when provided (verified at 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 session_key: str
created: datetime created: datetime
redirect_uri: str redirect_uri: str
scope: str scope: str
rp_id: str
nonce: str | None = None nonce: str | None = None
code_challenge: str | None = None code_challenge: str | None = None
class CookieCode(msgspec.Struct): 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 session_key: str
created: datetime created: datetime
rp_id: str
# Separate stores for each code type # Separate stores for each code type
+7 -1
View File
@@ -464,6 +464,7 @@ def update_session(
ip: str | None = None, ip: str | None = None,
user_agent: str | None = None, user_agent: str | None = None,
validated: datetime | None = None, validated: datetime | None = None,
issuer: str | None = None,
*, *,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
) -> None: ) -> None:
@@ -480,6 +481,8 @@ def update_session(
s.user_agent = user_agent s.user_agent = user_agent
if validated is not None: if validated is not None:
s.validated = validated s.validated = validated
if issuer is not None:
s.issuer = issuer
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None: def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
@@ -576,6 +579,7 @@ def login(
ip: str, ip: str,
user_agent: str, user_agent: str,
duration: timedelta = SESSION_LIFETIME, duration: timedelta = SESSION_LIFETIME,
rp_id: str | None = None,
) -> str: ) -> str:
"""Update user/credential on login and create session in a single transaction. """Update user/credential on login and create session in a single transaction.
@@ -583,7 +587,7 @@ def login(
- user.last_seen, user.visits - user.last_seen, user.visits
- credential.sign_count, credential.last_used - credential.sign_count, credential.last_used
Creates: Creates:
- new session - new session (stamped with rp_id when provided)
Returns the generated session token. Returns the generated session token.
""" """
@@ -606,6 +610,7 @@ def login(
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
validated=now, validated=now,
rp_id=rp_id,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _transaction("login", user=user_str): with _transaction("login", user=user_str):
@@ -679,6 +684,7 @@ def create_credential_session(
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
validated=now, validated=now,
rp_id=credential.rp_id,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _transaction("create_credential_session", user=user_str): with _transaction("create_credential_session", user=user_str):
+6 -4
View File
@@ -8,14 +8,15 @@ from paskia.fastapi.admin import (
oidc_clients, oidc_clients,
orgs, orgs,
permissions, permissions,
realms as realms_admin,
roles, roles,
server_config,
users, users,
) )
from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.util import ( from paskia.util import (
avatar, avatar,
permutil, permutil,
@@ -38,7 +39,7 @@ app.mount("/orgs", orgs.app)
app.mount("/roles", roles.app) app.mount("/roles", roles.app)
app.mount("/users", users.app) app.mount("/users", users.app)
app.mount("/permissions", permissions.app) app.mount("/permissions", permissions.app)
app.mount("/server-config", server_config.app) app.mount("/realms", realms_admin.app)
def master_admin(ctx) -> bool: 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 = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms} 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 = {} oidc_clients_dict = {}
if master_admin(ctx): 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 sessions = db.data().sessions
# Count active sessions per client # Count active sessions per client
client_session_counts = {} client_session_counts = {}
+5 -3
View File
@@ -8,6 +8,7 @@ from paskia.db.structs import Client
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.util import permutil from paskia.util import permutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) 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 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)} return {"status": "ok", "client_id": str(client.uuid)}
@@ -152,6 +153,7 @@ async def admin_update_oidc_client(
try: try:
db.update_oid_client( db.update_oid_client(
current_realm().rp_id,
client_uuid, client_uuid,
name=name, name=name,
redirect_uris=redirect_uris, 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)") raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try: 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: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -230,7 +232,7 @@ async def admin_delete_oidc_client(
) )
try: 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: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
+15 -6
View File
@@ -7,7 +7,7 @@ from paskia.db import Permission as PermDC
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey from paskia.realms import registry
from paskia.util import hostutil, permutil, querysafe from paskia.util import hostutil, permutil, querysafe
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) 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: 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: if domain is None:
return return
# Allow OIDC client UUIDs (used for groups claim) # Allow OIDC client UUIDs (used for groups claim)
try: try:
client_uuid = UUID(domain) 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 return
except ValueError: except ValueError:
pass pass
rp_id = passkey.rp_id reg = registry()
if domain == rp_id or domain.endswith(f".{rp_id}"): if reg.resolve(domain) is not None:
return return
raise ValueError( 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"
) )
+154
View File
@@ -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"}
-85
View File
@@ -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"}
+2 -1
View File
@@ -9,6 +9,7 @@ from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.util import avatar, hostutil, permutil from paskia.util import avatar, hostutil, permutil
from paskia.util.apistructs import ( from paskia.util.apistructs import (
ApiAaguidInfo, ApiAaguidInfo,
@@ -122,7 +123,7 @@ async def admin_create_user_registration_link(
token_type=token_type, token_type=token_type,
ctx=ctx, ctx=ctx,
) )
url = hostutil.reset_link_url(token) url = current_realm().reset_link_url(token)
return MsgspecResponse( return MsgspecResponse(
ApiCreateLinkResponse( ApiCreateLinkResponse(
url=url, url=url,
+10 -8
View File
@@ -20,7 +20,7 @@ from paskia.authsession import EXPIRES, get_reset, session_ctx
from paskia.fastapi import authz, session, user from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip 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 import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import ( from paskia.util.apistructs import (
ApiCheckUserResponse, ApiCheckUserResponse,
@@ -300,15 +300,15 @@ async def forward_authentication(
@app.get("/settings") @app.get("/settings")
async def get_settings(): async def get_settings():
pk = global_passkey realm = current_realm()
base_path = hostutil.ui_base_path()
return MsgspecResponse( return MsgspecResponse(
ApiSettings( ApiSettings(
rp_id=pk.rp_id, rp_id=realm.rp_id,
rp_name=pk.rp_name, rp_name=realm.rp_name,
ui_base_path=base_path, ui_base_path=realm.ui_base_path,
auth_host=hostutil.dedicated_auth_host(), auth_host=registry().effective_auth_host(realm),
auth_site_url=hostutil.auth_site_url(), own_auth_host=realm.own_auth_host,
auth_site_url=realm.auth_site_url,
session_cookie=AUTH_COOKIE_NAME, session_cookie=AUTH_COOKIE_NAME,
version=__version__, version=__version__,
), ),
@@ -407,6 +407,8 @@ async def api_set_session(
a = authcode.consume_cookie(auth.credentials) a = authcode.consume_cookie(auth.credentials)
if not a: if not a:
raise HTTPException(401, "Code expired or already used") 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 secret = a.session_key
+9 -2
View File
@@ -3,6 +3,7 @@
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from paskia.realms import current_realm
from paskia.util import hostutil, passphrase 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): async def redirect_middleware(request: Request, call_next):
"""Middleware to handle auth host redirects.""" """Middleware to handle auth host redirects.
cfg = hostutil.dedicated_auth_host()
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: if not cfg:
return await call_next(request) return await call_next(request)
+94
View File
@@ -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)
+32 -16
View File
@@ -1,27 +1,26 @@
import asyncio import asyncio
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import msgspec
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from kanta.logging import configure_logging as configure_kanta_logging 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.bootstrap import bootstrap_if_needed
from paskia.db.background import start_background, stop_background from paskia.db.background import start_background, stop_background
from paskia.db.lifecycle import kanta from paskia.db.lifecycle import kanta
from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi.admin.adminapp import adminapp from paskia.fastapi.admin.adminapp import adminapp
from paskia.fastapi.dispatch import DispatchMiddleware
# Import frontend instance # Import frontend instance
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
from paskia.fastapi.session import AUTH_COOKIE 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.constants import DEVMODE
from paskia.util.runtime import RuntimeConfig from paskia.util.runtime import serve_config
# Configure custom logging # Configure custom logging
configure_kanta_logging() configure_kanta_logging()
@@ -32,19 +31,22 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover - startup path 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) Process-global serve parameters (listen endpoints) are passed via the
so that uvicorn reload / multiprocess workers inherit the settings. PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so that
All keys are guaranteed to exist; values are already normalized by __main__.py. 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( await asyncio.to_thread(
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
) )
async with kanta: async with kanta:
try: try:
realms.init_registry(db.data().config)
await remoteauth.init() await remoteauth.init()
await authcode.start() await authcode.start()
except ValueError as e: except ValueError as e:
@@ -52,11 +54,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Re-raise to fail fast # Re-raise to fail fast
raise raise
# Bootstrap and persist config now that the full DB is loaded await bootstrap_if_needed()
await bootstrap_if_needed(config=runtime.config)
if runtime.save:
db.update_config(runtime.config)
await frontend.load() await frontend.load()
await start_background() await start_background()
yield yield
@@ -79,6 +77,10 @@ app = FastAPI(
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/) # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
app.middleware("http")(auth_host.redirect_middleware) 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/admin/", admin.app)
app.mount("/auth/api/", api.app) app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.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/iframe")
@app.get("/auth/restricted/oidc") @app.get("/auth/restricted/oidc")
async def restricted_view(request: Request): 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("/admin", include_in_schema=False)
@app.get("/auth/admin", include_in_schema=False) @app.get("/auth/admin", include_in_schema=False)
async def admin_root_redirect(): 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) @app.get("/admin/", include_in_schema=False)
+34 -8
View File
@@ -21,7 +21,8 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from paskia import authcode, db 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 import avatar, oidjwt
from paskia.util.crypto import hash_secret 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) 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") @app.get("/keys")
async def keys(): async def keys():
"""JSON Web Key Set for token verification.""" """JSON Web Key Set for token verification."""
return oidjwt.get_jwks() return oidjwt.get_jwks(current_realm().rp_id)
def _oidc_session_by_token( def _oidc_session_by_token(
@@ -148,7 +157,7 @@ async def token(
except ValueError: except ValueError:
return JSONResponse({"error": "invalid_client"}, status_code=401) 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): if not client or not client.verify_secret(client_secret):
return JSONResponse({"error": "invalid_client"}, status_code=401) return JSONResponse({"error": "invalid_client"}, status_code=401)
@@ -188,6 +197,16 @@ async def _handle_authorization_code(
status_code=400, 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 # Look up the OIDC session by token
session = _oidc_session_by_token(oidc_code.session_key, client.uuid) session = _oidc_session_by_token(oidc_code.session_key, client.uuid)
if not session: if not session:
@@ -297,6 +316,7 @@ async def _handle_refresh_token(
db.update_session( db.update_session(
session.key, session.key,
validated=now, validated=now,
issuer=_get_issuer(request),
) )
_logger.info("OIDC session refreshed: %s", session.key) _logger.info("OIDC session refreshed: %s", session.key)
@@ -327,6 +347,7 @@ def _build_token_response(
credential_uuid: UUID | None = None, credential_uuid: UUID | None = None,
): ):
"""Build the token response with access_token, id_token, and refresh_token.""" """Build the token response with access_token, id_token, and refresh_token."""
rp_id = current_realm().rp_id
issuer = _get_issuer(request) issuer = _get_issuer(request)
# Get user's permissions scoped to this OIDC client (domain == client UUID) # Get user's permissions scoped to this OIDC client (domain == client UUID)
@@ -353,6 +374,7 @@ def _build_token_response(
# Create ID token # Create ID token
id_token = oidjwt.create_id_token( id_token = oidjwt.create_id_token(
rp_id,
issuer=issuer, issuer=issuer,
subject=user.uuid, subject=user.uuid,
audience=client_id, audience=client_id,
@@ -368,6 +390,7 @@ def _build_token_response(
# Create access token # Create access token
access_token = oidjwt.create_access_token( access_token = oidjwt.create_access_token(
rp_id,
issuer=issuer, issuer=issuer,
subject=user.uuid, subject=user.uuid,
audience=client_id, audience=client_id,
@@ -401,8 +424,9 @@ async def userinfo(
if not credentials: if not credentials:
raise HTTPException(401, "Bearer token required") raise HTTPException(401, "Bearer token required")
rp_id = current_realm().rp_id
issuer = _get_issuer(request) 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: if not payload:
raise HTTPException(401, "Invalid or expired token") raise HTTPException(401, "Invalid or expired token")
@@ -416,7 +440,7 @@ async def userinfo(
except ValueError: except ValueError:
raise HTTPException(401, "Invalid token (invalid aud format)") 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)") raise HTTPException(401, "Invalid token (unknown client)")
# Get user # Get user
@@ -486,8 +510,9 @@ async def backchannel_logout(
) )
# Decode and verify the logout token # Decode and verify the logout token
rp_id = current_realm().rp_id
issuer = _get_issuer(request) 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: if not payload:
return JSONResponse( return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid logout_token"}, {"error": "invalid_request", "error_description": "Invalid logout_token"},
@@ -504,7 +529,7 @@ async def backchannel_logout(
if aud: if aud:
try: try:
client_uuid = UUID(aud) client_uuid = UUID(aud)
if not db.data().oidc.clients.get(client_uuid): if not _provider().clients.get(client_uuid):
return JSONResponse( return JSONResponse(
{ {
"error": "invalid_request", "error": "invalid_request",
@@ -556,12 +581,13 @@ async def backchannel_logout(
{"error": "invalid_request", "error_description": "Invalid sub claim"}, {"error": "invalid_request", "error_description": "Invalid sub claim"},
status_code=400, status_code=400,
) )
# Find and delete matching sessions # Find and delete matching sessions (this realm's OIDC sessions only)
sessions_to_delete = [ sessions_to_delete = [
s s
for s in db.data().sessions.values() for s in db.data().sessions.values()
if s.user_uuid == user_uuid if s.user_uuid == user_uuid
and s.client_uuid is not None and s.client_uuid is not None
and s.rp_id == rp_id
and (client_uuid is None or s.client_uuid == client_uuid) and (client_uuid is None or s.client_uuid == client_uuid)
] ]
for session in sessions_to_delete: for session in sessions_to_delete:
+12 -1
View File
@@ -22,6 +22,7 @@ from paskia.authsession import expires
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login from paskia.fastapi.wschat import authenticate_and_login
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.realms import current_realm, registry
from paskia.util import pow, useragent from paskia.util import pow, useragent
# Create a FastAPI subapp for remote auth WebSocket endpoints # Create a FastAPI subapp for remote auth WebSocket endpoints
@@ -94,6 +95,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
host=host, host=host,
ip=metadata.get("ip") or "", ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "", user_agent=metadata.get("user_agent") or "",
rp_id=current_realm().rp_id,
action=action, 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) # 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( exchange_code = authcode.store_cookie(
CookieCode( CookieCode(
session_key=secret, session_key=secret,
created=datetime.now(UTC), 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 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( await ws.send_json(
{ {
"status": "found", "status": "found",
"host": request.host, "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( "user_agent_pretty": useragent.compact_user_agent(
request.user_agent request.user_agent
), ),
+2 -1
View File
@@ -20,6 +20,7 @@ from paskia.authsession import (
from paskia.fastapi import authz, session from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.realms import current_realm
from paskia.util import avatar, hostutil from paskia.util import avatar, hostutil
from paskia.util.apistructs import ApiCreateLinkResponse from paskia.util.apistructs import ApiCreateLinkResponse
@@ -291,7 +292,7 @@ async def api_create_link(
token_type="device addition", token_type="device addition",
ctx=ctx, ctx=ctx,
) )
url = hostutil.reset_link_url(token) url = current_realm().reset_link_url(token)
return MsgspecResponse( return MsgspecResponse(
ApiCreateLinkResponse( ApiCreateLinkResponse(
message="Registration link generated successfully", message="Registration link generated successfully",
+12 -8
View File
@@ -17,7 +17,7 @@ from paskia.fastapi.wschat import (
register_chat, register_chat,
) )
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler 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 import hostutil, passphrase
from paskia.util.crypto import hash_secret from paskia.util.crypto import hash_secret
@@ -28,6 +28,7 @@ def create_exchange_code(session_key: str) -> str:
cookie_code = CookieCode( cookie_code = CookieCode(
session_key=session_key, session_key=session_key,
created=now, created=now,
rp_id=current_realm().rp_id,
) )
return authcode.store_cookie(cookie_code) return authcode.store_cookie(cookie_code)
@@ -55,10 +56,11 @@ async def websocket_register_add(
""" """
origin = validate_origin(ws) origin = validate_origin(ws)
host = hostutil.normalize_host(origin.split("://", 1)[1]) host = hostutil.normalize_host(origin.split("://", 1)[1])
realm = current_realm()
if reset is not None: if reset is not None:
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise ValueError( 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) s = get_reset(reset)
user_uuid = s.user_uuid user_uuid = s.user_uuid
@@ -75,7 +77,7 @@ async def websocket_register_add(
stripped = name.strip() stripped = name.strip()
if stripped: if stripped:
user_name = stripped user_name = stripped
credential_ids = user.credential_ids or None credential_ids = user.credential_ids_for(realm.rp_id) or None
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
@@ -123,6 +125,7 @@ async def websocket_authenticate(
): ):
origin = validate_origin(ws) origin = validate_origin(ws)
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
realm = current_realm()
# OIDC mode: validate client before auth # OIDC mode: validate client before auth
oidc_client = None oidc_client = None
@@ -133,7 +136,7 @@ async def websocket_authenticate(
await ws.send_json({"status": 400, "detail": "Invalid client_id"}) await ws.send_json({"status": 400, "detail": "Invalid client_id"})
return 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: if not oidc_client:
await ws.send_json({"status": 400, "detail": "Unknown client_id"}) await ws.send_json({"status": 400, "detail": "Unknown client_id"})
return return
@@ -145,9 +148,9 @@ async def websocket_authenticate(
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"}) await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return return
# Store as the only allowed redirect URI # 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 # 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: elif redirect_uri not in oidc_client.redirect_uris:
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"}) await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return return
@@ -204,8 +207,6 @@ async def websocket_authenticate(
cred, new_sign_count = await authenticate_chat(ws) cred, new_sign_count = await authenticate_chat(ws)
# Get metadata for session # Get metadata for session
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host) normalized_host = hostutil.normalize_host(host)
metadata = infodict(ws, "oidc_auth") metadata = infodict(ws, "oidc_auth")
@@ -223,6 +224,8 @@ async def websocket_authenticate(
user_agent=metadata["user_agent"], user_agent=metadata["user_agent"],
validated=now, validated=now,
client=oidc_client.uuid, client=oidc_client.uuid,
rp_id=realm.rp_id,
issuer=origin,
) )
db.oidc_login( db.oidc_login(
session=session, session=session,
@@ -235,6 +238,7 @@ async def websocket_authenticate(
created=now, created=now,
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
scope=scope, scope=scope,
rp_id=realm.rp_id,
nonce=nonce, nonce=nonce,
code_challenge=code_challenge, code_challenge=code_challenge,
) )
+12 -8
View File
@@ -11,7 +11,7 @@ from paskia.authsession import session_ctx
from paskia.db import Credential, SessionContext from paskia.db import Credential, SessionContext
from paskia.fastapi.session import infodict from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin from paskia.fastapi.wsutil import validate_origin
from paskia.globals import passkey from paskia.realms import current_realm, registry
from paskia.util import hostutil from paskia.util import hostutil
@@ -23,6 +23,7 @@ async def register_chat(
credential_ids: list[bytes] | None = None, credential_ids: list[bytes] | None = None,
): ):
"""Run WebAuthn registration flow and return the verified credential.""" """Run WebAuthn registration flow and return the verified credential."""
passkey = current_realm().passkey
options, challenge = passkey.reg_generate_options( options, challenge = passkey.reg_generate_options(
user_id=user_uuid, user_id=user_uuid,
user_name=user_name, user_name=user_name,
@@ -42,6 +43,8 @@ async def authenticate_chat(
Returns: Returns:
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
""" """
realm = current_realm()
passkey = realm.passkey
origin = validate_origin(ws) origin = validate_origin(ws)
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids) options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
await ws.send_json({"optionsJSON": options}) await ws.send_json({"optionsJSON": options})
@@ -51,7 +54,7 @@ async def authenticate_chat(
( (
c c
for c in db.data().credentials.values() 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, None,
) )
@@ -77,22 +80,20 @@ async def authenticate_and_login(
Args: Args:
ws: The WebSocket connection (used for WebAuthn and origin validation) ws: The WebSocket connection (used for WebAuthn and origin validation)
auth: Existing session cookie for re-auth credential restriction 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_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) session_user_agent: Override user-agent for the new session (defaults to ws headers)
Returns: Returns:
Tuple of (SessionContext for the authenticated session, session secret) Tuple of (SessionContext for the authenticated session, session secret)
""" """
realm = current_realm()
origin = validate_origin(ws) origin = validate_origin(ws)
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host) normalized_host = hostutil.normalize_host(host)
if not normalized_host: if not normalized_host:
raise ValueError("Host required for session creation") 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") metadata = infodict(ws, "auth")
# Get credential IDs if restricting to a user's credentials # Get credential IDs if restricting to a user's credentials
@@ -100,7 +101,7 @@ async def authenticate_and_login(
if auth: if auth:
existing_ctx = session_ctx(auth, host) existing_ctx = session_ctx(auth, host)
if existing_ctx: 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) cred, new_sign_count = await authenticate_chat(ws, credential_ids)
@@ -112,6 +113,8 @@ async def authenticate_and_login(
) )
if not login_host: if not login_host:
raise ValueError("Host required for session creation") 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_ip = session_ip if session_ip is not None else metadata["ip"]
login_user_agent = ( login_user_agent = (
session_user_agent if session_user_agent is not None else metadata["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, host=login_host,
ip=login_ip, ip=login_ip,
user_agent=login_user_agent, 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) # Fetch and return the full session context (using the same host the session was created with)
+3 -3
View File
@@ -10,7 +10,7 @@ from fastapi import WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.globals import passkey from paskia.realms import current_realm
from paskia.util import pow from paskia.util import pow
@@ -83,9 +83,9 @@ def validate_origin(ws: WebSocket) -> str:
"""Extract and validate origin from WebSocket request headers. """Extract and validate origin from WebSocket request headers.
Raises: 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") origin = ws.headers.get("origin")
if not origin: if not origin:
raise ValueError("Origin header is required for WebSocket connections") raise ValueError("Origin header is required for WebSocket connections")
return passkey.validate_origin(origin) return current_realm().passkey.validate_origin(origin)
-20
View File
@@ -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,
)
+3
View File
@@ -39,6 +39,7 @@ class RemoteAuthRequest:
host: str # The host where the session should be created host: str # The host where the session should be created
ip: str # IP of the requesting device ip: str # IP of the requesting device
user_agent: str # User agent 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" action: str = "login" # "login" or "register"
locked: bool = False # True once the authenticating device has entered the code locked: bool = False # True once the authenticating device has entered the code
# Callback to notify the requesting device when auth completes # Callback to notify the requesting device when auth completes
@@ -113,6 +114,7 @@ class RemoteAuthManager:
host: str, host: str,
ip: str, ip: str,
user_agent: str, user_agent: str,
rp_id: str,
action: str = "login", action: str = "login",
) -> tuple[str, datetime]: ) -> tuple[str, datetime]:
"""Create a new remote auth request. """Create a new remote auth request.
@@ -143,6 +145,7 @@ class RemoteAuthManager:
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
rp_id=rp_id,
action=action, action=action,
) )
+21 -1
View File
@@ -161,17 +161,37 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
class ApiSettings(msgspec.Struct): 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_id: str
rp_name: str rp_name: str
ui_base_path: str ui_base_path: str
auth_host: str | None auth_host: str | None
own_auth_host: str | None
auth_site_url: str auth_site_url: str
session_cookie: str session_cookie: str
version: 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): class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
"""Token info response struct.""" """Token info response struct."""
+4 -1
View File
@@ -46,7 +46,10 @@ def avatar_url(user_uuid: UUID) -> str | None:
"""Return the absolute public avatar URL for a user, or None.""" """Return the absolute public avatar URL for a user, or None."""
if not avatar_path(user_uuid).is_file(): if not avatar_path(user_uuid).is_file():
return None 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: def current_avatar_url(user_uuid: UUID) -> str | None: