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; +$$;