[aidan] ui/ux: auth flow fixes (#84)

* aidan] bug: fixed 2 auth claude login issue

* [aidan] bug: claude login w email+pw

* [aidan] ui: edited message for cases where anthropic redirects to enter email code even after login'

* [aidan] ui/ux: fix connecting status accuracy
This commit is contained in:
Aidan
2026-06-14 07:55:24 -07:00
committed by GitHub
parent 4e1df63b7c
commit f08f71313f
7 changed files with 151 additions and 16 deletions
+27
View File
@@ -39,6 +39,33 @@ const _http = require('http');
} catch (_) {}
})();
// 9Router's /callback page is a client-side relay (postMessage/BroadcastChannel/
// localStorage) that fails when the OAuth flow runs in the user's system browser:
// no opener, different cookie jar. 302 to the backend so the exchange happens
// server-side. Idempotent via _completed_oauth (backend/apps/oauth_state.py) so
// a racing renderer-driven exchange in popup mode dedups.
(function patchOauthCallbackRedirect() {
try {
const http = require('http');
const origEmit = http.Server.prototype.emit;
http.Server.prototype.emit = function patchedEmit(event, req, res) {
if (event === 'request' && req && res) {
try {
const url = req.url || '';
if (url.startsWith('/callback?')) {
const backendPort = process.env.OPENSWARM_PORT || '8324';
const target = 'http://localhost:' + backendPort + '/api/subscriptions/callback' + url.slice('/callback'.length);
res.writeHead(302, { Location: target });
res.end();
return true;
}
} catch (_) {}
}
return origEmit.apply(this, arguments);
};
} catch (_) {}
})();
const TARGET_HOSTS = new Set(['api.openai.com']);
const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1';
+14 -2
View File
@@ -432,6 +432,11 @@ async def subscriptions_poll(body: dict):
async def subscriptions_exchange(body: dict):
"""Exchange OAuth code for tokens via 9Router."""
from backend.apps.nine_router import exchange_oauth
from backend.apps.oauth_state import (
_pending_oauth as pending_oauth,
_completed_oauth as completed_oauth,
_mark_oauth_completed as mark_completed,
)
provider = body.get("provider", "")
code = body.get("code", "")
redirect_uri = body.get("redirect_uri", "")
@@ -444,11 +449,18 @@ async def subscriptions_exchange(body: dict):
try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
if result.get("success"):
from backend.apps.service.client import sync as _sync
# Claude races this path against /api/subscriptions/callback (popup + 9router patch
# 302 to backend); dedup so the loser sees the success page, not "Session expired".
if state:
pending_oauth.pop(state, None)
mark_completed(state)
from backend.apps.service.client import sync as do_sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
do_sync(load_settings().model_dump())
return result
except Exception as e:
if state and state in completed_oauth:
return {"success": True, "deduped": True}
raise HTTPException(status_code=500, detail=str(e))
+14 -6
View File
@@ -205,9 +205,14 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
# own Desktop-app OAuth guidance both prescribe the system browser.
# - codex: auth.openai.com renders blank in our popup on some machines (newer
# embed detection + regional checks); system browser surfaces the real error.
# - claude: email magic-link opens in the user's default browser, which is a
# different cookie jar from the embedded popup, so the popup can never receive
# the auth. Forcing the OAuth flow into the system browser keeps everything
# in one cookie jar.
# The callback for gemini-cli/antigravity lands on /api/subscriptions/callback
# and runs the exchange server-side; codex uses its fixed 1455 listener.
_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"}
# and runs the exchange server-side; codex uses its fixed 1455 listener; claude
# is special-cased in _callback_uri_for_provider below.
_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex", "claude"}
def _should_use_external_browser(provider: str) -> bool:
@@ -232,18 +237,21 @@ def _callback_uri_for_provider(provider: str) -> str:
"""Return the redirect URI to pass to 9Router's authorize endpoint.
Most providers accept 9Router's built-in callback page at port 20128.
Two special cases:
Special cases:
- Codex/OpenAI's OAuth client is bound to a fixed
http://localhost:1455/auth/callback URI; handled by
_start_codex_callback_listener above.
- Gemini/Google's OAuth consent page rejects embedded browsers, so we
route the callback through OpenSwarm's backend endpoint at
/api/subscriptions/callback (backend/main.py:138) which runs the
exchange itself. This is the only provider where the callback lands
on OpenSwarm's port rather than 9Router's.
/api/subscriptions/callback (backend/main.py) which runs the
exchange itself.
"""
if provider == "codex":
return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}"
# Anthropic's OAuth client only whitelists localhost:20128/callback;
# 9router_gpt5_patch.js 302-rewrites the hit to the backend handler.
if provider == "claude":
return f"http://localhost:{NINE_ROUTER_PORT}/callback"
if provider in _EXTERNAL_BROWSER_PROVIDERS:
return f"http://localhost:{_backend_port()}/api/subscriptions/callback"
return f"http://localhost:{NINE_ROUTER_PORT}/callback"
+1 -1
View File
@@ -453,7 +453,7 @@ _SUCCESS_HTML = (
'<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">&#10003;</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this window</p>'
'<p style="color:#888;margin:0">You can close this tab, and any other Claude login tab still open.</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
@@ -8,6 +8,7 @@ import { fetchModels } from '@/shared/state/modelsSlice';
import {
fetchSubscriptionStatus,
setSubscriptionStatus,
markSubscriptionConnected,
selectSubscriptionConnections,
} from '@/shared/state/subscriptionsSlice';
import { API_BASE } from '@/shared/config';
@@ -37,6 +38,10 @@ const SubscriptionCards: React.FC = () => {
// Refetch model picker after sub changes so newly-connected providers surface in the dropdown immediately.
const refreshPickerModels = () => { dispatch(fetchModels()); };
const markConnected = useCallback((provider: string) => {
dispatch(markSubscriptionConnected({ provider }));
}, [dispatch]);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -73,7 +78,7 @@ const SubscriptionCards: React.FC = () => {
});
if (!r.ok) { setConnecting(null); return; }
const data = await r.json();
runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels });
runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels, markConnected });
} catch { setConnecting(null); }
};
@@ -8,11 +8,12 @@ interface ConnectCtx {
setPollTimer: (v: any) => void;
fetchStatus: (opts?: { preserveTransient?: boolean }) => Promise<any>;
refreshPickerModels: () => void;
markConnected: (provider: string) => void;
}
// Device-code OAuth flow: popup + dual poller (device-code + status) + focus-listener safety net + 5min hard timeout.
function runDeviceCodeFlow(ctx: ConnectCtx) {
const { providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels } = ctx;
const { providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels, markConnected } = ctx;
const code = data.user_code || '';
setUserCode(code);
// Named window + features so Electron's setWindowOpenHandler spawns a BrowserWindow popup, not a webview tab.
@@ -31,6 +32,7 @@ function runDeviceCodeFlow(ctx: ConnectCtx) {
setPollTimer(null);
setConnecting(null);
setUserCode('');
markConnected(providerId);
fetchStatus();
refreshPickerModels();
// Auto-close popup 2s after success so the "Congratulations" page is briefly visible then closes.
@@ -131,7 +133,7 @@ function runDeviceCodeFlow(ctx: ConnectCtx) {
// Authorization-code flow: external-browser or popup + status poller + postMessage/IPC relay + bounded timeout.
function runAuthCodeFlow(ctx: ConnectCtx) {
const { providerId, data, setConnecting, setPollTimer, fetchStatus, refreshPickerModels } = ctx;
const { providerId, data, setConnecting, setPollTimer, fetchStatus, refreshPickerModels, markConnected } = ctx;
// Gemini/Google block embedded browsers; backend sets use_external_browser and exchange happens server-side via /api/subscriptions/callback. Detect via status poller (no postMessage possible).
const useExternal = !!data.use_external_browser;
let popup: Window | null = null;
@@ -141,16 +143,24 @@ function runAuthCodeFlow(ctx: ConnectCtx) {
popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
}
let stopped = false;
let resetTimer: ReturnType<typeof setTimeout> | null = null;
// Status polling: primary for external-browser flow, secondary for popup flow (postMessage is faster).
const statusPoller = setInterval(async () => {
if (stopped) return;
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
stopped = true;
if (resetTimer) clearTimeout(resetTimer);
clearInterval(statusPoller);
setPollTimer(null);
if (!useExternal) window.removeEventListener('message', msgHandler);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
setConnecting(null);
fetchStatus();
refreshPickerModels();
@@ -162,15 +172,20 @@ function runAuthCodeFlow(ctx: ConnectCtx) {
// Shared exchange helper invoked by whichever relay path delivers the code first.
let exchanged = false;
const runExchange = async (code: string, state?: string) => {
if (exchanged) return;
if (exchanged || stopped) return;
exchanged = true;
stopped = true;
if (resetTimer) clearTimeout(resetTimer);
window.removeEventListener('message', msgHandler);
if (ipcUnsub) ipcUnsub();
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
clearInterval(statusPoller);
setPollTimer(null);
if (popup && !popup.closed) popup.close();
let succeeded = false;
try {
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
const r = await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: providerId, code,
@@ -178,9 +193,13 @@ function runAuthCodeFlow(ctx: ConnectCtx) {
state: state || data.state,
}),
});
let body: any = null;
try { body = await r.json(); } catch {}
succeeded = r.ok && !!body?.success;
} catch {}
// 9Router /providers lags /exchange; an immediate fetchStatus would clobber the UI.
if (succeeded) markConnected(providerId);
setConnecting(null);
fetchStatus();
refreshPickerModels();
};
@@ -201,13 +220,55 @@ function runAuthCodeFlow(ctx: ConnectCtx) {
});
}
// If the user comes back to openswarm without finishing OAuth (closed the browser, cancelled),
// 3s of sustained focus + no active connection means abandoned; clear Connecting so they can retry.
// A blur during the wait cancels, so brief tab-backs to check progress don't false-positive.
const onBlur = () => {
if (resetTimer) { clearTimeout(resetTimer); resetTimer = null; }
};
const onFocus = () => {
if (stopped) return;
if (resetTimer) clearTimeout(resetTimer);
resetTimer = setTimeout(async () => {
resetTimer = null;
if (stopped) return;
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 || p.testStatus === 'active'))) return;
} catch {}
if (stopped) return;
stopped = true;
clearInterval(statusPoller);
setPollTimer(null);
if (!useExternal) window.removeEventListener('message', msgHandler);
if (ipcUnsub) ipcUnsub();
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
setConnecting(null);
}, 3000);
};
// Delay attach; popup mode's window.open blurs/refocuses the parent and would false-trigger.
setTimeout(() => {
if (!stopped) {
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
}
}, 2000);
// 3min popup / 5min external-browser; bounds the Connecting indicator, safety-net poller is the real exit.
const timeoutMs = useExternal ? 300_000 : 180_000;
setTimeout(() => {
if (stopped) return;
stopped = true;
if (resetTimer) clearTimeout(resetTimer);
clearInterval(statusPoller);
setPollTimer(null);
if (!useExternal) window.removeEventListener('message', msgHandler);
if (ipcUnsub) ipcUnsub();
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
setConnecting(null);
}, timeoutMs);
}
@@ -51,6 +51,28 @@ const subscriptionsSlice = createSlice({
setSubscriptionStatus(state, action: PayloadAction<SubscriptionStatus | null>) {
state.status = action.payload;
},
// Optimistic: 9Router /providers lags /exchange, so refetching right after would
// clobber the just-connected state with stale data. The 30s poller reconciles.
markSubscriptionConnected(state, action: PayloadAction<{ provider: string }>) {
if (!state.status) return;
const { provider } = action.payload;
const isArr = Array.isArray(state.status.providers);
const conns: SubscriptionConnection[] = isArr
? (state.status.providers as SubscriptionConnection[])
: ((state.status.providers as { connections?: SubscriptionConnection[] } | undefined)?.connections ?? []);
const existing = conns.find((c) => c.provider === provider);
if (existing) {
existing.isActive = true;
existing.testStatus = 'active';
} else {
conns.push({ provider, isActive: true, testStatus: 'active' });
}
if (isArr) {
state.status.providers = conns;
} else {
state.status.providers = { connections: conns };
}
},
},
extraReducers: (builder) => {
builder.addCase(fetchSubscriptionStatus.fulfilled, (state, action) => {
@@ -59,7 +81,7 @@ const subscriptionsSlice = createSlice({
},
});
export const { setSubscriptionStatus } = subscriptionsSlice.actions;
export const { setSubscriptionStatus, markSubscriptionConnected } = subscriptionsSlice.actions;
// Stable empty ref so the selector doesn't hand back a fresh [] each call (forces needless rerenders).
const EMPTY_CONNECTIONS: SubscriptionConnection[] = [];