Drop watch websockets on session loss, purge SSO cache on logout

- Successful SSO /auth/api/validate responses are cached per credential
  and perm/renew URL for 10s, so watch websocket re-checks do not hammer
  the auth backend. A POST to the logout endpoint purges all cached
  entries for the request's credentials immediately, so logout/login
  flows are not served stale successes.
- The watch websocket now re-validates auth before each forwarded
  message and every 10s when idle (SSO and built-in sessions alike).
  When the session is gone the client gets an auth error message and
  the socket is closed, instead of streaming updates forever.
- Token-authenticated (API/share token) sockets are exempt from
  re-validation; they are checked once at handshake.
This commit is contained in:
2026-08-12 21:20:12 +00:00
parent bd96b2c7ba
commit 7a0e473fb4
6 changed files with 523 additions and 38 deletions
+49
View File
@@ -10,8 +10,10 @@ Environment variables:
"""
import asyncio
import hashlib
import os
import re
from time import time
import httpx
import websockets
@@ -62,6 +64,40 @@ async def close_client():
_client = None
# In-memory cache for successful SSO /auth/api/validate responses.
# Keyed by (credential hash, validation URL) so that entries for different
# perms/renew flags coexist and all entries for a credential can be purged
# on logout.
_VALIDATE_CACHE_TTL = 10
_validate_cache: dict[tuple[str, str], tuple[float, dict]] = {}
def _validate_credential_key(request) -> str:
"""Return a stable key for the credential material in the request."""
cookie = request.headers.get("cookie", "")
authorization = request.headers.get("authorization", "")
return hashlib.sha256(f"{cookie}\x00{authorization}".encode()).hexdigest()
def _cleanup_validate_cache() -> None:
"""Drop expired cache entries."""
now = time()
for key, (timestamp, _) in list(_validate_cache.items()):
if now - timestamp >= _VALIDATE_CACHE_TTL:
del _validate_cache[key]
def invalidate_validation_cache(request) -> None:
"""Remove cached SSO validations for the credentials carried by *request*.
Called after a logout request so the next request is forced to the
backend instead of being served from a stale success cache.
"""
credential_key = _validate_credential_key(request)
for key in [key for key in _validate_cache if key[0] == credential_key]:
del _validate_cache[key]
async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
@@ -105,6 +141,17 @@ async def validate_sso_request(
if not renew:
url += "&renew=0"
credential_key = _validate_credential_key(request)
cache_key = (credential_key, url)
cached = _validate_cache.get(cache_key)
if cached is not None:
timestamp, data = cached
if time() - timestamp < _VALIDATE_CACHE_TTL:
request.ctx.sso_user = data
return data
del _validate_cache[cache_key]
try:
response = await client.post(
url,
@@ -121,6 +168,8 @@ async def validate_sso_request(
request.ctx.sso_user = {}
return {}
else:
_cleanup_validate_cache()
_validate_cache[cache_key] = (time(), data)
return data
try: