From 73022ff3b3a0d7d163aee84922adce46b4c0ff8f Mon Sep 17 00:00:00 2001 From: Nam Hoang Date: Tue, 28 Apr 2026 12:28:50 +0700 Subject: [PATCH] fix(oauth): target specific connection by id on re-auth token exchange (#1702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.7.2 — fixes OAuth re-auth targeting specific connection by ID --- .../dashboard/providers/[id]/page.tsx | 17 ++++++++++++++--- src/app/api/oauth/[provider]/[action]/route.ts | 9 +++++++-- src/shared/components/CursorAuthModal.tsx | 2 +- src/shared/components/KiroOAuthWrapper.tsx | 9 ++++++++- src/shared/components/OAuthModal.tsx | 18 +++++++++++++----- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index ea6b9a7fb8..cdcfa5dfdd 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -969,7 +969,8 @@ export default function ProviderDetailPage() { const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); - const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showOAuthModal, _setShowOAuthModal] = useState(false); + const [reauthConnection, setReauthConnection] = useState(null); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); @@ -1021,6 +1022,11 @@ export default function ProviderDetailPage() { const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible; const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible; + const setShowOAuthModal = (show: boolean, connectionRow?: ConnectionRowConnection) => { + _setShowOAuthModal(show); + setReauthConnection(show && connectionRow ? connectionRow : null); + }; + const providerInfo = resolveDashboardProviderInfo(providerId, { providerNode, compatibleLabels: { @@ -2928,7 +2934,9 @@ export default function ProviderDetailPage() { }} onDelete={() => handleDelete(conn.id)} onReauth={ - conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined + conn.authType === "oauth" + ? () => setShowOAuthModal(true, conn) + : undefined } onRefreshToken={ conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined @@ -3048,7 +3056,7 @@ export default function ProviderDetailPage() { onDelete={() => handleDelete(conn.id)} onReauth={ conn.authType === "oauth" - ? () => setShowOAuthModal(true) + ? () => setShowOAuthModal(true, conn) : undefined } onRefreshToken={ @@ -3183,6 +3191,7 @@ export default function ProviderDetailPage() { (providerId === "kiro" || providerId === "amazon-q" ? ( { @@ -3192,6 +3201,7 @@ export default function ProviderDetailPage() { ) : providerId === "cursor" ? ( { setShowOAuthModal(false); @@ -3200,6 +3210,7 @@ export default function ProviderDetailPage() { ) : ( 0 ? state : undefined; const providerData = getProvider(provider); @@ -278,6 +278,7 @@ export async function POST( if (tokenData.email) { const existing = await getProviderConnections({ provider }); const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7) if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; // For Codex, also check workspaceId to avoid overwriting different workspace connections @@ -322,7 +323,7 @@ export async function POST( } if (action === "poll") { - const { deviceCode, codeVerifier, extraData } = body; + const { deviceCode, connectionId, codeVerifier, extraData } = body; // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(provider); @@ -364,6 +365,7 @@ export async function POST( if (result.tokens.email) { const existing = await getProviderConnections({ provider }); const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-8/9) if (!safeEqual(c.email, result.tokens.email) || c.authType !== "oauth") return false; // For Codex, also check workspaceId to avoid overwriting different workspace connections @@ -418,6 +420,8 @@ export async function POST( } if (action === "poll-callback") { + const { connectionId } = body; + // Poll for Codex callback server result if (provider !== "codex") { return NextResponse.json( @@ -489,6 +493,7 @@ export async function POST( if (tokenData.email) { const existing = await getProviderConnections({ provider }); const match = existing.find((c: any) => { + if (c.id && safeEqual(connectionId, c.id)) return true; // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7) if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; // For Codex, also check workspaceId to avoid overwriting different workspace connections diff --git a/src/shared/components/CursorAuthModal.tsx b/src/shared/components/CursorAuthModal.tsx index f27057613b..aaf2ff4f54 100644 --- a/src/shared/components/CursorAuthModal.tsx +++ b/src/shared/components/CursorAuthModal.tsx @@ -11,7 +11,7 @@ import Input from "./Input"; * Cursor Auth Modal * Auto-detect and import token from Cursor IDE's local SQLite database */ -export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { +export default function CursorAuthModal({ isOpen, onSuccess, onClose, reauthConnection: _ }) { const t = useTranslations("cursorAuthModal"); const [accessToken, setAccessToken] = useState(""); const [machineId, setMachineId] = useState(""); diff --git a/src/shared/components/KiroOAuthWrapper.tsx b/src/shared/components/KiroOAuthWrapper.tsx index eb3ad6c747..dde999aa81 100644 --- a/src/shared/components/KiroOAuthWrapper.tsx +++ b/src/shared/components/KiroOAuthWrapper.tsx @@ -10,7 +10,13 @@ import KiroSocialOAuthModal from "./KiroSocialOAuthModal"; * Kiro OAuth Wrapper * Orchestrates between method selection, device code flow, and social login flow */ -export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onClose }) { +export default function KiroOAuthWrapper({ + isOpen, + providerInfo, + onSuccess, + onClose, + reauthConnection, +}) { const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" const [socialProvider, setSocialProvider] = useState(null); // "google" | "github" const [idcConfig, setIdcConfig] = useState(null); @@ -78,6 +84,7 @@ export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onCl provider={oauthProviderId} providerInfo={providerInfo} onSuccess={handleDeviceSuccess} + reauthConnection={reauthConnection} onClose={handleBack} idcConfig={idcConfig} /> diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index e6e39cca97..841147ccf7 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -17,6 +17,7 @@ type OAuthModalProps = { onSuccess?: () => void; onClose: () => void; idcConfig?: unknown; + reauthConnection?: null | { id?: string }; }; /** @@ -31,6 +32,7 @@ export default function OAuthModal({ onSuccess, onClose, idcConfig, + reauthConnection, }: OAuthModalProps) { const t = useTranslations("oauthModal"); const [step, setStep] = useState("waiting"); // waiting | input | success | error @@ -93,6 +95,7 @@ export default function OAuthModal({ body: JSON.stringify({ code, redirectUri: authData.redirectUri, + connectionId: reauthConnection?.id, codeVerifier: authData.codeVerifier, ...(normalizedState ? { state: normalizedState } : {}), }), @@ -141,7 +144,7 @@ export default function OAuthModal({ setStep("error"); } }, - [authData, provider, onSuccess] + [authData, provider, onSuccess, reauthConnection] ); // Poll for device code token @@ -157,7 +160,12 @@ export default function OAuthModal({ const res = await fetch(`/api/oauth/${provider}/poll`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deviceCode, codeVerifier, extraData }), + body: JSON.stringify({ + deviceCode, + connectionId: reauthConnection?.id, + codeVerifier, + extraData, + }), }); const data = await res.json(); @@ -188,7 +196,7 @@ export default function OAuthModal({ setStep("error"); setPolling(false); }, - [provider, onSuccess] + [provider, onSuccess, reauthConnection] ); // Start OAuth flow @@ -271,7 +279,7 @@ export default function OAuthModal({ const pollRes = await fetch(`/api/oauth/codex/poll-callback`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ connectionId: reauthConnection?.id }), }); const pollData = await pollRes.json(); @@ -371,7 +379,7 @@ export default function OAuthModal({ setError(err.message); setStep("error"); } - }, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess]); + }, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess, reauthConnection]); // Reset guard when modal closes useEffect(() => {