From 1fc58af88af1c8a7d4915296ab8dd39b563ee352 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 23 Mar 2026 17:23:22 -0700 Subject: [PATCH] =?UTF-8?q?[eric]=20fix=20subscription=20OAuth=20=E2=80=94?= =?UTF-8?q?=20direct=20exchange=20from=20callback=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modified 9Router's callback page to call the exchange endpoint directly instead of relying on postMessage/BroadcastChannel relay (which fails in Electron where window.opener is null). Flow: click Connect → sign in → callback page fetches pending state from our backend → calls 9Router exchange → connection saved → done. Backend: - Added GET /api/subscriptions/pending/{state} with CORS for callback - Added GET /api/subscriptions/callback as fallback - Store pending OAuth state (codeVerifier, redirectUri) keyed by state - redirect_uri stays on localhost:20128 (required by Anthropic) Frontend: - Popup window for auth URL + postMessage listener + status polling - Works in both Electron and regular browser --- backend/apps/agents/agents.py | 10 ++++ backend/apps/nine_router.py | 4 +- backend/main.py | 52 ++++++++++++++++++- .../src/app/components/OnboardingModal.tsx | 38 ++++++++------ frontend/src/app/pages/Settings/Settings.tsx | 48 ++++++++--------- 5 files changed, 106 insertions(+), 46 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 70eea76b..c6bee62f 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -311,6 +311,16 @@ async def subscriptions_connect(body: dict): try: result = await start_oauth(provider) + + # For auth_code flows, store pending state so the callback can exchange + if result.get("flow") == "authorization_code" and result.get("state"): + from backend.main import _pending_oauth + _pending_oauth[result["state"]] = { + "provider": provider, + "code_verifier": result.get("code_verifier", ""), + "redirect_uri": result.get("redirect_uri", ""), + } + return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index 1992396f..c53bf52a 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -123,8 +123,8 @@ async def start_oauth(provider: str) -> dict: except Exception: pass - # Authorization code flow — get the real auth URL from 9Router - # The callback goes to 9Router's /callback page which sends postMessage back + # Authorization code flow — redirect to 9Router's own callback page + # (Anthropic only accepts redirect URIs registered with 9Router's client ID) callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback" r = await client.get( f"{NINE_ROUTER_API}/oauth/{provider}/authorize", diff --git a/backend/main.py b/backend/main.py index 1be36e02..c07fd7c2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,8 +4,11 @@ from uuid import uuid4 logger = logging.getLogger(__name__) -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, HTMLResponse from fastapi import Request + +# In-memory store for pending OAuth flows (state → {provider, code_verifier, redirect_uri}) +_pending_oauth: dict[str, dict] = {} from backend.config.Apps import MainApp from backend.apps.health.health import health from backend.apps.agents.agents import agents @@ -127,6 +130,53 @@ async def browser_command(request: Request): return JSONResponse(result) +@app.get("/api/subscriptions/pending/{state}") +async def subscriptions_pending(state: str): + """Return pending OAuth data for a state param. Called by 9Router's callback page.""" + pending = _pending_oauth.get(state) + if not pending: + return JSONResponse({"error": "not found"}, status_code=404, + headers={"Access-Control-Allow-Origin": "*"}) + return JSONResponse({ + "provider": pending["provider"], + "code_verifier": pending["code_verifier"], + "redirect_uri": pending["redirect_uri"], + }, headers={"Access-Control-Allow-Origin": "*"}) + + +@app.get("/api/subscriptions/callback") +async def subscriptions_callback(request: Request): + """Catch OAuth redirect from provider, exchange code via 9Router, close window.""" + code = request.query_params.get("code", "") + state = request.query_params.get("state", "") + error = request.query_params.get("error", "") + + if error: + desc = request.query_params.get("error_description", error) + return HTMLResponse(f'

Authorization failed

{desc}

') + + pending = _pending_oauth.pop(state, None) + if not pending: + return HTMLResponse('

Session expired

Please try connecting again.

') + + from backend.apps.nine_router import exchange_oauth + try: + await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state) + except Exception as e: + return HTMLResponse(f'

Connection failed

{e}

') + + return HTMLResponse( + '' + '
' + '
' + '

Connected!

' + '

You can close this window

' + '
' + '' + '' + ) + + @app.post("/api/browser-agent/run") async def browser_agent_run(request: Request): """Run one or more browser sub-agents in parallel. diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index 2aa18330..02c7fb6f 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -81,43 +81,49 @@ const OnboardingModal: React.FC = () => { }, 5000); setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000); } else if (data.flow === 'authorization_code') { - const popup = window.open(data.auth_url, 'oauth', 'popup,width=600,height=700'); - const handler = async (event: MessageEvent) => { + // Open auth URL as popup — window.opener lets callback page postMessage back + const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); + + // Listen for postMessage from 9Router's callback page + // 9Router sends: { type: "oauth_callback", data: { code, state, ... } } + const msgHandler = async (event: MessageEvent) => { const d = event.data; - if (d?.code || d?.type === 'oauth-callback') { - window.removeEventListener('message', handler); + const callbackData = d?.type === 'oauth_callback' ? d.data : d; + if (callbackData?.code) { + window.removeEventListener('message', msgHandler); + clearInterval(statusPoller); + if (popup && !popup.closed) popup.close(); try { await fetch(`${API_BASE}/agents/subscriptions/exchange`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - provider: providerId, code: d.code, + provider: providerId, code: callbackData.code, redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, - state: d.state || data.state, + state: callbackData.state || data.state, }), }); } catch {} - if (popup && !popup.closed) popup.close(); setConnecting(null); setOpen(false); } }; - window.addEventListener('message', handler); - // Fallback poll - const timer = setInterval(async () => { + window.addEventListener('message', msgHandler); + + // Also poll status as fallback (in case postMessage doesn't work in Electron) + const statusPoller = setInterval(async () => { try { const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); const sd = await sr.json(); const conns = sd.providers?.connections || []; if (conns.some((p: any) => p.provider === providerId && p.isActive)) { - clearInterval(timer); - window.removeEventListener('message', handler); + clearInterval(statusPoller); + window.removeEventListener('message', msgHandler); setConnecting(null); setOpen(false); } } catch {} - }, 3000); - setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000); + }, 2000); + setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000); } } catch { setConnecting(null); diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 0a5df94d..adcd5ff1 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -252,53 +252,47 @@ const SubscriptionCards: React.FC = () => { setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000); } 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'); + // Open auth URL as popup — window.opener lets callback page postMessage back + const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); - // 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 msgHandler = async (event: MessageEvent) => { const d = event.data; - if (d?.code || d?.type === 'oauth-callback') { - window.removeEventListener('message', handler); + const callbackData = d?.type === 'oauth_callback' ? d.data : d; + if (callbackData?.code) { + window.removeEventListener('message', msgHandler); + clearInterval(statusPoller); + if (popup && !popup.closed) popup.close(); try { - // Exchange the code for tokens via our backend proxy await fetch(`${API_BASE}/agents/subscriptions/exchange`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + 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, + provider: providerId, code: callbackData.code, + redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, + state: callbackData.state || data.state, }), }); } catch {} - if (popup && !popup.closed) popup.close(); setConnecting(null); fetchStatus(); } }; - window.addEventListener('message', handler); + window.addEventListener('message', msgHandler); - // Also poll as fallback in case postMessage doesn't work - const timer = setInterval(async () => { + const statusPoller = setInterval(async () => { try { const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); const sd = await sr.json(); - 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); + const connections = sd.providers?.connections || []; + if (connections.some((p: any) => p.provider === providerId && p.isActive)) { + clearInterval(statusPoller); + window.removeEventListener('message', msgHandler); setConnecting(null); fetchStatus(); } } catch {} - }, 3000); - setPollTimer(timer); - setTimeout(() => { clearInterval(timer); window.removeEventListener('message', handler); setConnecting(null); }, 300000); + }, 2000); + setPollTimer(statusPoller); + setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000); } else { setConnecting(null);