From e343e3709d98de38834dcdccea5af8b5b11f4781 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 15:56:35 +0000 Subject: [PATCH] fix(mcp-oauth): friendly success page + private-use URI schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP clients (Claude, Cursor, Codex, Lovable, OpenClaw, Hermes, …) were dropping users on the browser's "site can't be reached" screen when the loopback listener had closed or the client used a deep-link scheme that DCR rejected. - New /oauth/success page confirms the connection visually, fires a no-cors fetch to the loopback listener (so the CLI still receives the code), triggers private-use URI schemes for desktop clients, and shows a copy-pasteable auth code as a manual fallback. - Auth code is handed off via sessionStorage so it never lands in the URL bar or browser history. - Client registration (HTTP + SQL) now accepts private-use URI scheme redirects per RFC 8252 §7, not just https / loopback http. - Docs updated to describe the new flow. https://claude.ai/code/session_019gMoupKKTVydpNwiiACQRd --- src/lib/oauth/mcp-oauth.functions.ts | 9 +- src/routeTree.gen.ts | 31 +++ src/routes/api/public/oauth/register.ts | 28 ++- src/routes/docs.mcp.tsx | 13 +- src/routes/oauth.authorize.tsx | 25 +- src/routes/oauth.success.tsx | 231 ++++++++++++++++++ ...00_mcp_oauth_allow_private_uri_schemes.sql | 75 ++++++ 7 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 src/routes/oauth.success.tsx create mode 100644 supabase/migrations/20260521000000_mcp_oauth_allow_private_uri_schemes.sql diff --git a/src/lib/oauth/mcp-oauth.functions.ts b/src/lib/oauth/mcp-oauth.functions.ts index 33b6af19..6bb87f35 100644 --- a/src/lib/oauth/mcp-oauth.functions.ts +++ b/src/lib/oauth/mcp-oauth.functions.ts @@ -60,5 +60,12 @@ export const issueOauthCode = createServerFn({ method: "POST" }) const sep = data.redirect_uri.includes("?") ? "&" : "?"; const stateQ = data.state ? `&state=${encodeURIComponent(data.state)}` : ""; - return { redirect_to: `${data.redirect_uri}${sep}code=${encodeURIComponent(code)}${stateQ}` }; + // We also return the raw `code` so the success page can show it as a + // manual paste fallback when the client's loopback listener is no + // longer up (or for clients that use private-use URI schemes that + // never actually surface back to the browser tab). + return { + redirect_to: `${data.redirect_uri}${sep}code=${encodeURIComponent(code)}${stateQ}`, + code, + }; }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1590baa8..2d68304c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -45,6 +45,7 @@ import { Route as UHandleRouteImport } from './routes/u.$handle' import { Route as SoulsSlugRouteImport } from './routes/souls.$slug' import { Route as RunSlugRouteImport } from './routes/run.$slug' import { Route as PacksSlugRouteImport } from './routes/packs.$slug' +import { Route as OauthSuccessRouteImport } from './routes/oauth.success' import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize' import { Route as MarketplaceRankingsRouteImport } from './routes/marketplace.rankings' import { Route as MarketplaceLeaderboardRouteImport } from './routes/marketplace.leaderboard' @@ -285,6 +286,11 @@ const PacksSlugRoute = PacksSlugRouteImport.update({ path: '/packs/$slug', getParentRoute: () => rootRouteImport, } as any) +const OauthSuccessRoute = OauthSuccessRouteImport.update({ + id: '/oauth/success', + path: '/oauth/success', + getParentRoute: () => rootRouteImport, +} as any) const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({ id: '/oauth/authorize', path: '/oauth/authorize', @@ -654,6 +660,7 @@ export interface FileRoutesByFullPath { '/marketplace/leaderboard': typeof MarketplaceLeaderboardRoute '/marketplace/rankings': typeof MarketplaceRankingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute + '/oauth/success': typeof OauthSuccessRoute '/packs/$slug': typeof PacksSlugRouteWithChildren '/run/$slug': typeof RunSlugRoute '/souls/$slug': typeof SoulsSlugRoute @@ -750,6 +757,7 @@ export interface FileRoutesByTo { '/marketplace/leaderboard': typeof MarketplaceLeaderboardRoute '/marketplace/rankings': typeof MarketplaceRankingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute + '/oauth/success': typeof OauthSuccessRoute '/packs/$slug': typeof PacksSlugRouteWithChildren '/run/$slug': typeof RunSlugRoute '/souls/$slug': typeof SoulsSlugRoute @@ -848,6 +856,7 @@ export interface FileRoutesById { '/marketplace/leaderboard': typeof MarketplaceLeaderboardRoute '/marketplace/rankings': typeof MarketplaceRankingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute + '/oauth/success': typeof OauthSuccessRoute '/packs/$slug': typeof PacksSlugRouteWithChildren '/run/$slug': typeof RunSlugRoute '/souls/$slug': typeof SoulsSlugRoute @@ -947,6 +956,7 @@ export interface FileRouteTypes { | '/marketplace/leaderboard' | '/marketplace/rankings' | '/oauth/authorize' + | '/oauth/success' | '/packs/$slug' | '/run/$slug' | '/souls/$slug' @@ -1043,6 +1053,7 @@ export interface FileRouteTypes { | '/marketplace/leaderboard' | '/marketplace/rankings' | '/oauth/authorize' + | '/oauth/success' | '/packs/$slug' | '/run/$slug' | '/souls/$slug' @@ -1140,6 +1151,7 @@ export interface FileRouteTypes { | '/marketplace/leaderboard' | '/marketplace/rankings' | '/oauth/authorize' + | '/oauth/success' | '/packs/$slug' | '/run/$slug' | '/souls/$slug' @@ -1226,6 +1238,7 @@ export interface RootRouteChildren { MarketplaceLeaderboardRoute: typeof MarketplaceLeaderboardRoute MarketplaceRankingsRoute: typeof MarketplaceRankingsRoute OauthAuthorizeRoute: typeof OauthAuthorizeRoute + OauthSuccessRoute: typeof OauthSuccessRoute PacksSlugRoute: typeof PacksSlugRouteWithChildren RunSlugRoute: typeof RunSlugRoute SoulsSlugRoute: typeof SoulsSlugRoute @@ -1507,6 +1520,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PacksSlugRouteImport parentRoute: typeof rootRouteImport } + '/oauth/success': { + id: '/oauth/success' + path: '/oauth/success' + fullPath: '/oauth/success' + preLoaderRoute: typeof OauthSuccessRouteImport + parentRoute: typeof rootRouteImport + } '/oauth/authorize': { id: '/oauth/authorize' path: '/oauth/authorize' @@ -2106,6 +2126,7 @@ const rootRouteChildren: RootRouteChildren = { MarketplaceLeaderboardRoute: MarketplaceLeaderboardRoute, MarketplaceRankingsRoute: MarketplaceRankingsRoute, OauthAuthorizeRoute: OauthAuthorizeRoute, + OauthSuccessRoute: OauthSuccessRoute, PacksSlugRoute: PacksSlugRouteWithChildren, RunSlugRoute: RunSlugRoute, SoulsSlugRoute: SoulsSlugRoute, @@ -2138,3 +2159,13 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/src/routes/api/public/oauth/register.ts b/src/routes/api/public/oauth/register.ts index 02b5f6b8..4ab5cac4 100644 --- a/src/routes/api/public/oauth/register.ts +++ b/src/routes/api/public/oauth/register.ts @@ -4,18 +4,34 @@ import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client. const supabaseAdmin = _supabaseAdmin as any; import { corsPreflight, jsonResponse, oauthError } from "@/lib/oauth/mcp-oauth.server"; +// RFC 8252 (OAuth for Native Apps) permits three redirect_uri shapes: +// 1. https:// (web / claimed-https) +// 2. loopback http (http://127.0.0.1, http://[::1], http://localhost) +// 3. private-use URI schemes registered by the native app (e.g. cursor://, +// vscode://, claude://, codex://, lovable://, hermes://, openclaw://) +// Many MCP clients (Cursor, VS Code, Claude Desktop dev builds) advertise +// scheme-style redirects, so rejecting them forced users into the broken +// "loopback page that doesn't exist" failure mode. +const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i; const RedirectUri = z .string() .min(3) .max(2000) - .refine( - (u) => - u.startsWith("https://") || + .refine((u) => { + if (u.startsWith("https://")) return true; + if ( u.startsWith("http://localhost") || u.startsWith("http://127.0.0.1") || - u.startsWith("http://[::1]"), - "redirect_uri must use https or loopback http", - ); + u.startsWith("http://[::1]") + ) { + return true; + } + // Private-use URI scheme: must contain a dot in the scheme per RFC 8252 + // §7.1 (e.g. com.example.app:/oauth) OR be a known MCP client scheme. + if (!SCHEME.test(u)) return false; + if (u.startsWith("http://")) return false; // non-loopback plain http forbidden + return true; + }, "redirect_uri must use https, loopback http, or a private-use URI scheme (RFC 8252)"); const RegisterSchema = z.object({ client_name: z.string().min(1).max(120).optional(), diff --git a/src/routes/docs.mcp.tsx b/src/routes/docs.mcp.tsx index 29e2aa24..353dedd4 100644 --- a/src/routes/docs.mcp.tsx +++ b/src/routes/docs.mcp.tsx @@ -177,8 +177,17 @@ POST /api/public/oauth/revoke → RFC 7009 WWW-Authenticate: Bearer resource_metadata="https://superagentskill.com/.well-known/oauth-protected-resource/api/mcp"`} />

- Compliant clients run the browser consent flow automatically. The fastest path on - any client is the CLI below — it does the whole dance for you. + Compliant clients run the browser consent flow automatically. After you click + Authorize, we route you to /oauth/success — a + friendly confirmation page that delivers the auth code to your client (loopback + listener or private-use URI scheme like cursor://, vscode://, + claude://, codex://, lovable://, + hermes://, openclaw://) and falls back to a + copy-pasteable code if your local listener already closed. No more browser + "site can't be reached" dead-ends. +

+

+ The fastest path on any client is the CLI below — it does the whole dance for you.

{/* CLI */} diff --git a/src/routes/oauth.authorize.tsx b/src/routes/oauth.authorize.tsx index 5786529b..5434eae3 100644 --- a/src/routes/oauth.authorize.tsx +++ b/src/routes/oauth.authorize.tsx @@ -65,7 +65,7 @@ function AuthorizePage() { async function approve() { setWorking(true); try { - const { redirect_to } = await consent({ + const { redirect_to, code } = await consent({ data: { client_id: params.client_id, redirect_uri: params.redirect_uri, @@ -75,7 +75,28 @@ function AuthorizePage() { code_challenge_method: "S256", }, }); - window.location.href = redirect_to; + // Hand off to /oauth/success via sessionStorage so the auth code + // never lands in the URL bar / history of the consent tab. The + // success page handles delivery to loopback listeners, private-use + // URI schemes, and https callbacks — and shows a friendly screen + // (with a manual paste fallback) instead of the browser's + // "site can't be reached" page when a loopback listener closed. + try { + window.sessionStorage.setItem( + "sas_oauth_handoff_v1", + JSON.stringify({ + redirect_to, + code, + client_name: client?.client_name ?? params.client_id, + redirect_uri: params.redirect_uri, + }), + ); + navigate({ to: "/oauth/success" }); + } catch { + // sessionStorage blocked (rare): fall back to the original + // direct redirect so the flow still completes. + window.location.href = redirect_to; + } } catch (e) { setError(e instanceof Error ? e.message : "Authorization failed"); setWorking(false); diff --git a/src/routes/oauth.success.tsx b/src/routes/oauth.success.tsx new file mode 100644 index 00000000..2c5b4972 --- /dev/null +++ b/src/routes/oauth.success.tsx @@ -0,0 +1,231 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Nav } from "@/components/site/Nav"; + +// We stash the sensitive payload (redirect_to URL + raw auth code) in +// sessionStorage rather than putting it in the URL bar, so the auth code +// doesn't end up in browser history or referrers. The authorize page +// writes this key right before navigating here. +const STORAGE_KEY = "sas_oauth_handoff_v1"; + +type Handoff = { + redirect_to: string; + code: string; + client_name: string; + redirect_uri: string; +}; + +export const Route = createFileRoute("/oauth/success")({ + head: () => ({ + meta: [ + { title: "Connected — Super Agent Skill" }, + { name: "robots", content: "noindex" }, + ], + }), + component: SuccessPage, +}); + +function readHandoff(): Handoff | null { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as Handoff; + } catch { + return null; + } +} + +function classifyRedirect(uri: string): "loopback" | "private-scheme" | "https" { + if ( + uri.startsWith("http://localhost") || + uri.startsWith("http://127.0.0.1") || + uri.startsWith("http://[::1]") + ) { + return "loopback"; + } + if (uri.startsWith("https://")) return "https"; + return "private-scheme"; +} + +function SuccessPage() { + const [handoff, setHandoff] = useState(null); + const [copied, setCopied] = useState(false); + const deliveredRef = useRef(false); + + useEffect(() => { + const h = readHandoff(); + setHandoff(h); + // One-shot: clear immediately so a refresh of this URL doesn't + // re-replay the loopback delivery. + if (h) window.sessionStorage.removeItem(STORAGE_KEY); + }, []); + + const kind = useMemo( + () => (handoff ? classifyRedirect(handoff.redirect_uri) : null), + [handoff], + ); + + useEffect(() => { + if (!handoff || !kind || deliveredRef.current) return; + deliveredRef.current = true; + + if (kind === "loopback") { + // Fire-and-forget GET to the local listener so the CLI/desktop + // client picks up the code. We do not navigate the tab — that's + // exactly what produced the broken "site can't be reached" page + // when the listener had already closed. With no-cors we don't get + // to read the response, but the listener typically only needs to + // see the request hit /callback?code=… to complete the flow. + fetch(handoff.redirect_to, { mode: "no-cors", cache: "no-store" }).catch( + () => { + /* swallow; the manual-paste fallback below covers this */ + }, + ); + return; + } + if (kind === "private-scheme") { + // Trigger the OS deep link. If the app is registered, the user + // pops back into Claude/Cursor/etc. If not, the browser stays on + // this page (which is fine — we show the manual fallback). + try { + window.location.href = handoff.redirect_to; + } catch { + /* ignored — manual fallback covers it */ + } + return; + } + // https: a normal remote callback — let the browser handle it. + window.location.replace(handoff.redirect_to); + }, [handoff, kind]); + + async function copyCode() { + if (!handoff) return; + try { + await navigator.clipboard.writeText(handoff.code); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } catch { + /* clipboard may be denied; the code is still visible */ + } + } + + if (!handoff) { + return ( +
+
+ ); + } + + const isHttps = kind === "https"; + + return ( +
+
+ ); +} diff --git a/supabase/migrations/20260521000000_mcp_oauth_allow_private_uri_schemes.sql b/supabase/migrations/20260521000000_mcp_oauth_allow_private_uri_schemes.sql new file mode 100644 index 00000000..bc45c037 --- /dev/null +++ b/supabase/migrations/20260521000000_mcp_oauth_allow_private_uri_schemes.sql @@ -0,0 +1,75 @@ +-- RFC 8252 §7: native MCP clients (Cursor, VS Code, Claude Desktop, Codex, +-- Lovable, Hermes, OpenClaw, …) register private-use URI scheme callbacks +-- such as cursor://callback. The previous validator only accepted https +-- and loopback http, so those clients fell back to a broken loopback +-- listener and the user ended up on a "site can't be reached" page. +-- +-- This migration loosens the server-side check to also permit any +-- non-http private-use URI scheme (still rejecting plain http://… to a +-- non-loopback host, which would be unsafe). + +CREATE OR REPLACE FUNCTION public.mcp_oauth_register_client( + _client_name TEXT, + _redirect_uris TEXT[], + _client_uri TEXT, + _logo_uri TEXT, + _software_id TEXT, + _software_version TEXT, + _ip TEXT +) RETURNS JSONB +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + _cid TEXT; + _u TEXT; + _scheme TEXT; +BEGIN + IF _redirect_uris IS NULL OR array_length(_redirect_uris, 1) IS NULL THEN + RAISE EXCEPTION 'redirect_uris_required'; + END IF; + FOREACH _u IN ARRAY _redirect_uris LOOP + IF _u IS NULL OR length(_u) < 3 OR length(_u) > 2000 THEN + RAISE EXCEPTION 'invalid_redirect_uri'; + END IF; + IF _u LIKE 'https://%' + OR _u LIKE 'http://localhost%' + OR _u LIKE 'http://127.0.0.1%' + OR _u LIKE 'http://[::1]%' THEN + -- ok + CONTINUE; + END IF; + -- Plain http to non-loopback is never allowed. + IF _u LIKE 'http://%' THEN + RAISE EXCEPTION 'redirect_uri_must_be_https_or_loopback: %', _u; + END IF; + -- Allow private-use URI schemes (RFC 8252 §7.1) like cursor://callback. + _scheme := substring(_u from '^([a-zA-Z][a-zA-Z0-9+.\-]*):'); + IF _scheme IS NULL THEN + RAISE EXCEPTION 'redirect_uri_must_be_https_or_loopback: %', _u; + END IF; + END LOOP; + + _cid := 'mcp_' || encode(gen_random_bytes(18), 'hex'); + INSERT INTO public.mcp_oauth_clients ( + client_id, client_name, redirect_uris, client_uri, logo_uri, + software_id, software_version, created_ip + ) VALUES ( + _cid, + COALESCE(NULLIF(trim(_client_name), ''), 'MCP Client'), + _redirect_uris, + _client_uri, + _logo_uri, + _software_id, + _software_version, + _ip + ); + + RETURN jsonb_build_object( + 'client_id', _cid, + 'client_name', COALESCE(NULLIF(trim(_client_name), ''), 'MCP Client'), + 'redirect_uris', _redirect_uris, + 'token_endpoint_auth_method', 'none', + 'grant_types', jsonb_build_array('authorization_code', 'refresh_token'), + 'response_types', jsonb_build_array('code') + ); +END; +$$;