mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 13:32:22 +02:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
"""Pending and recently-completed OAuth flows.
|
|
|
|
The pending map used to live only in memory, and the gap that opens is the whole of ENG-363: the
|
|
user clicks Connect, the browser leaves for the provider, the backend restarts for ANY reason
|
|
(uvicorn reload in dev, the ENG-357 frozen-loop exit, a watchdog respawn, a crash), and the state
|
|
that proves the returning callback is ours is simply gone. The callback then lands on the
|
|
unknown-state branch and renders "Session expired", the Settings row spins forever, and the user
|
|
concludes the product cannot connect to Anthropic. Haik reported exactly that.
|
|
|
|
Retrying is not the fix, because the user is not the one who failed. Making the state outlive a
|
|
restart is: the callback then completes on its own and there is nothing to click.
|
|
|
|
The verifier is a short-lived, single-use secret, so it is written 0600, expires on a TTL, and is
|
|
deleted the moment it is consumed. It never becomes a durable credential lying around on disk.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
from typing import Dict, Optional
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.config.paths import DATA_ROOT
|
|
|
|
# One OAuth round trip is a browser hop and a login; a quarter hour is generous for a human doing that, and short enough that an abandoned flow's verifier does not linger.
|
|
PENDING_TTL_S = 15 * 60
|
|
|
|
PENDING_PATH = os.path.join(DATA_ROOT, "pending_oauth.json")
|
|
|
|
|
|
@typechecked
|
|
def p_load() -> Dict[str, dict]:
|
|
"""Read the durable map, dropping anything past its TTL. Unreadable state is treated as empty: a corrupt file must not make Connect permanently impossible."""
|
|
try:
|
|
with open(PENDING_PATH, encoding="utf-8") as fh:
|
|
raw = json.load(fh)
|
|
except Exception:
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
now = time.time()
|
|
return {
|
|
k: v for k, v in raw.items()
|
|
if isinstance(v, dict) and float(v.get("stored_at", 0) or 0) + PENDING_TTL_S > now
|
|
}
|
|
|
|
|
|
@typechecked
|
|
def p_store(entries: Dict[str, dict]) -> None:
|
|
"""Write 0600 and replace atomically, so a crash mid-write cannot leave a half-parsed file that strands every later Connect."""
|
|
try:
|
|
os.makedirs(DATA_ROOT, exist_ok=True)
|
|
tmp = f"{PENDING_PATH}.tmp"
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
json.dump(entries, fh)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, PENDING_PATH)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class PendingOAuth:
|
|
"""Dict-shaped so every existing call site keeps working, but backed by disk.
|
|
|
|
Deliberately not a plain dict subclass: the whole point is that reads come from the file, so an
|
|
entry written before a restart is still found by the process that comes back.
|
|
"""
|
|
|
|
@typechecked
|
|
def __setitem__(self, state: str, value: dict) -> None:
|
|
entries = p_load()
|
|
entries[state] = {**value, "stored_at": time.time()}
|
|
p_store(entries)
|
|
|
|
@typechecked
|
|
def get(self, state: str, default: Optional[dict] = None) -> Optional[dict]:
|
|
return p_load().get(state, default)
|
|
|
|
@typechecked
|
|
def pop(self, state: str, default: Optional[dict] = None) -> Optional[dict]:
|
|
entries = p_load()
|
|
found = entries.pop(state, None)
|
|
if found is None:
|
|
return default
|
|
# Consumed: the verifier is single-use, so it stops existing here rather than aging out later.
|
|
p_store(entries)
|
|
return found
|
|
|
|
@typechecked
|
|
def __contains__(self, state: str) -> bool:
|
|
return state in p_load()
|
|
|
|
@typechecked
|
|
def __len__(self) -> int:
|
|
return len(p_load())
|
|
|
|
|
|
pending_oauth = PendingOAuth()
|
|
# Recently-completed OAuth states so the /api/subscriptions/callback handler can distinguish a legitimate duplicate callback (browser prefetch, refresh, or Google redirect retry after a slow first response) from a truly stale request. Bounded FIFO, drops the oldest entries once it grows past MAX_COMPLETED_OAUTH so it can't leak memory.
|
|
completed_oauth: list[str] = []
|
|
MAX_COMPLETED_OAUTH = 64
|
|
|
|
|
|
@typechecked
|
|
def mark_oauth_completed(state: str) -> None:
|
|
if state in completed_oauth:
|
|
return
|
|
completed_oauth.append(state)
|
|
# Trim head if we've outgrown the bound
|
|
while len(completed_oauth) > MAX_COMPLETED_OAUTH:
|
|
completed_oauth.pop(0)
|