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
8 changes: 8 additions & 0 deletions .changeset/chrome-extension-protect-loader.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions integration/templates/chrome-extension-vite/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 10 additions & 2 deletions integration/templates/chrome-extension-vite/src/popup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ClerkProvider
publishableKey={PUBLISHABLE_KEY}
Expand All @@ -13,7 +15,13 @@ function App() {
>
<main>
<Show when='signed-out'>
<SignIn />
{signingUp ? <SignUp /> : <SignIn />}
<button
type='button'
onClick={() => setSigningUp(value => !value)}
>
{signingUp ? 'Back to sign in' : 'Create account'}
</button>
</Show>
<Show when='signed-in'>
<UserButton />
Expand Down
100 changes: 100 additions & 0 deletions integration/tests/chrome-extension/auth-flows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 }) => {
test.fail(environmentType === 'development', 'Known issue: development sign-up from an extension is rejected');
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 }),
]);
expect(response.ok()).toBe(true);
expect((await preparation)?.ok()).toBe(true);
await signUp.enterTestOtpCode({ awaitPrepare: false });

await expect(extensionPage.getByTestId('user-id')).toHaveText(/^user_/, { timeout: 30_000 });
await expect(extensionPage.getByTestId('session-id')).toHaveText(/^sess_/);
});
});
}
23 changes: 16 additions & 7 deletions integration/tests/chrome-extension/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -25,18 +28,21 @@ type TestFixtures = {
* Test-scoped fixtures create a fresh persistent context per test.
*/
export const test = base.extend<TestFixtures, WorkerFixtures>({
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();
await app.withEnv(env);
await app.setup();
await app.build();

await setupClerkTestingEnv(env);
if (extensionTestingToken) {
await setupClerkTestingEnv(env);
}

await use(app);
await app.teardown();
Expand All @@ -53,8 +59,11 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
],

// 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();
},
Expand Down
17 changes: 10 additions & 7 deletions integration/tests/chrome-extension/helpers.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -61,7 +62,9 @@ export async function launchExtensionContext(extensionDistPath: string, opts?: {
],
});

await setupClerkTestingToken({ context });
if (opts?.testingToken !== false) {
await setupClerkTestingToken({ context });
}
return context;
}

Expand Down
99 changes: 99 additions & 0 deletions integration/tests/chrome-extension/no-rhc.test.ts
Original file line number Diff line number Diff line change
@@ -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'), '<body><script type="module" src="popup.js"></script></body>'),
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 });
}
});
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading