diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index f9f9c9793..9c446e0e9 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -5,11 +5,14 @@ import { fetchProjects, } from "../utils/api" import { + API_ENDPOINTS, CONTAINER_TAGS, MESSAGE_TYPES, POSTHOG_EVENT_KEY, } from "../utils/constants" +import { getExtensionAuthCredentials } from "../utils/extension-auth" import { trackEvent } from "../utils/posthog" +import { bearerToken, userData } from "../utils/storage" import { captureTwitterTokens } from "../utils/twitter-auth" import { type TwitterImportConfig, @@ -181,7 +184,34 @@ export default defineBackground(() => { * Handle extension messages */ browser.runtime.onMessage.addListener( - (message: ExtensionMessage, _sender, sendResponse) => { + (message: ExtensionMessage, sender, sendResponse) => { + if (message.action === MESSAGE_TYPES.SYNC_EXTENSION_AUTH) { + ;(async () => { + try { + const pageUrl = sender.url ?? sender.tab?.url ?? "" + const cookies = await browser.cookies.getAll({ + url: API_ENDPOINTS.SUPERMEMORY_API, + }) + const credentials = await getExtensionAuthCredentials( + pageUrl, + cookies, + API_ENDPOINTS.SUPERMEMORY_API, + ) + await Promise.all([ + bearerToken.setValue(credentials.token), + userData.setValue(credentials.user), + ]) + sendResponse({ success: true }) + } catch (error) { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }) + } + })() + return true + } + // Handle Twitter import request if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) { const importConfig: TwitterImportConfig = { diff --git a/apps/browser-extension/entrypoints/content/index.ts b/apps/browser-extension/entrypoints/content/index.ts index 1c8635305..b191ae4b4 100644 --- a/apps/browser-extension/entrypoints/content/index.ts +++ b/apps/browser-extension/entrypoints/content/index.ts @@ -6,7 +6,7 @@ import { initializeGrok } from "./grok" import { saveMemory, setupGlobalKeyboardShortcut, - setupStorageListener, + setupExtensionAuth, } from "./shared" import { initializeT3 } from "./t3" import { @@ -37,8 +37,8 @@ export default defineContentScript({ // Setup global keyboard shortcuts setupGlobalKeyboardShortcut() - // Setup storage listener - setupStorageListener() + // Synchronize extension auth without exposing credentials to the page. + setupExtensionAuth() // Observer for dynamic content changes const observeForDynamicChanges = () => { diff --git a/apps/browser-extension/entrypoints/content/shared.ts b/apps/browser-extension/entrypoints/content/shared.ts index 68d117a1a..2af7700ae 100644 --- a/apps/browser-extension/entrypoints/content/shared.ts +++ b/apps/browser-extension/entrypoints/content/shared.ts @@ -1,5 +1,5 @@ import { MESSAGE_TYPES } from "../../utils/constants" -import { bearerToken, userData } from "../../utils/storage" +import { isExtensionAuthUrl } from "../../utils/extension-auth" import { DOMUtils } from "../../utils/ui-components" import { default as TurndownService } from "turndown" @@ -95,35 +95,19 @@ export function setupGlobalKeyboardShortcut() { }) } -export function setupStorageListener() { - window.addEventListener("message", async (event) => { - if (event.source !== window) { - return - } - const token = event.data.token - const user = event.data.userData - if (token && user) { - if ( - !( - window.location.hostname === "localhost" || - window.location.hostname === "supermemory.ai" || - window.location.hostname === "app.supermemory.ai" - ) - ) { - console.log( - "Bearer token and user data is only allowed to be used on localhost or supermemory.ai", - ) - return - } +export async function setupExtensionAuth() { + if (!isExtensionAuthUrl(window.location.href)) return - try { - await Promise.all([ - bearerToken.setValue(token), - userData.setValue(user), - ]) - } catch { - // Do nothing - } - } - }) + try { + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.SYNC_EXTENSION_AUTH, + }) + if (!response?.success) return + + const url = new URL(window.location.href) + url.searchParams.delete("extension-auth-success") + window.history.replaceState({}, "", url.toString()) + } catch { + // Do nothing + } } diff --git a/apps/browser-extension/utils/constants.ts b/apps/browser-extension/utils/constants.ts index a70834204..4b4998966 100644 --- a/apps/browser-extension/utils/constants.ts +++ b/apps/browser-extension/utils/constants.ts @@ -83,6 +83,7 @@ export const MESSAGE_TYPES = { GET_RELATED_MEMORIES: "sm-get-related-memories", CAPTURE_PROMPT: "sm-capture-prompt", FETCH_PROJECTS: "sm-fetch-projects", + SYNC_EXTENSION_AUTH: "sm-sync-extension-auth", TWITTER_IMPORT_OPEN_MODAL: "sm-twitter-import-open-modal", } as const diff --git a/apps/browser-extension/utils/extension-auth.test.ts b/apps/browser-extension/utils/extension-auth.test.ts new file mode 100644 index 000000000..cb269963f --- /dev/null +++ b/apps/browser-extension/utils/extension-auth.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { + getExtensionAuthCredentials, + getSessionToken, + isExtensionAuthUrl, +} from "./extension-auth" + +describe("extension auth", () => { + it("accepts only flagged Supermemory auth pages", () => { + expect( + isExtensionAuthUrl( + "https://app.supermemory.ai/?extension-auth-success=true", + ), + ).toBe(true) + expect( + isExtensionAuthUrl("http://localhost:3000/?extension-auth-success=true"), + ).toBe(true) + expect(isExtensionAuthUrl("https://app.supermemory.ai/")).toBe(false) + expect( + isExtensionAuthUrl( + "https://app.supermemory.ai.evil.example/?extension-auth-success=true", + ), + ).toBe(false) + expect( + isExtensionAuthUrl( + "http://app.supermemory.ai/?extension-auth-success=true", + ), + ).toBe(false) + }) + + it("finds production and development Better Auth cookies", () => { + expect( + getSessionToken([ + { name: "__Secure-better-auth.session_token", value: "production" }, + ]), + ).toBe("production") + expect( + getSessionToken([ + { name: "better-auth-dev.session_token", value: "development" }, + ]), + ).toBe("development") + expect(getSessionToken([{ name: "unrelated", value: "token" }])).toBeNull() + }) + + it("validates the cookie before returning credentials", async () => { + const requests: Array<{ url: string; authorization: string | null }> = [] + const fetchSession = async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const headers = new Headers(init?.headers) + requests.push({ + url: input.toString(), + authorization: headers.get("authorization"), + }) + return Response.json({ + user: { + id: "user-1", + email: "user@example.com", + name: "Test User", + }, + }) + } + + const credentials = await getExtensionAuthCredentials( + "https://app.supermemory.ai/?extension-auth-success=true", + [{ name: "better-auth.session_token", value: "session-token" }], + "https://api.supermemory.ai", + fetchSession, + ) + + expect(requests).toEqual([ + { + url: "https://api.supermemory.ai/v3/session", + authorization: "Bearer session-token", + }, + ]) + expect(credentials).toEqual({ + token: "session-token", + user: { + userId: "user-1", + email: "user@example.com", + name: "Test User", + }, + }) + }) + + it("rejects untrusted callers and invalid sessions", async () => { + const validResponse = async () => Response.json({ user: { id: "user-1" } }) + const invalidResponse = async () => new Response(null, { status: 401 }) + + await expect( + getExtensionAuthCredentials( + "https://evil.example/?extension-auth-success=true", + [{ name: "better-auth.session_token", value: "session-token" }], + "https://api.supermemory.ai", + validResponse, + ), + ).rejects.toThrow("Extension auth is not allowed from this page") + await expect( + getExtensionAuthCredentials( + "https://app.supermemory.ai/?extension-auth-success=true", + [{ name: "better-auth.session_token", value: "session-token" }], + "https://api.supermemory.ai", + invalidResponse, + ), + ).rejects.toThrow("Session validation failed") + }) + + it("does not publish credentials through the page message bus", () => { + const appExperience = readFileSync( + new URL("../../web/components/app-experience.tsx", import.meta.url), + "utf8", + ) + expect(appExperience).not.toContain("window.postMessage") + expect(appExperience).not.toContain("session?.token") + }) +}) diff --git a/apps/browser-extension/utils/extension-auth.ts b/apps/browser-extension/utils/extension-auth.ts new file mode 100644 index 000000000..faedce36c --- /dev/null +++ b/apps/browser-extension/utils/extension-auth.ts @@ -0,0 +1,90 @@ +import type { UserData } from "./storage" + +const EXTENSION_AUTH_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "supermemory.ai", + "app.supermemory.ai", +]) + +const SESSION_COOKIE_NAMES = [ + "__Secure-better-auth.session_token", + "better-auth.session_token", + "__Secure-better-auth-session_token", + "better-auth-session_token", + "__Secure-better-auth-dev.session_token", + "better-auth-dev.session_token", + "__Secure-better-auth-dev-session_token", + "better-auth-dev-session_token", +] + +interface ExtensionAuthCookie { + name: string + value: string +} + +interface ExtensionSessionResponse { + user?: { + id?: string + email?: string + name?: string + } +} + +export function isExtensionAuthUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl) + const allowedProtocol = + url.protocol === "https:" || + (url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1")) + return ( + allowedProtocol && + EXTENSION_AUTH_HOSTS.has(url.hostname) && + url.searchParams.get("extension-auth-success") === "true" + ) + } catch { + return false + } +} + +export function getSessionToken(cookies: ExtensionAuthCookie[]): string | null { + for (const name of SESSION_COOKIE_NAMES) { + const cookie = cookies.find((candidate) => candidate.name === name) + if (cookie?.value) return cookie.value + } + return null +} + +export async function getExtensionAuthCredentials( + pageUrl: string, + cookies: ExtensionAuthCookie[], + apiUrl: string, + fetchSession: typeof fetch = fetch, +): Promise<{ token: string; user: UserData }> { + if (!isExtensionAuthUrl(pageUrl)) { + throw new Error("Extension auth is not allowed from this page") + } + + const token = getSessionToken(cookies) + if (!token) throw new Error("Session cookie not found") + + const response = await fetchSession(`${apiUrl}/v3/session`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + if (!response.ok) throw new Error("Session validation failed") + + const session = (await response.json()) as ExtensionSessionResponse + if (!session.user?.id) throw new Error("Session user not found") + + return { + token, + user: { + userId: session.user.id, + email: session.user.email, + name: session.user.name, + }, + } +} diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts index 2f3342970..a8cef1934 100644 --- a/apps/browser-extension/wxt.config.ts +++ b/apps/browser-extension/wxt.config.ts @@ -30,11 +30,12 @@ export default defineConfig({ name: "supermemory", homepage_url: "https://supermemory.ai", version: "6.1.4", - permissions: ["storage", "activeTab", "webRequest", "tabs"], + permissions: ["storage", "activeTab", "webRequest", "tabs", "cookies"], host_permissions: [ "*://x.com/*", "*://twitter.com/*", "*://supermemory.ai/*", + "*://app.supermemory.ai/*", "*://api.supermemory.ai/*", "*://chatgpt.com/*", "*://chat.openai.com/*", diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 5f9bf8cc1..77160f9bb 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -153,7 +153,7 @@ export default function LoginPage() { ) return } - // Carry the flag so the dashboard posts the session token to the extension (else: sign-in loop). + // Carry the flag so the extension can synchronize auth after login. const dest = new URL("/", window.location.origin) dest.searchParams.set("extension-auth-success", "true") window.location.assign(dest.toString()) diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx index 259584ee6..2c396a13a 100644 --- a/apps/web/components/app-experience.tsx +++ b/apps/web/components/app-experience.tsx @@ -159,22 +159,6 @@ export function AppExperience() { const queryClient = useQueryClient() const [highlightsForceAt, setHighlightsForceAt] = useState(0) - // Chrome extension auth: send session token via postMessage so the content script can store it - useEffect(() => { - const url = new URL(window.location.href) - if (!url.searchParams.get("extension-auth-success")) return - const sessionToken = session?.token - const userData = { email: user?.email, name: user?.name, userId: user?.id } - if (sessionToken && userData.email) { - window.postMessage( - { token: encodeURIComponent(sessionToken), userData }, - window.location.origin, - ) - url.searchParams.delete("extension-auth-success") - window.history.replaceState({}, "", url.toString()) - } - }, [user, session]) - // URL-driven modal states const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)