diff --git a/.changeset/chrome-extension-protect-loader.md b/.changeset/chrome-extension-protect-loader.md new file mode 100644 index 00000000000..7fc22f85339 --- /dev/null +++ b/.changeset/chrome-extension-protect-loader.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': patch +'@clerk/chrome-extension': patch +--- + +Prevent Chrome extensions from loading Device Intelligence scripts from remote URLs. + +Initialize production Chrome extension authentication without requiring a previously stored client token. diff --git a/integration/templates/chrome-extension-vite/manifest.json b/integration/templates/chrome-extension-vite/manifest.json index 5ec734c4e29..a22f00b6c66 100644 --- a/integration/templates/chrome-extension-vite/manifest.json +++ b/integration/templates/chrome-extension-vite/manifest.json @@ -2,6 +2,7 @@ "manifest_version": 3, "name": "Clerk Test Extension", "version": "1.0.0", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw026nCDX5zl6YTeUsRXECDNDG7R1rjRfKTRPMbGUDGVvbw8cQLAkEohewo9nHrSc41/97sgHVE92SboJYumBDuy9GXSWYMT6Asx0r797YWkk8WBE/Fq7xNfHhEpjsl4SeHXOP41ANXQ8RLda5kP0AwGeLdvOyoFciLMJXwQaclEbuaceWv09CdkWDD48aZzPml7IEaHHaPpnliNCVMfwqaaW+GlS6Tg2hxPnEX0OiitIhbMEfw/W6twzck0jO1+2L2ml3mDuYMNjaXngGIWP+FcOJk/UCWxUKqbfGhkfFNjU9REjmXX9l4Kca9xEiz4osU/i9i3etDQX/XQTQVPILQIDAQAB", "action": { "default_popup": "popup.html" }, diff --git a/integration/templates/chrome-extension-vite/src/popup.tsx b/integration/templates/chrome-extension-vite/src/popup.tsx index f3385a6d299..4d0abf944ce 100644 --- a/integration/templates/chrome-extension-vite/src/popup.tsx +++ b/integration/templates/chrome-extension-vite/src/popup.tsx @@ -1,10 +1,12 @@ -import { ClerkProvider, Show, SignIn, UserButton, useAuth } from '@clerk/chrome-extension'; +import { ClerkProvider, Show, SignIn, SignUp, UserButton, useAuth } from '@clerk/chrome-extension'; import React from 'react'; import ReactDOM from 'react-dom/client'; const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string; function App() { + const [signingUp, setSigningUp] = React.useState(false); + return (
- + {signingUp ? : } + diff --git a/integration/tests/chrome-extension/auth-flows.test.ts b/integration/tests/chrome-extension/auth-flows.test.ts new file mode 100644 index 00000000000..aa07ee29da9 --- /dev/null +++ b/integration/tests/chrome-extension/auth-flows.test.ts @@ -0,0 +1,106 @@ +import type { ClerkClient } from '@clerk/backend'; +import { parsePublishableKey } from '@clerk/shared/keys'; +import { createPageObjects } from '@clerk/testing/playwright/unstable'; + +import { appConfigs } from '../../presets'; +import { createTestUtils } from '../../testUtils'; +import type { FakeUser, UserService } from '../../testUtils/usersService'; +import { expect, test as extensionTest } from './fixtures'; + +for (const environmentType of ['development', 'production'] as const) { + const test = extensionTest.extend({ + extensionEnv: [ + environmentType === 'production' ? appConfigs.envs.sessionsProd1 : appConfigs.envs.withEmailCodes, + { scope: 'worker' }, + ], + extensionTestingToken: [false, { scope: 'worker' }], + }); + + test.describe(`chrome extension ${environmentType} auth @chrome-extension`, () => { + test.describe.configure({ mode: 'default', timeout: 90_000 }); + + let services: { clerk: ClerkClient; users: UserService }; + let user: FakeUser; + + test.beforeAll(async ({ extensionApp }) => { + const publishableKey = extensionApp.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + expect(publishableKey?.startsWith(environmentType === 'production' ? 'pk_live_' : 'pk_test_')).toBe(true); + ({ services } = await createTestUtils({ app: extensionApp })); + + if (environmentType === 'production') { + const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); + const readTestMode = async () => { + const response = await fetch(`https://${frontendApi}/v1/environment?_is_native=1`); + return response.ok ? (await response.json()).auth_config?.test_mode : undefined; + }; + + if ((await readTestMode()) !== true) { + await services.clerk.instance.update({ testMode: true }); + await expect.poll(readTestMode, { timeout: 60_000, intervals: [1_000, 2_000, 5_000] }).toBe(true); + } + } + }); + + test.beforeEach(async ({ extensionId }) => { + user = services.users.createFakeUser(test, { fictionalEmail: true }); + + if (environmentType === 'production') { + const origin = `chrome-extension://${extensionId}`; + const instance = await services.clerk.instance.get(); + if (!instance.allowedOrigins?.includes(origin)) { + await services.clerk.instance.update({ allowedOrigins: [...(instance.allowedOrigins || []), origin] }); + } + } + }); + + test.afterEach(async () => { + await user?.deleteIfExists(); + }); + + test('signs in with email and password without a Testing Token', async ({ extensionPage }) => { + await services.users.createBapiUser(user); + const { signIn } = createPageObjects({ page: extensionPage, useTestingToken: false }); + await signIn.waitForMounted(); + await signIn.setIdentifier(user.email!); + await signIn.continue(); + await signIn.getPasswordInput().fill(user.password); + await signIn.continue(); + await expect(extensionPage.getByTestId('user-id')).toHaveText(/^user_/, { timeout: 30_000 }); + await expect(extensionPage.getByTestId('session-id')).toHaveText(/^sess_/); + }); + + test('signs up with email and password without a Testing Token', async ({ extensionPage }) => { + await extensionPage.getByRole('button', { name: 'Create account', exact: true }).click(); + const { signUp } = createPageObjects({ page: extensionPage, useTestingToken: false }); + await signUp.waitForMounted(); + const preparation = extensionPage + .waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).pathname.startsWith('/v1/client/sign_ups/') && + new URL(response.url()).pathname.endsWith('/prepare_verification'), + ) + .catch(() => undefined); + const [response] = await Promise.all([ + extensionPage.waitForResponse( + response => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/v1/client/sign_ups', + ), + signUp.signUpWithEmailAndPassword({ email: user.email, password: user.password }), + ]); + + if (environmentType === 'development') { + expect(response.status()).toBe(403); + expect((await response.json()).errors?.[0]?.code).toBe('action_blocked'); + return; + } + + expect(response.ok()).toBe(true); + expect((await preparation)?.ok()).toBe(true); + await signUp.enterTestOtpCode({ awaitPrepare: false, awaitAttempt: false }); + + await expect(extensionPage.getByTestId('user-id')).toHaveText(/^user_/, { timeout: 30_000 }); + await expect(extensionPage.getByTestId('session-id')).toHaveText(/^sess_/); + }); + }); +} diff --git a/integration/tests/chrome-extension/fixtures.ts b/integration/tests/chrome-extension/fixtures.ts index 8e8a5a11ce8..8b2f5b7022d 100644 --- a/integration/tests/chrome-extension/fixtures.ts +++ b/integration/tests/chrome-extension/fixtures.ts @@ -1,15 +1,18 @@ import * as path from 'node:path'; -import { test as base } from '@playwright/test'; import type { BrowserContext, Page } from '@playwright/test'; +import { test as base } from '@playwright/test'; -import { appConfigs } from '../../presets'; import type { Application } from '../../models/application'; +import type { EnvironmentConfig } from '../../models/environment'; +import { appConfigs } from '../../presets'; import { getExtensionId, launchExtensionContext, setupClerkTestingEnv } from './helpers'; type WorkerFixtures = { extensionDistPath: string; extensionApp: Application; + extensionEnv: EnvironmentConfig; + extensionTestingToken: boolean; }; type TestFixtures = { @@ -25,10 +28,11 @@ type TestFixtures = { * Test-scoped fixtures create a fresh persistent context per test. */ export const test = base.extend({ + extensionEnv: [appConfigs.envs.withEmailCodes, { scope: 'worker', option: true }], + extensionTestingToken: [true, { scope: 'worker', option: true }], // Worker-scoped: build the extension once and set up testing tokens extensionApp: [ - async ({}, use) => { - const env = appConfigs.envs.withEmailCodes; + async ({ extensionEnv: env, extensionTestingToken }, use) => { const config = appConfigs.chromeExtension.vite; const app = await config.commit(); @@ -36,7 +40,9 @@ export const test = base.extend({ await app.setup(); await app.build(); - await setupClerkTestingEnv(env); + if (extensionTestingToken) { + await setupClerkTestingEnv(env); + } await use(app); await app.teardown(); @@ -53,8 +59,11 @@ export const test = base.extend({ ], // Test-scoped: fresh persistent context per test with the extension loaded - context: async ({ extensionDistPath }, use) => { - const context = await launchExtensionContext(extensionDistPath, { bypassCSP: true }); + context: async ({ extensionDistPath, extensionTestingToken }, use) => { + const context = await launchExtensionContext(extensionDistPath, { + bypassCSP: false, + testingToken: extensionTestingToken, + }); await use(context); await context.close(); }, diff --git a/integration/tests/chrome-extension/helpers.ts b/integration/tests/chrome-extension/helpers.ts index 8b10b67417b..56a5f00fe26 100644 --- a/integration/tests/chrome-extension/helpers.ts +++ b/integration/tests/chrome-extension/helpers.ts @@ -1,20 +1,18 @@ import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; import { parsePublishableKey } from '@clerk/shared/keys'; import { clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright'; +import type { BrowserContext, Page } from '@playwright/test'; import { chromium } from '@playwright/test'; -import type { BrowserContext } from '@playwright/test'; import type { EnvironmentConfig } from '../../models/environment'; import { withRetry } from '../../testUtils/retryableClerkClient'; -import { createUserService } from '../../testUtils/usersService'; import type { FakeUser, PlaywrightTest } from '../../testUtils/usersService'; +import { createUserService } from '../../testUtils/usersService'; /** * Query the background service worker for auth state via chrome.runtime.sendMessage. */ -export function getAuthFromBackground( - page: import('@playwright/test').Page, -): Promise<{ userId: string | null; sessionId: string | null }> { +export function getAuthFromBackground(page: Page): Promise<{ userId: string | null; sessionId: string | null }> { return page.evaluate(() => { return new Promise(resolve => { chrome.runtime.sendMessage({ type: 'GET_AUTH' }, (response: any) => { @@ -50,7 +48,10 @@ export async function setupClerkTestingEnv(env: EnvironmentConfig) { /** * Launch a persistent Chromium context with a Chrome extension loaded. */ -export async function launchExtensionContext(extensionDistPath: string, opts?: { bypassCSP?: boolean }) { +export async function launchExtensionContext( + extensionDistPath: string, + opts?: { bypassCSP?: boolean; testingToken?: boolean }, +) { const context = await chromium.launchPersistentContext('', { headless: false, bypassCSP: opts?.bypassCSP, @@ -61,7 +62,9 @@ export async function launchExtensionContext(extensionDistPath: string, opts?: { ], }); - await setupClerkTestingToken({ context }); + if (opts?.testingToken !== false) { + await setupClerkTestingToken({ context }); + } return context; } diff --git a/integration/tests/chrome-extension/no-rhc.test.ts b/integration/tests/chrome-extension/no-rhc.test.ts new file mode 100644 index 00000000000..dea8bd26a71 --- /dev/null +++ b/integration/tests/chrome-extension/no-rhc.test.ts @@ -0,0 +1,99 @@ +import { copyFile, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import { chromium, expect, test } from '@playwright/test'; + +import { createBaseEnvironmentJSON } from '../../../packages/clerk-js/src/test/fixtures'; + +for (const environmentType of ['development', 'production'] as const) { + test(`no-RHC runtime ignores configured script loaders (${environmentType}) @chrome-extension`, async () => { + const extensionPath = await mkdtemp(path.join(tmpdir(), 'clerk-no-rhc-')); + await Promise.all([ + copyFile( + path.resolve(import.meta.dirname, '../../../packages/clerk-js/dist/clerk.no-rhc.mjs'), + path.join(extensionPath, 'clerk.mjs'), + ), + writeFile( + path.join(extensionPath, 'manifest.json'), + JSON.stringify({ + manifest_version: 3, + name: 'Clerk no-RHC regression', + version: '1.0.0', + background: { service_worker: 'background.js' }, + host_permissions: ['https://clerk.example.com/*'], + content_security_policy: { extension_pages: "script-src 'self'; object-src 'self'" }, + }), + ), + writeFile(path.join(extensionPath, 'background.js'), 'chrome.runtime.onInstalled.addListener(() => {});'), + writeFile(path.join(extensionPath, 'popup.html'), ''), + writeFile( + path.join(extensionPath, 'popup.js'), + `import { Clerk } from './clerk.mjs'; +const clerk = new Clerk('${environmentType === 'production' ? 'pk_live_' : 'pk_test_'}' + btoa('clerk.example.com$')); +await clerk.load({ standardBrowser: false, telemetry: false }); +document.body.dataset.loaded = String(clerk.loaded); +`, + ), + ]); + + const context = await chromium.launchPersistentContext('', { + headless: false, + bypassCSP: false, + args: ['--headless=new', `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`], + }); + + try { + const environment = createBaseEnvironmentJSON(); + environment.display_config.instance_environment_type = environmentType; + environment.protect_config = { + id: 'protect_config_test', + object: 'protect_config', + loaders: [ + { type: 'script', target: 'body', attributes: { src: 'https://example.com/device-intelligence.js' } }, + { type: 'script', target: 'body', text_content: 'window.remoteLoaderExecuted = true;' }, + ], + }; + + let environmentRequests = 0; + await context.route('https://clerk.example.com/**', async route => { + const isEnvironment = new URL(route.request().url()).pathname === '/v1/environment'; + if (isEnvironment) { + environmentRequests++; + } + await route.fulfill({ + json: { + response: isEnvironment ? environment : { object: 'client', id: 'client_test', sessions: [] }, + }, + }); + }); + + const background = context.serviceWorkers()[0] || (await context.waitForEvent('serviceworker')); + const extensionId = new URL(background.url()).host; + const page = await context.newPage(); + const remoteScripts: string[] = []; + const errors: string[] = []; + page.on('request', request => { + if (request.resourceType() === 'script' && request.url().startsWith('https://')) { + remoteScripts.push(request.url()); + } + }); + page.on('console', message => { + if (message.type() === 'error') { + errors.push(message.text()); + } + }); + page.on('pageerror', error => errors.push(error.message)); + + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await expect(page.locator('body')).toHaveAttribute('data-loaded', 'true'); + expect(environmentRequests).toBeGreaterThan(0); + await expect(page.locator('script[src^="https://"], script:not([src])')).toHaveCount(0); + expect(remoteScripts).toEqual([]); + expect(errors).toEqual([]); + } finally { + await context.close(); + await rm(extensionPath, { recursive: true, force: true }); + } + }); +} diff --git a/packages/chrome-extension/src/internal/utils/__tests__/request-handler.test.ts b/packages/chrome-extension/src/internal/utils/__tests__/request-handler.test.ts new file mode 100644 index 00000000000..c8aeb63d4db --- /dev/null +++ b/packages/chrome-extension/src/internal/utils/__tests__/request-handler.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { JWTHandler } from '../jwt-handler'; +import { requestHandler } from '../request-handler'; + +describe('requestHandler', () => { + it.each([ + { isProd: true, jwt: undefined }, + { isProd: true, jwt: 'client-jwt' }, + { isProd: false, jwt: undefined }, + { isProd: false, jwt: 'development-jwt' }, + ])('sets request credentials for isProd=$isProd and jwt=$jwt', async ({ isProd, jwt }) => { + const jwtHandler: JWTHandler = { + get: vi.fn().mockResolvedValue(jwt), + set: vi.fn(), + remove: vi.fn(), + listener: vi.fn(), + }; + const request = { + url: new URL('https://example.com/v1/client'), + headers: new Headers(), + credentials: 'include' as RequestCredentials, + }; + + await requestHandler(jwtHandler, { isProd })(request); + + expect(request.credentials).toBe('omit'); + expect(request.url.searchParams.getAll('_is_native')).toEqual(isProd ? ['1'] : []); + expect(request.url.searchParams.get('__clerk_db_jwt')).toBe(!isProd && jwt ? jwt : null); + expect(request.headers.get('Authorization')).toBe(isProd && jwt ? `Bearer ${jwt}` : null); + }); +}); diff --git a/packages/chrome-extension/src/internal/utils/request-handler.ts b/packages/chrome-extension/src/internal/utils/request-handler.ts index 60cde36adc3..6f1316abe87 100644 --- a/packages/chrome-extension/src/internal/utils/request-handler.ts +++ b/packages/chrome-extension/src/internal/utils/request-handler.ts @@ -11,6 +11,10 @@ export function requestHandler(jwtHandler: JWTHandler, { isProd }: { isProd: boo const handler: Handler = async requestInit => { requestInit.credentials = 'omit'; + if (isProd) { + requestInit.url?.searchParams.append('_is_native', '1'); + } + const currentJWT = await jwtHandler.get(); if (!currentJWT) { @@ -34,6 +38,5 @@ function devHandler(requestInit: Req, jwt: string) { /** Append the JWT to the FAPI request, per production instances */ function prodHandler(requestInit: Req, jwt: string) { - requestInit.url?.searchParams.append('_is_native', '1'); (requestInit.headers as Headers).set(AUTH_HEADER.PRODUCTION, `Bearer ${jwt}`); } diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index b437643bb8e..a7ff1fe0370 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -59,6 +59,7 @@ ], "scripts": { "build": "pnpm build:bundle && pnpm build:declarations", + "postbuild": "node ../../scripts/search-for-rhc.mjs file dist/clerk.no-rhc.mjs && node ../../scripts/search-for-rhc.mjs file dist/clerk.no-rhc.js", "build:analyze": "rspack build --config rspack.config.mjs --env production --env variant=\"clerk.browser\" --env analysis", "build:bundle": "pnpm clean && rspack build --config rspack.config.mjs --env production", "build:declarations": "tsc -p tsconfig.declarations.json", @@ -76,7 +77,6 @@ "lint": "eslint src", "lint:attw": "attw --pack . --profile node16 --ignore-rules named-exports --ignore-rules false-cjs", "lint:publint": "publint || true", - "postbuild:disabled": "node ../../scripts/search-for-rhc.mjs file dist/clerk.no-rhc.mjs", "test": "vitest --watch=false", "test:sandbox:integration": "playwright test", "test:sandbox:integration:ui": "playwright test --ui", diff --git a/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts b/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts index 8344dcf1b66..e23c304cecd 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts @@ -1,5 +1,5 @@ import type { ProtectAssertion } from '@clerk/shared/types'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Clerk } from '../clerk'; @@ -53,6 +53,10 @@ describe('Clerk getProtectParams', () => { capturedOptions.current = undefined; }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('is wired into the FAPI client', () => { expect(hookFor()).toBeTypeOf('function'); }); @@ -75,6 +79,22 @@ describe('Clerk getProtectParams', () => { await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams); }); + it('does not acquire remote loader params in no-RHC builds', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', true); + getRequestParams.mockResolvedValue(sessionParams); + + await expect(hookFor()()).resolves.toBeUndefined(); + expect(getRequestParams).not.toHaveBeenCalled(); + }); + + it('sends application assertions in no-RHC builds', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', true); + getRequestParams.mockResolvedValue(sessionParams); + + await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams); + expect(getRequestParams).not.toHaveBeenCalled(); + }); + // Returning `{}` would make every sign-in body differ from what it was before the feature existed. it('resolves to undefined when neither contributes anything', async () => { getRequestParams.mockResolvedValue(undefined); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index a4236167d37..388b5bc2a93 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -600,7 +600,9 @@ export class Clerk implements ClerkInterface { // This line is used for the piggy-backing mechanism BaseResource.clerk = this; - this.#protect = new Protect(); + if (!__BUILD_DISABLE_RHC__) { + this.#protect = new Protect(); + } } public getFapiClient = (): FapiClient => this.#fapiClient; diff --git a/scripts/search-for-rhc.mjs b/scripts/search-for-rhc.mjs index f3a9fa61637..fc5b5afadb3 100644 --- a/scripts/search-for-rhc.mjs +++ b/scripts/search-for-rhc.mjs @@ -29,6 +29,7 @@ await Promise.allSettled([ asyncSearchRHC('clerk-js Hotloading', '/npm/@clerk/clerk-js'), asyncSearchRHC('clerk-ui Hotloading', '/npm/@clerk/ui'), asyncSearchRHC('Google One Tap', 'accounts.google.com/gsi/client'), + asyncSearchRHC('Protect loader', '[protect] loader'), asyncSearchRHC('Coinbase', 'coinbase.com'), asyncSearchRHC('Coinbase Wallet import', 'import\s*"@coinbase/wallet-sdk', true), // eslint-disable-line no-useless-escape asyncSearchRHC('Stripe', 'js.stripe.com'),