mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[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:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user