Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mcp/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
API_URL=https://api.supermemory.ai
MCP_URL=https://mcp.supermemory.ai
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
23 changes: 13 additions & 10 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mcpBaseUrl falls back to x-forwarded-host / host headers when MCP_URL is not set. Since this value ends up in the resource field of the OAuth protected resource metadata (line 74), a spoofed x-forwarded-host could cause MCP clients to send tokens to the wrong resource. The MCP_URL env var mitigates this in production, but in dev mode without it set, this is exploitable. Consider only using host (not x-forwarded-host) as the fallback, since CF Workers set host reliably.

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(
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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", {
Expand Down
3 changes: 2 additions & 1 deletion apps/mcp/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MCP_URL var is set to https://mcp.supermemory.ai here but the resource field in the mono API's /.well-known/oauth-protected-resource (PR #1552, well-known.ts) uses BETTER_AUTH_URL (i.e. https://api.supermemory.ai). These are intentionally different resources, but make sure the oauthValidAudiences in mono PR #1552 includes the MCP origin — it does via mcpServerUrl(env), so this should be fine.

},
"routes": [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(auth)/login/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Endpoint changed from /api/auth/mcp/authorize to /api/auth/oauth2/authorize. This must be deployed simultaneously with mono PR #1552 — if this PR lands first, the login page will redirect to a non-existent endpoint. If #1552 lands first, the old endpoint is removed and existing login pages break. Coordinate the deployment.

}

function AnimatedGradientBackground() {
Expand Down
309 changes: 309 additions & 0 deletions apps/web/app/oauth/consent/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changeOrg calls authClient.organization.setActive but doesn't refresh the consent page's query params. After switching orgs, the HMAC-signed query string still references the original session state. Will the consent POST succeed with the new active org, or will the server reject it because the session changed mid-flow?

"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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sending the raw query string to preserve the HMAC signature is the right call. But if the browser ever normalizes or re-encodes query params (e.g. special characters, unicode), the HMAC will fail. The invalid_signature handling below (line 99) covers this — good.

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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div
className={cn(
"w-full max-w-[420px] rounded-[14px] bg-[#14161A] p-6 text-center shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
dmSans125ClassName(),
)}
>
<LogoFull className="mx-auto h-5 w-auto text-[#FAFAFA]" />
<h2 className="mt-5 font-semibold text-[18px] text-[#FAFAFA]">
{title}
</h2>
<p className="mt-1.5 text-[13px] text-[#737373]">{subtitle}</p>
</div>
</div>
)
}

return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-[520px] rounded-[14px] bg-[#14161A] p-6 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<LogoFull className="h-6 w-auto shrink-0 text-[#FAFAFA]" />
{session?.user && (
<div className="flex min-w-0 flex-col items-end">
<p className="truncate text-[12px] text-[#FAFAFA]">
{session.user.email}
</p>
{canSwitchOrg ? (
<Popover open={orgMenuOpen} onOpenChange={setOrgMenuOpen}>
<PopoverTrigger
className={cn(
"flex max-w-[200px] items-center gap-1 text-[11px] text-[#737373] transition-colors hover:text-[#FAFAFA]",
dmSans125ClassName(),
)}
>
<span className="truncate">
{activeOrgName ?? "Select organization"}
</span>
<ChevronDown className="size-3 shrink-0" />
</PopoverTrigger>
<PopoverContent
align="end"
className="w-max min-w-[12rem] max-w-[18rem] rounded-[12px] border-white/10 bg-[#1B1F24] p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
>
{organizations?.map((o) => {
const isCurrent = o.id === activeOrgId
const isSwitching = switchingOrgId === o.id
return (
<button
key={o.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() => changeOrg(o.id)}
className={cn(
"flex w-full items-center gap-3 rounded-[8px] px-3 py-2 text-left transition-colors",
isCurrent
? "bg-white/5"
: "cursor-pointer hover:bg-white/5",
"disabled:cursor-default",
dmSans125ClassName(),
)}
>
<Building2 className="size-4 shrink-0 text-[#737373]" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<p className="truncate text-[13px] text-[#FAFAFA] tracking-[-0.14px]">
{o.name}
</p>
{isCurrent && (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
)}
{isSwitching && (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
)}
</div>
</button>
)
})}
</PopoverContent>
</Popover>
) : activeOrgName ? (
<p className="truncate text-[11px] text-[#737373]">
{activeOrgName}
</p>
) : null}
</div>
)}
</div>
<div className="h-px bg-[#1E293B]" />
</div>

<div className="text-center">
<h2 className="font-semibold text-[18px] text-[#FAFAFA]">
Authorize access
</h2>
<p className="mt-1 text-[13px] text-[#737373]">
An application is requesting access to your Supermemory account.
</p>
</div>

{clientId && (
<p className="text-center text-[12px] text-[#8B8B8B]">
Client ID:{" "}
<code className="break-all text-[#FAFAFA]">{clientId}</code>
</p>
)}

{scopes.length > 0 && (
<div>
<p className="mb-1.5 text-[12px] text-[#737373]">
Requested permissions
</p>
<ul className="space-y-1">
{scopes.map((s) => (
<li
key={s}
className="rounded-md bg-[#0D121A] px-2.5 py-1.5 text-[13px] text-[#8B8B8B]"
>
{s}
</li>
))}
</ul>
</div>
)}

{error && <p className="text-[13px] text-red-400">{error}</p>}

<div className="flex gap-2">
<button
type="button"
onClick={() => submit(false)}
disabled={submitting !== null}
className={cn(
"flex h-11 flex-1 items-center justify-center rounded-[10px]",
"border border-[#1E293B] bg-[#0D121A] text-[#FAFAFA]",
"font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-colors hover:bg-[#1E293B]",
"disabled:cursor-not-allowed disabled:opacity-60",
dmSans125ClassName(),
)}
>
{submitting === "deny" ? "Cancelling…" : "Deny"}
</button>
<button
type="button"
onClick={() => submit(true)}
disabled={submitting !== null}
className={cn(
"relative flex h-11 flex-1 items-center justify-center rounded-[10px]",
"font-medium text-[14px] text-[#FAFAFA] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
"disabled:cursor-not-allowed disabled:opacity-60",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{submitting === "approve" ? "Authorizing…" : "Approve"}
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
</div>
</div>
</div>
</div>
)
}

export default function OAuthConsentPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="size-6 animate-spin rounded-full border-2 border-[#4BA0FA] border-t-transparent" />
</div>
}
>
<OAuthConsentContent />
</Suspense>
)
}
Loading