Files
paskia/paskia/fastapi/api.py
T
LeoVasanko 44364fdffc Fix replica-path leaks and availability semantics from live testing
- _remote_headers and /check used struct convenience properties that
  read the global database; they now use the SessionContext / the handed
  store (also fixes Remote-Credential carrying a struct repr instead of
  the UUID).
- Replica availability: TTL clock starts at disconnect, not at last
  message or failed reconnect; tight WS keepalive for prompt dead-peer
  detection.
- Proxy preserves repeated Set-Cookie via raw headers; sync endpoint
  does its own accept (wsutil decorator pre-accepts) and bypasses host
  dispatch (server-to-server; satellite may use an out-of-domain
  address).
- Admin-credential bootstrap warning skips remote domains.

Verified live with two instances (remote :4501, satellite :4402):
replica snapshot + events, 204 forward with Remote-* in <1ms, validate
write-behind landing on the remote, proxied logout with instant local
eviction, 503 after cache_ttl of disconnect, resync after remote
restart.
2026-09-20 23:45:01 +00:00

461 lines
16 KiB
Python

import logging
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import (
Depends,
FastAPI,
HTTPException,
Query,
Request,
Response,
)
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import authcode, db, satellite
from paskia._version import __version__
from paskia.authsession import EXPIRES, get_reset, session_ctx
from paskia.domains import current_domain
from paskia.fastapi import authz, proxy, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import (
ApiCheckUserResponse,
ApiOrgContext,
ApiRoleContext,
ApiSessionContext,
ApiSettings,
ApiTokenInfo,
ApiUserContext,
ApiValidateResponse,
)
from paskia.util.crypto import hash_secret
bearer_auth = HTTPBearer(auto_error=False)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/user", user.app)
@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException):
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
if exc.status_code == 401:
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
session.clear_session_cookie(resp)
return resp
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# Refresh only if at least this much of the session lifetime has been *consumed*.
# Consumption is derived from (now + EXPIRES) - current_expires.
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
_REFRESH_INTERVAL = timedelta(minutes=5)
def _set_log_extra(request: Request, *parts: str) -> None:
values = [part for part in parts if part]
if values:
request.state.log_extra = " ".join(values)
@app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
return JSONResponse(
status_code=exc.status_code,
content=await authz.auth_error_content(exc),
)
@app.exception_handler(Exception)
async def general_exception_handler(
request: Request, exc: Exception
): # pragma: no cover
logging.exception("Unhandled exception in API app")
# Identify the origin endpoint for proxied clients (e.g. forward auth)
return JSONResponse(
status_code=500,
content={"detail": f"{request.url.path}: Internal server error"},
)
def _parse_perm(perm: list[str]) -> list[tuple[str, ...]]:
"""Parse perm query arguments into groups of OR alternatives (400 on syntax error)."""
try:
return permutil.parse_perm_args(perm)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
def _store(request: Request):
"""The dispatched domain's data store (local DB or read replica).
Remote domains fail closed once the sync channel has been silent for
longer than their cache_ttl.
"""
domain = request.state.domain
if domain.remote is not None:
replica = satellite.manager.replica_for(domain)
if replica is None or not replica.available():
raise HTTPException(503, "Remote authentication service unavailable")
return domain.store
@app.post("/validate")
async def validate_token(
request: Request,
response: Response,
perm: list[str] = Query([]),
max_age: str | None = Query(None),
renew: bool = Query(True),
auth=AUTH_COOKIE,
):
"""Validate session and return context. Refreshes session expiry by default."""
perm_groups = _parse_perm(perm)
try:
ctx = await authz.verify(
auth,
perm_groups,
host=request.headers.get("host"),
max_age=max_age,
store=_store(request),
)
except HTTPException:
# Global handler will clear cookie if 401
raise
renewed = False
if auth and renew:
consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
replica = satellite.manager.replica_for(request.state.domain)
if replica is not None:
replica.refresh_session(
ctx.session.key,
datetime.now(UTC),
get_client_ip(request),
request.headers.get("user-agent", ""),
)
else:
db.update_session(
ctx.session.key,
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
validated=datetime.now(UTC),
ctx=ctx,
)
renewed = True
_set_log_extra(request, ctx.session.key)
resp = MsgspecResponse(
ApiValidateResponse(
valid=True,
renewed=renewed,
ctx=userinfo.build_session_context(ctx),
)
)
if renewed:
session.set_session_cookie(resp, auth)
return resp
@app.get("/check")
async def check_user(
request: Request,
user_uuid: UUID = Query(..., alias="user"),
perm: list[str] = Query([]),
):
"""Check permissions for a user by UUID without requiring a session.
Query Params:
- user: UUID of the user to check.
- perm: repeated permission scope the user must possess (ALL required;
separate alternatives with '|' for OR semantics within a group).
Returns 200 with valid=True/False and the user's effective permissions,
scoped to the requesting host (domain-restricted permissions are filtered).
Returns 404 if the user UUID does not exist.
No session cookie is read or written. Caller authentication is not required.
"""
data = _store(request)
try:
u = data.users[user_uuid]
role = data.roles[u.role_uuid]
org = data.orgs[role.org_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
host = hostutil.normalize_host(request.headers.get("host"))
org_perm_uuids = {p.uuid for p in data.permissions.values() if org.uuid in p.orgs}
effective_perms = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
try:
p = data.permissions[perm_uuid]
except KeyError:
continue
if p.domain is not None and p.domain != host:
continue
effective_perms.append(p)
required_groups = _parse_perm(perm)
effective_scopes = {p.scope for p in effective_perms}
valid = permutil.has_all_scopes_groups(effective_scopes, required_groups)
ctx = ApiSessionContext(
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
permissions=sorted(effective_scopes),
)
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
def _remote_headers(ctx) -> dict[str, str]:
"""Build the Remote-* identity headers for a verified session context."""
role_permissions = {p.scope for p in ctx.permissions} if ctx.permissions else set()
return {
"Remote-User": str(ctx.user.uuid),
"Remote-Name": ctx.user.display_name,
"Remote-Groups": ",".join(sorted(role_permissions)),
"Remote-Org": str(ctx.org.uuid),
"Remote-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": (
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.credential.uuid),
}
@app.get("/forward")
async def forward_authentication(
request: Request,
response: Response,
perm: list[str] = Query([]),
max_age: str | None = Query(None),
public: bool = Query(False),
auth=AUTH_COOKIE,
):
"""Forward auth validation for Caddy/Nginx.
Query Params:
- perm: repeated permission scopes the authenticated user must possess (ALL
required; separate alternatives with '|' for OR semantics within a group).
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
is older than this, user must re-authenticate.
- public: allow public access — instead of 401 (no/expired session) or 403
(permission denied), return 204 with a Remote-Public header
(anonymous/forbidden) so the backend can decide. Reauth (max_age)
still requires the auth flow. Successful checks are marked
Remote-Public: authenticated.
Success: 204 No Content with Remote-* headers describing the authenticated user.
Failure (unauthenticated / unauthorized): 4xx response.
- If Accept header contains "text/html": HTML page for authentication
with data attributes for mode and other metadata.
- Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
"""
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
forwarded = (
f"{forwarded_method} {forwarded_uri}"
if forwarded_method and forwarded_uri
else ""
)
_set_log_extra(request, forwarded)
try:
perm_groups = permutil.parse_perm_args(perm)
except ValueError:
# Identify the error origin for proxied clients; do not echo query args
raise HTTPException(
status_code=400, detail="/auth/api/forward: invalid perm argument"
)
try:
ctx = await authz.verify(
auth,
perm_groups,
host=request.headers.get("host"),
max_age=max_age,
store=_store(request),
)
_set_log_extra(request, forwarded, ctx.session.key)
remote_headers = _remote_headers(ctx)
if public:
remote_headers["Remote-Public"] = "authenticated"
return Response(status_code=204, headers=remote_headers)
except authz.AuthException as e:
# Public access: pass the request through instead of an auth flow.
# Reauth is never soft-passed: an authenticated user was explicitly asked
# for fresh verification (log out first to use the public mode).
if public and e.mode in ("login", "forbidden"):
_set_log_extra(request, forwarded, f"public:{e.mode}")
if e.mode == "forbidden" and e.ctx is not None:
headers = {**_remote_headers(e.ctx), "Remote-Public": "forbidden"}
else:
headers = {"Remote-Public": "anonymous"}
return Response(status_code=204, headers=headers)
# Clear cookie only if session is invalid (not for reauth)
if e.clear_session:
session.clear_session_cookie(response)
# Browser request? - return full-page HTML with metadata patched into data attrs
if "text/html" in request.headers.get("accept", ""):
return await htmlutil.patched_html_response(
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
)
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code, content=await authz.auth_error_content(e)
)
@app.get("/settings")
async def get_settings():
domain = current_domain()
return MsgspecResponse(
ApiSettings(
rp_id=domain.rp_id,
rp_name=domain.rp_name,
ui_base_path=domain.ui_base_path,
auth_host=domain.own_auth_host,
own_auth_host=domain.own_auth_host,
auth_site_url=domain.auth_site_url,
session_cookie=AUTH_COOKIE_NAME,
version=__version__,
),
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
)
@app.get("/user-info")
async def api_user_info(
request: Request,
response: Response,
auth=AUTH_COOKIE,
):
"""Get full user profile including credentials and sessions."""
if auth is None:
raise authz.AuthException(
status_code=401,
detail="Authentication required",
mode="login",
)
ctx = session_ctx(auth, request.headers.get("host"), store=_store(request))
if not ctx:
raise authz.AuthException(
status_code=401,
detail="Session expired",
mode="login",
clear_session=True,
)
_set_log_extra(request, ctx.session.key)
return MsgspecResponse(
await userinfo.build_user_info(
user_uuid=ctx.user.uuid,
session_key=ctx.session.key,
request_host=request.headers.get("host"),
ctx=ctx,
store=_store(request),
)
)
@app.get("/token-info")
async def token_info(request: Request, credentials=Depends(bearer_auth)):
"""Get reset/device-add token info. Pass token via Bearer header."""
if request.state.domain.remote is not None:
return await proxy.proxy_to_remote(request, request.state.domain.remote)
if not credentials or not credentials.credentials:
raise HTTPException(401, "Bearer token required")
token = credentials.credentials
if not passphrase.is_well_formed(token):
raise HTTPException(400, "Invalid token format")
try:
reset_token = get_reset(token)
except ValueError as e:
raise HTTPException(401, str(e))
u = reset_token.user
return MsgspecResponse(
ApiTokenInfo(
token_type=reset_token.token_type,
display_name=u.display_name,
theme=u.theme,
)
)
@app.post("/logout")
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if request.state.domain.remote is not None:
proxied = await proxy.proxy_to_remote(request, request.state.domain.remote)
if auth and proxied.status_code == 200:
replica = satellite.manager.replica_for(request.state.domain)
if replica is not None:
replica.evict_session(auth)
return proxied
if not auth:
return {"message": "Already logged out"}
host = request.headers.get("host")
ctx = session_ctx(auth, host)
if not ctx:
return {"message": "Already logged out"}
with suppress(Exception):
db.delete_session(ctx.session.key, ctx=ctx, action="logout")
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
@app.post("/set-session")
async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth)
):
"""Exchange an auth code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
if not auth or not auth.credentials:
raise HTTPException(400, "Bearer token required")
if request.state.domain.remote is not None:
# The exchange code lives in the remote's RAM; redeem it there. The
# session itself reaches the replica via the sync channel.
return await proxy.proxy_to_remote(request, request.state.domain.remote)
host = hostutil.normalize_host(request.headers.get("host", ""))
if not host:
raise HTTPException(400, "Host header required")
a = authcode.consume_cookie(auth.credentials)
if not a:
raise HTTPException(401, "Code expired or already used")
if a.rp_id != current_domain().rp_id:
raise HTTPException(401, "Code was issued for a different domain")
secret = a.session_key
ctx = session_ctx(secret, host)
if not ctx:
raise HTTPException(401, f"Session not found on {host}")
_set_log_extra(request, hash_secret("cookie", secret))
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}