From e75349d82cebdfa78aed3a45a6e5668edd5c4e20 Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:42:59 +0000 Subject: [PATCH] fail closed on mcp approve (#1123) The consent screen now fails the approve action if the MCP scope bind fails, if the OAuth consent response omits a redirect URL, or if approve tries to send the user back into a signed Better Auth interaction URL. That prevents the UI from showing Access authorized while Claude never receives the callback. Testing: - bunx biome check --write apps/web/app/oauth/consent/page.tsx - bun run --filter @repo/web lint (passes with existing warnings outside this file) - bunx tsc --noEmit --project apps/web/tsconfig.json (fails on existing unrelated app errors) --- apps/web/app/oauth/consent/page.tsx | 43 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx index 209a69f4..ddbd049c 100644 --- a/apps/web/app/oauth/consent/page.tsx +++ b/apps/web/app/oauth/consent/page.tsx @@ -93,17 +93,31 @@ function OAuthConsentContent() { setError(null) try { if (accept && clientId) { - await fetch(`${API_URL}/v3/mcp/connect-scope`, { + const scopeRes = await fetch(`${API_URL}/v3/mcp/connect-scope`, { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, body: JSON.stringify({ clientId, permission: scope.permission, containerTags: scope.scopeType === "scoped" ? scope.tags : [], expiresDays: scope.expiresDays, }), - }).catch(() => {}) + }) + if (!scopeRes.ok) { + const scopeData = (await scopeRes.json().catch(() => ({}))) as { + error?: string + message?: string + } + throw new Error( + scopeData.message || + scopeData.error || + "Could not save MCP access settings. Start the connection again.", + ) + } } const res = await fetch(`${API_URL}/api/auth/oauth2/consent`, { method: "POST", @@ -138,10 +152,27 @@ function OAuthConsentContent() { "Authorization failed.", ) } - // Show the final state regardless: many clients use a loopback or custom - // scheme redirect_uri that hands off without replacing this tab. - setDone(accept ? "approved" : "denied") + // Many clients use a loopback or custom-scheme redirect URI that hands + // off without replacing this tab, but the server still has to provide it. const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri + if (!redirectUrl) { + throw new Error( + "Authorization completed but no redirect URL was returned. Start the connection again from your app.", + ) + } + const targetUrl = new URL(redirectUrl, window.location.href) + const isSignedInteractionRedirect = + targetUrl.origin === window.location.origin && + (targetUrl.pathname === "/login" || + targetUrl.pathname === "/oauth/consent") && + targetUrl.searchParams.has("sig") && + targetUrl.searchParams.has("exp") + if (accept && isSignedInteractionRedirect) { + throw new Error( + "Authorization could not finish because your session changed. Start the connection again from your app.", + ) + } + setDone(accept ? "approved" : "denied") if (redirectUrl) window.location.href = redirectUrl } catch (err) { console.error("OAuth consent failed:", err)