[eric] fix subscription OAuth — direct exchange from callback page

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
This commit is contained in:
ciregenz
2026-03-23 17:23:22 -07:00
parent 8d18ca18c7
commit 1fc58af88a
5 changed files with 106 additions and 46 deletions
+10
View File
@@ -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))
+2 -2
View File
@@ -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",
+51 -1
View File
@@ -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'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Authorization failed</h2><p style="color:#888">{desc}</p></div></body></html>')
pending = _pending_oauth.pop(state, None)
if not pending:
return HTMLResponse('<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Session expired</h2><p style="color:#888">Please try connecting again.</p></div></body></html>')
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'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{e}</p></div></body></html>')
return HTMLResponse(
'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">'
'<div style="text-align:center">'
'<div style="width:64px;height:64px;border-radius:50%;background:#22c55e20;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px">✓</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this window</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
)
@app.post("/api/browser-agent/run")
async def browser_agent_run(request: Request):
"""Run one or more browser sub-agents in parallel.
+22 -16
View File
@@ -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);
+21 -27
View File
@@ -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);