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:
+11
-1
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
|
||||
+10
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+34
-8
@@ -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:
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
+12
-8
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user