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'')
+
+ 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);