From 752c9cccd4f70e89ca3bd1c37a7905e0c0330e9b Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Mon, 11 May 2026 22:06:03 -0700 Subject: [PATCH] feat(web): OAuth consent page for the new OAuth 2.1 provider; fix mcp resource metadata --- apps/mcp/.dev.vars.example | 2 + apps/mcp/package.json | 2 +- apps/mcp/src/index.ts | 23 +- apps/mcp/wrangler.jsonc | 3 +- apps/web/app/(auth)/login/new/page.tsx | 2 +- apps/web/app/oauth/consent/page.tsx | 309 +++++++++++++++++++++++++ 6 files changed, 328 insertions(+), 13 deletions(-) create mode 100644 apps/mcp/.dev.vars.example create mode 100644 apps/web/app/oauth/consent/page.tsx diff --git a/apps/mcp/.dev.vars.example b/apps/mcp/.dev.vars.example new file mode 100644 index 00000000..1c4c88b4 --- /dev/null +++ b/apps/mcp/.dev.vars.example @@ -0,0 +1,2 @@ +API_URL=https://api.supermemory.ai +MCP_URL=https://mcp.supermemory.ai diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 936c8446..1dee2946 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -6,7 +6,7 @@ "scripts": { "build:ui": "vite build", "dev": "portless", - "dev:app": "vite build && wrangler dev --port ${PORT:-8788}", + "dev:app": "vite build && wrangler dev --port ${PORT:-8788} ${PORTLESS_URL:+--var API_URL:${PORTLESS_URL/mcp./api.} --var MCP_URL:${PORTLESS_URL}}", "deploy": "vite build && wrangler deploy --minify", "cf-typegen": "wrangler types --env-interface CloudflareBindings" }, diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 94a0bc74..b2d0e299 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -8,6 +8,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" type Bindings = { MCP_SERVER: DurableObjectNamespace API_URL?: string + MCP_URL?: string POSTHOG_API_KEY?: string } @@ -22,6 +23,16 @@ type Props = { const app = new Hono<{ Bindings: Bindings }>() const DEFAULT_API_URL = "https://api.supermemory.ai" +const DEFAULT_MCP_URL = "https://mcp.supermemory.ai" + +// This worker's public origin. Prefer the explicit MCP_URL binding (proxy hops can drop the +// Host header); fall back to the request host, then the prod custom domain. +const mcpBaseUrl = (c: Context<{ Bindings: Bindings }>): string => { + if (c.env.MCP_URL) return c.env.MCP_URL.replace(/\/+$/, "") + const host = c.req.header("x-forwarded-host") || c.req.header("host") + const proto = c.req.header("x-forwarded-proto") || "https" + return host ? `${proto}://${host}` : DEFAULT_MCP_URL +} // CORS app.use( @@ -60,12 +71,8 @@ app.get("/", (c) => { app.get("/.well-known/oauth-protected-resource", (c) => { const apiUrl = c.env.API_URL || DEFAULT_API_URL - const host = c.req.header("x-forwarded-host") || c.req.header("host") - const proto = c.req.header("x-forwarded-proto") || "https" - const resourceUrl = host ? `${proto}://${host}` : "https://mcp.supermemory.ai" - return c.json({ - resource: resourceUrl, + resource: mcpBaseUrl(c), authorization_servers: [apiUrl], scopes_supported: ["openid", "profile", "email", "offline_access"], bearer_methods_supported: ["header"], @@ -116,11 +123,7 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => { const containerTag = c.req.header("x-sm-project") const apiUrl = c.env.API_URL || DEFAULT_API_URL - const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || "" - const reqProto = c.req.header("x-forwarded-proto") || "https" - const resourceMetadataUrl = reqHost - ? `${reqProto}://${reqHost}/.well-known/oauth-protected-resource` - : "/.well-known/oauth-protected-resource" + const resourceMetadataUrl = `${mcpBaseUrl(c)}/.well-known/oauth-protected-resource` if (!token) { return new Response("Unauthorized", { diff --git a/apps/mcp/wrangler.jsonc b/apps/mcp/wrangler.jsonc index 1c2f3a19..72e7ef2c 100644 --- a/apps/mcp/wrangler.jsonc +++ b/apps/mcp/wrangler.jsonc @@ -11,7 +11,8 @@ "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": false }], "vars": { - "API_URL": "https://api.supermemory.ai" + "API_URL": "https://api.supermemory.ai", + "MCP_URL": "https://mcp.supermemory.ai" }, "routes": [ { diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 82d0fd48..a8b8d593 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -30,7 +30,7 @@ function buildMcpAuthorizeResumeUrl( const p = new URLSearchParams(sp.toString()) p.delete("redirect") p.delete("error") - return `${backend}/api/auth/mcp/authorize?${p.toString()}` + return `${backend}/api/auth/oauth2/authorize?${p.toString()}` } function AnimatedGradientBackground() { diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx new file mode 100644 index 00000000..1b476404 --- /dev/null +++ b/apps/web/app/oauth/consent/page.tsx @@ -0,0 +1,309 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { authClient, useSession } from "@lib/auth" +import { cn } from "@lib/utils" +import { LogoFull } from "@ui/assets/Logo" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { Building2, Check, ChevronDown, LoaderIcon } from "lucide-react" +import { useSearchParams } from "next/navigation" +import { Suspense, useState } from "react" + +const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +function OAuthConsentContent() { + const params = useSearchParams() + const { data: session } = useSession() + const { data: organizations } = authClient.useListOrganizations() + const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null) + const [done, setDone] = useState<"approved" | "denied" | null>(null) + const [orgMenuOpen, setOrgMenuOpen] = useState(false) + const [switchingOrgId, setSwitchingOrgId] = useState(null) + const [error, setError] = useState(null) + + const activeOrgId = session?.session.activeOrganizationId ?? null + const activeOrgName = + organizations?.find((o) => o.id === activeOrgId)?.name ?? null + const canSwitchOrg = (organizations?.length ?? 0) > 1 + const clientId = params.get("client_id") ?? "" + const scopes = (params.get("scope") ?? "").split(/\s+/).filter(Boolean) + // A valid consent page is reached only via /oauth2/authorize, which appends a + // signed (`sig`) + short-lived (`exp`) query. Without that it can't succeed. + const expSeconds = Number(params.get("exp")) + const requestExpired = expSeconds > 0 && expSeconds * 1000 < Date.now() + const invalidRequest = !params.get("sig") || requestExpired + + async function changeOrg(orgId: string) { + if (!orgId || orgId === activeOrgId) return + setSwitchingOrgId(orgId) + try { + await authClient.organization.setActive({ organizationId: orgId }) + setOrgMenuOpen(false) + } catch (err) { + console.error("Failed to switch organization:", err) + } finally { + setSwitchingOrgId(null) + } + } + + async function submit(accept: boolean) { + // Send the raw, unmodified query string — better-auth re-verifies its HMAC, + // so it must be byte-for-byte what we were redirected with (not re-serialized). + const oauthQuery = window.location.search.replace(/^\?/, "") + if (!oauthQuery) { + setError( + "Missing authorization request. Start the flow again from your app.", + ) + return + } + setSubmitting(accept ? "approve" : "deny") + setError(null) + try { + const res = await fetch(`${API_URL}/api/auth/oauth2/consent`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + // Omit `scope` → better-auth accepts all originally-requested scopes + // (we don't offer per-scope toggles, and sending a mismatched list 400s). + body: JSON.stringify({ accept, oauth_query: oauthQuery }), + }) + const data = (await res.json().catch(() => ({}))) as { + url?: string + redirectURI?: string + redirect_uri?: string + redirect?: boolean + message?: string + error?: string + error_description?: string + } + if (!res.ok) { + // The signed authorize query is short-lived (~10 min) and bound to the + // auth server's secret — a stale/expired consent page fails here. + if ( + data.error === "invalid_signature" || + data.error === "invalid_request" + ) { + throw new Error( + "This authorization request has expired. Start the connection again from your app.", + ) + } + throw new Error( + data.error_description || + data.message || + data.error || + "Authorization failed.", + ) + } + // Show the final state regardless: many clients use a loopback or custom + // scheme (cursor://) redirect_uri that hands off without replacing this tab. + setDone(accept ? "approved" : "denied") + const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri + if (redirectUrl) window.location.href = redirectUrl + } catch (err) { + console.error("OAuth consent failed:", err) + setError(err instanceof Error ? err.message : "Authorization failed.") + setSubmitting(null) + } + } + + if (done || invalidRequest) { + const title = done + ? done === "approved" + ? "Access authorized" + : "Access denied" + : requestExpired + ? "This request has expired" + : "No authorization request" + const subtitle = done + ? "You can return to your app — it's safe to close this tab." + : "Start the connection again from your app — this page only works as part of that flow." + return ( +
+
+ +

+ {title} +

+

{subtitle}

+
+
+ ) + } + + return ( +
+
+
+
+
+ + {session?.user && ( +
+

+ {session.user.email} +

+ {canSwitchOrg ? ( + + + + {activeOrgName ?? "Select organization"} + + + + + {organizations?.map((o) => { + const isCurrent = o.id === activeOrgId + const isSwitching = switchingOrgId === o.id + return ( + + ) + })} + + + ) : activeOrgName ? ( +

+ {activeOrgName} +

+ ) : null} +
+ )} +
+
+
+ +
+

+ Authorize access +

+

+ An application is requesting access to your Supermemory account. +

+
+ + {clientId && ( +

+ Client ID:{" "} + {clientId} +

+ )} + + {scopes.length > 0 && ( +
+

+ Requested permissions +

+
    + {scopes.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} + +export default function OAuthConsentPage() { + return ( + +
+
+ } + > + +
+ ) +}