Files
cista-storage/cista/sso.py
T

385 lines
12 KiB
Python

"""SSO (paskia) authentication proxy and validation module.
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
- All /auth/* requests are proxied to the paskia backend
Environment variables:
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
Must include scheme (http/https), no trailing slash
"""
import asyncio
import os
import re
import httpx
import websockets
from sanic import Blueprint, json
from sanic import raw as raw_response
from sanic.exceptions import Forbidden, SanicException, Unauthorized
from sanic.log import logger
# Auth backend URL for SSO validation (from env, no trailing slash)
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
# Validate and set PASKIA_BACKEND_URL
if _raw_url:
if not re.match(r"^https?://[^\s/]+$", _raw_url):
raise ValueError(
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
"must be http(s)://host[:port] with no path or trailing slash"
)
PASKIA_BACKEND_URL = _raw_url
else:
PASKIA_BACKEND_URL = ""
def paskia_enabled() -> bool:
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
return bool(PASKIA_BACKEND_URL)
# Shared httpx client for SSO requests (reused for connection pooling)
_client: httpx.AsyncClient | None = None
async def get_client() -> httpx.AsyncClient:
"""Get or create the shared httpx client."""
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=1.0)
if "user-agent" in _client.headers:
del _client.headers["user-agent"] # No httpx UA
return _client
async def close_client():
"""Close the shared httpx client."""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
"""Validate an SSO request against the auth backend.
Args:
request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin)
renew: Whether to allow the auth backend to renew the session cookie.
Use ``False`` for WebSocket validation where Set-Cookie cannot be
forwarded to the client; this makes the request read-only and avoids
resetting the backend renewal timeout.
Returns:
User info dict if valid, None if validation fails with auth required response
Raises:
Forbidden: If access is denied (403)
Unauthorized: If authentication is required (401)
"""
if not paskia_enabled():
return None
client = await get_client()
headers = {}
if "host" in request.headers:
headers["host"] = request.headers["host"]
if "cookie" in request.headers:
headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"]
if "user-agent" in request.headers:
headers["user-agent"] = request.headers["user-agent"]
headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
if not renew:
url += "&renew=0"
try:
response = await client.post(
url,
headers=headers,
)
if response.status_code == 200:
try:
data = response.json()
request.ctx.sso_user = data
if "set-cookie" in response.headers:
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
except Exception:
request.ctx.sso_user = {}
return {}
else:
return data
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text or "Authentication error"}
if response.status_code == 401:
if "auth" in error_data and "iframe" in error_data["auth"]:
error_data["auth"]["iframe"] += "&theme=light"
raise Unauthorized(
error_data.get("detail", "Authentication required"),
"cookie",
context=error_data,
quiet=True,
)
if response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
context=error_data,
quiet=True,
)
detail = error_data.get("detail", "")
logger.warning(
f"SSO validation {url} returned {response.status_code}: {detail}"
)
raise Forbidden(
detail or "Authentication error",
context=error_data,
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"SSO validation {url} network error: {e}")
raise SanicException(
"Authentication service unavailable",
status_code=502,
quiet=True,
) from e
async def check_permissions(user_id: str, perm: str) -> dict:
"""Check if a Paskia user has the given permission.
Calls /auth/api/check?user=<UUID>&perm=<perm> — no session or cookies needed.
Args:
user_id: The Paskia user UUID
perm: Permission to check (e.g. cista:login or cista:admin)
Returns:
User info dict if permission is granted
Raises:
Forbidden: If permission is denied or check fails
SanicException: If the auth service is unreachable
"""
if not paskia_enabled():
raise ValueError("Paskia not enabled")
client = await get_client()
url = f"{PASKIA_BACKEND_URL}/auth/api/check"
try:
response = await client.get(
url,
params={"user": user_id, "perm": perm},
headers={"accept": "application/json"},
)
if response.status_code == 200:
return response.json()
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text or "Permission check failed"}
if response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
quiet=True,
)
raise Forbidden(
error_data.get("detail", "Permission check failed"),
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"Permission check {url} network error: {e}")
raise SanicException(
"Authentication service unavailable",
status_code=502,
quiet=True,
) from e
async def proxy_auth_request(request):
"""Proxy a request to the auth backend.
All requests under /auth/ are proxied when paskia mode is enabled.
"""
client = await get_client()
path = request.path
query_string = request.query_string
url = f"{PASKIA_BACKEND_URL}{path}"
if query_string:
url = f"{url}?{query_string}"
skip_headers = {
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"forwarded",
}
headers = [
(key, value)
for key, value in request.headers.items()
if key.lower() not in skip_headers
]
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
headers.append(("x-forwarded-host", request.host))
headers.append(("x-forwarded-proto", request.scheme))
headers.append(
(
"forwarded",
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
)
)
try:
async with client.stream(
method=request.method,
url=url,
headers=headers,
content=request.body or None,
) as response:
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
resp_hop_by_hop = {
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
}
resp_headers = [
(key, value)
for key, value in response.headers.multi_items()
if key.lower() not in resp_hop_by_hop
]
return raw_response(
raw_content,
status=response.status_code,
headers=resp_headers,
content_type=response.headers.get("content-type", "application/json"),
)
except httpx.RequestError as e:
logger.error(f"Auth proxy request failed: {e}")
return json(
{"detail": "Authentication service unavailable"},
status=503,
)
async def proxy_auth_websocket(request, ws):
"""Proxy a WebSocket connection to the auth backend."""
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
if request.query_string:
url = f"{url}?{request.query_string}"
additional_headers = {}
if "cookie" in request.headers:
additional_headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
additional_headers["authorization"] = request.headers["authorization"]
if "host" in request.headers:
additional_headers["host"] = request.headers["host"]
if "origin" in request.headers:
additional_headers["origin"] = request.headers["origin"]
if "user-agent" in request.headers:
additional_headers["user-agent"] = request.headers["user-agent"]
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
additional_headers["x-forwarded-host"] = request.host
additional_headers["x-forwarded-proto"] = request.scheme
try:
async with websockets.connect(
url, additional_headers=additional_headers
) as backend_ws:
async def forward_to_backend():
try:
async for message in ws:
await backend_ws.send(message)
except Exception as e:
logger.debug("WebSocket forward_to_backend ended: %s", e)
async def forward_to_client():
try:
async for message in backend_ws:
await ws.send(message)
except Exception as e:
logger.debug("WebSocket forward_to_client ended: %s", e)
await asyncio.gather(
forward_to_backend(),
forward_to_client(),
return_exceptions=True,
)
except Exception as e:
logger.error(f"WebSocket proxy to {url} failed: {e}")
# Blueprint for auth proxy routes (only registered when paskia_enabled())
bp = Blueprint("sso", url_prefix="/auth")
@bp.websocket("/ws/<path:path>")
async def auth_websocket_proxy(request, ws, path=""):
"""Proxy WebSocket connections to the auth backend."""
_ = path
await proxy_auth_websocket(request, ws)
@bp.websocket("/ws/")
async def auth_websocket_proxy_root(request, ws):
"""Proxy root WebSocket connections to the auth backend."""
await proxy_auth_websocket(request, ws)
@bp.route(
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
)
async def auth_proxy(request, path=""):
"""Proxy all auth requests to the auth backend."""
_ = path
return await proxy_auth_request(request)
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def auth_proxy_root(request):
"""Proxy root auth requests to the auth backend."""
return await proxy_auth_request(request)