diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index fc24b045..70eea76b 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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.""" diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index fd098bba..1992396f 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -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: diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 9d4727ef..0a5df94d 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -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);