Files

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"))}