Files
openswarm/backend/auth.py
T

434 lines
18 KiB
Python

"""Per-install auth token for the localhost API.
OpenSwarm's backend runs a FastAPI server on `127.0.0.1:<random-port>`
and streams sensitive agent data (tool inputs, approval requests,
messages) over WebSockets. Without auth, any webpage loaded in any
browser on the same machine can connect to those endpoints — WebSockets
aren't subject to Same-Origin Policy — and impersonate the user.
This module issues a cryptographically random token on first boot,
writes it 0600 to `<DATA_ROOT>/auth.token`, and reuses it on subsequent
restarts (so dev-mode hot-reload doesn't break the renderer's cached
copy). The token is regenerated only when the file is missing or empty.
Only code running as the same OS user can read the file.
Delivery to legitimate consumers:
- Electron main process reads the file and exposes it to the renderer
via a contextBridge method in preload.js (NOT plain window global).
- Our Python MCP subprocesses receive it via env var
`OPENSWARM_AUTH_TOKEN` that agent_manager passes when spawning.
- The Claude Code CLI we spawn receives it as `ANTHROPIC_API_KEY` in
env; the anthropic-proxy route trusts that value.
None of those paths are accessible from a third-party webpage.
"""
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 _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
"""Write `data` to `path` atomically with the given file mode.
Uses `os.open(..., O_CREAT|O_WRONLY|O_TRUNC, mode)` + rename so the
final file is never world-readable and never left half-written if
the backend crashes mid-write. Windows-safe (rename of a file over
an existing one works on NTFS when the source was just closed).
"""
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:
"""Initialise the per-install auth token, persisting to disk.
Behaviour: prefer an existing token on disk; only mint a fresh one
when the file is absent or empty. This matters for two cases:
1. Dev mode (`bash run.sh`) — uvicorn's WatchFiles reload restarts
the worker process and re-runs init_auth_token. If we generated
a fresh token every reload, Electron's cached token (read once
at app boot) would mismatch and every authed request 401s
until the user fully restarts. Preserving the on-disk token
keeps Electron and the backend in sync across reloads.
2. Packaged builds — the user can restart the backend (Quit + reopen)
without the renderer reloading. Same mismatch risk, same fix.
Security trade-off: we no longer rotate the token on every restart.
The threat model that rotation was protecting against (a stale token
sitting in a log/crash dump being usable later) is marginal — anyone
who can read the artifact can also re-read the on-disk token, and
real rotation requires the file to be deleted (e.g. by signing out
or wiping the data root). Net: dev-mode reliability wins.
"""
global _TOKEN
# Try existing on-disk token first.
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:
# Fall through to fresh generation on any read error.
logger.warning(f"auth: failed to read existing token, generating new: {e}")
_TOKEN = secrets.token_urlsafe(32)
try:
_write_atomic(AUTH_TOKEN_FILE, _TOKEN, mode=0o600)
logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)")
except Exception as e:
# Fail open is NOT an option here — if we can't write the file,
# Electron can't read it, and the user sees a broken app. But
# don't hard-crash the backend either; log loudly.
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 _TokenScrubFilter(logging.Filter):
"""Logging filter that redacts the install token from any log record.
The token leaks into logs in a few mundane ways: subprocess env dicts
that get logged when an MCP server fails to spawn, urllib retry logs
that include `?token=...` query strings, exception tracebacks that
print the response body of a failed proxied request. None of those
are intentional but they all happen, and the file ends up in crash
dumps / shared bug reports / hosted log aggregators. This filter is
pure defense in depth — behavior is unchanged when the token is
absent from a record.
"""
_PLACEHOLDER = "<REDACTED:openswarm-token>"
@staticmethod
def _args_might_contain_token(args) -> bool:
"""Cheap pre-check: does any positional arg or dict-arg value mention
the token? Avoids the cost of `record.getMessage()` (which eagerly
does %-formatting) on the >99% of log lines that don't touch it."""
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 _scrub_args(cls, args):
"""Replace token within args in-place-equivalent, preserving the
original tuple/dict shape. Uvicorn's AccessFormatter unpacks
record.args as a 5-tuple (client_addr, method, full_path,
http_version, status_code); blanking args to None — what the
previous slow path did — triggered `cannot unpack non-iterable
NoneType object` for every access-logged line that contained
`?token=...`. Returns the same object if nothing was rewritten."""
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._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._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._PLACEHOLDER)
return args
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive)
if not _TOKEN:
return True
# Fast path: the overwhelming majority of log records don't mention
# the token at all. Two cheap string-in-string scans (raw msg + each
# arg) are far cheaper than forcing record.getMessage(), which would
# do eager %-formatting on every record in the process.
raw_msg = record.msg if isinstance(record.msg, str) else ""
if _TOKEN not in raw_msg and not self._args_might_contain_token(record.args):
return True
# Slow path. Two-step scrub so we cover both shapes:
# 1. In-place rewrite of record.msg and any string in record.args
# (or string-valued dict entry). Preserves args shape so
# uvicorn's AccessFormatter — which unpacks record.args as a
# 5-tuple and would explode on args=None — keeps working.
# 2. Render via record.getMessage() and check the substituted
# output. If a token survived step 1 (because it was buried
# inside a nested structure or a custom object's repr, e.g.
# `logger.info("env: %s", env_dict)` where the dict's repr
# exposes the value), bake the redacted final string into
# record.msg and clear args. This last-resort path only
# trips for records that the in-place pass couldn't reach,
# and uvicorn access logs never hit it (their args are
# always primitive strings/ints, fully scrubbed by step 1).
try:
if isinstance(record.msg, str) and _TOKEN in record.msg:
record.msg = record.msg.replace(_TOKEN, self._PLACEHOLDER)
scrubbed = self._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._PLACEHOLDER)
record.args = None
except Exception:
pass
except Exception:
# Never let the scrubber suppress a log line — if formatting
# fails for any reason, fall through and let normal handling
# proceed (worst case the token leaks for that one record).
pass
return True
_scrubber_installed = False
def install_token_scrubber() -> None:
"""Attach the token-scrubbing filter to every log handler in the process.
Why not just `root.addFilter(...)`: filters attached to a Logger only
fire on records emitted DIRECTLY on that logger. Records propagated up
from child loggers (uvicorn.access, uvicorn.error, websockets, etc.)
flow into root's HANDLERS without ever consulting root's logger-level
filters. So a logger-level install silently misses the access log —
which is exactly the line that contains `?token=...` query params.
This implementation:
1. Walks every currently-registered logger (root + everything in
`Logger.manager.loggerDict`) and attaches the scrubber to each
of their handlers.
2. Monkey-patches `Logger.addHandler` so any handler installed
AFTER this call (uvicorn configures its loggers during startup,
after main.py finishes importing) also gets the scrubber.
3. Keeps the root-logger filter as belt-and-suspenders for the
records that ARE emitted directly on root.
Idempotent — repeated calls are a no-op.
"""
global _scrubber_installed
if _scrubber_installed:
return
scrubber = _TokenScrubFilter()
def _attach(handler: logging.Handler) -> None:
if not any(isinstance(f, _TokenScrubFilter) for f in handler.filters):
handler.addFilter(scrubber)
# 1. Existing handlers across every known logger.
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):
_attach(h)
# 2. Future handlers — patch Logger.addHandler so anything attached
# after this point (uvicorn finishing its log config, plugins
# that reconfigure logging on their own) also gets scrubbed.
_original_addHandler = logging.Logger.addHandler
def _patched_addHandler(self: logging.Logger, hdlr: logging.Handler) -> None:
_attach(hdlr)
return _original_addHandler(self, hdlr)
logging.Logger.addHandler = _patched_addHandler # type: ignore[assignment]
# 3. Belt-and-suspenders on root logger itself.
root = logging.getLogger()
if not any(isinstance(f, _TokenScrubFilter) for f in root.filters):
root.addFilter(scrubber)
_scrubber_installed = True
# Paths that never require auth. These are the public surface.
_AUTH_EXEMPT_EXACT = {
# External OAuth providers redirect the user's browser here. The
# browser has no way to inject our bearer token (it's a 302 from
# Google/Anthropic/etc). The `state` query param is already a
# one-time nonce validated against `_pending_oauth`.
"/api/subscriptions/callback",
# Same pattern for the per-tool OAuth flow (Notion / Google Workspace /
# Airtable / HubSpot / Discord). The browser hits this with ?code=...&state=...
# after the user approves on the provider's site; the `state` param is
# the tool_id which we cross-check against _pending_oauth in tools_lib.py.
# Without this exemption the redirect lands a 401 page in the user's
# browser — see tools_lib.py:1156 where redirect_uri is constructed.
"/api/tools/oauth/callback",
# Browser-redirect target for the proxied OAuth claim handoff. Browser
# has no way to inject our bearer token; the install_id check inside
# the handler is what binds the request to this user.
"/api/tools/oauth/cloud-claim",
# Bearer-handoff endpoints called by api.openswarm.com's success page
# AFTER Stripe checkout / Google sign-in / magic-link sign-in. The
# request POSTs the just-minted cloud bearer; the handler then re-
# validates it against the cloud (/api/me or /api/auth/signin-activate).
# The browser has no way to attach our per-install token here — the
# cloud-validated bearer in the body is the actual auth mechanism.
"/api/subscription/activate",
"/api/auth/signin-activate",
"/api/version",
}
# Path prefixes that never require auth. Trailing slash optional.
_AUTH_EXEMPT_PREFIX = (
# Electron's boot handshake polls /api/health/check before it has a
# token (the HTTP port is up before main.js calls loadAuthToken()).
# Use a prefix so /api/health/check — and any future sub-route — is
# covered without re-introducing the bootstrap deadlock that an
# exact "/api/health" match caused.
"/api/health",
# OpenAI API pass-through. 9Router calls this with the user's
# OpenAI Bearer token (sk-…), NOT our local auth token, so our
# middleware would reject. Localhost-only network boundary is the
# security gate — the route only forwards to api.openai.com and
# never touches user data on this machine. See
# backend/apps/agents/openai_passthrough.py for why this exists.
"/api/openai-passthrough",
# FastAPI's default health/docs/schema surface (packaged app never
# ships /docs, but be defensive).
"/docs",
"/openapi",
"/redoc",
"/favicon",
)
def is_path_exempt(path: str) -> bool:
"""True if this request path bypasses token auth."""
if path in _AUTH_EXEMPT_EXACT:
return True
for p in _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 <token>`."""
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 incoming HTTP / WS request carries our token.
Accepts any of:
- `Authorization: Bearer <token>`
- `x-openswarm-token: <token>` (custom header for callers that
can't easily set Authorization — e.g. future CLI clients)
- `?token=<token>` query param (WS only; browsers can't easily
set custom WS headers, so the token rides in the URL)
The token comparison is constant-time via `secrets.compare_digest`.
"""
if not _TOKEN:
# Backend started without auth init — fail closed. This should
# only happen in test fixtures that intentionally bypass main.
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
# Origin allowlist for WS handshakes. Electron's renderer loads from
# `file://` when packaged; `http://localhost:3000` (Vite dev server) and
# `http://127.0.0.1:3000` in dev. A bare `null` Origin is sent by some
# Electron contexts.
_ORIGIN_ALLOWLIST_DEV = {
"http://localhost:3000",
"http://127.0.0.1:3000",
# Electron may load prod build from file:// or an app:// scheme.
"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:
# No Origin header = curl / native WS client / MCP subprocess.
# Token check is still required, so allow.
return True
if origin in _ORIGIN_ALLOWLIST_DEV:
return True
# file:// origins in Electron prod sometimes include paths like
# file:///Applications/OpenSwarm.app/... — match by prefix.
if origin.startswith("file://"):
return True
# localhost + any port (dev servers, tools the developer is running).
if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"):
return True
return False