""" Session auth middleware. Validates the JWT cookie on every request. Unprotected paths are explicitly listed in _PUBLIC. Webhook endpoints have their own auth (HMAC/JWT) so they are also excluded. Sets request.state.session_user to the authenticated username so downstream routers can enforce ownership without re-reading the cookie. """ import jwt from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import RedirectResponse, JSONResponse from auth_utils import COOKIE_NAME, decode_token # Paths that don't require a session cookie _PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico"} # Path prefixes that are always public (setup flow + webhooks + Google OAuth) _PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google") class SessionAuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path # Always allow public paths and setup/webhook prefixes if path in _PUBLIC or any(path.startswith(p) for p in _PUBLIC_PREFIXES): return await call_next(request) # Allow static assets without a cookie if path.startswith("/static/"): return await call_next(request) # Validate session cookie token = request.cookies.get(COOKIE_NAME) if token: try: request.state.session_user = decode_token(token) return await call_next(request) except jwt.InvalidTokenError: pass # No valid session — redirect browser requests, 401 for API/JSON accept = request.headers.get("accept", "") if "text/html" in accept: return RedirectResponse("/login", status_code=302) return JSONResponse({"detail": "Not authenticated"}, status_code=401)