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/.vscode/settings.json b/.vscode/settings.json
index 87589341d80..a6b1d9843e3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,6 +10,12 @@
"mode": "auto"
}
],
+ "json.schemas": [
+ {
+ "fileMatch": ["manifest.json"],
+ "url": "https://json.schemastore.org/chrome-manifest.json"
+ }
+ ],
"cSpell.words": [
"accountsstage",
"adduser",
diff --git a/package-lock.json b/package-lock.json
index b95d0b128f6..200792db5e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"http-server": "^14.1.1",
"husky": "^8.0.3",
"jest": "^29.7.0",
+ "jest-chrome": "^0.8.0",
"jest-environment-jsdom": "^29.3.1",
"lint-staged": "^14.0.1",
"npm-cli-adduser": "^1.1.4",
@@ -8187,9 +8188,10 @@
"license": "MIT"
},
"node_modules/@types/chrome": {
- "version": "0.0.237",
+ "version": "0.0.114",
+ "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.114.tgz",
+ "integrity": "sha512-i7qRr74IrxHtbnrZSKUuP5Uvd5EOKwlwJq/yp7+yTPihOXnPhNQO4Z5bqb1XTnrjdbUKEJicaVVbhcgtRijmLA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
@@ -8678,6 +8680,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/webextension-polyfill": {
+ "version": "0.10.7",
+ "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.10.7.tgz",
+ "integrity": "sha512-10ql7A0qzBmFB+F+qAke/nP1PIonS0TXZAOMVOxEUsm+lGSW6uwVcISFNa0I4Oyj0884TZVWGGMIWeXOVSNFHw==",
+ "dev": true
+ },
"node_modules/@types/webpack-dev-server": {
"version": "4.7.2",
"dev": true,
@@ -20342,6 +20350,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/jest-chrome": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/jest-chrome/-/jest-chrome-0.8.0.tgz",
+ "integrity": "sha512-39RR1GT9nI4e4jsuH1vIf4l5ApxxkcstjGJr+GsOURL8f4Db0UlbRnsZaM+ZRniaGtokqklUH5VFKGZZ6YztUg==",
+ "dev": true,
+ "dependencies": {
+ "@types/chrome": "^0.0.114"
+ },
+ "peerDependencies": {
+ "jest": "^26.0.1 || ^27.0.0"
+ }
+ },
"node_modules/jest-circus": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
@@ -32019,6 +32039,11 @@
"node": ">= 8"
}
},
+ "node_modules/webextension-polyfill": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.10.0.tgz",
+ "integrity": "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g=="
+ },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"dev": true,
@@ -33280,16 +33305,16 @@
},
"packages/backend": {
"name": "@clerk/backend",
- "version": "1.0.0-alpha-v5.4",
+ "version": "1.0.0-alpha-v5.5",
"license": "MIT",
"dependencies": {
- "@clerk/shared": "2.0.0-alpha-v5.3",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
"cookie": "0.5.0",
"snakecase-keys": "5.4.4",
"tslib": "2.4.1"
},
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@cloudflare/workers-types": "^3.18.0",
"@types/chai": "^4.3.3",
"@types/cookie": "^0.5.1",
@@ -33319,17 +33344,19 @@
},
"packages/chrome-extension": {
"name": "@clerk/chrome-extension",
- "version": "1.0.0-alpha-v5.6",
+ "version": "1.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/clerk-js": "5.0.0-alpha-v5.6",
- "@clerk/clerk-react": "5.0.0-alpha-v5.6"
+ "@clerk/clerk-js": "5.0.0-alpha-v5.7",
+ "@clerk/clerk-react": "5.0.0-alpha-v5.7",
+ "webextension-polyfill": "^0.10.0"
},
"devDependencies": {
"@types/chrome": "*",
"@types/node": "^18.17.0",
"@types/react": "*",
"@types/react-dom": "*",
+ "@types/webextension-polyfill": "^0.10.7",
"eslint-config-custom": "*",
"tsup": "*",
"typescript": "*"
@@ -33344,12 +33371,12 @@
},
"packages/clerk-js": {
"name": "@clerk/clerk-js",
- "version": "5.0.0-alpha-v5.6",
+ "version": "5.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
"@clerk/localizations": "2.0.0-alpha-v5.5",
- "@clerk/shared": "2.0.0-alpha-v5.3",
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@emotion/cache": "11.11.0",
"@emotion/react": "11.11.1",
"@floating-ui/react": "0.25.4",
@@ -33684,17 +33711,17 @@
},
"packages/expo": {
"name": "@clerk/clerk-expo",
- "version": "1.0.0-alpha-v5.6",
+ "version": "1.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/clerk-js": "5.0.0-alpha-v5.6",
- "@clerk/clerk-react": "5.0.0-alpha-v5.6",
- "@clerk/shared": "2.0.0-alpha-v5.3",
+ "@clerk/clerk-js": "5.0.0-alpha-v5.7",
+ "@clerk/clerk-react": "5.0.0-alpha-v5.7",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
"base-64": "^1.0.0",
"react-native-url-polyfill": "2.0.0"
},
"devDependencies": {
- "@clerk/types": "^4.0.0-alpha-v5.6",
+ "@clerk/types": "^4.0.0-alpha-v5.7",
"@types/base-64": "^1.0.2",
"@types/node": "^18.17.0",
"@types/react": "*",
@@ -33717,12 +33744,12 @@
},
"packages/fastify": {
"name": "@clerk/fastify",
- "version": "1.0.0-alpha-v5.6",
+ "version": "1.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/backend": "1.0.0-alpha-v5.4",
- "@clerk/shared": "2.0.0-alpha-v5.3",
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/backend": "1.0.0-alpha-v5.5",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"cookies": "0.8.0"
},
"devDependencies": {
@@ -33740,17 +33767,17 @@
}
},
"packages/gatsby-plugin-clerk": {
- "version": "5.0.0-alpha-v5.6",
+ "version": "5.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/backend": "1.0.0-alpha-v5.4",
- "@clerk/clerk-react": "5.0.0-alpha-v5.6",
- "@clerk/clerk-sdk-node": "5.0.0-alpha-v5.4",
+ "@clerk/backend": "1.0.0-alpha-v5.5",
+ "@clerk/clerk-react": "5.0.0-alpha-v5.7",
+ "@clerk/clerk-sdk-node": "5.0.0-alpha-v5.5",
"cookie": "0.5.0",
"tslib": "2.4.1"
},
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@types/cookie": "^0.5.0",
"@types/node": "^18.17.0",
"eslint-config-custom": "*",
@@ -33773,7 +33800,7 @@
"version": "2.0.0-alpha-v5.5",
"license": "MIT",
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@types/node": "^18.17.0",
"eslint-config-custom": "*",
"tsup": "*",
@@ -33789,16 +33816,16 @@
},
"packages/nextjs": {
"name": "@clerk/nextjs",
- "version": "5.0.0-alpha-v5.6",
+ "version": "5.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/backend": "1.0.0-alpha-v5.4",
- "@clerk/clerk-react": "5.0.0-alpha-v5.6",
- "@clerk/shared": "2.0.0-alpha-v5.3",
+ "@clerk/backend": "1.0.0-alpha-v5.5",
+ "@clerk/clerk-react": "5.0.0-alpha-v5.7",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
"path-to-regexp": "6.2.1"
},
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@types/node": "^18.17.0",
"@types/react": "*",
"@types/react-dom": "*",
@@ -33823,11 +33850,11 @@
},
"packages/react": {
"name": "@clerk/clerk-react",
- "version": "5.0.0-alpha-v5.6",
+ "version": "5.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/shared": "2.0.0-alpha-v5.3",
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"eslint-config-custom": "*",
"semver": "^7.5.4",
"tslib": "2.4.1"
@@ -33855,17 +33882,17 @@
},
"packages/remix": {
"name": "@clerk/remix",
- "version": "4.0.0-alpha-v5.6",
+ "version": "4.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
- "@clerk/backend": "1.0.0-alpha-v5.4",
- "@clerk/clerk-react": "5.0.0-alpha-v5.6",
- "@clerk/shared": "2.0.0-alpha-v5.3",
+ "@clerk/backend": "1.0.0-alpha-v5.5",
+ "@clerk/clerk-react": "5.0.0-alpha-v5.7",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
"cookie": "0.5.0",
"tslib": "2.4.1"
},
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@remix-run/react": "^2.0.0",
"@remix-run/server-runtime": "^2.0.0",
"@types/cookie": "^0.5.0",
@@ -33891,16 +33918,16 @@
},
"packages/sdk-node": {
"name": "@clerk/clerk-sdk-node",
- "version": "5.0.0-alpha-v5.4",
+ "version": "5.0.0-alpha-v5.5",
"license": "MIT",
"dependencies": {
- "@clerk/backend": "1.0.0-alpha-v5.4",
- "@clerk/shared": "2.0.0-alpha-v5.3",
+ "@clerk/backend": "1.0.0-alpha-v5.5",
+ "@clerk/shared": "2.0.0-alpha-v5.4",
"camelcase-keys": "6.2.2",
"snakecase-keys": "3.2.1"
},
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@types/express": "4.17.14",
"@types/node": "^18.17.0",
"eslint-config-custom": "*",
@@ -33933,7 +33960,7 @@
},
"packages/shared": {
"name": "@clerk/shared",
- "version": "2.0.0-alpha-v5.3",
+ "version": "2.0.0-alpha-v5.4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -33942,7 +33969,7 @@
"swr": "2.2.0"
},
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@types/glob-to-regexp": "0.4.1",
"@types/js-cookie": "3.0.2",
"@types/node": "^18.17.0",
@@ -33978,7 +34005,7 @@
"version": "2.0.0-alpha-v5.2",
"license": "MIT",
"devDependencies": {
- "@clerk/types": "4.0.0-alpha-v5.6",
+ "@clerk/types": "4.0.0-alpha-v5.7",
"@types/node": "^18.17.0",
"eslint-config-custom": "*",
"typescript": "*"
@@ -33993,7 +34020,7 @@
},
"packages/types": {
"name": "@clerk/types",
- "version": "4.0.0-alpha-v5.6",
+ "version": "4.0.0-alpha-v5.7",
"license": "MIT",
"dependencies": {
"csstype": "3.1.1"
diff --git a/package.json b/package.json
index bfbfb338c6c..358fe856159 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"http-server": "^14.1.1",
"husky": "^8.0.3",
"jest": "^29.7.0",
+ "jest-chrome": "^0.8.0",
"jest-environment-jsdom": "^29.3.1",
"lint-staged": "^14.0.1",
"npm-cli-adduser": "^1.1.4",
@@ -101,5 +102,8 @@
"engines": {
"node": ">=18.17.0",
"npm": ">=8.5.0"
+ },
+ "overrides": {
+ "jest": "$jest"
}
}
diff --git a/packages/chrome-extension/README.md b/packages/chrome-extension/README.md
index bae26037817..b4280f6c21e 100644
--- a/packages/chrome-extension/README.md
+++ b/packages/chrome-extension/README.md
@@ -117,12 +117,13 @@ WebSSO usage snippet:
// ...
navigate(to)}
+ routerPush={to => navigate(to)}
+ routerReplace={to => navigate(to, { replace: true })}
syncSessionWithTab
>
{/* ... */}
-//...
+// ...
```
Examples of a chrome extension using the `@clerk/chrome-extension` package for authentication
diff --git a/packages/chrome-extension/jest.config.js b/packages/chrome-extension/jest.config.js
index 82554df0854..3bc23afa620 100644
--- a/packages/chrome-extension/jest.config.js
+++ b/packages/chrome-extension/jest.config.js
@@ -6,13 +6,13 @@ module.exports = {
roots: ['/src'],
testEnvironment: 'jsdom',
- setupFilesAfterEnv: ['../../jest.setup-after-env.ts'],
+ setupFilesAfterEnv: ['../../jest.setup-after-env.ts', '/jest.setup.ts'],
moduleDirectories: ['node_modules', '/src'],
transform: {
'^.+\\.m?tsx?$': ['ts-jest', { diagnostics: false }],
},
-
+ clearMocks: true,
testRegex: ['/src/.*.test.[jt]sx?$'],
testPathIgnorePatterns: ['/node_modules/'],
diff --git a/packages/chrome-extension/jest.setup.ts b/packages/chrome-extension/jest.setup.ts
new file mode 100644
index 00000000000..b5df3d97614
--- /dev/null
+++ b/packages/chrome-extension/jest.setup.ts
@@ -0,0 +1,5 @@
+import { chrome } from 'jest-chrome';
+
+// @ts-expect-error - required for the browser polyfill
+chrome.runtime.id = 'chrome-extension-test';
+Object.assign(global, { chrome, browser: chrome });
diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json
index e93e89b4901..e0721ea2029 100644
--- a/packages/chrome-extension/package.json
+++ b/packages/chrome-extension/package.json
@@ -46,13 +46,15 @@
},
"dependencies": {
"@clerk/clerk-js": "5.0.0-alpha-v5.7",
- "@clerk/clerk-react": "5.0.0-alpha-v5.7"
+ "@clerk/clerk-react": "5.0.0-alpha-v5.7",
+ "webextension-polyfill": "^0.10.0"
},
"devDependencies": {
"@types/chrome": "*",
"@types/node": "^18.17.0",
"@types/react": "*",
"@types/react-dom": "*",
+ "@types/webextension-polyfill": "^0.10.7",
"eslint-config-custom": "*",
"tsup": "*",
"typescript": "*"
diff --git a/packages/chrome-extension/src/ClerkProvider.tsx b/packages/chrome-extension/src/ClerkProvider.tsx
index 52f613f003b..d78dbcd7e21 100644
--- a/packages/chrome-extension/src/ClerkProvider.tsx
+++ b/packages/chrome-extension/src/ClerkProvider.tsx
@@ -3,9 +3,8 @@ 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';
+import { type StorageCache } from './utils/storage';
Clerk.sdkMetadata = {
name: PACKAGE_NAME,
@@ -18,30 +17,29 @@ __internal__setErrorThrowerOptions({
type WebSSOClerkProviderCustomProps =
| {
+ syncSessionHost?: never;
syncSessionWithTab?: false;
- tokenCache?: never;
+ storageCache?: never;
}
| {
+ syncSessionHost?: string;
syncSessionWithTab: true;
- tokenCache?: TokenCache;
+ storageCache?: StorageCache;
};
type WebSSOClerkProviderProps = ClerkReactProviderProps & WebSSOClerkProviderCustomProps;
const WebSSOClerkProvider = (props: WebSSOClerkProviderProps): JSX.Element | null => {
- const { children, tokenCache: runtimeTokenCache, ...rest } = props;
+ const { children, storageCache: runtimeStorageCache, syncSessionWithTab, ...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, storageCache: runtimeStorageCache }));
})();
- }, []);
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
if (!clerkInstance) {
return null;
@@ -71,16 +69,9 @@ const StandaloneClerkProvider = (props: ClerkReactProviderProps): JSX.Element =>
);
};
-type ChromeExtensionClerkProviderProps = WebSSOClerkProviderProps;
+export type ChromeExtensionClerkProviderProps = WebSSOClerkProviderProps;
export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
- const { tokenCache, syncSessionWithTab, ...rest } = props;
- return syncSessionWithTab ? (
-
- ) : (
-
- );
+ const { syncSessionHost, storageCache, syncSessionWithTab, ...rest } = props;
+ return syncSessionWithTab ? : ;
}
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/errors.ts b/packages/chrome-extension/src/errors.ts
new file mode 100644
index 00000000000..08634d9f674
--- /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, err.stack);
+
+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..8591e7867b9 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 type { ChromeExtensionClerkProviderProps } from './ClerkProvider';
// 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..7c40a17ac6c 100644
--- a/packages/chrome-extension/src/singleton.ts
+++ b/packages/chrome-extension/src/singleton.ts
@@ -1,53 +1,72 @@
import { Clerk } from '@clerk/clerk-js';
import type { ClerkProp } from '@clerk/clerk-react';
+import { CLIENT_JWT_KEY, DEV_BROWSER_JWT_MARKER } from '@clerk/shared';
+import browser from 'webextension-polyfill';
-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 { BrowserStorageCache, type StorageCache } from './utils/storage';
+import { parseAndValidatePublishableKey, validateManifest } from './utils/validation';
export let clerk: ClerkProp;
type BuildClerkOptions = {
publishableKey: string;
- tokenCache: TokenCache;
+ storageCache?: StorageCache;
+ syncSessionHost?: string;
};
-// 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,
+ storageCache = BrowserStorageCache,
+ syncSessionHost = 'http://localhost',
+}: BuildClerkOptions): Promise {
+ if (clerk) {
+ return clerk;
+ }
-export async function buildClerk({ publishableKey, tokenCache }: BuildClerkOptions): Promise {
- if (!clerk) {
- const clerkFrontendAPIOrigin = convertPublishableKeyToFrontendAPIOrigin(publishableKey);
+ // Will throw if manifest is invalid
+ validateManifest(browser.runtime.getManifest());
- const clientCookie = await getClientCookie(clerkFrontendAPIOrigin).catch(logErrorHandler);
+ // Will throw if publishableKey is invalid
+ const { frontendApi, instanceType } = parseAndValidatePublishableKey(publishableKey);
- // TODO: Listen to client cookie changes and sync updates
- // https://developer.chrome.com/docs/extensions/reference/cookies/#event-onChanged
+ const isProd = instanceType === 'production';
- if (clientCookie) {
- await tokenCache.saveToken(KEY, clientCookie.value).catch(logErrorHandler);
- }
+ // Get client cookie from browser
+ const clientCookie = await (isProd
+ ? getClientCookie(frontendApi, CLIENT_JWT_KEY)
+ : getClientCookie(syncSessionHost, DEV_BROWSER_JWT_MARKER)
+ ).catch(logErrorHandler);
- clerk = new Clerk(publishableKey);
+ // Create StorageCache key
+ const CACHE_KEY = storageCache.createKey(isProd ? frontendApi : syncSessionHost, 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 storageCache.set(CACHE_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 publicly
+ clerk.__unstable__onBeforeRequest(async requestInit => {
+ requestInit.credentials = 'omit';
+ requestInit.url?.searchParams.append('_is_native', '1');
+
+ const jwt = await storageCache.get(CACHE_KEY);
+
+ (requestInit.headers as Headers).set('authorization', jwt || '');
+ });
+
+ // @ts-expect-error - Clerk doesn't expose this unstable method publicly
+ clerk.__unstable__onAfterResponse(async (_, response) => {
+ const authHeader = response.headers.get('authorization');
+
+ if (authHeader) {
+ await storageCache.set(CACHE_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..3b1da25ce13
--- /dev/null
+++ b/packages/chrome-extension/src/utils/cookies.test.ts
@@ -0,0 +1,37 @@
+import browser from 'webextension-polyfill';
+
+import { getClientCookie } from './cookies';
+
+type RequiredCookieOpts = 'domain' | 'name' | 'value';
+type CreateCookieOpts = Pick &
+ Partial>;
+
+const createCookie = (opts: CreateCookieOpts): browser.Cookies.Cookie => ({
+ firstPartyDomain: opts.domain,
+ hostOnly: false,
+ httpOnly: true,
+ path: '/',
+ sameSite: 'lax',
+ secure: true,
+ session: false,
+ storeId: '0',
+ ...opts,
+});
+
+describe('browser.cookies', () => {
+ describe('getClientCookie', () => {
+ const domain = 'clerk.domain.com';
+ const url = `https://${domain}`;
+ const name = '__client';
+ const cookie = createCookie({ name, value: 'foo', domain });
+
+ test('returns cookie value from browser.cookies if is set for url', async () => {
+ const getMock = jest.mocked(browser.cookies.get).mockResolvedValue(cookie);
+
+ expect(await getClientCookie(url, name)).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..8aca56a2422
--- /dev/null
+++ b/packages/chrome-extension/src/utils/cookies.ts
@@ -0,0 +1,5 @@
+import browser from 'webextension-polyfill';
+
+export async function getClientCookie(url: string, name: string) {
+ return browser.cookies.get({ url, name });
+}
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..c0575bf8cbe
--- /dev/null
+++ b/packages/chrome-extension/src/utils/storage.test.ts
@@ -0,0 +1,104 @@
+import browser from 'webextension-polyfill';
+
+import { BrowserStorageCache, MemoryStorageCache } from './storage';
+
+describe('StorageCache', () => {
+ const KEY = 'foo';
+ const VALUE = 'bar';
+
+ const _void = void 0;
+
+ describe('createKey', () => {
+ test('returns a string key', () => {
+ expect(BrowserStorageCache.createKey('a', 'b', 'c')).toBe('a|b|c');
+ });
+
+ test('omits falsy values', () => {
+ // @ts-expect-error - Testing; Intentionally passing undefined value
+ expect(BrowserStorageCache.createKey('a', undefined, false, null, 'c')).toBe('a|c');
+ });
+ });
+
+ describe('set', () => {
+ test('setting the storage cache', async () => {
+ const setMock = jest.mocked(browser.storage.local.set).mockResolvedValueOnce(_void);
+
+ expect(await BrowserStorageCache.set(KEY, VALUE)).toBe(_void);
+ expect(setMock).toHaveBeenCalledTimes(1);
+ expect(setMock).toHaveBeenCalledWith({ [KEY]: VALUE });
+ });
+ });
+
+ describe('remove', () => {
+ test('removing from the storage cache', async () => {
+ const removeMock = jest.mocked(browser.storage.local.remove).mockResolvedValueOnce(_void);
+
+ expect(await BrowserStorageCache.remove(KEY)).toBe(_void);
+ expect(removeMock).toHaveBeenCalledTimes(1);
+ expect(removeMock).toHaveBeenCalledWith(KEY);
+ });
+ });
+
+ describe('get', () => {
+ test('value missing', async () => {
+ const getMock = jest.mocked(browser.storage.local.get).mockResolvedValue({});
+
+ expect(await BrowserStorageCache.get(KEY)).toBeUndefined();
+ expect(getMock).toHaveBeenCalledTimes(1);
+ expect(getMock).toHaveBeenCalledWith(KEY);
+ });
+
+ test('value exists', async () => {
+ const getMock = jest.mocked(browser.storage.local.get).mockResolvedValue({ [KEY]: VALUE });
+
+ expect(await BrowserStorageCache.get(KEY)).toBe(VALUE);
+ expect(getMock).toHaveBeenCalledTimes(1);
+ expect(getMock).toHaveBeenCalledWith(KEY);
+ });
+ });
+});
+
+describe('MemoryStorageCache', () => {
+ const KEY = 'foo';
+ const VALUE = 'bar';
+
+ const _void = void 0;
+
+ describe('createKey', () => {
+ test('returns a string key', () => {
+ expect(MemoryStorageCache.createKey('a', 'b', 'c')).toBe('a|b|c');
+ });
+
+ test('omits falsy values', () => {
+ // @ts-expect-error - Testing; Intentionally passing undefined value
+ expect(MemoryStorageCache.createKey('a', undefined, false, null, 'c')).toBe('a|c');
+ });
+ });
+
+ describe('set', () => {
+ test('setting the storage cache', async () => {
+ await MemoryStorageCache.set(KEY, VALUE);
+ expect(await MemoryStorageCache.get(KEY)).toBe(VALUE);
+ });
+ });
+
+ describe('remove', () => {
+ test('removing from the storage cache', async () => {
+ expect(await MemoryStorageCache.set(KEY, VALUE)).toBe(_void);
+ expect(await MemoryStorageCache.get(KEY)).toBe(VALUE);
+ expect(await MemoryStorageCache.remove(KEY)).toBe(_void);
+ expect(await MemoryStorageCache.get(KEY)).toBeUndefined();
+ });
+ });
+
+ describe('get', () => {
+ test('value missing', async () => {
+ expect(await MemoryStorageCache.get(KEY)).toBeUndefined();
+ });
+
+ test('value exists', async () => {
+ expect(await MemoryStorageCache.set(KEY, VALUE)).toBe(_void);
+ expect(await MemoryStorageCache.get(KEY)).toBe(VALUE);
+ });
+ });
+});
diff --git a/packages/chrome-extension/src/utils/storage.ts b/packages/chrome-extension/src/utils/storage.ts
new file mode 100644
index 00000000000..8dedf36bb09
--- /dev/null
+++ b/packages/chrome-extension/src/utils/storage.ts
@@ -0,0 +1,47 @@
+import browser from 'webextension-polyfill';
+
+type StorageCacheOptions = {
+ storageArea?: 'local' | 'sync';
+};
+
+export type StorageCache = {
+ createKey: (...keys: string[]) => string;
+ get: (key: string) => Promise;
+ remove: (key: string) => Promise;
+ set: (key: string, value: string) => Promise;
+};
+
+const createKey: StorageCache['createKey'] = (...keys: string[]) => keys.filter(Boolean).join('|');
+
+// Use browser.storage (local or sync) to persist Clerk client JWT.
+// More information at https://developer.chrome.com/docs/extensions/reference/storage
+const createBrowserStorageCache = (opts: StorageCacheOptions = {}): StorageCache => {
+ const __storageArea = opts.storageArea || 'local';
+
+ return {
+ createKey,
+ get: (key: string) => browser.storage[__storageArea].get(key).then(result => result[key] || undefined),
+ remove: (key: string) => browser.storage[__storageArea].remove(key),
+ set: (key: string, value: string) => browser.storage[__storageArea].set({ [key]: value }),
+ };
+};
+
+const createMemoryStorageCache = (): StorageCache => {
+ const cache: Map = new Map();
+
+ return {
+ createKey,
+ get: (key: string) => Promise.resolve(cache.get(key)),
+ remove: (key: string) => {
+ cache.delete(key);
+ return Promise.resolve();
+ },
+ set: (key: string, value: string) => {
+ cache.set(key, value);
+ return Promise.resolve();
+ },
+ };
+};
+
+export const BrowserStorageCache = createBrowserStorageCache();
+export const MemoryStorageCache = createMemoryStorageCache();
diff --git a/packages/chrome-extension/src/utils/validation.test.ts b/packages/chrome-extension/src/utils/validation.test.ts
new file mode 100644
index 00000000000..792273f023d
--- /dev/null
+++ b/packages/chrome-extension/src/utils/validation.test.ts
@@ -0,0 +1,68 @@
+import browser, { type Manifest } from 'webextension-polyfill';
+
+import { validateManifest } from './validation';
+
+const validClerkManifest = {
+ permissions: ['cookies', 'storage'],
+ host_permissions: ['https://*/*'],
+} as Manifest.WebExtensionManifest;
+
+describe('browser.runtime', () => {
+ test('polyfill', async () => {
+ const mockGetManifest = (browser.runtime.getManifest = jest.fn().mockReturnValue({}));
+
+ expect(() => browser.runtime.getManifest()).not.toThrowError();
+ expect(mockGetManifest).toHaveBeenCalledTimes(1);
+ });
+
+ describe('validateManifest(manifest)', () => {
+ test('valid configuration', async () => {
+ browser.runtime.getManifest = jest.fn().mockReturnValue({
+ permissions: ['cookies', 'storage'],
+ host_permissions: ['https://*/*'],
+ } as Manifest.WebExtensionManifest);
+
+ expect(() => validateManifest(validClerkManifest)).not.toThrowError();
+ });
+
+ describe('invalid configuration', () => {
+ describe('permissions', () => {
+ test('missing root key', async () => {
+ const manifest = {
+ host_permissions: ['https://*/*'],
+ } as Manifest.WebExtensionManifest;
+
+ expect(() => validateManifest(manifest)).toThrowError(/^Missing `permissions`/);
+ });
+
+ test('missing cookies', async () => {
+ const manifest = {
+ permissions: ['storage'],
+ host_permissions: ['https://*/*'],
+ } as Manifest.WebExtensionManifest;
+
+ expect(() => validateManifest(manifest)).toThrowError(/^Missing `cookies`/);
+ });
+
+ test('missing storage', async () => {
+ const manifest = {
+ permissions: ['cookies'],
+ host_permissions: ['https://*/*'],
+ } as Manifest.WebExtensionManifest;
+
+ expect(() => validateManifest(manifest)).toThrowError(/^Missing `storage`/);
+ });
+ });
+
+ describe('host_permissions', () => {
+ test('should work', async () => {
+ const manifest = {
+ permissions: ['cookies', 'storage'],
+ } as Manifest.WebExtensionManifest;
+
+ expect(() => validateManifest(manifest)).toThrowError(/^Missing `host_permissions`/);
+ });
+ });
+ });
+ });
+});
diff --git a/packages/chrome-extension/src/utils/validation.ts b/packages/chrome-extension/src/utils/validation.ts
new file mode 100644
index 00000000000..f222f29e5d3
--- /dev/null
+++ b/packages/chrome-extension/src/utils/validation.ts
@@ -0,0 +1,43 @@
+import { isPublishableKey, parsePublishableKey } from '@clerk/shared';
+import type { PublishableKey } from '@clerk/types';
+import type { Manifest } from 'webextension-polyfill';
+
+import { ClerkChromeExtensionError } from '../errors';
+
+export function parseAndValidatePublishableKey(publishableKey: string): PublishableKey {
+ if (!isPublishableKey(publishableKey)) {
+ throw new ClerkChromeExtensionError('Invalid publishable key');
+ }
+
+ const key = parsePublishableKey(publishableKey);
+
+ if (!key?.frontendApi) {
+ throw new ClerkChromeExtensionError('Missing Frontend API from publishableKey');
+ }
+
+ if (!key?.instanceType) {
+ throw new ClerkChromeExtensionError('Missing Instance Type from publishableKey');
+ }
+
+ return key;
+}
+
+export function validateManifest(manifest: Manifest.WebExtensionManifest): void {
+ if (!manifest.permissions) {
+ throw new Error('Missing `permissions` key in manifest.json');
+ }
+
+ if (!manifest.host_permissions) {
+ throw new Error('Missing `host_permissions` key in manifest.json');
+ }
+
+ if (!manifest.permissions.includes('cookies')) {
+ throw new Error('Missing `cookies` in the `permissions` key in manifest.json');
+ }
+
+ if (!manifest.permissions.includes('storage')) {
+ throw new Error('Missing `storage` in the `permissions` key in manifest.json');
+ }
+
+ // TODO: Validate hosts
+}
diff --git a/packages/chrome-extension/tsconfig.lint.json b/packages/chrome-extension/tsconfig.lint.json
new file mode 100644
index 00000000000..41f511c28c8
--- /dev/null
+++ b/packages/chrome-extension/tsconfig.lint.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src", "./*.ts"]
+}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index bf3fc978401..9febf9a924f 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -16,3 +16,4 @@ export const STAGING_ENV_SUFFIXES = ['.accountsstage.dev'];
export const LOCAL_API_URL = 'https://api.lclclerk.com';
export const STAGING_API_URL = 'https://api.clerkstage.dev';
export const PROD_API_URL = 'https://api.clerk.com';
+export const CLIENT_JWT_KEY = '__client';
diff --git a/playground/chrome-extension/.env.example b/playground/chrome-extension/.env.example
new file mode 100644
index 00000000000..4f4796f3fc9
--- /dev/null
+++ b/playground/chrome-extension/.env.example
@@ -0,0 +1 @@
+VITE_CLERK_PUBLISHABLE_KEY=YOUR_CLERK_PUBLISHABLE_KEY_GOES_HERE
diff --git a/playground/chrome-extension/.gitignore b/playground/chrome-extension/.gitignore
new file mode 100644
index 00000000000..b780dd861d3
--- /dev/null
+++ b/playground/chrome-extension/.gitignore
@@ -0,0 +1,14 @@
+# dependencies
+/node_modules
+
+# manifests
+manifest.*
+!manifest.*.example
+
+# production
+/build
+/dist
+
+# misc
+.DS_Store
+.env
diff --git a/playground/chrome-extension/README.md b/playground/chrome-extension/README.md
new file mode 100644
index 00000000000..ec04cbcaf49
--- /dev/null
+++ b/playground/chrome-extension/README.md
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+# Clerk Chrome Extension Starter
+
+This starter project shows how to use [Clerk](https://www.clerk.dev/?utm_source=github&utm_medium=starter_repos&utm_campaign=chrome_extension_start) authentication in a React based [Chrome Extension](https://developer.chrome.com/docs/extensions/).
+
+[](https://discord.com/invite/b5rXHjAg7A)
+[](https://docs.clerk.dev)
+[](https://twitter.com/intent/follow?screen_name=ClerkDev)
+
+If you run into issues, be sure to check our [main npm page](https://www.npmjs.com/package/@clerk/chrome-extension) for any updated settings/steps you may need to be aware of.
+
+---
+
+**Clerk is Hiring!**
+
+Would you like to work on Open Source software and help maintain this repository? [Apply today!](https://apply.workable.com/clerk-dev/)
+
+---
+
+## Introduction
+
+This project was bootstrapped with [Vite](https://vitejs.dev/) with [CRXJ](https://crxjs.dev/vite-plugin/).
+
+It's a kitchen-sink starter of how to use ClerkJS in a Chrome Extension either as a Standalone App or alongside a Web Application via WebSSO.
+
+
+
+It demonstrates a basic password or OTP flow using ClerkJS Components in the following extension contexts:
+
+- [x] Popup `action.*`
+- [x] Chrome Pages
+ - [x] New Tab `chrome_url_overrides.newtab`
+ - [x] History `chrome_url_overrides.history`
+ - [x] Bookmark Manager `chrome_url_overrides.bookmarks`
+- [x] Dev Tools `devtools_page`
+- [x] Extension Options `options_ui`
+
+You may safely remove any of the above contexts from the `manifest.json` if you do not need them.
+
+This repo will be enhanced with examples of authentication redirection flows such as OAuth or Magic Links and advanced extension patterns.
+
+## Getting Started
+
+1. Sign up for a Clerk account at [https://clerk.com](https://dashboard.clerk.com/sign-up?utm_source=github&utm_medium=template_repos&utm_campaign=chrome_extension_template).
+2. Go to the [Clerk dashboard](https://dashboard.clerk.com?utm_source=github&utm_medium=template_repos&utm_campaign=chrome_extension_template) and create an application.
+3. Clone the repository `git clone https://github.com/clerkinc/clerk-chrome-extension-starter.git clerk-chrome-extension-starter`
+4. Go to the project directory: cd clerk-chrome-extension-starter
+5. Install dependencies: `npm install`
+6. Copy example files and sent the required variables in each file:
+ - `cp .env.example .env`
+ - `cp manifest.json.example manifest.json`
+ - `cp manifest.dev.json.example manifest.dev.json` (The attributes in this file overwrite the attributes in `manifest.json` when running in development mode.)
+7. Launch the development server: `npm run dev`
+
+The files generated in the `dist` directory can be loaded as an unpacked extension in Chrome.
+
+PLEASE NOTE: Any changes to the manifest require a reload of the extension in [chrome://extensions/](chrome://extensions/).
diff --git a/playground/chrome-extension/demo.png b/playground/chrome-extension/demo.png
new file mode 100644
index 00000000000..4f7f1975a5e
Binary files /dev/null and b/playground/chrome-extension/demo.png differ
diff --git a/playground/chrome-extension/manifest.dev.json.example b/playground/chrome-extension/manifest.dev.json.example
new file mode 100644
index 00000000000..4e80c18a706
--- /dev/null
+++ b/playground/chrome-extension/manifest.dev.json.example
@@ -0,0 +1,10 @@
+{
+ "host_permissions": [
+ "*://localhost/*",
+ "https:///*"
+ ],
+ "icons": {
+ "32": "dev-icon-32.png",
+ "128": "dev-icon-128.png"
+ }
+}
diff --git a/playground/chrome-extension/manifest.json.example b/playground/chrome-extension/manifest.json.example
new file mode 100644
index 00000000000..adf133812bc
--- /dev/null
+++ b/playground/chrome-extension/manifest.json.example
@@ -0,0 +1,26 @@
+{
+ "manifest_version": 3,
+ "name": "Clerk Chrome Extension Playground",
+ "description": "Playground App for the Clerk Chrome Extension",
+ "action": {
+ "default_popup": "src/pages/popup/index.html"
+ },
+ "chrome_url_overrides": {
+ "newtab": "src/pages/new-tab/index.html"
+ },
+ "devtools_page": "src/pages/devtools/index.html",
+ "host_permissions": [
+ "https:///*"
+ ],
+ "icons": {
+ "32": "icon-32.png",
+ "128": "icon-128.png"
+ },
+ "options_ui": {
+ "page": "src/pages/options/index.html"
+ },
+ "permissions": [
+ "cookies",
+ "storage"
+ ]
+}
diff --git a/playground/chrome-extension/nodemon.json b/playground/chrome-extension/nodemon.json
new file mode 100644
index 00000000000..72faa888928
--- /dev/null
+++ b/playground/chrome-extension/nodemon.json
@@ -0,0 +1,14 @@
+{
+ "env": {
+ "__DEV__": "true"
+ },
+ "watch": [
+ ".yalc/**/*", "src", "utils", "vite.config.ts", "manifest.json", "manifest.dev.json", "node_modules/@clerk/**/*"
+ ],
+ "ext": "tsx,css,html,ts,json",
+ "ignore": [
+ "src/**/*.spec.ts"
+ ],
+ "exec": "vite build",
+ "delay": "1000"
+}
diff --git a/playground/chrome-extension/package.json b/playground/chrome-extension/package.json
new file mode 100644
index 00000000000..fe85b6c9017
--- /dev/null
+++ b/playground/chrome-extension/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "clerk-chrome-extension-starter",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@clerk/chrome-extension": "file:.yalc/@clerk/chrome-extension",
+ "@clerk/clerk-js": "file:.yalc/@clerk/clerk-js",
+ "@clerk/clerk-react": "file:.yalc/@clerk/clerk-react",
+ "@clerk/localizations": "file:.yalc/@clerk/localizations",
+ "@clerk/shared": "file:.yalc/@clerk/shared",
+ "@clerk/themes": "file:.yalc/@clerk/themes",
+ "@clerk/types": "file:.yalc/@clerk/types",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.9.0",
+ "webextension-polyfill": "^0.10.0"
+ },
+ "devDependencies": {
+ "@crxjs/vite-plugin": "^1.0.14",
+ "@types/chrome": "^0.0.253",
+ "@types/node": "^18.17.1",
+ "@types/react": "^18.2.39",
+ "@types/react-dom": "^18.2.17",
+ "@types/webextension-polyfill": "^0.10.0",
+ "@vitejs/plugin-react-swc": "^3.0.1",
+ "autoprefixer": "^10.4.16",
+ "nodemon": "^2.0.20",
+ "postcss": "^8.4.31",
+ "ts-node": "^10.9.1",
+ "typescript": "^4.9.4",
+ "vite": "^4.5.0"
+ },
+ "scripts": {
+ "build": "vite build",
+ "dev": "npm run yalc:add && nodemon",
+ "yalc:add": "yalc add -- @clerk/chrome-extension @clerk/clerk-react @clerk/clerk-js @clerk/localizations @clerk/themes @clerk/types @clerk/shared"
+ }
+}
diff --git a/playground/chrome-extension/public/dev-icon-128.png b/playground/chrome-extension/public/dev-icon-128.png
new file mode 100644
index 00000000000..2272f3c2883
Binary files /dev/null and b/playground/chrome-extension/public/dev-icon-128.png differ
diff --git a/playground/chrome-extension/public/dev-icon-32.png b/playground/chrome-extension/public/dev-icon-32.png
new file mode 100644
index 00000000000..2b72ebdb2ce
Binary files /dev/null and b/playground/chrome-extension/public/dev-icon-32.png differ
diff --git a/playground/chrome-extension/public/icon-128.png b/playground/chrome-extension/public/icon-128.png
new file mode 100644
index 00000000000..15bd934a116
Binary files /dev/null and b/playground/chrome-extension/public/icon-128.png differ
diff --git a/playground/chrome-extension/public/icon-32.png b/playground/chrome-extension/public/icon-32.png
new file mode 100644
index 00000000000..d93c9d3a09f
Binary files /dev/null and b/playground/chrome-extension/public/icon-32.png differ
diff --git a/playground/chrome-extension/src/assets/styles/index.css b/playground/chrome-extension/src/assets/styles/index.css
new file mode 100644
index 00000000000..629dadba4fa
--- /dev/null
+++ b/playground/chrome-extension/src/assets/styles/index.css
@@ -0,0 +1,134 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Source+Code+Pro&display=swap');
+
+body {
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+ background-color: #0c042a;
+ font-family: 'Inter', sans-serif;
+ font-weight: 400;
+ margin: 0;
+ padding: 24px;
+}
+
+pre {
+ background-image: radial-gradient(at 50% 100%, rgba(91, 197, 239, 0.1) 0%, rgba(255, 255, 255, 0) 80%, rgba(91, 197, 239, 0) 80%);
+ background: linear-gradient(0deg, rgba(28, 15, 77, 0.12) -52.71%, rgba(28, 15, 77, 0) 85%),rgba(10, 12, 61, 1);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ box-shadow: 0px 42px 56px rgba(5, 1, 54, 0.42),0px 17.5466px 23.3955px rgba(5, 1, 54, 0.301919),0px 9.38125px 12.5083px rgba(5, 1, 54, 0.250365),0px 5.25905px 7.01207px rgba(5, 1, 54, 0.21),0px 2.79304px 3.72406px rgba(5, 1, 54, 0.169635),0px 1.16225px 1.54966px rgba(5, 1, 54, 0.118081);
+ font-family: 'Source Code Pro', monospace;
+ font-weight: 400;
+ letter-spacing: 1px;
+ line-height: 1.5;
+ margin: auto;
+ max-width: 100%;
+ min-height: 100px;
+ padding: 1.5rem;
+ text-align: left;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ user-select: all;
+}
+
+a {
+ color: #61dafb;
+}
+
+h1 {
+ font-size: 56px;
+ font-weight: 700;
+ letter-spacing: -1.12px;
+ margin: 2vh 0 0;
+ margin-bottom: 5vh;
+}
+
+h2 {
+ font-size: 32px;
+ font-weight: 700;
+ letter-spacing: -1.12px;
+ margin: 0;
+}
+
+header {
+ align-items: center;
+ border-bottom: 1px solid #3d3b3b;
+ color: white;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 40vh;
+ padding-bottom: 5vh;
+}
+
+main {
+ margin-left: 2vw;
+ margin-right: 2vw;
+ text-align: left;
+}
+
+.cl-rootBox {
+ margin: auto;
+}
+
+.container {
+ color: white;
+ display: flex;
+ flex-direction: column;
+ gap: 10vh;
+ margin-inline: auto;
+ min-height: 100vh;
+ text-align: center;
+ width: 600px;
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+button, a.button {
+ align-items: center;
+ appearance: none;
+ background: #FFFFFF;
+ border: 0 solid #d8dde8;
+ border-width: 0;
+ border-style: solid;
+ border-radius: 0.5rem;
+ border-color: #D8DDE8;
+ box-sizing: border-box;
+ box-shadow: 0px 1px 2px rgba(57, 67, 86, 0.08);
+ color: #6C47FF;
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 14px;
+ font-weight: 700;
+ height: 3rem;
+ justify-content: center;
+ line-height: 1.2;
+ margin: 0;
+ min-width: 2.5rem;
+ min-height: 48px;
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ overflow: visible;
+ text-decoration: none;
+ text-transform: uppercase;
+ padding: 0;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ padding-inline-start: 1rem;
+ padding-inline-end: 1rem;
+ position: relative;
+ user-select: none;
+ vertical-align: middle;
+ white-space: nowrap;
+ width: 225px;
+ word-wrap: break-word;
+ z-index: 100;
+}
+
+a.invert {
+ background: #6C47FF;
+ color: #FFFFFF;
+}
diff --git a/playground/chrome-extension/src/components/CurrentUser.tsx b/playground/chrome-extension/src/components/CurrentUser.tsx
new file mode 100644
index 00000000000..5b8d5ddb796
--- /dev/null
+++ b/playground/chrome-extension/src/components/CurrentUser.tsx
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react';
+import { useAuth, useUser } from "@clerk/chrome-extension";
+
+export function CurrentUser() {
+ const [sessionToken, setSessionToken] = useState("");
+ const { isSignedIn, user } = useUser();
+ const { getToken, signOut } = useAuth();
+
+ useEffect(() => {
+ const scheduler = setInterval(async () => {
+ const token = await getToken();
+ setSessionToken(token as string);
+ }, 1000);
+
+ return () => clearInterval(scheduler);
+ }, []);
+
+ if (!isSignedIn) {
+ return null;
+ }
+
+ const email = user.primaryEmailAddress?.emailAddress;
+
+ return (
+
+
Hi, {email ? `${email}!` : ''}
+
+
+
Clerk Session Token:
+
{sessionToken}
+
+
+
+
+ );
+}
diff --git a/playground/chrome-extension/src/components/SharedApp.tsx b/playground/chrome-extension/src/components/SharedApp.tsx
new file mode 100644
index 00000000000..7b49b579938
--- /dev/null
+++ b/playground/chrome-extension/src/components/SharedApp.tsx
@@ -0,0 +1,78 @@
+import { ClerkProvider, type ChromeExtensionClerkProviderProps } from "@clerk/chrome-extension";
+import { Routes, Route, useNavigate } from "react-router-dom";
+import {
+ SignedIn,
+ SignedOut,
+ SignIn,
+ SignUp,
+} from "@clerk/chrome-extension";
+
+import "@/assets/styles/index.css"
+
+import { CurrentUser } from '@/components/CurrentUser';
+
+const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || "";
+
+export type SharedAppProps = Pick & {
+ className?: string;
+}
+
+export function SharedApp({ className, ...rest }: SharedAppProps) {
+ const navigate = useNavigate();
+
+ return (
+ navigate(to)}
+ routerReplace={to => navigate(to, { replace: true })}
+ syncSessionWithTab
+ {...rest}
+ >
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/playground/chrome-extension/src/global.d.ts b/playground/chrome-extension/src/global.d.ts
new file mode 100644
index 00000000000..7ad3edd14db
--- /dev/null
+++ b/playground/chrome-extension/src/global.d.ts
@@ -0,0 +1,11 @@
+declare module '*.svg' {
+ import React = require('react');
+ export const ReactComponent: React.SFC>;
+ const src: string;
+ export default src;
+}
+
+declare module '*.json' {
+ const content: string;
+ export default content;
+}
diff --git a/playground/chrome-extension/src/pages/background/index.ts b/playground/chrome-extension/src/pages/background/index.ts
new file mode 100644
index 00000000000..def0db5c2cf
--- /dev/null
+++ b/playground/chrome-extension/src/pages/background/index.ts
@@ -0,0 +1 @@
+console.log('background script loaded');
diff --git a/playground/chrome-extension/src/pages/content/index.tsx b/playground/chrome-extension/src/pages/content/index.tsx
new file mode 100644
index 00000000000..de0869e4012
--- /dev/null
+++ b/playground/chrome-extension/src/pages/content/index.tsx
@@ -0,0 +1 @@
+console.log('content script loaded')
diff --git a/playground/chrome-extension/src/pages/devtools/index.html b/playground/chrome-extension/src/pages/devtools/index.html
new file mode 100644
index 00000000000..666bb682e32
--- /dev/null
+++ b/playground/chrome-extension/src/pages/devtools/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Devtools
+
+
+
+
+
diff --git a/playground/chrome-extension/src/pages/devtools/index.ts b/playground/chrome-extension/src/pages/devtools/index.ts
new file mode 100644
index 00000000000..cf2258c594f
--- /dev/null
+++ b/playground/chrome-extension/src/pages/devtools/index.ts
@@ -0,0 +1,6 @@
+import browser from 'webextension-polyfill';
+
+browser
+ .devtools
+ .panels
+ .create('Clerk Starter', 'icon-32.png', 'src/pages/panel/index.html');
diff --git a/playground/chrome-extension/src/pages/new-tab/index.html b/playground/chrome-extension/src/pages/new-tab/index.html
new file mode 100644
index 00000000000..e794ed94acd
--- /dev/null
+++ b/playground/chrome-extension/src/pages/new-tab/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ New tab
+
+
+
+
+
+
+
diff --git a/playground/chrome-extension/src/pages/new-tab/index.tsx b/playground/chrome-extension/src/pages/new-tab/index.tsx
new file mode 100644
index 00000000000..48b5565878b
--- /dev/null
+++ b/playground/chrome-extension/src/pages/new-tab/index.tsx
@@ -0,0 +1,11 @@
+import { createRoot } from 'react-dom/client';
+import NewTab from './new-tab';
+
+function init() {
+ const rootContainer = document.querySelector("#__root");
+ if (!rootContainer) throw new Error("Can't find NewTab root element");
+ const root = createRoot(rootContainer);
+ root.render();
+}
+
+init();
diff --git a/playground/chrome-extension/src/pages/new-tab/new-tab.tsx b/playground/chrome-extension/src/pages/new-tab/new-tab.tsx
new file mode 100644
index 00000000000..aeb1cd4b2dc
--- /dev/null
+++ b/playground/chrome-extension/src/pages/new-tab/new-tab.tsx
@@ -0,0 +1,12 @@
+import { MemoryRouter } from "react-router-dom";
+import { SharedApp } from '@/components/SharedApp';
+
+function NewTab() {
+ return (
+
+
+
+ )
+}
+
+export default NewTab;
diff --git a/playground/chrome-extension/src/pages/options/index.html b/playground/chrome-extension/src/pages/options/index.html
new file mode 100644
index 00000000000..db5fa745afe
--- /dev/null
+++ b/playground/chrome-extension/src/pages/options/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Options
+
+
+
+
+
+
+
diff --git a/playground/chrome-extension/src/pages/options/index.tsx b/playground/chrome-extension/src/pages/options/index.tsx
new file mode 100644
index 00000000000..48630999fb9
--- /dev/null
+++ b/playground/chrome-extension/src/pages/options/index.tsx
@@ -0,0 +1,11 @@
+import { createRoot } from 'react-dom/client';
+import Options from './options';
+
+function init() {
+ const rootContainer = document.querySelector("#__root");
+ if (!rootContainer) throw new Error("Can't find Options root element");
+ const root = createRoot(rootContainer);
+ root.render();
+}
+
+init();
diff --git a/playground/chrome-extension/src/pages/options/options.tsx b/playground/chrome-extension/src/pages/options/options.tsx
new file mode 100644
index 00000000000..f2b20d9fed4
--- /dev/null
+++ b/playground/chrome-extension/src/pages/options/options.tsx
@@ -0,0 +1,12 @@
+import { MemoryRouter } from "react-router-dom";
+import { SharedApp } from '@/components/SharedApp';
+
+function Options() {
+ return (
+
+
+
+ )
+}
+
+export default Options;
diff --git a/playground/chrome-extension/src/pages/panel/index.html b/playground/chrome-extension/src/pages/panel/index.html
new file mode 100644
index 00000000000..7aa306d0ca7
--- /dev/null
+++ b/playground/chrome-extension/src/pages/panel/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Devtools Panel
+
+
+
+
+
+
+
diff --git a/playground/chrome-extension/src/pages/panel/index.tsx b/playground/chrome-extension/src/pages/panel/index.tsx
new file mode 100644
index 00000000000..88ce49ce797
--- /dev/null
+++ b/playground/chrome-extension/src/pages/panel/index.tsx
@@ -0,0 +1,11 @@
+import { createRoot } from 'react-dom/client';
+import Panel from './panel';
+
+function init() {
+ const rootContainer = document.querySelector("#__root");
+ if (!rootContainer) throw new Error("Can't find Panel root element");
+ const root = createRoot(rootContainer);
+ root.render();
+}
+
+init();
diff --git a/playground/chrome-extension/src/pages/panel/panel.tsx b/playground/chrome-extension/src/pages/panel/panel.tsx
new file mode 100644
index 00000000000..a2ae4f3cb00
--- /dev/null
+++ b/playground/chrome-extension/src/pages/panel/panel.tsx
@@ -0,0 +1,12 @@
+import { MemoryRouter } from "react-router-dom";
+import { SharedApp } from '@/components/SharedApp';
+
+function Panel() {
+ return (
+
+
+
+ )
+}
+
+export default Panel;
diff --git a/playground/chrome-extension/src/pages/popup/index.html b/playground/chrome-extension/src/pages/popup/index.html
new file mode 100644
index 00000000000..b60f054fcee
--- /dev/null
+++ b/playground/chrome-extension/src/pages/popup/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Popup
+
+
+
+
+
+
+
diff --git a/playground/chrome-extension/src/pages/popup/index.tsx b/playground/chrome-extension/src/pages/popup/index.tsx
new file mode 100644
index 00000000000..695a35a37d8
--- /dev/null
+++ b/playground/chrome-extension/src/pages/popup/index.tsx
@@ -0,0 +1,11 @@
+import { createRoot } from 'react-dom/client';
+import Popup from './popup';
+
+function init() {
+ const rootContainer = document.querySelector("#__root");
+ if (!rootContainer) throw new Error("Can't find Popup root element");
+ const root = createRoot(rootContainer);
+ root.render();
+}
+
+init();
diff --git a/playground/chrome-extension/src/pages/popup/popup.tsx b/playground/chrome-extension/src/pages/popup/popup.tsx
new file mode 100644
index 00000000000..f3fa7470612
--- /dev/null
+++ b/playground/chrome-extension/src/pages/popup/popup.tsx
@@ -0,0 +1,12 @@
+import { MemoryRouter } from "react-router-dom";
+import { SharedApp } from '@/components/SharedApp';
+
+function Popup() {
+ return (
+
+
+
+ )
+}
+
+export default Popup;
diff --git a/playground/chrome-extension/src/vite-env.d.ts b/playground/chrome-extension/src/vite-env.d.ts
new file mode 100644
index 00000000000..11f02fe2a00
--- /dev/null
+++ b/playground/chrome-extension/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/playground/chrome-extension/tsconfig.json b/playground/chrome-extension/tsconfig.json
new file mode 100644
index 00000000000..5dd31527680
--- /dev/null
+++ b/playground/chrome-extension/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "types": ["vite/client", "node", "chrome"],
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"],
+}
diff --git a/playground/chrome-extension/vite.config.ts b/playground/chrome-extension/vite.config.ts
new file mode 100644
index 00000000000..0e40558bb78
--- /dev/null
+++ b/playground/chrome-extension/vite.config.ts
@@ -0,0 +1,50 @@
+import { fileURLToPath, URL } from 'url';
+import { resolve } from 'path';
+
+import react from '@vitejs/plugin-react-swc';
+import { crx, ManifestV3Export } from '@crxjs/vite-plugin';
+import { defineConfig } from 'vite';
+
+import manifest from './manifest.json';
+import devManifest from './manifest.dev.json';
+import pkg from './package.json';
+
+const isDev = process.env.__DEV__ === 'true';
+
+const extensionManifest = {
+ ...manifest,
+ ...(isDev ? devManifest : {} as ManifestV3Export),
+ name: isDev ? `[DEV] ${manifest.name}` : manifest.name,
+ version: pkg.version,
+};
+
+const crxPlugin = crx({
+ manifest: extensionManifest as ManifestV3Export,
+ contentScripts: {
+ injectCss: true,
+ }
+})
+
+export default defineConfig({
+ resolve: {
+ alias: [
+ { find: '@/', replacement: fileURLToPath(new URL('./src/', import.meta.url)) },
+ ],
+ },
+ plugins: [
+ react(),
+ crxPlugin,
+ ],
+ publicDir: resolve(__dirname, 'public'),
+ build: {
+ emptyOutDir: !isDev,
+ outDir: resolve(__dirname, 'dist'),
+ minify: !isDev,
+ sourcemap: isDev,
+ rollupOptions: {
+ input: {
+ panel: 'src/pages/panel/index.html',
+ },
+ },
+ },
+});
diff --git a/playground/vite-react-ts/.gitignore b/playground/vite-react-ts/.gitignore
index a547bf36d8d..d7de12f3812 100644
--- a/playground/vite-react-ts/.gitignore
+++ b/playground/vite-react-ts/.gitignore
@@ -12,6 +12,8 @@ dist
dist-ssr
*.local
+.env
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json