mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 03:35:40 +02:00
297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""Desktop-side sign-in endpoints. Mirrors openswarm-cloud/src/routes/auth/*.
|
|
|
|
The cloud handles the actual OAuth / magic-link flows. The desktop's role is
|
|
narrow: when the bearer-handoff page POSTs the token to localhost, we
|
|
validate it with the cloud (returns user_id + email + plan) and persist it
|
|
to the local settings store so subsequent requests carry the bearer.
|
|
|
|
POST /api/auth/signin-activate {token, signin_method, email?}
|
|
Validates the bearer at cloud /api/auth/signin-activate.
|
|
Persists user_id, user_email, signin_method, and (if a paid plan was
|
|
returned) bearer + plan + expires.
|
|
|
|
POST /api/auth/signout
|
|
Calls cloud /api/auth/signout to revoke the bearer, then clears local
|
|
identity fields.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from typing import Optional, Literal
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from backend.config.Apps import SubApp
|
|
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
|
|
from backend.apps.settings.settings import load_settings, save_settings_async
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def auth_lifespan():
|
|
yield
|
|
|
|
|
|
auth = SubApp("auth", auth_lifespan)
|
|
|
|
|
|
def p_proxy_url() -> str:
|
|
settings_obj = load_settings()
|
|
url = (getattr(settings_obj, "openswarm_proxy_url", None)
|
|
or OPENSWARM_DEFAULT_PROXY_URL)
|
|
return url.rstrip("/")
|
|
|
|
|
|
async def p_sync_pro_routing(settings_obj) -> None:
|
|
"""Mirror connection state into 9Router's Claude lane; sign-in can flip a
|
|
paying user into pro mode and sign-out must tear the lane down so a
|
|
revoked bearer doesn't linger in the router."""
|
|
try:
|
|
from backend.apps.nine_router import sync_pro_routing
|
|
await sync_pro_routing(settings_obj)
|
|
except Exception as e:
|
|
logger.debug("pro routing sync skipped: %s", e)
|
|
|
|
|
|
def p_sync_identity_to_service(settings_obj) -> None:
|
|
"""Push user_id + email + signin_method into the service-sync identify
|
|
pipeline so every event from this user has the right Person properties."""
|
|
try:
|
|
from backend.apps.service.client import identify as p_identify
|
|
except Exception:
|
|
return
|
|
props = {
|
|
"signin_method": getattr(settings_obj, "signin_method", None),
|
|
"is_signed_in": bool(getattr(settings_obj, "user_id", None)),
|
|
}
|
|
email = getattr(settings_obj, "user_email", None)
|
|
if email:
|
|
props["email"] = email
|
|
try:
|
|
p_identify(props)
|
|
except Exception as e:
|
|
logger.debug("identify sync failed: %s", e)
|
|
if email:
|
|
try:
|
|
from backend.apps.service.analytics.client import track_link_email
|
|
track_link_email(email)
|
|
except Exception as e:
|
|
logger.debug("analytics link_email sync failed: %s", e)
|
|
|
|
|
|
# --------------------------------------------------------------------------- POST /api/auth/signin-activate ---------------------------------------------------------------------------
|
|
|
|
class SigninActivateRequest(BaseModel):
|
|
token: str
|
|
signin_method: Literal["google", "email"]
|
|
email: Optional[str] = None
|
|
|
|
|
|
@auth.router.post("/signin-activate")
|
|
async def signin_activate(body: SigninActivateRequest):
|
|
"""Validate a freshly-minted sign-in bearer and persist it locally.
|
|
|
|
The bearer-handoff page (cloud lib/authMint.ts → bearerHandoffPage())
|
|
POSTs to this endpoint after a Google OAuth or magic-link flow. We
|
|
re-validate the bearer with the cloud; never just trust whatever
|
|
arrives at the localhost endpoint; then write user_id + email +
|
|
signin_method to settings so the renderer flips to signed-in.
|
|
"""
|
|
if not body.token or len(body.token) < 16:
|
|
raise HTTPException(status_code=400, detail="Invalid token")
|
|
|
|
proxy = p_proxy_url()
|
|
payload = {
|
|
"token": body.token,
|
|
"signin_method": body.signin_method,
|
|
"email": body.email,
|
|
}
|
|
p_install_id = getattr(load_settings(), "installation_id", None)
|
|
if p_install_id:
|
|
payload["install_id"] = p_install_id
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.post(
|
|
f"{proxy}/api/auth/signin-activate",
|
|
json=payload,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Could not reach sign-in service: {e}",
|
|
)
|
|
|
|
if r.status_code == 401:
|
|
raise HTTPException(status_code=401, detail="Token rejected by service")
|
|
if r.status_code >= 400:
|
|
raise HTTPException(
|
|
status_code=r.status_code,
|
|
detail=r.text[:200] or "Service error",
|
|
)
|
|
|
|
me = r.json()
|
|
user_id = me.get("user_id")
|
|
email = me.get("email")
|
|
plan = me.get("plan")
|
|
expires = me.get("expires")
|
|
method = me.get("signin_method") or body.signin_method
|
|
|
|
settings_obj = load_settings()
|
|
settings_obj.user_id = user_id
|
|
settings_obj.user_email = email
|
|
settings_obj.signin_method = method
|
|
# If the user happens to be a paying customer too (Stripe + sign-in share a user row by email), surface plan/expires so the chat picker exposes Pro models. Free-tier signups land here with plan="free" and expires=null; connection_mode stays own_key.
|
|
if isinstance(plan, str) and plan != "free":
|
|
settings_obj.connection_mode = "openswarm-pro"
|
|
settings_obj.openswarm_bearer_token = body.token
|
|
settings_obj.openswarm_proxy_url = proxy
|
|
settings_obj.openswarm_subscription_plan = plan
|
|
if isinstance(expires, str):
|
|
settings_obj.openswarm_subscription_expires = expires
|
|
else:
|
|
# Free-tier: still store the bearer so future API calls can identify the user (used by /api/me/profile, /api/auth/signout). Do NOT flip connection_mode; that's reserved for paid plans only so chat routing keeps using own_key/BYO.
|
|
settings_obj.openswarm_bearer_token = body.token
|
|
settings_obj.openswarm_proxy_url = proxy
|
|
|
|
await save_settings_async(settings_obj)
|
|
p_sync_identity_to_service(settings_obj)
|
|
await p_sync_pro_routing(settings_obj)
|
|
|
|
return {
|
|
"ok": True,
|
|
"user_id": user_id,
|
|
"email": email,
|
|
"plan": plan or "free",
|
|
"signin_method": method,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- POST /api/auth/signout ---------------------------------------------------------------------------
|
|
|
|
@auth.router.post("/signout")
|
|
async def signout():
|
|
"""Revoke the cloud-side bearer + clear local identity state.
|
|
|
|
Also stops every in-flight agent session so any 9Router subprocess
|
|
that captured the now-revoked bearer at spawn time can't keep using
|
|
it. Without this, a signed-out user's old chat tabs would keep
|
|
making /v1/messages calls with a token the cloud has revoked,
|
|
surfacing as 401s in the agent UI ("Invalid bearer token").
|
|
"""
|
|
settings_obj = load_settings()
|
|
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
|
|
proxy = p_proxy_url()
|
|
if bearer:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(
|
|
f"{proxy}/api/auth/signout",
|
|
headers={"Authorization": f"Bearer {bearer}"},
|
|
)
|
|
except httpx.HTTPError as e:
|
|
# Network failure shouldn't strand the user signed-in locally; the cloud token is invalidated lazily on next use anyway.
|
|
logger.warning("cloud signout failed (clearing local anyway): %s", e)
|
|
|
|
# Stop every running agent session AND drop their cached SDK resume state BEFORE clearing local settings. Two failure modes this prevents: 1. A 9Router subprocess captured the now-revoked bearer at spawn time and would 401 on the next /v1/messages call. 2. A session has an `sdk_session_id` from a conversation served by the previous identity's Claude account; resuming against the new bearer would 404 or 401 because the new account has no record of that thread. Wiping it forces the SDK to start a fresh thread on next send (transcript replay still works; only the SDK's server-side resume cache is reset). Best-effort: failures here shouldn't block the sign-out itself.
|
|
try:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.manager.session.session_store import save_session
|
|
|
|
running = list(agent_manager.tasks.keys())
|
|
for session_id in running:
|
|
try:
|
|
await agent_manager.stop_agent(session_id)
|
|
except Exception as e:
|
|
logger.warning("signout: stop_agent(%s) failed: %s", session_id, e)
|
|
|
|
# Walk every loaded session (running, stopped, persisted-but-resumed) and clear the SDK resume id so the next send starts a fresh thread under whichever identity the user re-signs-in with.
|
|
for sess in list(agent_manager.sessions.values()):
|
|
if sess.sdk_session_id:
|
|
sess.sdk_session_id = None
|
|
try:
|
|
save_session(sess.id, sess.model_dump(mode="json"))
|
|
except Exception as e:
|
|
logger.warning("signout: save_session(%s) failed: %s", sess.id, e)
|
|
|
|
if running:
|
|
logger.info("signout: stopped %d in-flight agent session(s)", len(running))
|
|
except Exception as e:
|
|
logger.warning("signout: agent shutdown skipped: %s", e)
|
|
|
|
settings_obj.user_id = None
|
|
settings_obj.user_email = None
|
|
settings_obj.signin_method = None
|
|
settings_obj.openswarm_bearer_token = None
|
|
settings_obj.connection_mode = "own_key"
|
|
settings_obj.openswarm_subscription_plan = None
|
|
settings_obj.openswarm_subscription_expires = None
|
|
settings_obj.openswarm_usage_cached = None
|
|
await save_settings_async(settings_obj)
|
|
p_sync_identity_to_service(settings_obj)
|
|
await p_sync_pro_routing(settings_obj)
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- /api/auth/email-prefs ---------------------------------------------------------------------------
|
|
|
|
class EmailPrefsUpdate(BaseModel):
|
|
run_emails: bool
|
|
|
|
|
|
def p_email_prefs_unavailable() -> dict:
|
|
return {"available": False, "run_emails": None}
|
|
|
|
|
|
@auth.router.get("/email-prefs")
|
|
async def get_email_prefs():
|
|
"""Proxy the cloud's signed-in run-email preference for the Settings toggle.
|
|
|
|
Degrades to available:false (the Settings row falls back to its honest
|
|
ghost label) when signed out, when the cloud is unreachable, or when prod
|
|
predates the prefs endpoint. Never 500s the Settings page.
|
|
"""
|
|
from backend.apps.settings.credentials import account_auth
|
|
|
|
token, base = account_auth(load_settings())
|
|
if not token:
|
|
return p_email_prefs_unavailable()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=6.0) as client:
|
|
r = await client.get(
|
|
f"{base}/api/email-prefs/mine",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
except httpx.HTTPError:
|
|
return p_email_prefs_unavailable()
|
|
if r.status_code != 200:
|
|
return p_email_prefs_unavailable()
|
|
data = r.json()
|
|
return {"available": True, "run_emails": bool(data.get("run_emails"))}
|
|
|
|
|
|
@auth.router.put("/email-prefs")
|
|
async def put_email_prefs(body: EmailPrefsUpdate):
|
|
from backend.apps.settings.credentials import account_auth
|
|
|
|
token, base = account_auth(load_settings())
|
|
if not token:
|
|
return p_email_prefs_unavailable()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=6.0) as client:
|
|
r = await client.put(
|
|
f"{base}/api/email-prefs/mine",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"run_emails": body.run_emails},
|
|
)
|
|
except httpx.HTTPError:
|
|
return p_email_prefs_unavailable()
|
|
if r.status_code != 200:
|
|
return p_email_prefs_unavailable()
|
|
data = r.json()
|
|
return {"available": True, "run_emails": bool(data.get("run_emails"))}
|