[eric] seamless subscription connect — no 9Router GUI needed

OAuth flow now stays entirely within OpenSwarm:
1. Click "Connect Claude" → Anthropic sign-in opens as popup
2. User signs in → 9Router's /callback catches redirect
3. postMessage sends code back to OpenSwarm window
4. OpenSwarm exchanges code via /subscriptions/exchange proxy
5. Shows "Connected ✓" — done in 3 steps, 1 app

Changes:
- nine_router.py: return real authUrl for auth_code providers
  instead of redirecting to 9Router dashboard
- nine_router.py: add exchange_oauth() proxy function
- agents.py: add POST /subscriptions/exchange endpoint
- Settings.tsx: popup + window.addEventListener('message') for
  OAuth callback, with polling fallback

Before: 8 steps across 2 apps
After: 3 steps in 1 app (click → sign in → done)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-22 10:52:56 -07:00
co-authored by Claude Opus 4.6
parent 7430eae88a
commit 24ad784cd0
3 changed files with 85 additions and 13 deletions
+20
View File
@@ -336,6 +336,26 @@ async def subscriptions_poll(body: dict):
raise HTTPException(status_code=500, detail=str(e))
@agents.router.post("/subscriptions/exchange")
async def subscriptions_exchange(body: dict):
"""Exchange OAuth code for tokens via 9Router."""
from backend.apps.nine_router import exchange_oauth
provider = body.get("provider", "")
code = body.get("code", "")
redirect_uri = body.get("redirect_uri", "")
code_verifier = body.get("code_verifier", "")
state = body.get("state", "")
if not provider or not code:
raise HTTPException(status_code=400, detail="provider and code required")
try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@agents.router.get("/subscriptions/models")
async def subscriptions_models():
"""List all models available through connected subscriptions."""
+30 -5
View File
@@ -123,12 +123,21 @@ async def start_oauth(provider: str) -> dict:
except Exception:
pass
# For authorization_code providers (Claude, Codex, Gemini),
# open 9Router's own dashboard page where the user can connect.
# 9Router handles the full OAuth flow (popup + callback) internally.
# Authorization code flow — get the real auth URL from 9Router
# The callback goes to 9Router's /callback page which sends postMessage back
callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback"
r = await client.get(
f"{NINE_ROUTER_API}/oauth/{provider}/authorize",
params={"redirect_uri": callback_url},
)
r.raise_for_status()
data = r.json()
return {
"flow": "dashboard_redirect",
"dashboard_url": f"http://localhost:{NINE_ROUTER_PORT}/dashboard/providers",
"flow": "authorization_code",
"auth_url": data.get("authUrl", ""),
"code_verifier": data.get("codeVerifier", ""),
"state": data.get("state", ""),
"redirect_uri": callback_url,
}
@@ -152,6 +161,22 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None
return r.json()
async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict:
"""Exchange OAuth code for tokens via 9Router."""
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(
f"{NINE_ROUTER_API}/oauth/{provider}/exchange",
json={
"code": code,
"redirectUri": redirect_uri,
"codeVerifier": code_verifier,
"state": state,
},
)
r.raise_for_status()
return r.json()
async def get_models() -> list[dict]:
"""Get all available models from 9Router."""
try:
+35 -8
View File
@@ -251,27 +251,54 @@ const SubscriptionCards: React.FC = () => {
setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000);
} else if (data.flow === 'authorization_code' || data.flow === 'dashboard_redirect') {
// Auth code flow (Claude, Codex, Gemini) — open 9Router dashboard, poll for connection
const url = data.dashboard_url || data.auth_url;
if (url) window.open(url, '_blank');
} else if (data.flow === 'authorization_code') {
// Auth code flow (Claude, Codex, Gemini) — open popup, listen for postMessage callback
const popup = window.open(data.auth_url, 'oauth', 'popup,width=600,height=700');
// Poll the providers list until this provider appears as connected
// Listen for OAuth callback from 9Router's /callback page
const handler = async (event: MessageEvent) => {
// 9Router's callback page sends: { type: 'oauth-callback', code, state } or just { code, state }
const d = event.data;
if (d?.code || d?.type === 'oauth-callback') {
window.removeEventListener('message', handler);
try {
// Exchange the code for tokens via our backend proxy
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: providerId,
code: d.code,
redirect_uri: data.redirect_uri,
code_verifier: data.code_verifier,
state: d.state || data.state,
}),
});
} catch {}
if (popup && !popup.closed) popup.close();
setConnecting(null);
fetchStatus();
}
};
window.addEventListener('message', handler);
// Also poll as fallback in case postMessage doesn't work
const timer = setInterval(async () => {
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const providers = Array.isArray(sd.providers?.connections) ? sd.providers.connections : Array.isArray(sd.providers) ? sd.providers : [];
const found = providers.some((p: any) => p.provider === providerId && p.isActive);
const connections = sd.providers?.connections || (Array.isArray(sd.providers) ? sd.providers : []);
const found = connections.some((p: any) => p.provider === providerId && p.isActive);
if (found) {
clearInterval(timer);
window.removeEventListener('message', handler);
setConnecting(null);
fetchStatus();
}
} catch {}
}, 3000);
setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000);
setTimeout(() => { clearInterval(timer); window.removeEventListener('message', handler); setConnecting(null); }, 300000);
} else {
setConnecting(null);