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.
|
"""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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user