From 7be02e951d942282d545720669b394c6c7b1cabb Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 30 Jan 2026 18:28:05 +0000 Subject: [PATCH] Consistent dialog styling widgets and using Paskia's shared backdrop. Internal password auth mimics Paskia. API paths changed (/auth goes to internal or paskia depending on config). All API calls and previews get access checks. --- cista/__main__.py | 27 +- cista/api.py | 22 +- cista/app.py | 18 +- cista/auth.py | 346 +++++++++++++++--- cista/config.py | 15 +- cista/preview.py | 11 +- cista/sso.py | 225 ++++++++++++ cista/util/apphelpers.py | 7 +- frontend/package.json | 1 + frontend/src/App.vue | 1 - frontend/src/components/DownloadButton.vue | 3 +- frontend/src/components/HeaderMain.vue | 51 ++- frontend/src/components/LoginModal.vue | 101 ----- frontend/src/components/ModalDialog.vue | 237 ++++++++++-- frontend/src/components/SettingsModal.vue | 41 +-- .../src/components/UserManagementModal.vue | 82 ++--- frontend/src/repositories/Client.ts | 97 ++--- frontend/src/repositories/User.ts | 14 +- frontend/src/repositories/WS.ts | 71 +++- frontend/src/stores/main.ts | 11 +- frontend/src/stores/ssoAuth.ts | 107 ++++++ frontend/vite.config.ts | 4 +- pyproject.toml | 3 + 23 files changed, 1109 insertions(+), 386 deletions(-) create mode 100644 cista/sso.py delete mode 100644 frontend/src/components/LoginModal.vue create mode 100644 frontend/src/stores/ssoAuth.ts diff --git a/cista/__main__.py b/cista/__main__.py index dbf5784..807bd2c 100644 --- a/cista/__main__.py +++ b/cista/__main__.py @@ -29,7 +29,7 @@ banner = create_banner() doc = """\ Usage: - cista [-c ] [-l ] [--import-droppy] [--dev] [] + cista [-c ] [-l ] [--auth ] [--import-droppy] [--dev] [] cista [-c ] --user [--privileged] [--password] Options: @@ -39,11 +39,15 @@ Options: :3000 (bind another address, port) /path/to/unix.sock (unix socket) example.com (run on 80 and 443 with LetsEncrypt) + --auth MODE Authentication mode: none, password, paskia + none - public access, no login required + password - built-in user accounts (default) + paskia - external SSO via PASKIA_BACKEND_URL --import-droppy Import Droppy config from ~/.droppy/config --dev Developer mode (reloads, friendlier crashes, more logs) -Listen address, path and imported options are preserved in config, and only -custom config dir and dev mode need to be specified on subsequent runs. +Listen address, path, auth mode and imported options are preserved in config, +and only config dir and dev mode need to be specified on subsequent runs. User management: --user NAME Create or modify user @@ -107,6 +111,11 @@ def _main(): f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}", ) settings = droppy.readconf() + # Convert Droppy's public flag to authentication mode + if "public" in settings: + settings["authentication"] = ( + "none" if settings.pop("public") else "password" + ) if path: settings["path"] = path elif not exists: @@ -115,9 +124,15 @@ def _main(): settings["listen"] = listen elif not exists: settings["listen"] = ":8000" - if not exists and not import_droppy: + # Authentication mode + auth_mode = args["--auth"] + if auth_mode: + if auth_mode not in ("none", "password", "paskia"): + raise ValueError(f"Invalid auth mode: {auth_mode}. Use: none, password, paskia") + settings["authentication"] = auth_mode + elif not exists and not import_droppy: # We have no users, so make it public - settings["public"] = True + settings["authentication"] = "none" operation = config.update_config(settings) sys.stderr.write(f"Config {operation}: {config.conffile}\n") # Prepare to serve @@ -159,7 +174,7 @@ def _user(args): { "listen": ":8000", "path": Path.home() / "Downloads", - "public": False, + "authentication": "password", } ) sys.stderr.write(f"Config {operation}: {config.conffile}\n\n") diff --git a/cista/api.py b/cista/api.py index 3a94085..c40e317 100644 --- a/cista/api.py +++ b/cista/api.py @@ -5,7 +5,10 @@ from secrets import token_bytes import msgspec from sanic import Blueprint -from cista import __version__, config, watching +from sanic import json +from sanic.exceptions import BadRequest + +from cista import __version__, auth, config, watching from cista.fileio import FileServer from cista.protocol import ControlTypes, FileRange, StatusMsg from cista.util.apphelpers import asend, websocket_wrapper @@ -98,7 +101,7 @@ async def watch(req, ws): "server": { "name": config.config.name or config.config.path.name, "version": __version__, - "public": config.config.public, + "authentication": config.config.authentication, }, "user": { "username": req.ctx.username, @@ -136,3 +139,18 @@ def subscribe(uuid, ws): watching.format_space(watching.state.space), watching.format_root(watching.state.root), ) + + +@bp.put("config/authentication") +async def update_authentication(request): + await auth.verify(request, privileged=True) + try: + mode = request.json["authentication"] + if mode not in ("none", "paskia", "password"): + raise ValueError("Invalid authentication mode") + except KeyError: + raise BadRequest("Missing authentication field") from None + except ValueError as e: + raise BadRequest(str(e)) from None + config.update_config({"authentication": mode}) + return json({"message": "Authentication setting updated", "authentication": mode}) diff --git a/cista/app.py b/cista/app.py index e52963e..1d15422 100644 --- a/cista/app.py +++ b/cista/app.py @@ -18,7 +18,7 @@ from setproctitle import setproctitle from stream_zip import ZIP_AUTO, stream_zip from zstandard import ZstdCompressor -from cista import auth, config, preview, session, watching +from cista import auth, config, preview, session, sso, watching from cista.api import bp from cista.util.apphelpers import handle_sanic_exception @@ -27,6 +27,7 @@ sanic.helpers._ENTITY_HEADERS = frozenset() app = Sanic("cista", strict_slashes=True) app.blueprint(auth.bp) +app.blueprint(sso.bp) # SSO proxy for /auth/* routes (when paskia mode enabled) app.blueprint(preview.bp) app.blueprint(bp) app.exception(Exception)(handle_sanic_exception) @@ -52,6 +53,7 @@ async def main_stop(app): quit.set() watching.stop(app) app.ctx.threadexec.shutdown() + await sso.close_client() logger.debug("Cista worker threads all finished") @@ -77,7 +79,15 @@ async def use_session(req): @app.before_server_start def http_fileserver(app): bp = Blueprint("fileserver") - bp.on_request(auth.verify) + + @bp.on_request + async def verify_fileserver(request): + """Verify access to file server routes.""" + if config.config.authentication == "paskia": + await auth.verify_sso(request) + else: + await auth.verify(request) + bp.static( "/files/", config.config.path, @@ -239,6 +249,10 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]: @app.get("/zip//") async def zip_download(req, keys, zipfile, ext): """Download a zip archive of the given keys""" + if config.config.authentication == "paskia": + await auth.verify_sso(req) + else: + auth.verify(req) wanted = set(keys.split("+")) files = get_files(wanted) diff --git a/cista/auth.py b/cista/auth.py index 2781186..fcb72ea 100644 --- a/cista/auth.py +++ b/cista/auth.py @@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized from cista import config, session from cista.util import pwgen +_LOGIN_PAGE_CSS = """\ +/* =========================================== + LOGIN PAGE STYLES + Must match ModalDialog.vue global styles. + =========================================== */ +* { box-sizing: border-box; } +body { + font-family: 'Roboto', system-ui, -apple-system, sans-serif; + font-size: 1rem; + margin: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: transparent; +} +.login-card { + background: #ddd; + color: #000; + border-radius: 0.5rem; + box-shadow: 0 0 1rem #0008; + width: 100%; + max-width: 320px; +} +h1 { + background: #146; + color: #fff; + margin: 0; + padding: 0.5rem 1rem; + font-size: 1.2rem; + font-weight: normal; + border-radius: 0.5rem 0.5rem 0 0; +} +.content { + padding: 1rem; +} +.message { + color: #444; + margin: 0 0 0.5rem 0; + font-size: 0.875rem; +} +form { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1rem; + align-items: center; +} +label { + font-size: 1rem; +} +input[type="text"], +input[type="password"] { + font: inherit; + font-size: 1rem; + padding: 0.5rem; + border: 2px solid #888; + border-radius: 0.25rem; + background: #fff; + color: #000; + min-width: 0; +} +input:focus { + outline: none; + border-color: #f80; +} +.button-row { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} +button { + font: inherit; + font-size: 1rem; + padding: 0.5rem 1rem; + background: #146; + color: #fff; + border: none; + border-radius: 0.25rem; + cursor: pointer; +} +button:hover { background: #f80; } +button:disabled { + background: #888; + cursor: not-allowed; +} +.error { + grid-column: 1 / -1; + color: #c00; + font-size: 0.875rem; + min-height: 1.2em; + margin: 0; +} +""" + +_LOGIN_PAGE_JS = """\ +const form = document.getElementById('loginForm'); +const error = document.getElementById('error'); +const submitBtn = document.getElementById('submitBtn'); +const usernameField = document.getElementById('username'); +const passwordField = document.getElementById('password'); +const isInIframe = window.parent !== window; + +// Focus username field on load +usernameField.focus(); + +const showError = (msg) => { + error.textContent = msg; + submitBtn.disabled = false; + submitBtn.textContent = 'Log in'; + // Focus and select the relevant field + if (msg.toLowerCase().includes('password')) { + passwordField.focus(); + passwordField.select(); + } else { + usernameField.focus(); + usernameField.select(); + } +}; + +form.onsubmit = async (e) => { + e.preventDefault(); + error.textContent = ''; + submitBtn.disabled = true; + submitBtn.textContent = 'Logging in...'; + + try { + const res = await fetch('/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + username: usernameField.value, + password: passwordField.value + }) + }); + + if (res.ok) { + if (isInIframe) { + window.parent.postMessage({type: 'auth-success'}, '*'); + } else { + window.location.href = '/'; + } + } else { + const data = await res.json(); + showError(data.message || data.detail || 'Login failed'); + } + } catch (err) { + showError('Connection error. Please try again.'); + } +}; +""" + +# Import for SSO validation (lazily loaded to avoid circular imports) +_sso_module = None + + +def _get_sso(): + global _sso_module + if _sso_module is None: + from cista import sso + + _sso_module = sso + return _sso_module + + _argon = argon2.PasswordHasher() _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") @@ -63,62 +231,133 @@ class LoginResponse(msgspec.Struct): error: str = "" -def verify(request, *, privileged=False): - """Raise Unauthorized or Forbidden if the request is not authorized""" - if privileged: - if request.ctx.user: - if request.ctx.user.privileged: - return - raise Forbidden("Access Forbidden: Only for privileged users", quiet=True) - elif config.config.public or request.ctx.user: +async def verify(request, *, privileged=False): + """Verify that the request is authorized. + + For paskia mode, validates against the SSO backend. + For password mode, checks session-based authentication. + For none mode, allows all requests. + + All 401/403 responses include auth.iframe URL for consistent frontend handling + via the paskia library's showAuthIframe(). + + Args: + request: The Sanic request object + privileged: If True, requires admin privileges + + Raises: + Unauthorized: If authentication is required + Forbidden: If access is denied + """ + if config.config.authentication == "paskia": + # SSO validation against auth backend + sso = _get_sso() + perm = "cista:login cista:admin" if privileged else "cista:login" + await sso.validate_sso_request(request, perm=perm) return - raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True) + + user = getattr(request.ctx, "user", None) + if privileged: + if user: + if user.privileged: + return + raise Forbidden( + "Access Forbidden: Only for privileged users", + context={"auth": {"iframe": "/auth/api/restricted?mode=forbidden"}}, + quiet=True, + ) + elif config.config.authentication == "none" or user: + return + # Return iframe URL for paskia library to show login dialog + raise Unauthorized( + f"Login required for {request.path}", + "cookie", + context={"auth": {"iframe": "/auth/api/restricted?mode=login"}}, + quiet=True, + ) -bp = Blueprint("auth") +bp = Blueprint("auth", url_prefix="/auth") -@bp.get("/login") +@bp.on_request +async def check_external_auth(request): + """Disable built-in auth routes when external auth is enabled""" + if config.config.authentication == "paskia": + from sanic.exceptions import NotFound + + raise NotFound("Not available in external auth mode") + + +@bp.get("/api/restricted") async def login_page(request): - doc = Document("Cista Login") - with doc.div(id="login"): - with doc.form(method="POST", autocomplete="on"): - doc.h1("Login") - doc.input( - name="username", - placeholder="Username", - autocomplete="username", - required=True, - ).br - doc.input( - type="password", - name="password", - placeholder="Password", - autocomplete="current-password", - required=True, - ).br - doc.input(type="submit", value="Login") - s = session.get(request) - if s: - name = s["username"] - with doc.form(method="POST", action="/logout"): - doc.input(type="submit", value=f"Logout {name}") - flash = request.cookies.message - if flash: - doc.dialog( - flash, - id="flash", - open=True, - style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8", - ) + """Login page that works both standalone and in paskia iframe. + + Query params: + - mode: 'login' (default), 'reauth', or 'forbidden' - affects messaging + """ + mode = request.args.get("mode", "login") + s = session.get(request) + + # Check if already logged in + if s and mode == "login": + # Already authenticated - signal success if in iframe + return html(_login_success_page(s["username"])) + + title = { + "forbidden": "Access Denied", + "reauth": "Re-authenticate", + }.get(mode, "Login Required") + + message = { + "forbidden": "You don't have permission. Try logging in with a different account.", + "reauth": "Your session has expired. Please log in again.", + }.get(mode, "Please log in to continue.") + + doc = Document(f"Cista - {title}") + # Add paskia-compatible styling and scripts + doc.style(_LOGIN_PAGE_CSS) + with doc.div(class_="login-card"): + doc.h1(title) + with doc.div(class_="content"): + doc.p(message, class_="message") + with doc.form(method="POST", id="loginForm", autocomplete="on"): + doc.label("Username:", for_="username") + doc.input( + type="text", + id="username", + name="username", + autocomplete="username webauthn", + required=True, + ) + doc.label("Password:", for_="password") + doc.input( + type="password", + id="password", + name="password", + autocomplete="current-password webauthn", + required=True, + ) + with doc.div(class_="button-row"): + doc.button("Log in", type="submit", id="submitBtn") + doc.p("", class_="error", id="error") + + # JavaScript for AJAX login and postMessage communication + doc.script_(_LOGIN_PAGE_JS) + res = html(doc) - if flash: - res.cookies.delete_cookie("flash") if s is False: session.delete(res) return res +def _login_success_page(username: str) -> str: + """Minimal page that signals auth-success to parent iframe.""" + return str( + Document().script_("window.parent.postMessage({type:'auth-success'},'*')") + ) + + @bp.post("/login") async def login_post(request): try: @@ -196,7 +435,7 @@ async def change_password(request): @bp.get("/users") async def list_users(request): - verify(request, privileged=True) + await verify(request, privileged=True) users = [] for name, user in config.config.users.items(): users.append( @@ -211,7 +450,7 @@ async def list_users(request): @bp.post("/users") async def create_user(request): - verify(request, privileged=True) + await verify(request, privileged=True) try: if request.headers.content_type == "application/json": username = request.json["username"] @@ -240,7 +479,7 @@ async def create_user(request): @bp.put("/users/") async def update_user(request, username): - verify(request, privileged=True) + await verify(request, privileged=True) try: if request.headers.content_type == "application/json": changes = request.json @@ -273,7 +512,7 @@ async def update_user(request, username): @bp.delete("/users/") async def delete_user(request, username): - verify(request, privileged=True) + await verify(request, privileged=True) if username not in config.config.users: raise BadRequest("User does not exist") try: @@ -281,14 +520,3 @@ async def delete_user(request, username): except Exception as e: raise BadRequest(str(e)) from e return json({"message": f"User {username} deleted"}) - - -@bp.put("/config/public") -async def update_public(request): - verify(request, privileged=True) - try: - public = request.json["public"] - except KeyError: - raise BadRequest("Missing public field") from None - config.update_config({"public": public}) - return json({"message": "Public setting updated"}) diff --git a/cista/config.py b/cista/config.py index 093d0c2..ad843a5 100644 --- a/cista/config.py +++ b/cista/config.py @@ -13,12 +13,15 @@ from typing import Callable, Concatenate, Literal, ParamSpec import msgspec import msgspec.toml +# Authentication modes +AuthMode = Literal["none", "paskia", "password"] + class Config(msgspec.Struct): path: Path listen: str secret: str = secrets.token_hex(12) - public: bool = False + authentication: AuthMode = "password" name: str = "" users: dict[str, User] = {} links: dict[str, Link] = {} @@ -152,7 +155,15 @@ def modifies_config( def load_config(): global config init_confdir() - config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) + raw = conffile.read_bytes() + config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook) + # Migrate from old public flag if present + raw_dict = msgspec.toml.decode(raw) + if "public" in raw_dict and "authentication" not in raw_dict: + # Old config: migrate public flag to authentication mode + new_auth = "none" if raw_dict["public"] else "password" + config = msgspec.structs.replace(config, authentication=new_auth) + update_config({}) # Save the migrated config @modifies_config diff --git a/cista/preview.py b/cista/preview.py index f9d8e3b..87bd6e0 100644 --- a/cista/preview.py +++ b/cista/preview.py @@ -17,13 +17,20 @@ from sanic import Blueprint, empty, raw, redirect from sanic.exceptions import NotFound from sanic.log import logger -from cista import config +from cista import auth, config from cista.util.filename import sanitize pillow_heif.register_heif_opener() bp = Blueprint("preview", url_prefix="/preview") + +@bp.on_request +async def verify_preview(request): + """Verify access to preview routes.""" + await auth.verify(request) + + # Map EXIF Orientation value to a corresponding PIL transpose EXIF_ORI = { 2: Image.Transpose.FLIP_LEFT_RIGHT, @@ -53,7 +60,7 @@ async def preview(req, path): "etag": etag, "last-modified": format_date_time(stat.st_mtime), "cache-control": "max-age=604800, immutable" - + ("" if config.config.public else ", private"), + + ("" if config.config.authentication == "none" else ", private"), "content-type": "image/avif", "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", } diff --git a/cista/sso.py b/cista/sso.py new file mode 100644 index 0000000..2bf31ff --- /dev/null +++ b/cista/sso.py @@ -0,0 +1,225 @@ +"""SSO (paskia) authentication proxy and validation module. + +When paskia authentication mode is enabled: +- 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 (default: http://localhost:4401) +""" + +import os + +import httpx +from sanic import Blueprint +from sanic.exceptions import Forbidden, Unauthorized +from sanic.log import logger + +from cista import config + +# Auth backend URL for SSO validation (from env with default, no trailing slash) +PASKIA_BACKEND_URL = os.environ.get("PASKIA_BACKEND_URL", "http://localhost:4401").rstrip("/") + +# 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=10.0) + 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") -> 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) + + 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 config.config.authentication != "paskia": + return None + + client = await get_client() + + # Forward relevant headers (especially cookies for session validation) + headers = {} + if "cookie" in request.headers: + headers["cookie"] = request.headers["cookie"] + if "authorization" in request.headers: + headers["authorization"] = request.headers["authorization"] + headers["accept"] = "application/json" + headers["x-forwarded-for"] = request.ip + if "x-forwarded-for" in request.headers: + headers["x-forwarded-for"] = request.headers["x-forwarded-for"] + + try: + response = await client.post( + f"{PASKIA_BACKEND_URL}/auth/api/validate", + params={"perm": perm}, + headers=headers, + ) + + if response.status_code == 200: + # Validation successful + try: + return response.json() + except Exception: + return {} + + # Handle auth errors - return the JSON response for frontend handling + try: + error_data = response.json() + except Exception: + error_data = {"detail": response.text or "Authentication error"} + + if response.status_code == 401: + raise Unauthorized( + error_data.get("detail", "Authentication required"), + "cookie", + context=error_data, + quiet=True, + ) + elif response.status_code == 403: + raise Forbidden( + error_data.get("detail", "Access denied"), + context=error_data, + quiet=True, + ) + else: + logger.warning( + f"SSO validation returned unexpected status: {response.status_code}" + ) + raise Forbidden( + error_data.get("detail", "Authentication error"), + context=error_data, + quiet=True, + ) + + except httpx.RequestError as e: + logger.error(f"SSO validation request failed: {e}") + raise Forbidden( + "Authentication service unavailable", + quiet=True, + ) + + +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() + + # Build the target URL - strip any prefix and forward to auth backend + path = request.path + query_string = request.query_string + url = f"{PASKIA_BACKEND_URL}{path}" + if query_string: + url = f"{url}?{query_string}" + + # Forward headers + headers = dict(request.headers) + # Remove hop-by-hop headers + for hop_header in [ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-authorization", + "proxy-authenticate", + ]: + headers.pop(hop_header, None) + + # Add forwarded headers + headers["x-forwarded-for"] = request.ip + headers["x-forwarded-host"] = request.host + headers["x-forwarded-proto"] = request.scheme + + try: + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=request.body if request.body else None, + ) + + # Build response headers + resp_headers = dict(response.headers) + # Remove hop-by-hop headers from response + for hop_header in [ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "content-encoding", + "content-length", + ]: + resp_headers.pop(hop_header, None) + + from sanic import raw as raw_response + + return raw_response( + response.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}") + from sanic import json + + return json( + {"detail": "Authentication service unavailable", "error": str(e)}, + status=503, + ) + + +# Blueprint for auth proxy routes +bp = Blueprint("sso", url_prefix="/auth") + + +@bp.on_request +async def check_sso_enabled(request): + """Only handle requests if paskia mode is enabled.""" + if config.config.authentication != "paskia": + from sanic.exceptions import NotFound + + raise NotFound("SSO authentication not enabled") + + +@bp.route( + "/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] +) +async def auth_proxy(request, path=""): + """Proxy all auth requests to the auth backend.""" + 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) diff --git a/cista/util/apphelpers.py b/cista/util/apphelpers.py index fd6b63b..f6e943c 100644 --- a/cista/util/apphelpers.py +++ b/cista/util/apphelpers.py @@ -33,8 +33,11 @@ async def handle_sanic_exception(request, e): logger.exception(e) # Non-browsers get JSON errors if "text/html" not in request.headers.accept: + # Include auth context if present (for SSO auth required responses) + # Auth must be at top level for paskia library to detect it + response_data = {"code": code, "message": message, "detail": message, **context} return jres( - ErrorMsg({"code": code, "message": message, **context}), + response_data, status=code, ) # Redirections flash the error message via cookies @@ -52,7 +55,7 @@ def websocket_wrapper(handler): @wraps(handler) async def wrapper(request, ws, *args, **kwargs): try: - auth.verify(request) + await auth.verify(request) await handler(request, ws, *args, **kwargs) except Exception as e: context, code, message = {}, 500, str(e) or "Internal Server Error" diff --git a/frontend/package.json b/frontend/package.json index 27894cd..a63b799 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "esbuild": "^0.19.5", "lodash": "^4.17.21", "lodash-es": "^4.17.21", + "paskia": "^0.1.2", "pinia": "^2.1.6", "pinia-plugin-persistedstate": "^3.2.0", "unplugin-vue-components": "^0.25.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 40d7c8c..5f99389 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,4 @@