From 6cb7922804ed063f711f9a0e1402dec15161d291 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Sun, 3 Dec 2023 19:16:27 -0500 Subject: [PATCH] feat(clerk-js,chrome-extension,shared): Expand WebSSO capabilities (Content Scripts) [SDK-836] --- .changeset/shiny-glasses-switch.md | 29 +++++++ .../chrome-extension/src/ClerkProvider.tsx | 33 ++----- .../src/__snapshots__/exports.test.ts.snap | 1 + packages/chrome-extension/src/cache.ts | 31 ------- packages/chrome-extension/src/constants.ts | 1 + packages/chrome-extension/src/content.ts | 55 ++++++++++++ packages/chrome-extension/src/errors.ts | 10 +++ packages/chrome-extension/src/index.ts | 3 +- packages/chrome-extension/src/singleton.ts | 69 ++++++++------- packages/chrome-extension/src/utils.test.ts | 67 --------------- packages/chrome-extension/src/utils.ts | 17 ---- .../src/utils/cookies.test.ts | 56 ++++++++++++ .../chrome-extension/src/utils/cookies.ts | 3 + .../src/utils/storage.test.ts | 85 +++++++++++++++++++ .../chrome-extension/src/utils/storage.ts | 18 ++++ .../clerk-js/src/core/devBrowserHandler.ts | 8 ++ packages/shared/package.json | 11 +-- packages/shared/src/extensionSyncManager.ts | 59 +++++++++++++ packages/shared/subpaths.mjs | 8 +- .../src/pages/content/index.tsx | 22 +---- 20 files changed, 384 insertions(+), 202 deletions(-) create mode 100644 .changeset/shiny-glasses-switch.md delete mode 100644 packages/chrome-extension/src/cache.ts create mode 100644 packages/chrome-extension/src/constants.ts create mode 100644 packages/chrome-extension/src/content.ts create mode 100644 packages/chrome-extension/src/errors.ts delete mode 100644 packages/chrome-extension/src/utils.test.ts delete mode 100644 packages/chrome-extension/src/utils.ts create mode 100644 packages/chrome-extension/src/utils/cookies.test.ts create mode 100644 packages/chrome-extension/src/utils/cookies.ts create mode 100644 packages/chrome-extension/src/utils/storage.test.ts create mode 100644 packages/chrome-extension/src/utils/storage.ts create mode 100644 packages/shared/src/extensionSyncManager.ts diff --git a/.changeset/shiny-glasses-switch.md b/.changeset/shiny-glasses-switch.md new file mode 100644 index 00000000000..9d52570a675 --- /dev/null +++ b/.changeset/shiny-glasses-switch.md @@ -0,0 +1,29 @@ +--- +'@clerk/chrome-extension': major +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Expand the ability for `@clerk/chrome-extension` WebSSO to sync with host applications which use URL-based session syncing. + +### How to Update + +**WebSSO Local Host Permissions:** + +Add the following to the top-level `content_scripts` array key in your `manifest.json` file: +```json +{ + "matches": ["*://localhost/*"], // URL of your host application + "js": ["src/content.tsx"] // Path to your content script +} +``` + +**Content Script:** + +In order to sync with your host application, you must add the following to your content script to the path specified in the `manifest.json` file above: + +```ts +import { ContentScript } from '@clerk/chrome-extension'; + +ContentScript.init(process.env.CLERK_PUBLISHABLE_KEY || ""); +``` diff --git a/packages/chrome-extension/src/ClerkProvider.tsx b/packages/chrome-extension/src/ClerkProvider.tsx index 52f613f003b..33be899b818 100644 --- a/packages/chrome-extension/src/ClerkProvider.tsx +++ b/packages/chrome-extension/src/ClerkProvider.tsx @@ -3,8 +3,6 @@ import type { ClerkProp, ClerkProviderProps as ClerkReactProviderProps } from '@ import { __internal__setErrorThrowerOptions, ClerkProvider as ClerkReactProvider } from '@clerk/clerk-react'; import React from 'react'; -import type { TokenCache } from './cache'; -import { ChromeStorageCache } from './cache'; import { buildClerk } from './singleton'; Clerk.sdkMetadata = { @@ -16,30 +14,21 @@ __internal__setErrorThrowerOptions({ packageName: '@clerk/chrome-extension', }); -type WebSSOClerkProviderCustomProps = - | { - syncSessionWithTab?: false; - tokenCache?: never; - } - | { - syncSessionWithTab: true; - tokenCache?: TokenCache; - }; +type WebSSOClerkProviderCustomProps = { + syncSessionWithTab?: boolean; +}; type WebSSOClerkProviderProps = ClerkReactProviderProps & WebSSOClerkProviderCustomProps; const WebSSOClerkProvider = (props: WebSSOClerkProviderProps): JSX.Element | null => { - const { children, tokenCache: runtimeTokenCache, ...rest } = props; + const { children, ...rest } = props; const { publishableKey = '' } = props; const [clerkInstance, setClerkInstance] = React.useState(null); - // When syncSessionWithTab is set tokenCache is an optional parameter that defaults to ChromeStorageCache - const tokenCache = runtimeTokenCache || ChromeStorageCache; - React.useEffect(() => { void (async () => { - setClerkInstance(await buildClerk({ publishableKey, tokenCache })); + setClerkInstance(await buildClerk({ publishableKey })); })(); }, []); @@ -73,14 +62,6 @@ const StandaloneClerkProvider = (props: ClerkReactProviderProps): JSX.Element => type ChromeExtensionClerkProviderProps = WebSSOClerkProviderProps; -export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null { - const { tokenCache, syncSessionWithTab, ...rest } = props; - return syncSessionWithTab ? ( - - ) : ( - - ); +export function ClerkProvider({ syncSessionWithTab, ...rest }: ChromeExtensionClerkProviderProps): JSX.Element | null { + return syncSessionWithTab ? : ; } diff --git a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap index 5850b5cf888..9f2f3d56060 100644 --- a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap @@ -6,6 +6,7 @@ exports[`public exports should not include a breaking change 1`] = ` "ClerkLoaded", "ClerkLoading", "ClerkProvider", + "ContentScript", "CreateOrganization", "EmailLinkErrorCode", "Experimental__Gate", diff --git a/packages/chrome-extension/src/cache.ts b/packages/chrome-extension/src/cache.ts deleted file mode 100644 index 597368ffa26..00000000000 --- a/packages/chrome-extension/src/cache.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getFromStorage, setInStorage } from './utils'; - -export interface TokenCache { - getToken: (key: string) => Promise; - saveToken: (key: string, token: string) => Promise; -} - -const createMemoryTokenCache = (): TokenCache => { - const cache: Record = {}; - return { - saveToken: (key, token) => { - cache[key] = token; - return Promise.resolve(); - }, - getToken: key => { - return Promise.resolve(cache[key]); - }, - }; -}; - -// Use chrome.storage (local or sync) to persist Clerk client JWT. -// More information athttps://developer.chrome.com/docs/extensions/reference/storage -const createChromeStorageCache = (): TokenCache => { - return { - saveToken: setInStorage, - getToken: getFromStorage, - }; -}; - -export const ChromeStorageCache = createChromeStorageCache(); -export const MemoryTokenCache = createMemoryTokenCache(); diff --git a/packages/chrome-extension/src/constants.ts b/packages/chrome-extension/src/constants.ts new file mode 100644 index 00000000000..3bf8a76ae3a --- /dev/null +++ b/packages/chrome-extension/src/constants.ts @@ -0,0 +1 @@ +export const STORAGE_KEY_CLIENT_JWT = '__clerk_client_jwt'; diff --git a/packages/chrome-extension/src/content.ts b/packages/chrome-extension/src/content.ts new file mode 100644 index 00000000000..299acfdcce5 --- /dev/null +++ b/packages/chrome-extension/src/content.ts @@ -0,0 +1,55 @@ +import { parsePublishableKey } from '@clerk/shared'; +import { createExtensionSyncManager, events } from '@clerk/shared/extensionSyncManager'; + +import { STORAGE_KEY_CLIENT_JWT } from './constants'; +import { ClerkChromeExtensionError, logErrorHandler } from './errors'; +import { ChromeStorageCache } from './utils/storage'; + +export const ContentScript = { + init(publishableKey: string) { + try { + // Ensure we have a publishable key + if (!publishableKey) { + throw new ClerkChromeExtensionError('Missing publishable key.'); + } + + // Parse the publishable key + const { frontendApi, instanceType } = parsePublishableKey(publishableKey) || {}; + + // Ensure we have a valid publishable key + if (!frontendApi || !instanceType) { + throw new ClerkChromeExtensionError('Invalid publishable key.'); + } + + // Ensure we're in a development environment + if (instanceType !== 'development') { + throw new ClerkChromeExtensionError(` + You're attempting to load the Clerk Chrome Extension content script in an unsupported environment. + Please update your manifest.json to exclude production URLs in content_scripts. + `); + } + + // Create an extension sync manager + const extensionSyncManager = createExtensionSyncManager(); + + // Listen for token update events from other Clerk hosts + extensionSyncManager.on(events.DevJWTUpdate, ({ data }) => { + // Ignore events from other Clerk hosts + if (data.frontendApi !== frontendApi) { + console.log('Received a token update event for a different Clerk host. Ignoring.'); + return; + } + + const KEY = ChromeStorageCache.createKey(data.frontendApi, STORAGE_KEY_CLIENT_JWT); + + if (data.action === 'set') { + void ChromeStorageCache.set(KEY, data.token); + } else if (data.action === 'remove') { + void ChromeStorageCache.remove(KEY); + } + }); + } catch (e) { + logErrorHandler(e as Error); + } + }, +}; diff --git a/packages/chrome-extension/src/errors.ts b/packages/chrome-extension/src/errors.ts new file mode 100644 index 00000000000..0067fb2eefa --- /dev/null +++ b/packages/chrome-extension/src/errors.ts @@ -0,0 +1,10 @@ +// error handler that logs the error (used in cookie retrieval and token saving) +export const logErrorHandler = (err: Error) => console.error(err); + +export class ClerkChromeExtensionError extends Error { + clerk: boolean = true; + + constructor(message: string) { + super(`[Clerk: Chrome Extension]: ${message}`); + } +} diff --git a/packages/chrome-extension/src/index.ts b/packages/chrome-extension/src/index.ts index e16785a2c57..e5bd9848c43 100644 --- a/packages/chrome-extension/src/index.ts +++ b/packages/chrome-extension/src/index.ts @@ -1,6 +1,5 @@ -// eslint-disable-next-line import/export export * from '@clerk/clerk-react'; +export { ContentScript } from './content'; // order matters since we want override @clerk/clerk-react ClerkProvider -// eslint-disable-next-line import/export export { ClerkProvider } from './ClerkProvider'; diff --git a/packages/chrome-extension/src/singleton.ts b/packages/chrome-extension/src/singleton.ts index 21caa55bd05..8d81ca29d51 100644 --- a/packages/chrome-extension/src/singleton.ts +++ b/packages/chrome-extension/src/singleton.ts @@ -1,53 +1,58 @@ import { Clerk } from '@clerk/clerk-js'; import type { ClerkProp } from '@clerk/clerk-react'; +import { parsePublishableKey } from '@clerk/shared'; -import type { TokenCache } from './cache'; -import { convertPublishableKeyToFrontendAPIOrigin, getClientCookie } from './utils'; - -const KEY = '__clerk_client_jwt'; +import { STORAGE_KEY_CLIENT_JWT } from './constants'; +import { logErrorHandler } from './errors'; +import { getClientCookie } from './utils/cookies'; +import { ChromeStorageCache } from './utils/storage'; export let clerk: ClerkProp; type BuildClerkOptions = { publishableKey: string; - tokenCache: TokenCache; }; -// error handler that logs the error (used in cookie retrieval and token saving) -const logErrorHandler = (err: Error) => console.error(err); +export async function buildClerk({ publishableKey }: BuildClerkOptions): Promise { + if (clerk) { + return clerk; + } -export async function buildClerk({ publishableKey, tokenCache }: BuildClerkOptions): Promise { - if (!clerk) { - const clerkFrontendAPIOrigin = convertPublishableKeyToFrontendAPIOrigin(publishableKey); + const { frontendApi, instanceType } = parsePublishableKey(publishableKey) || {}; - const clientCookie = await getClientCookie(clerkFrontendAPIOrigin).catch(logErrorHandler); + if (!frontendApi || !instanceType) { + throw new Error('Invalid publishable key.'); + } - // TODO: Listen to client cookie changes and sync updates - // https://developer.chrome.com/docs/extensions/reference/cookies/#event-onChanged + const clientCookie = await getClientCookie(frontendApi).catch(logErrorHandler); - if (clientCookie) { - await tokenCache.saveToken(KEY, clientCookie.value).catch(logErrorHandler); - } + // TODO: Listen to client cookie changes and sync updates + // https://developer.chrome.com/docs/extensions/reference/cookies/#event-onChanged - clerk = new Clerk(publishableKey); + const KEY = ChromeStorageCache.createKey(frontendApi, STORAGE_KEY_CLIENT_JWT); - // @ts-expect-error - clerk.__unstable__onBeforeRequest(async requestInit => { - requestInit.credentials = 'omit'; - requestInit.url?.searchParams.append('_is_native', '1'); + if (clientCookie) { + await ChromeStorageCache.set(KEY, clientCookie.value).catch(logErrorHandler); + } - const jwt = await tokenCache.getToken(KEY); - (requestInit.headers as Headers).set('authorization', jwt || ''); - }); + clerk = new Clerk(publishableKey); - // @ts-expect-error - clerk.__unstable__onAfterResponse(async (_, response) => { - const authHeader = response.headers.get('authorization'); - if (authHeader) { - await tokenCache.saveToken(KEY, authHeader); - } - }); - } + // @ts-expect-error - Clerk doesn't expose this unstable method + clerk.__unstable__onBeforeRequest(async requestInit => { + requestInit.credentials = 'omit'; + requestInit.url?.searchParams.append('_is_native', '1'); + + const jwt = await ChromeStorageCache.get(KEY); + (requestInit.headers as Headers).set('authorization', jwt || ''); + }); + + // @ts-expect-error - Clerk doesn't expose this unstable method + clerk.__unstable__onAfterResponse(async (_, response) => { + const authHeader = response.headers.get('authorization'); + if (authHeader) { + await ChromeStorageCache.set(KEY, authHeader); + } + }); return clerk; } diff --git a/packages/chrome-extension/src/utils.test.ts b/packages/chrome-extension/src/utils.test.ts deleted file mode 100644 index d0c1ff69e1a..00000000000 --- a/packages/chrome-extension/src/utils.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { convertPublishableKeyToFrontendAPIOrigin, getClientCookie, getFromStorage, setInStorage } from './utils'; - -describe('utils', () => { - const _chrome = globalThis.chrome; - - beforeAll(() => { - globalThis.chrome = { - storage: { - // @ts-ignore - local: { set: jest.fn(), get: jest.fn(k => Promise.resolve({ [k]: `storage.get:${k}` })) }, - }, - // @ts-ignore - cookies: { get: jest.fn(({ url, name }) => `cookies.get:${url}:${name}`) }, - }; - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterAll(() => { - globalThis.chrome = _chrome; - }); - - describe('convertPublishableKeyToFrontendAPIOrigin(key)', () => { - test('returns FAPI domain for production', () => { - const livePk = 'pk_live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk'; - - expect(convertPublishableKeyToFrontendAPIOrigin(livePk)).toEqual('https://example.clerk.accounts.dev'); - }); - - test('returns FAPI domain for development', () => { - const devPk = 'pk_test_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk'; - - expect(convertPublishableKeyToFrontendAPIOrigin(devPk)).toEqual('https://example.clerk.accounts.dev'); - }); - - test('returns FAPI domain for invalid key', () => { - const invalidPk = 'pk_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk'; - - const errMsg = 'The string to be decoded contains invalid characters.'; - expect(() => convertPublishableKeyToFrontendAPIOrigin(invalidPk)).toThrowError(errMsg); - }); - }); - - describe('getClientCookie(url)', () => { - test('returns cookie value from chrome.cookies if is set for url', async () => { - const url = 'http://localhost:3000'; - await expect(getClientCookie(url)).resolves.toEqual(`cookies.get:${url}:__client`); - }); - }); - - describe('setInStorage(key, value)', () => { - test('sets value in chrome.storage', async () => { - await setInStorage('key', 'value'); - - expect(globalThis.chrome.storage.local.set).toBeCalledTimes(1); - expect(globalThis.chrome.storage.local.set).toBeCalledWith({ key: 'value' }); - }); - }); - - describe('getFromStorage(key)', () => { - test('gets value from chrome.storage', async () => { - await expect(getFromStorage('key')).resolves.toEqual(`storage.get:key`); - }); - }); -}); diff --git a/packages/chrome-extension/src/utils.ts b/packages/chrome-extension/src/utils.ts deleted file mode 100644 index c627450b5c7..00000000000 --- a/packages/chrome-extension/src/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function convertPublishableKeyToFrontendAPIOrigin(key = '') { - return `https://${atob(key.replace(/pk_(test|live)_/, '')).slice(0, -1)}`; -} - -export async function getClientCookie(url: string) { - return chrome.cookies.get({ url, name: '__client' }); -} - -export function setInStorage(key: string, value: string) { - return chrome.storage.local.set({ [key]: value }); -} - -export function getFromStorage(key: string) { - return chrome.storage.local.get(key).then(result => { - return result[key]; - }); -} diff --git a/packages/chrome-extension/src/utils/cookies.test.ts b/packages/chrome-extension/src/utils/cookies.test.ts new file mode 100644 index 00000000000..2cd3c4982cf --- /dev/null +++ b/packages/chrome-extension/src/utils/cookies.test.ts @@ -0,0 +1,56 @@ +import { getClientCookie } from './cookies'; + +const domain = 'clerk.domain.com'; +const createCookie = ( + name: string, + value: string, + opts: Partial>, +): chrome.cookies.Cookie => ({ + domain: 'clerk.domain.com', + secure: true, + httpOnly: true, + path: '/', + storeId: '0', + session: false, + hostOnly: false, + sameSite: 'no_restriction', + ...opts, + name, + value, +}); + +describe('utils', () => { + const _chrome = globalThis.chrome; + + // export function get(details: Details): Promise; + + globalThis.chrome = { + // @ts-expect-error - Mock + cookies: { + get: jest.fn(), + // get: jest.fn(({ url, name }) => `cookies.get:${url}:${name}`), + }, + }; + + afterEach(() => jest.resetAllMocks()); + afterAll(() => { + jest.clearAllMocks(); + globalThis.chrome = _chrome; + }); + + // export function get(details: Details): Promise; + describe('getClientCookie', () => { + const url = `https://${domain}`; + const name = '__client'; + const cookie = createCookie(name, 'foo', { domain }); + + test('returns cookie value from chrome.cookies if is set for url', async () => { + const getMock = jest.mocked(globalThis.chrome.cookies.get).mockResolvedValue(cookie); + + expect(await getClientCookie(url)).toBe(cookie); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith({ url, name }); + }); + }); +}); diff --git a/packages/chrome-extension/src/utils/cookies.ts b/packages/chrome-extension/src/utils/cookies.ts new file mode 100644 index 00000000000..760df91e581 --- /dev/null +++ b/packages/chrome-extension/src/utils/cookies.ts @@ -0,0 +1,3 @@ +export async function getClientCookie(url: string) { + return chrome.cookies.get({ url, name: '__client' }); +} diff --git a/packages/chrome-extension/src/utils/storage.test.ts b/packages/chrome-extension/src/utils/storage.test.ts new file mode 100644 index 00000000000..6c668031c7e --- /dev/null +++ b/packages/chrome-extension/src/utils/storage.test.ts @@ -0,0 +1,85 @@ +import { ChromeStorageCache } from './storage'; + +type ChromeStorageGetPromise = ( + keys?: string | string[] | { [key: string]: any } | null, +) => Promise<{ [key: string]: any }>; + +describe('ChromeStorageCache', () => { + const _void = void 0; + const _chrome = globalThis.chrome; + + globalThis.chrome = { + storage: { + // @ts-expect-error - Mock + local: { + get: jest.fn(), + remove: jest.fn(), + set: jest.fn(), + }, + }, + }; + + afterEach(() => jest.resetAllMocks()); + + afterAll(() => { + jest.clearAllMocks(); + globalThis.chrome = _chrome; + }); + + describe('createKey', () => { + test('returns a string key', () => { + expect(ChromeStorageCache.createKey('a', 'b', 'c')).toBe('a|b|c'); + }); + + test('omits falsy values', () => { + // @ts-expect-error - Testing; Intentionally passing undefined value + expect(ChromeStorageCache.createKey('a', undefined, false, null, 'c')).toBe('a|c'); + }); + }); + + describe('set', () => { + test('FOO', () => { + const setMock = jest.mocked(globalThis.chrome.storage.local.set).mockImplementationOnce(() => void 0); + + expect(ChromeStorageCache.set('foo', 'bar')).toBe(_void); + expect(setMock).toHaveBeenCalledTimes(1); + expect(setMock).toHaveBeenCalledWith({ foo: 'bar' }); + }); + }); + + describe('remove', () => { + test('FOO', () => { + const removeMock = jest.mocked(globalThis.chrome.storage.local.remove).mockImplementationOnce(() => void 0); + + expect(ChromeStorageCache.remove('foo')).toBe(_void); + expect(removeMock).toHaveBeenCalledTimes(1); + expect(removeMock).toHaveBeenCalledWith('foo'); + }); + }); + + describe('get', () => { + const key = 'foo'; + + test('value missing', async () => { + const getMock = jest.mocked(globalThis.chrome.storage.local.get).mockResolvedValue({}); + + ChromeStorageCache.get(key); + + expect(await ChromeStorageCache.get(key)).toBeUndefined(); + expect(getMock).toHaveBeenCalledTimes(2); // Called Twice?! + expect(getMock).toHaveBeenCalledWith(key); + }); + + test('value exists', async () => { + const value = 'bar'; + + const getMock = jest + .mocked(globalThis.chrome.storage.local.get) + .mockResolvedValue({ [key]: value }); + + expect(await ChromeStorageCache.get(key)).toBe(value); + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith(key); + }); + }); +}); diff --git a/packages/chrome-extension/src/utils/storage.ts b/packages/chrome-extension/src/utils/storage.ts new file mode 100644 index 00000000000..21ec4c88d6d --- /dev/null +++ b/packages/chrome-extension/src/utils/storage.ts @@ -0,0 +1,18 @@ +type ChromeStorageCacheOptions = { + storageArea?: 'local' | 'sync'; +}; + +// Use chrome.storage (local or sync) to persist Clerk client JWT. +// More information at https://developer.chrome.com/docs/extensions/reference/storage +const createChromeStorageCache = (opts: ChromeStorageCacheOptions = {}) => { + const __storageArea = opts.storageArea || 'local'; + + return { + createKey: (...keys: string[]) => keys.filter(Boolean).join('|'), + get: (key: string) => chrome.storage[__storageArea].get(key).then(result => result[key] || undefined), + remove: (key: string) => chrome.storage[__storageArea].remove(key), + set: (key: string, value: string) => chrome.storage[__storageArea].set({ [key]: value }), + }; +}; + +export const ChromeStorageCache = createChromeStorageCache(); diff --git a/packages/clerk-js/src/core/devBrowserHandler.ts b/packages/clerk-js/src/core/devBrowserHandler.ts index 90851a2575a..0013c0d192e 100644 --- a/packages/clerk-js/src/core/devBrowserHandler.ts +++ b/packages/clerk-js/src/core/devBrowserHandler.ts @@ -4,6 +4,7 @@ import { getDevBrowserJWTFromURL, setDevBrowserJWTInURL, } from '@clerk/shared/devBrowser'; +import { createExtensionSyncManager, events as extensionSyncEvents } from '@clerk/shared/extensionSyncManager'; import { buildURL, createCookieHandler, isDevOrStagingUrl, runIframe } from '../utils'; import { clerkErrorDevInitFailed } from './errors'; @@ -34,6 +35,7 @@ export function createDevBrowserHandler({ fapiClient, }: CreateDevBrowserHandlerOptions): DevBrowserHandler { const cookieHandler = createCookieHandler(); + const extensionSyncManager = createExtensionSyncManager(); const key = DEV_BROWSER_SSO_JWT_KEY; let usesUrlBasedSessionSyncing = true; @@ -46,12 +48,18 @@ export function createDevBrowserHandler({ localStorage.setItem(key, jwt); // Append dev browser JWT to cookies, because server-side redirects (e.g. middleware) has no access to local storage cookieHandler.setDevBrowserCookie(jwt); + + if (jwt) { + extensionSyncManager.dispatch(extensionSyncEvents.DevJWTUpdate, { action: 'set', token: jwt, frontendApi }); + } } function removeDevBrowserJWT() { // TODO: Maybe clear keys for both dev session sync modes to be on the safe side? localStorage.removeItem(key); cookieHandler.removeDevBrowserCookie(); + + extensionSyncManager.dispatch(extensionSyncEvents.DevJWTUpdate, { action: 'remove', frontendApi }); } // location.host == *.[lcl.dev](http://lcl.dev) diff --git a/packages/shared/package.json b/packages/shared/package.json index ea5f893d1f7..75ce762caa7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -45,13 +45,16 @@ "main": "./dist/index.js", "files": [ "dist", + "apiUrlFromPublishableKey", "browser", "callWithRetry", "color", + "constants", "cookie", "date", "deprecated", "error", + "extensionSyncManager", "file", "globs", "handleValueOrFn", @@ -62,13 +65,11 @@ "localStorageBroadcastChannel", "poller", "proxy", - "underscore", - "url", "react", - "constants", - "apiUrlFromPublishableKey", + "telemetry", "scripts", - "telemetry" + "underscore", + "url" ], "scripts": { "build": "tsup", diff --git a/packages/shared/src/extensionSyncManager.ts b/packages/shared/src/extensionSyncManager.ts new file mode 100644 index 00000000000..1e333f34e6b --- /dev/null +++ b/packages/shared/src/extensionSyncManager.ts @@ -0,0 +1,59 @@ +export const events = { + DevJWTUpdate: 'clerk:sync:dev:jwt:update', +} as const; + +type SyncEvent = (typeof events)[keyof typeof events]; + +type SyncPayloads = { + [events.DevJWTUpdate]: + | { + action: 'set'; + token: string; + frontendApi: string; + } + | { + action: 'remove'; + frontendApi: string; + }; +}; + +type SyncPayload = SyncPayloads[E] & { + event: E; +}; + +type SyncHandler = (evt: MessageEvent>) => void; + +export function createExtensionSyncManager() { + const listeners = new Map>(); + + function dispatch( + event: E, + payload: SyncPayloads[E], + targetOrigin: string = window.location.origin, + transfer?: Transferable[], + ) { + window.postMessage({ event, ...payload } satisfies SyncPayload, targetOrigin, transfer); + } + + function on(event: E, handler: SyncHandler) { + const wrappedHandler: SyncHandler = evt => { + // Only accept messages from ourselves + if (evt.source === window && evt.data.event === event) { + handler(evt); + } + }; + + window.addEventListener('message', wrappedHandler, false); + listeners.set(event, wrappedHandler); + } + + function off(event: E) { + const handler = listeners.get(event); + if (handler) { + window.removeEventListener('message', handler, false); + listeners.delete(event); + } + } + + return { dispatch, off, on }; +} diff --git a/packages/shared/subpaths.mjs b/packages/shared/subpaths.mjs index 7aeddf9defd..5a07267f703 100644 --- a/packages/shared/subpaths.mjs +++ b/packages/shared/subpaths.mjs @@ -2,13 +2,17 @@ // We have to polyfill our "exports" subpaths :cry: export const subpathNames = [ + 'apiUrlFromPublishableKey', 'browser', 'callWithRetry', 'color', + 'constants', 'cookie', 'date', 'deprecated', + 'devBrowser', 'error', + 'extensionSyncManager', 'file', 'globs', 'handleValueOrFn', @@ -19,11 +23,9 @@ export const subpathNames = [ 'localStorageBroadcastChannel', 'poller', 'proxy', + 'telemetry', 'underscore', 'url', - 'constants', - 'apiUrlFromPublishableKey', - 'telemetry', ]; export const subpathFoldersBarrel = ['react']; diff --git a/playground/chrome-extension/src/pages/content/index.tsx b/playground/chrome-extension/src/pages/content/index.tsx index 693adb4fc2f..f8115e18b1b 100644 --- a/playground/chrome-extension/src/pages/content/index.tsx +++ b/playground/chrome-extension/src/pages/content/index.tsx @@ -1,20 +1,4 @@ -import { createRoot } from 'react-dom/client'; -import './style.css' -const div = document.createElement('div'); -div.id = '__root'; -document.body.appendChild(div); +import { ContentScript } from '@clerk/chrome-extension'; -const rootContainer = document.querySelector('#__root'); -if (!rootContainer) throw new Error("Can't find Options root element"); -const root = createRoot(rootContainer); -root.render( -
- content script loaded -
-); - -try { - console.log('content script loaded'); -} catch (e) { - console.error(e); -} +const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || ""; +ContentScript.init(publishableKey);