Skip to content

Commit 5df38ed

Browse files
committed
fix(all): resolve OAuth popup not communicating back to parent window
When installing OAuth-requiring MCP servers (e.g., plane.so), the popup window loses window.opener after cross-origin redirect chain, causing postMessage to silently fail. Users get stuck on the install wizard. Add fallback: when window.opener is null, backend redirects popup to a new frontend route (/oauth/callback-complete) which uses BroadcastChannel (same-origin) to notify the parent window, then closes itself.
1 parent ff34160 commit 5df38ed

File tree

5 files changed

+119
-42
lines changed

5 files changed

+119
-42
lines changed

services/backend/src/routes/mcp/installations/callback.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,15 @@ export default async function oauthCallbackRoute(server: FastifyInstance) {
7575
${query.error_description ? `<p>${sanitizeText(query.error_description)}</p>` : ''}
7676
<p>Closing window...</p>
7777
<script>
78-
// Post error message to parent window
7978
if (window.opener) {
8079
window.opener.postMessage({
8180
type: 'oauth_error',
8281
error: '${sanitizeText(errorMsg)}'
8382
}, '${frontendUrl}');
83+
setTimeout(function() { window.close(); }, 500);
84+
} else {
85+
window.location.href = '${frontendUrl}/oauth/callback-complete?type=oauth_error&error=${encodeURIComponent(sanitizeText(errorMsg))}';
8486
}
85-
86-
// Close the popup window
87-
setTimeout(() => {
88-
window.close();
89-
}, 500);
9087
</script>
9188
</body>
9289
</html>
@@ -293,10 +290,10 @@ export default async function oauthCallbackRoute(server: FastifyInstance) {
293290
type: 'oauth_error',
294291
error: 'Token record not found'
295292
}, '${frontendUrl}');
293+
setTimeout(function() { window.close(); }, 2000);
294+
} else {
295+
window.location.href = '${frontendUrl}/oauth/callback-complete?type=oauth_error&error=Token+record+not+found';
296296
}
297-
setTimeout(() => {
298-
window.close();
299-
}, 2000);
300297
</script>
301298
</body>
302299
</html>
@@ -438,10 +435,10 @@ export default async function oauthCallbackRoute(server: FastifyInstance) {
438435
type: 'oauth_reauth_success',
439436
installation_id: '${flow.installation_id}'
440437
}, '${frontendUrl}');
438+
setTimeout(function() { window.close(); }, 500);
439+
} else {
440+
window.location.href = '${frontendUrl}/oauth/callback-complete?type=oauth_reauth_success&installation_id=${flow.installation_id}';
441441
}
442-
setTimeout(() => {
443-
window.close();
444-
}, 500);
445442
</script>
446443
</body>
447444
</html>
@@ -629,18 +626,15 @@ export default async function oauthCallbackRoute(server: FastifyInstance) {
629626
<h1>Authorization Successful</h1>
630627
<p>Closing window...</p>
631628
<script>
632-
// Post success message to parent window
633629
if (window.opener) {
634630
window.opener.postMessage({
635631
type: 'oauth_success',
636632
installation_id: '${installationId}'
637633
}, '${frontendUrl}');
634+
setTimeout(function() { window.close(); }, 500);
635+
} else {
636+
window.location.href = '${frontendUrl}/oauth/callback-complete?type=oauth_success&installation_id=${installationId}';
638637
}
639-
640-
// Close the popup window
641-
setTimeout(() => {
642-
window.close();
643-
}, 500);
644638
</script>
645639
</body>
646640
</html>
@@ -672,18 +666,15 @@ export default async function oauthCallbackRoute(server: FastifyInstance) {
672666
<p>An error occurred while processing the authorization.</p>
673667
<p>Closing window...</p>
674668
<script>
675-
// Post error message to parent window
676669
if (window.opener) {
677670
window.opener.postMessage({
678671
type: 'oauth_error',
679672
error: '${sanitizeText(errorMessage)}'
680673
}, '${frontendUrl}');
674+
setTimeout(function() { window.close(); }, 500);
675+
} else {
676+
window.location.href = '${frontendUrl}/oauth/callback-complete?type=oauth_error&error=${encodeURIComponent(sanitizeText(errorMessage))}';
681677
}
682-
683-
// Close the popup window
684-
setTimeout(() => {
685-
window.close();
686-
}, 500);
687678
</script>
688679
</body>
689680
</html>

services/frontend/src/components/deploy/steps/ConfigureEnvironmentStep.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ function onArgTypeChange(arg: ArgItem) {
179179
<!-- Type selector -->
180180
<Select
181181
:model-value="env.type"
182-
@update:model-value="(val: ConfigType) => { env.type = val; onEnvTypeChange(env) }"
182+
@update:model-value="(val) => { env.type = val as ConfigType; onEnvTypeChange(env) }"
183183
>
184184
<SelectTrigger class="w-[120px]">
185185
<SelectValue />
@@ -255,7 +255,7 @@ function onArgTypeChange(arg: ArgItem) {
255255
<!-- Type selector -->
256256
<Select
257257
:model-value="arg.type"
258-
@update:model-value="(val: ConfigType) => { arg.type = val; onArgTypeChange(arg) }"
258+
@update:model-value="(val) => { arg.type = val as ConfigType; onArgTypeChange(arg) }"
259259
>
260260
<SelectTrigger class="w-[120px]">
261261
<SelectValue />

services/frontend/src/components/mcp-server/wizard/McpServerInstallWizard.vue

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ const handleValidationChange = (isValid: boolean, missingFields: string[]) => {
304304
// OAuth popup reference
305305
const oauthPopup = ref<Window | null>(null)
306306
307+
// BroadcastChannel for cross-tab OAuth communication (fallback when window.opener is null)
308+
let oauthBroadcastChannel: BroadcastChannel | null = null
309+
307310
// Handle OAuth authorization
308311
const handleOAuthAuthorization = async () => {
309312
try {
@@ -366,17 +369,9 @@ const handleOAuthAuthorization = async () => {
366369
}
367370
}
368371
369-
// Handle OAuth popup messages
370-
const handleOAuthMessage = (event: MessageEvent) => {
371-
const backendUrl = new URL(import.meta.env.VITE_DEPLOYSTACK_BACKEND_URL || 'http://localhost:3000')
372-
const allowedOrigins = [window.location.origin, backendUrl.origin]
373-
374-
if (!allowedOrigins.includes(event.origin)) {
375-
console.warn('Rejected postMessage from unauthorized origin:', event.origin)
376-
return
377-
}
378-
379-
if (event.data.type === 'oauth_success') {
372+
// Process OAuth result data (shared between postMessage and BroadcastChannel)
373+
const processOAuthResult = (data: any) => {
374+
if (data.type === 'oauth_success') {
380375
if (oauthPopup.value && !oauthPopup.value.closed) {
381376
oauthPopup.value.close()
382377
}
@@ -387,23 +382,44 @@ const handleOAuthMessage = (event: MessageEvent) => {
387382
})
388383
389384
eventBus.emit('mcp-installations-updated')
390-
router.push('/mcp-server')
391-
}
392385
393-
else if (event.data.type === 'oauth_error') {
394-
const { error } = event.data
386+
const installationId = data.installation_id
387+
if (installationId) {
388+
router.push(`/mcp-server/installation/${installationId}/general`)
389+
} else {
390+
router.push('/mcp-server')
391+
}
392+
}
395393
394+
else if (data.type === 'oauth_error') {
396395
if (oauthPopup.value && !oauthPopup.value.closed) {
397396
oauthPopup.value.close()
398397
}
399398
oauthPopup.value = null
400399
401400
toast.error('Authentication failed', {
402-
description: error || 'OAuth authorization failed. Please try again.'
401+
description: data.error || 'OAuth authorization failed. Please try again.'
403402
})
404403
}
405404
}
406405
406+
// Handle OAuth popup messages via postMessage
407+
const handleOAuthMessage = (event: MessageEvent) => {
408+
if (!event.data?.type || !['oauth_success', 'oauth_error', 'oauth_reauth_success'].includes(event.data.type)) {
409+
return
410+
}
411+
412+
const backendUrl = new URL(import.meta.env.VITE_DEPLOYSTACK_BACKEND_URL || 'http://localhost:3000')
413+
const allowedOrigins = [window.location.origin, backendUrl.origin]
414+
415+
if (!allowedOrigins.includes(event.origin)) {
416+
console.warn('Rejected postMessage from unauthorized origin:', event.origin)
417+
return
418+
}
419+
420+
processOAuthResult(event.data)
421+
}
422+
407423
// Watch for serverData changes and reinitialize form
408424
watch(() => props.serverData, () => {
409425
initializeEnvironmentForm()
@@ -423,11 +439,29 @@ onMounted(async () => {
423439
}
424440
425441
window.addEventListener('message', handleOAuthMessage)
442+
443+
// BroadcastChannel fallback for when window.opener is null (cross-origin redirect chain)
444+
try {
445+
oauthBroadcastChannel = new BroadcastChannel('deploystack_oauth')
446+
oauthBroadcastChannel.onmessage = (event: MessageEvent) => {
447+
if (!event.data?.type || !['oauth_success', 'oauth_error', 'oauth_reauth_success'].includes(event.data.type)) {
448+
return
449+
}
450+
processOAuthResult(event.data)
451+
}
452+
} catch {
453+
// BroadcastChannel not supported - postMessage is the only fallback
454+
}
426455
})
427456
428457
onUnmounted(() => {
429458
window.removeEventListener('message', handleOAuthMessage)
430459
460+
if (oauthBroadcastChannel) {
461+
oauthBroadcastChannel.close()
462+
oauthBroadcastChannel = null
463+
}
464+
431465
if (oauthPopup.value && !oauthPopup.value.closed) {
432466
oauthPopup.value.close()
433467
}

services/frontend/src/router/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ const routes: RouteRecordRaw[] = [
8585
title: 'Authorize MCP Access'
8686
},
8787
},
88+
{
89+
path: '/oauth/callback-complete',
90+
name: 'OAuthCallbackComplete',
91+
component: () => import('../views/oauth/OAuthCallbackComplete.vue'),
92+
meta: {
93+
requiresSetup: false,
94+
title: 'Authorization Complete'
95+
},
96+
},
8897
{
8998
path: '/dashboard',
9099
name: 'Dashboard',
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
/**
3+
* Lightweight page loaded in the OAuth popup after the backend callback completes.
4+
* Since this page is same-origin as the parent window, BroadcastChannel works
5+
* to notify the install wizard, even when window.opener is null.
6+
*/
7+
import { onMounted } from 'vue'
8+
import { useRoute } from 'vue-router'
9+
10+
const route = useRoute()
11+
12+
onMounted(() => {
13+
const type = route.query.type as string
14+
const installationId = route.query.installation_id as string
15+
const error = route.query.error as string
16+
17+
if (type) {
18+
try {
19+
const bc = new BroadcastChannel('deploystack_oauth')
20+
bc.postMessage({
21+
type,
22+
installation_id: installationId || undefined,
23+
error: error || undefined
24+
})
25+
bc.close()
26+
} catch {
27+
// BroadcastChannel not supported
28+
}
29+
}
30+
31+
// Always try to close the popup
32+
window.close()
33+
})
34+
</script>
35+
36+
<template>
37+
<div class="flex items-center justify-center h-screen bg-gray-50">
38+
<div class="text-center p-8 bg-white rounded-lg shadow-sm">
39+
<p class="text-gray-600">Completing authorization...</p>
40+
<p class="text-sm text-gray-400 mt-2">This window will close automatically.</p>
41+
</div>
42+
</div>
43+
</template>

0 commit comments

Comments
 (0)