fix(oauth): target specific connection by id on re-auth token exchange (#1702)

Integrated into release/v3.7.2 — fixes OAuth re-auth targeting specific connection by ID
This commit is contained in:
Nam Hoang
2026-04-28 12:28:50 +07:00
committed by GitHub
parent 58fb988c52
commit 73022ff3b3
5 changed files with 43 additions and 12 deletions

View File

@@ -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<ConnectionRowConnection | null>(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" ? (
<KiroOAuthWrapper
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
providerInfo={{ ...providerInfo, id: providerId }}
onSuccess={handleOAuthSuccess}
onClose={() => {
@@ -3192,6 +3201,7 @@ export default function ProviderDetailPage() {
) : providerId === "cursor" ? (
<CursorAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => {
setShowOAuthModal(false);
@@ -3200,6 +3210,7 @@ export default function ProviderDetailPage() {
) : (
<OAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
provider={providerId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}

View File

@@ -234,7 +234,7 @@ export async function POST(
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state } = body;
const { code, redirectUri, connectionId, codeVerifier, state } = body;
const normalizedState = typeof state === "string" && state.length > 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

View File

@@ -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("");

View File

@@ -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}
/>

View File

@@ -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(() => {