"""Per-install bearer token gating the localhost API and WS streams.""" from __future__ import annotations import logging import os import secrets from backend.config.paths import AUTH_TOKEN_FILE, DATA_ROOT logger = logging.getLogger(__name__) TOKEN: str = "" def p_write_atomic(path: str, data: str, mode: int = 0o600) -> None: """Atomic write to `path` at the given file mode; never world-readable or half-written.""" os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, mode) try: os.write(fd, data.encode("utf-8")) finally: os.close(fd) try: os.chmod(tmp, mode) except Exception: pass os.replace(tmp, path) def init_auth_token() -> str: """Load the per-install token from disk, or mint one if missing; reused across restarts so Electron's cached copy stays valid.""" global TOKEN try: if os.path.exists(AUTH_TOKEN_FILE): with open(AUTH_TOKEN_FILE, "r", encoding="utf-8") as f: existing = f.read().strip() if existing and 16 <= len(existing) <= 512: TOKEN = existing logger.info( f"auth: reusing existing token from {AUTH_TOKEN_FILE}" ) return TOKEN except Exception as e: logger.warning(f"auth: failed to read existing token, generating new: {e}") TOKEN = secrets.token_urlsafe(32) try: p_write_atomic(AUTH_TOKEN_FILE, TOKEN, mode=0o600) logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)") except Exception as e: # If we can't write the file, Electron can't read it; log loudly but don't crash. logger.error(f"auth: failed to write token file: {e}") return TOKEN def get_auth_token() -> str: """Return the current token. Empty string if init_auth_token() hasn't run.""" return TOKEN class p_TokenScrubFilter(logging.Filter): """Logging filter that redacts the install token from log records (defense in depth).""" P_PLACEHOLDER = "" @staticmethod def p_args_might_contain_token(args) -> bool: """Cheap pre-check; avoids eager %-formatting on the >99% of records that don't mention the token.""" if not args: return False items = args if isinstance(args, (tuple, list)) else (args,) for a in items: if isinstance(a, str) and TOKEN in a: return True if isinstance(a, dict): for v in a.values(): if isinstance(v, str) and TOKEN in v: return True return False @classmethod def p_scrub_args(cls, args): """Scrub token from args while preserving tuple/dict shape; uvicorn's AccessFormatter unpacks args as a 5-tuple and explodes on None.""" if args is None: return args if isinstance(args, dict): new_dict = None for k, v in args.items(): if isinstance(v, str) and TOKEN in v: if new_dict is None: new_dict = dict(args) new_dict[k] = v.replace(TOKEN, cls.P_PLACEHOLDER) return new_dict if new_dict is not None else args if isinstance(args, tuple): new_list = None for i, v in enumerate(args): if isinstance(v, str) and TOKEN in v: if new_list is None: new_list = list(args) new_list[i] = v.replace(TOKEN, cls.P_PLACEHOLDER) return tuple(new_list) if new_list is not None else args if isinstance(args, str) and TOKEN in args: return args.replace(TOKEN, cls.P_PLACEHOLDER) return args def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive) if not TOKEN: return True # Fast path: skip eager %-formatting on records that don't mention the token. raw_msg = record.msg if isinstance(record.msg, str) else "" if TOKEN not in raw_msg and not self.p_args_might_contain_token(record.args): return True # Slow path: in-place args rewrite (preserves shape for AccessFormatter), then re-render to catch tokens buried in custom reprs. try: if isinstance(record.msg, str) and TOKEN in record.msg: record.msg = record.msg.replace(TOKEN, self.P_PLACEHOLDER) scrubbed = self.p_scrub_args(record.args) if scrubbed is not record.args: record.args = scrubbed try: rendered = record.getMessage() if TOKEN in rendered: record.msg = rendered.replace(TOKEN, self.P_PLACEHOLDER) record.args = None except Exception: pass except Exception: # Never let the scrubber suppress a log line; worst case the token leaks for that one record. pass return True p_scrubber_installed = False def install_token_scrubber() -> None: """Attach the scrubbing filter to every existing AND future log handler; logger-level filters miss propagated child records.""" global p_scrubber_installed if p_scrubber_installed: return scrubber = p_TokenScrubFilter() def p_attach(handler: logging.Handler) -> None: if not any(isinstance(f, p_TokenScrubFilter) for f in handler.filters): handler.addFilter(scrubber) loggers: list[logging.Logger] = [logging.getLogger()] for logger in logging.root.manager.loggerDict.values(): if isinstance(logger, logging.Logger): loggers.append(logger) for logger in loggers: for h in list(logger.handlers): p_attach(h) # Patch addHandler so handlers attached later (uvicorn finishes log config after main.py imports) get the scrubber too. p_original_addHandler = logging.Logger.addHandler def p_patched_addHandler(self: logging.Logger, hdlr: logging.Handler) -> None: p_attach(hdlr) return p_original_addHandler(self, hdlr) logging.Logger.addHandler = p_patched_addHandler # type: ignore[assignment] root = logging.getLogger() if not any(isinstance(f, p_TokenScrubFilter) for f in root.filters): root.addFilter(scrubber) p_scrubber_installed = True # Auth-exempt paths: external redirects with their own nonce/state validation, plus the bootstrap health probe. P_AUTH_EXEMPT_EXACT = { "/api/subscriptions/callback", "/api/tools/oauth/callback", "/api/tools/oauth/cloud-claim", "/api/subscription/activate", "/api/auth/signin-activate", "/api/version", # Local Google OAuth token-endpoint proxy: hit by the google-workspace-mcp subprocess we spawn. It doesn't (and can't easily) carry the install bearer in google-auth's refresh post. Localhost binding is the gate, and the route does nothing the public api.openswarm.com/api/oauth/google/refresh doesn't already do for any internet caller, so no new attack surface. "/api/tools/google-oauth-token", # Dev-only token handoff for the split-port frontend (no Electron preload to read the token from). The route itself 404s in packaged builds. "/api/dev/token", } P_AUTH_EXEMPT_PREFIX = ( # Electron polls /api/health/check before loading the token. "/api/health", # 9Router proxies OpenAI requests with the user's sk-... bearer, not our local token; localhost-only is the gate. "/api/openai-passthrough", "/docs", "/openapi", "/redoc", "/favicon", ) def is_path_exempt(path: str) -> bool: """True if this request path bypasses token auth.""" if path in P_AUTH_EXEMPT_EXACT: return True for p in P_AUTH_EXEMPT_PREFIX: if path.startswith(p): return True return False def extract_bearer(header_value: str | None) -> str: """Pull the token out of `Authorization: Bearer `.""" if not header_value: return "" if header_value.startswith("Bearer "): return header_value[len("Bearer "):].strip() if header_value.startswith("bearer "): return header_value[len("bearer "):].strip() return "" def request_matches_token(request_headers: dict, query_params: dict | None = None) -> bool: """Validate that an HTTP/WS request carries our token (Bearer, x-openswarm-token, or ?token=); constant-time compare.""" if not TOKEN: # Backend not initialized: fail closed. Only test fixtures that bypass main hit this. return False candidates: list[str] = [] auth = request_headers.get("authorization") or request_headers.get("Authorization") bearer = extract_bearer(auth) if bearer: candidates.append(bearer) openswarm_header = ( request_headers.get("x-openswarm-token") or request_headers.get("X-OpenSwarm-Token") ) if openswarm_header: candidates.append(openswarm_header.strip()) if query_params: qp_token = query_params.get("token") if qp_token: candidates.append(qp_token) for candidate in candidates: if secrets.compare_digest(candidate, TOKEN): return True return False # WS Origin allowlist: Electron packaged is file://, dev is localhost:3000, some Electron contexts send bare "null". P_ORIGIN_ALLOWLIST_DEV = { "http://localhost:3000", "http://127.0.0.1:3000", "file://", "null", } def is_origin_allowed(origin: str | None) -> bool: """True if the WS connection's Origin header is from our app.""" if origin is None: # Native WS client / curl / MCP subprocess: token check still required, so allow. return True if origin in P_ORIGIN_ALLOWLIST_DEV: return True # Packaged Electron file:// includes paths like file:///Applications/OpenSwarm.app/...; match by prefix. if origin.startswith("file://"): return True if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"): return True return False