From 02cdda762040a0e29fc74137b73fc219278d2f24 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 13:59:51 -0700 Subject: [PATCH 1/5] test: Initial e2e tests --- .github/workflows/ci.yml | 17 +++- integration/presets/electron.ts | 17 ++++ integration/presets/index.ts | 2 + .../templates/electron-vite/index.html | 18 +++++ integration/templates/electron-vite/main.mjs | 81 +++++++++++++++++++ .../templates/electron-vite/package.json | 25 ++++++ .../templates/electron-vite/preload.mjs | 3 + .../templates/electron-vite/src/main.tsx | 65 +++++++++++++++ .../templates/electron-vite/src/style.css | 20 +++++ .../templates/electron-vite/src/vite-env.d.ts | 1 + .../templates/electron-vite/tsconfig.json | 15 ++++ .../templates/electron-vite/vite.config.ts | 6 ++ integration/templates/index.ts | 1 + integration/tests/electron/basic.test.ts | 37 +++++++++ integration/tests/electron/fixtures.ts | 62 ++++++++++++++ package.json | 1 + .../src/main/__tests__/setup-main.test.ts | 67 ++++++++++++++- packages/electron/src/main/setup-main.ts | 62 +++++++++++++- turbo.json | 7 ++ 19 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 integration/presets/electron.ts create mode 100644 integration/templates/electron-vite/index.html create mode 100644 integration/templates/electron-vite/main.mjs create mode 100644 integration/templates/electron-vite/package.json create mode 100644 integration/templates/electron-vite/preload.mjs create mode 100644 integration/templates/electron-vite/src/main.tsx create mode 100644 integration/templates/electron-vite/src/style.css create mode 100644 integration/templates/electron-vite/src/vite-env.d.ts create mode 100644 integration/templates/electron-vite/tsconfig.json create mode 100644 integration/templates/electron-vite/vite.config.ts create mode 100644 integration/tests/electron/basic.test.ts create mode 100644 integration/tests/electron/fixtures.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6e639c157e..630ed662984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,6 +347,7 @@ jobs: "custom", "hono", "chrome-extension", + "electron", ] test-project: ["chrome"] include: @@ -486,10 +487,24 @@ jobs: working-directory: ./integration/certs run: ls -la && pwd + - name: Ensure Xvfb is installed + if: ${{ matrix.test-name == 'electron' }} + run: | + if ! command -v xvfb-run &> /dev/null; then + sudo apt-get update + sudo apt-get install -y xvfb + fi + xvfb-run --help > /dev/null + - name: Run Integration Tests id: integration-tests timeout-minutes: 25 - run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS + run: | + if [ "${{ matrix.test-name }}" = "electron" ]; then + xvfb-run -a pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS + else + pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS + fi env: E2E_DEBUG: "1" E2E_APP_CLERK_JS_DIR: ${{runner.temp}} diff --git a/integration/presets/electron.ts b/integration/presets/electron.ts new file mode 100644 index 00000000000..78bbe9a5132 --- /dev/null +++ b/integration/presets/electron.ts @@ -0,0 +1,17 @@ +import { applicationConfig } from '../models/applicationConfig'; +import { templates } from '../templates'; +import { PKGLAB } from './utils'; + +const vite = applicationConfig() + .setName('electron-vite') + .useTemplate(templates['electron-vite']) + .setEnvFormatter('public', key => `VITE_${key}`) + .addScript('setup', 'pnpm install') + .addScript('dev', 'pnpm build') + .addScript('build', 'pnpm build') + .addScript('serve', 'echo noop') + .addDependency('@clerk/electron', PKGLAB); + +export const electron = { + vite, +} as const; diff --git a/integration/presets/index.ts b/integration/presets/index.ts index f67f3b36385..de6cacfcb03 100644 --- a/integration/presets/index.ts +++ b/integration/presets/index.ts @@ -1,6 +1,7 @@ import { astro } from './astro'; import { chromeExtension } from './chrome-extension'; import { customFlows } from './custom-flows'; +import { electron } from './electron'; import { envs, instanceKeys } from './envs'; import { expo } from './expo'; import { express } from './express'; @@ -17,6 +18,7 @@ import { vue } from './vue'; export const appConfigs = { chromeExtension, customFlows, + electron, envs, express, fastify, diff --git a/integration/templates/electron-vite/index.html b/integration/templates/electron-vite/index.html new file mode 100644 index 00000000000..bdb9021af6b --- /dev/null +++ b/integration/templates/electron-vite/index.html @@ -0,0 +1,18 @@ + + + + + + Clerk Electron Integration + + +
+ + + diff --git a/integration/templates/electron-vite/main.mjs b/integration/templates/electron-vite/main.mjs new file mode 100644 index 00000000000..29e4ca7ebf6 --- /dev/null +++ b/integration/templates/electron-vite/main.mjs @@ -0,0 +1,81 @@ +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { setupMain } from '@clerk/electron'; +import { app, BrowserWindow, net, protocol } from 'electron'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const RENDERER_SCHEME = 'clerk-electron'; +const RENDERER_HOST = 'renderer'; +const rendererRoot = path.resolve(__dirname, 'dist'); +const tokens = new Map(); + +const clerk = setupMain({ + storage: { + getItem: key => tokens.get(key) ?? null, + setItem: (key, value) => { + tokens.set(key, value); + }, + removeItem: key => { + tokens.delete(key); + }, + }, + renderer: { + scheme: RENDERER_SCHEME, + host: RENDERER_HOST, + }, +}); + +function resolveRendererPath(requestUrl) { + const url = new URL(requestUrl); + if (url.host !== RENDERER_HOST) { + return null; + } + + const pathname = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname); + const filePath = path.resolve(rendererRoot, `.${pathname}`); + const relativePath = path.relative(rendererRoot, filePath); + + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + + return filePath; +} + +async function createWindow() { + const win = new BrowserWindow({ + width: 1000, + height: 800, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: path.resolve(__dirname, 'preload.mjs'), + sandbox: false, + }, + }); + + await win.loadURL(`${RENDERER_SCHEME}://${RENDERER_HOST}/`); +} + +app.whenReady().then(async () => { + protocol.handle(RENDERER_SCHEME, request => { + const filePath = resolveRendererPath(request.url); + + if (!filePath) { + return new Response('Not found', { status: 404 }); + } + + return net.fetch(pathToFileURL(filePath).toString()); + }); + + await createWindow(); +}); + +app.on('window-all-closed', () => { + app.quit(); +}); + +app.on('before-quit', () => { + clerk.cleanup(); +}); diff --git a/integration/templates/electron-vite/package.json b/integration/templates/electron-vite/package.json new file mode 100644 index 00000000000..a192891b76c --- /dev/null +++ b/integration/templates/electron-vite/package.json @@ -0,0 +1,25 @@ +{ + "name": "electron-vite", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "electron": "^39.2.6", + "typescript": "^5.7.3", + "vite": "^7.3.3" + }, + "engines": { + "node": ">=22.11.0" + } +} diff --git a/integration/templates/electron-vite/preload.mjs b/integration/templates/electron-vite/preload.mjs new file mode 100644 index 00000000000..718064bfd3c --- /dev/null +++ b/integration/templates/electron-vite/preload.mjs @@ -0,0 +1,3 @@ +import { setupPreload } from '@clerk/electron/preload'; + +setupPreload(); diff --git a/integration/templates/electron-vite/src/main.tsx b/integration/templates/electron-vite/src/main.tsx new file mode 100644 index 00000000000..bbd8ccce8da --- /dev/null +++ b/integration/templates/electron-vite/src/main.tsx @@ -0,0 +1,65 @@ +import { ClerkProvider, Show, SignIn, UserButton, useAuth, useClerk, useSessionList } from '@clerk/electron/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import './style.css'; + +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string; + +function App() { + return ( + {}} + routerReplace={() => {}} + > +
+ + + + + + + + +
+
+ ); +} + +function NativeSessionActivator() { + const clerk = useClerk(); + const { sessionId } = useAuth(); + const { isLoaded, sessions } = useSessionList(); + const activatingRef = React.useRef(false); + + React.useEffect(() => { + if (!isLoaded || sessionId || !sessions.length || activatingRef.current) { + return; + } + + activatingRef.current = true; + void clerk.setActive({ session: sessions[0].id }).finally(() => { + activatingRef.current = false; + }); + }, [clerk, isLoaded, sessionId, sessions]); + + return null; +} + +function AuthInfo() { + const { sessionId, userId } = useAuth(); + + return ( +
+

{userId}

+

{sessionId}

+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/integration/templates/electron-vite/src/style.css b/integration/templates/electron-vite/src/style.css new file mode 100644 index 00000000000..db4a37bd4ba --- /dev/null +++ b/integration/templates/electron-vite/src/style.css @@ -0,0 +1,20 @@ +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + background: #f8fafc; +} + +main { + display: grid; + min-height: 100vh; + place-items: center; +} diff --git a/integration/templates/electron-vite/src/vite-env.d.ts b/integration/templates/electron-vite/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/integration/templates/electron-vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/integration/templates/electron-vite/tsconfig.json b/integration/templates/electron-vite/tsconfig.json new file mode 100644 index 00000000000..f0b7f75c359 --- /dev/null +++ b/integration/templates/electron-vite/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "target": "ES2020", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/integration/templates/electron-vite/vite.config.ts b/integration/templates/electron-vite/vite.config.ts new file mode 100644 index 00000000000..fabde1a8f5e --- /dev/null +++ b/integration/templates/electron-vite/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/integration/templates/index.ts b/integration/templates/index.ts index 5588158e1f5..2fd05daa6ae 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -24,6 +24,7 @@ export const templates = { 'react-router-library': resolve(__dirname, './react-router-library'), 'custom-flows-react-vite': resolve(__dirname, './custom-flows-react-vite'), 'chrome-extension-vite': resolve(__dirname, './chrome-extension-vite'), + 'electron-vite': resolve(__dirname, './electron-vite'), } as const; if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) { diff --git a/integration/tests/electron/basic.test.ts b/integration/tests/electron/basic.test.ts new file mode 100644 index 00000000000..f11603d822e --- /dev/null +++ b/integration/tests/electron/basic.test.ts @@ -0,0 +1,37 @@ +import { createPageObjects } from '@clerk/testing/playwright/unstable'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { expect, test } from './fixtures'; + +test.describe('electron basic auth @electron', () => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async ({ testApp }) => { + const u = createTestUtils({ app: testApp }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + }); + + test('signs in with email and password', async ({ electronPage }) => { + const { signIn } = createPageObjects({ page: electronPage, useTestingToken: false }); + + await signIn.waitForMounted(); + await expect(electronPage.locator('.cl-signIn-root')).toBeVisible(); + + await signIn.setIdentifier(fakeUser.email); + await signIn.continue(); + const passField = signIn.getPasswordInput(); + await passField.waitFor({ state: 'visible' }); + await passField.fill(fakeUser.password); + await signIn.continue(); + + await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 }); + }); +}); diff --git a/integration/tests/electron/fixtures.ts b/integration/tests/electron/fixtures.ts new file mode 100644 index 00000000000..acad90d5e8a --- /dev/null +++ b/integration/tests/electron/fixtures.ts @@ -0,0 +1,62 @@ +import * as path from 'node:path'; + +import { setupClerkTestingToken } from '@clerk/testing/playwright'; +import { _electron as electron, test as base } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { run } from '../../scripts'; +import { setupClerkTestingEnv } from '../chrome-extension/helpers'; + +type WorkerFixtures = { + testApp: Application; +}; + +type TestFixtures = { + electronApplication: ElectronApplication; + electronPage: Page; +}; + +const electronExecutable = (app: Application) => + path.resolve(app.appDir, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'); + +export const test = base.extend({ + testApp: [ + async ({}, use) => { + const env = appConfigs.envs.withEmailCodes; + const app = await appConfigs.electron.vite.commit(); + + await app.withEnv(env); + await app.setup(); + // pkglab installs with scripts disabled, so install Electron's binary explicitly. + await run('node node_modules/electron/install.js', { cwd: app.appDir }); + await app.build(); + await setupClerkTestingEnv(env); + + await use(app); + await app.teardown(); + }, + { scope: 'worker', timeout: 120_000 }, + ], + + electronApplication: async ({ testApp }, use) => { + const application = await electron.launch({ + args: [path.resolve(testApp.appDir, 'main.mjs')], + cwd: testApp.appDir, + executablePath: electronExecutable(testApp), + }); + + await use(application); + await application.close(); + }, + + electronPage: async ({ electronApplication }, use) => { + const page = await electronApplication.firstWindow(); + await setupClerkTestingToken({ context: page.context() }); + await page.reload(); + await use(page); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/package.json b/package.json index d49b3668f42..94790c2f874 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts", "test:integration:custom": "pnpm test:integration:base --grep @custom", "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", + "test:integration:electron": "pnpm test:integration:base --grep @electron", "test:integration:expo-web:disabled": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web", "test:integration:express": "E2E_APP_ID=express.* pnpm test:integration:base --grep @express", "test:integration:fastify": "E2E_APP_ID=fastify.* pnpm test:integration:base --grep @fastify", diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts index 32701055806..2beb600b8cc 100644 --- a/packages/electron/src/main/__tests__/setup-main.test.ts +++ b/packages/electron/src/main/__tests__/setup-main.test.ts @@ -1,4 +1,4 @@ -import { ipcMain, protocol } from 'electron'; +import { app, ipcMain, protocol, session } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TokenStorage } from '../../shared/types'; @@ -9,9 +9,20 @@ vi.mock('electron', () => ({ handle: vi.fn(), removeHandler: vi.fn(), }, + app: { + isReady: vi.fn(() => true), + whenReady: vi.fn(() => Promise.resolve()), + }, protocol: { registerSchemesAsPrivileged: vi.fn(), }, + session: { + defaultSession: { + webRequest: { + onBeforeSendHeaders: vi.fn(), + }, + }, + }, })); describe('setupMain', () => { @@ -59,6 +70,41 @@ describe('setupMain', () => { ]); }); + it('strips the renderer Origin from native Clerk requests that use Authorization', () => { + setupMain({ + storage, + renderer: { + host: 'renderer', + scheme: 'my-app', + }, + }); + + expect(session.defaultSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledWith( + { urls: ['https://*/*'] }, + expect.any(Function), + ); + + const listener = vi.mocked(session.defaultSession.webRequest.onBeforeSendHeaders).mock.calls[0][1]; + const callback = vi.fn(); + + listener( + { + requestHeaders: { + Authorization: 'Bearer jwt', + Origin: 'my-app://renderer', + }, + url: 'https://frontend-api.clerk.dev/v1/client?_is_native=1', + } as Electron.OnBeforeSendHeadersListenerDetails, + callback, + ); + + expect(callback).toHaveBeenCalledWith({ + requestHeaders: { + Authorization: 'Bearer jwt', + }, + }); + }); + it('requires renderer.scheme to be a scheme name, not a URL', () => { expect(() => setupMain({ @@ -90,4 +136,23 @@ describe('setupMain', () => { expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); }); + + it('removes the renderer request header handler on cleanup', () => { + vi.mocked(app.isReady).mockReturnValue(true); + + const clerk = setupMain({ + storage, + renderer: { + host: 'renderer', + scheme: 'my-app', + }, + }); + + clerk.cleanup(); + + expect(session.defaultSession.webRequest.onBeforeSendHeaders).toHaveBeenLastCalledWith( + { urls: ['https://*/*'] }, + null, + ); + }); }); diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts index aef67fda011..88eb8b7d7ba 100644 --- a/packages/electron/src/main/setup-main.ts +++ b/packages/electron/src/main/setup-main.ts @@ -1,4 +1,4 @@ -import { protocol } from 'electron'; +import { app, protocol, session } from 'electron'; import type { SetupMainOptions, SetupMainReturn } from '../shared/types'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; @@ -17,6 +17,61 @@ function assertValidRendererOriginConfig(renderer: NonNullable, header: string): string | undefined { + return Object.keys(headers).find(key => key.toLowerCase() === header.toLowerCase()); +} + +function setupRendererRequestHeaders(renderer: NonNullable): () => void { + const rendererOrigin = `${renderer.scheme}://${renderer.host}`; + let registered = false; + let disposed = false; + + const listener = ( + details: Electron.OnBeforeSendHeadersListenerDetails, + callback: (beforeSendResponse: Electron.BeforeSendResponse) => void, + ) => { + const url = new URL(details.url); + const originKey = findHeaderKey(details.requestHeaders, 'origin'); + const authorizationKey = findHeaderKey(details.requestHeaders, 'authorization'); + + if ( + url.searchParams.get('_is_native') === '1' && + originKey && + authorizationKey && + details.requestHeaders[originKey] === rendererOrigin + ) { + delete details.requestHeaders[originKey]; + } + + callback({ requestHeaders: details.requestHeaders }); + }; + + const register = () => { + if (disposed || registered) { + return; + } + + session.defaultSession.webRequest.onBeforeSendHeaders(rendererRequestHeaderFilter, listener); + registered = true; + }; + + if (app.isReady()) { + register(); + } else { + void app.whenReady().then(register); + } + + return () => { + disposed = true; + + if (registered) { + session.defaultSession.webRequest.onBeforeSendHeaders(rendererRequestHeaderFilter, null); + } + }; +} + export function setupMain(options: SetupMainOptions): SetupMainReturn { if (!options.storage) { throw new Error( @@ -26,6 +81,8 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); + let cleanupRendererRequestHeaders = () => {}; + if (options.renderer) { assertValidRendererOriginConfig(options.renderer); @@ -41,11 +98,14 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { }, }, ]); + + cleanupRendererRequestHeaders = setupRendererRequestHeaders(options.renderer); } return { cleanup() { cleanupTokenPersistence(); + cleanupRendererRequestHeaders(); }, }; } diff --git a/turbo.json b/turbo.json index 42298cbc8f2..1766fb9c531 100644 --- a/turbo.json +++ b/turbo.json @@ -363,6 +363,13 @@ "outputs": ["integration/playwright-report/**"], "outputLogs": "new-only" }, + "//#test:integration:electron": { + "dependsOn": ["@clerk/electron#build"], + "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS"], + "inputs": ["integration/**", "packages/electron/**"], + "outputs": ["integration/playwright-report/**"], + "outputLogs": "new-only" + }, "//#typedoc:generate": { "dependsOn": ["@clerk/nextjs#build", "@clerk/react#build", "@clerk/shared#build"], "inputs": ["tsconfig.typedoc.json", "typedoc.config.mjs", ".typedoc/**/*.mjs"], From aad428221d7f97e29a5b6cceb37d10083737fa44 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 14:50:50 -0700 Subject: [PATCH 2/5] chore: clean up --- integration/templates/electron-vite/main.mjs | 16 ++--- .../templates/electron-vite/package.json | 1 + .../src/main/__tests__/setup-main.test.ts | 67 +------------------ packages/electron/src/main/setup-main.ts | 62 +---------------- 4 files changed, 7 insertions(+), 139 deletions(-) diff --git a/integration/templates/electron-vite/main.mjs b/integration/templates/electron-vite/main.mjs index 29e4ca7ebf6..8eeafde4662 100644 --- a/integration/templates/electron-vite/main.mjs +++ b/integration/templates/electron-vite/main.mjs @@ -2,24 +2,16 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { setupMain } from '@clerk/electron'; +import { storage } from '@clerk/electron/storage'; import { app, BrowserWindow, net, protocol } from 'electron'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const RENDERER_SCHEME = 'clerk-electron'; -const RENDERER_HOST = 'renderer'; +const RENDERER_SCHEME = 'clerk'; +const RENDERER_HOST = 'app'; const rendererRoot = path.resolve(__dirname, 'dist'); -const tokens = new Map(); const clerk = setupMain({ - storage: { - getItem: key => tokens.get(key) ?? null, - setItem: (key, value) => { - tokens.set(key, value); - }, - removeItem: key => { - tokens.delete(key); - }, - }, + storage: storage(), renderer: { scheme: RENDERER_SCHEME, host: RENDERER_HOST, diff --git a/integration/templates/electron-vite/package.json b/integration/templates/electron-vite/package.json index a192891b76c..55a1393117f 100644 --- a/integration/templates/electron-vite/package.json +++ b/integration/templates/electron-vite/package.json @@ -7,6 +7,7 @@ "build": "vite build" }, "dependencies": { + "electron-store": "^8.2.0", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts index 2beb600b8cc..32701055806 100644 --- a/packages/electron/src/main/__tests__/setup-main.test.ts +++ b/packages/electron/src/main/__tests__/setup-main.test.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, protocol, session } from 'electron'; +import { ipcMain, protocol } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TokenStorage } from '../../shared/types'; @@ -9,20 +9,9 @@ vi.mock('electron', () => ({ handle: vi.fn(), removeHandler: vi.fn(), }, - app: { - isReady: vi.fn(() => true), - whenReady: vi.fn(() => Promise.resolve()), - }, protocol: { registerSchemesAsPrivileged: vi.fn(), }, - session: { - defaultSession: { - webRequest: { - onBeforeSendHeaders: vi.fn(), - }, - }, - }, })); describe('setupMain', () => { @@ -70,41 +59,6 @@ describe('setupMain', () => { ]); }); - it('strips the renderer Origin from native Clerk requests that use Authorization', () => { - setupMain({ - storage, - renderer: { - host: 'renderer', - scheme: 'my-app', - }, - }); - - expect(session.defaultSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledWith( - { urls: ['https://*/*'] }, - expect.any(Function), - ); - - const listener = vi.mocked(session.defaultSession.webRequest.onBeforeSendHeaders).mock.calls[0][1]; - const callback = vi.fn(); - - listener( - { - requestHeaders: { - Authorization: 'Bearer jwt', - Origin: 'my-app://renderer', - }, - url: 'https://frontend-api.clerk.dev/v1/client?_is_native=1', - } as Electron.OnBeforeSendHeadersListenerDetails, - callback, - ); - - expect(callback).toHaveBeenCalledWith({ - requestHeaders: { - Authorization: 'Bearer jwt', - }, - }); - }); - it('requires renderer.scheme to be a scheme name, not a URL', () => { expect(() => setupMain({ @@ -136,23 +90,4 @@ describe('setupMain', () => { expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); }); - - it('removes the renderer request header handler on cleanup', () => { - vi.mocked(app.isReady).mockReturnValue(true); - - const clerk = setupMain({ - storage, - renderer: { - host: 'renderer', - scheme: 'my-app', - }, - }); - - clerk.cleanup(); - - expect(session.defaultSession.webRequest.onBeforeSendHeaders).toHaveBeenLastCalledWith( - { urls: ['https://*/*'] }, - null, - ); - }); }); diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts index 88eb8b7d7ba..aef67fda011 100644 --- a/packages/electron/src/main/setup-main.ts +++ b/packages/electron/src/main/setup-main.ts @@ -1,4 +1,4 @@ -import { app, protocol, session } from 'electron'; +import { protocol } from 'electron'; import type { SetupMainOptions, SetupMainReturn } from '../shared/types'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; @@ -17,61 +17,6 @@ function assertValidRendererOriginConfig(renderer: NonNullable, header: string): string | undefined { - return Object.keys(headers).find(key => key.toLowerCase() === header.toLowerCase()); -} - -function setupRendererRequestHeaders(renderer: NonNullable): () => void { - const rendererOrigin = `${renderer.scheme}://${renderer.host}`; - let registered = false; - let disposed = false; - - const listener = ( - details: Electron.OnBeforeSendHeadersListenerDetails, - callback: (beforeSendResponse: Electron.BeforeSendResponse) => void, - ) => { - const url = new URL(details.url); - const originKey = findHeaderKey(details.requestHeaders, 'origin'); - const authorizationKey = findHeaderKey(details.requestHeaders, 'authorization'); - - if ( - url.searchParams.get('_is_native') === '1' && - originKey && - authorizationKey && - details.requestHeaders[originKey] === rendererOrigin - ) { - delete details.requestHeaders[originKey]; - } - - callback({ requestHeaders: details.requestHeaders }); - }; - - const register = () => { - if (disposed || registered) { - return; - } - - session.defaultSession.webRequest.onBeforeSendHeaders(rendererRequestHeaderFilter, listener); - registered = true; - }; - - if (app.isReady()) { - register(); - } else { - void app.whenReady().then(register); - } - - return () => { - disposed = true; - - if (registered) { - session.defaultSession.webRequest.onBeforeSendHeaders(rendererRequestHeaderFilter, null); - } - }; -} - export function setupMain(options: SetupMainOptions): SetupMainReturn { if (!options.storage) { throw new Error( @@ -81,8 +26,6 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); - let cleanupRendererRequestHeaders = () => {}; - if (options.renderer) { assertValidRendererOriginConfig(options.renderer); @@ -98,14 +41,11 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { }, }, ]); - - cleanupRendererRequestHeaders = setupRendererRequestHeaders(options.renderer); } return { cleanup() { cleanupTokenPersistence(); - cleanupRendererRequestHeaders(); }, }; } From 4d0a666e16078b59d6f717a5eb5cc4817c1fb3c0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 21:28:20 -0700 Subject: [PATCH 3/5] chore: apply renderer fix --- integration/templates/electron-vite/main.mjs | 67 ++++++++++++++------ integration/tests/electron/basic.test.ts | 6 +- integration/tests/electron/fixtures.ts | 28 ++++++++ 3 files changed, 76 insertions(+), 25 deletions(-) diff --git a/integration/templates/electron-vite/main.mjs b/integration/templates/electron-vite/main.mjs index 8eeafde4662..360b380f292 100644 --- a/integration/templates/electron-vite/main.mjs +++ b/integration/templates/electron-vite/main.mjs @@ -8,6 +8,8 @@ import { app, BrowserWindow, net, protocol } from 'electron'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RENDERER_SCHEME = 'clerk'; const RENDERER_HOST = 'app'; +// electron-vite sets this in dev. Its presence is how we pick proxy vs. file serving. +const DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL; const rendererRoot = path.resolve(__dirname, 'dist'); const clerk = setupMain({ @@ -18,23 +20,6 @@ const clerk = setupMain({ }, }); -function resolveRendererPath(requestUrl) { - const url = new URL(requestUrl); - if (url.host !== RENDERER_HOST) { - return null; - } - - const pathname = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname); - const filePath = path.resolve(rendererRoot, `.${pathname}`); - const relativePath = path.relative(rendererRoot, filePath); - - if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - return null; - } - - return filePath; -} - async function createWindow() { const win = new BrowserWindow({ width: 1000, @@ -50,17 +35,57 @@ async function createWindow() { await win.loadURL(`${RENDERER_SCHEME}://${RENDERER_HOST}/`); } -app.whenReady().then(async () => { - protocol.handle(RENDERER_SCHEME, request => { - const filePath = resolveRendererPath(request.url); +function registerClerkAppProtocol() { + protocol.handle(RENDERER_SCHEME, async request => { + const url = new URL(request.url); - if (!filePath) { + // Only the app host serves the UI. Other hosts can be reserved for deep links. + if (url.host !== RENDERER_HOST) { return new Response('Not found', { status: 404 }); } + // Dev: proxy to Vite so the renderer always runs from clerk://app. + if (DEV_SERVER_URL) { + const target = new URL(url.pathname + url.search, DEV_SERVER_URL); + // Intentionally do not forward the renderer's request headers to localhost. + const init = { method: request.method }; + + if (request.method !== 'GET' && request.method !== 'HEAD') { + init.body = request.body; + init.duplex = 'half'; + } + + try { + return await net.fetch(target.toString(), init); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[clerk-app-protocol] dev proxy failed', { + requested: request.url, + target: target.toString(), + devServer: DEV_SERVER_URL, + error: message, + }); + return new Response(`clerk://app dev proxy error: ${message}`, { status: 502 }); + } + } + + // Prod: serve the bundled renderer with a traversal guard and SPA fallback. + const requestedPath = decodeURIComponent(url.pathname); + const resolvedPath = path.resolve(rendererRoot, `.${requestedPath}`); + + if (resolvedPath !== rendererRoot && !resolvedPath.startsWith(rendererRoot + path.sep)) { + return new Response(null, { status: 403 }); + } + + const hasExtension = /\.[^/]+$/.test(url.pathname); + const filePath = hasExtension ? resolvedPath : path.join(rendererRoot, 'index.html'); + return net.fetch(pathToFileURL(filePath).toString()); }); +} +app.whenReady().then(async () => { + registerClerkAppProtocol(); await createWindow(); }); diff --git a/integration/tests/electron/basic.test.ts b/integration/tests/electron/basic.test.ts index f11603d822e..9c838b982f2 100644 --- a/integration/tests/electron/basic.test.ts +++ b/integration/tests/electron/basic.test.ts @@ -16,7 +16,7 @@ test.describe('electron basic auth @electron', () => { }); test.afterAll(async () => { - await fakeUser.deleteIfExists(); + await fakeUser?.deleteIfExists(); }); test('signs in with email and password', async ({ electronPage }) => { @@ -27,9 +27,7 @@ test.describe('electron basic auth @electron', () => { await signIn.setIdentifier(fakeUser.email); await signIn.continue(); - const passField = signIn.getPasswordInput(); - await passField.waitFor({ state: 'visible' }); - await passField.fill(fakeUser.password); + await signIn.setPassword(fakeUser.password); await signIn.continue(); await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 }); diff --git a/integration/tests/electron/fixtures.ts b/integration/tests/electron/fixtures.ts index acad90d5e8a..e79840f8c2a 100644 --- a/integration/tests/electron/fixtures.ts +++ b/integration/tests/electron/fixtures.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; +import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey'; import { setupClerkTestingToken } from '@clerk/testing/playwright'; import { _electron as electron, test as base } from '@playwright/test'; import type { ElectronApplication, Page } from '@playwright/test'; @@ -7,6 +8,7 @@ import type { ElectronApplication, Page } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; import { run } from '../../scripts'; +import { createTestUtils } from '../../testUtils'; import { setupClerkTestingEnv } from '../chrome-extension/helpers'; type WorkerFixtures = { @@ -20,6 +22,31 @@ type TestFixtures = { const electronExecutable = (app: Application) => path.resolve(app.appDir, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'); +const ELECTRON_RENDERER_ORIGIN = 'clerk://app'; + +async function addElectronRendererAllowedOrigin(app: Application) { + const u = createTestUtils({ app }); + const instance = await u.services.clerk.instance.get(); + const allowedOrigins = new Set(instance.allowedOrigins ?? []); + allowedOrigins.add(ELECTRON_RENDERER_ORIGIN); + + const publishableKey = app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const apiUrl = app.env.privateVariables.get('CLERK_API_URL') || apiUrlFromPublishableKey(publishableKey); + const secretKey = app.env.privateVariables.get('CLERK_SECRET_KEY'); + const res = await fetch(`${apiUrl}/v1/instance`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${secretKey}`, + 'Clerk-API-Version': '2026-05-12', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ allowed_origins: Array.from(allowedOrigins) }), + }); + + if (!res.ok) { + throw new Error(`Failed to add Electron allowed origin: ${res.status} ${await res.text()}`); + } +} export const test = base.extend({ testApp: [ @@ -33,6 +60,7 @@ export const test = base.extend({ await run('node node_modules/electron/install.js', { cwd: app.appDir }); await app.build(); await setupClerkTestingEnv(env); + await addElectronRendererAllowedOrigin(app); await use(app); await app.teardown(); From c396fd925568288e5caff56733035de01767d668 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 21:30:18 -0700 Subject: [PATCH 4/5] test: remove unused component --- .../templates/electron-vite/src/main.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/integration/templates/electron-vite/src/main.tsx b/integration/templates/electron-vite/src/main.tsx index bbd8ccce8da..bcd7ca66d73 100644 --- a/integration/templates/electron-vite/src/main.tsx +++ b/integration/templates/electron-vite/src/main.tsx @@ -1,4 +1,4 @@ -import { ClerkProvider, Show, SignIn, UserButton, useAuth, useClerk, useSessionList } from '@clerk/electron/react'; +import { ClerkProvider, Show, SignIn, UserButton, useAuth } from '@clerk/electron/react'; import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -14,7 +14,6 @@ function App() { routerReplace={() => {}} >
- @@ -27,26 +26,6 @@ function App() { ); } -function NativeSessionActivator() { - const clerk = useClerk(); - const { sessionId } = useAuth(); - const { isLoaded, sessions } = useSessionList(); - const activatingRef = React.useRef(false); - - React.useEffect(() => { - if (!isLoaded || sessionId || !sessions.length || activatingRef.current) { - return; - } - - activatingRef.current = true; - void clerk.setActive({ session: sessions[0].id }).finally(() => { - activatingRef.current = false; - }); - }, [clerk, isLoaded, sessionId, sessions]); - - return null; -} - function AuthInfo() { const { sessionId, userId } = useAuth(); From 96543b335d8905d0a3da881f60d2e79c9dd7f08b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 21:31:38 -0700 Subject: [PATCH 5/5] chore: remove unused helpers --- integration/tests/electron/fixtures.ts | 28 -------------------------- 1 file changed, 28 deletions(-) diff --git a/integration/tests/electron/fixtures.ts b/integration/tests/electron/fixtures.ts index e79840f8c2a..acad90d5e8a 100644 --- a/integration/tests/electron/fixtures.ts +++ b/integration/tests/electron/fixtures.ts @@ -1,6 +1,5 @@ import * as path from 'node:path'; -import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey'; import { setupClerkTestingToken } from '@clerk/testing/playwright'; import { _electron as electron, test as base } from '@playwright/test'; import type { ElectronApplication, Page } from '@playwright/test'; @@ -8,7 +7,6 @@ import type { ElectronApplication, Page } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; import { run } from '../../scripts'; -import { createTestUtils } from '../../testUtils'; import { setupClerkTestingEnv } from '../chrome-extension/helpers'; type WorkerFixtures = { @@ -22,31 +20,6 @@ type TestFixtures = { const electronExecutable = (app: Application) => path.resolve(app.appDir, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'); -const ELECTRON_RENDERER_ORIGIN = 'clerk://app'; - -async function addElectronRendererAllowedOrigin(app: Application) { - const u = createTestUtils({ app }); - const instance = await u.services.clerk.instance.get(); - const allowedOrigins = new Set(instance.allowedOrigins ?? []); - allowedOrigins.add(ELECTRON_RENDERER_ORIGIN); - - const publishableKey = app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); - const apiUrl = app.env.privateVariables.get('CLERK_API_URL') || apiUrlFromPublishableKey(publishableKey); - const secretKey = app.env.privateVariables.get('CLERK_SECRET_KEY'); - const res = await fetch(`${apiUrl}/v1/instance`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${secretKey}`, - 'Clerk-API-Version': '2026-05-12', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ allowed_origins: Array.from(allowedOrigins) }), - }); - - if (!res.ok) { - throw new Error(`Failed to add Electron allowed origin: ${res.status} ${await res.text()}`); - } -} export const test = base.extend({ testApp: [ @@ -60,7 +33,6 @@ export const test = base.extend({ await run('node node_modules/electron/install.js', { cwd: app.appDir }); await app.build(); await setupClerkTestingEnv(env); - await addElectronRendererAllowedOrigin(app); await use(app); await app.teardown();