Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
32 changes: 31 additions & 1 deletion apps/browser-extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
6 changes: 3 additions & 3 deletions apps/browser-extension/entrypoints/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { initializeGrok } from "./grok"
import {
saveMemory,
setupGlobalKeyboardShortcut,
setupStorageListener,
setupExtensionAuth,
} from "./shared"
import { initializeT3 } from "./t3"
import {
Expand Down Expand Up @@ -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 = () => {
Expand Down
46 changes: 15 additions & 31 deletions apps/browser-extension/entrypoints/content/shared.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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
}
}
1 change: 1 addition & 0 deletions apps/browser-extension/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
119 changes: 119 additions & 0 deletions apps/browser-extension/utils/extension-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
90 changes: 90 additions & 0 deletions apps/browser-extension/utils/extension-auth.ts
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This line uses a type assertion (as ExtensionSessionResponse) on the result of response.json(). According to the style guide rule 'Use type annotations instead of assertions for object literals', type assertions should be avoided when a type annotation can be used instead. While this is not an object literal, the broader rule 'Avoid unnecessary type assertions' applies here. Since response.json() returns Promise<any>, a cleaner approach would be to define a typed wrapper or use a type annotation on the variable declaration: const session: ExtensionSessionResponse = await response.json() instead of casting with as.

Suggested change
const session = (await response.json()) as ExtensionSessionResponse
const session: ExtensionSessionResponse = await response.json()

Spotted by Graphite (based on custom rule: TypeScript style guide (Google))

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

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,
},
}
}
3 changes: 2 additions & 1 deletion apps/browser-extension/wxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/*",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
16 changes: 0 additions & 16 deletions apps/web/components/app-experience.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading