Skip to content

Commit

Permalink
feat(chrome-extension,shared): Extended WebSSO Features
Browse files Browse the repository at this point in the history
  • Loading branch information
tmilewski committed Dec 6, 2023
1 parent 59559bd commit 7fc6a2d
Show file tree
Hide file tree
Showing 51 changed files with 605 additions and 622 deletions.
5 changes: 3 additions & 2 deletions packages/chrome-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,13 @@ WebSSO usage snippet:
// ...
<ClerkProvider
publishableKey={publishableKey}
navigate={to => navigate(to)}
routerPush={to => navigate(to)}
routerReplace={to => navigate(to, { replace: true })}
syncSessionWithTab
>
{/* ... */}
</ClerkProvider>
//...
// ...
```

Examples of a chrome extension using the `@clerk/chrome-extension` package for authentication
Expand Down
4 changes: 2 additions & 2 deletions packages/chrome-extension/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ module.exports = {

roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>../../jest.setup-after-env.ts'],
setupFilesAfterEnv: ['<rootDir>../../jest.setup-after-env.ts', '<rootDir>/jest.setup.ts'],

moduleDirectories: ['node_modules', '<rootDir>/src'],
transform: {
'^.+\\.m?tsx?$': ['ts-jest', { diagnostics: false }],
},

clearMocks: true,
testRegex: ['/src/.*.test.[jt]sx?$'],
testPathIgnorePatterns: ['/node_modules/'],

Expand Down
6 changes: 6 additions & 0 deletions packages/chrome-extension/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { chrome } from 'jest-chrome';

// @ts-expect-error - Mock implementation
chrome.runtime.id = 'chrome-extension-test';

Object.assign(globalThis, { chrome, browser: chrome });
5 changes: 4 additions & 1 deletion packages/chrome-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@
},
"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": "*",
"jest-chrome": "^0.8.0",
"tsup": "*",
"typescript": "*"
},
Expand Down
28 changes: 19 additions & 9 deletions packages/chrome-extension/src/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { __internal__setErrorThrowerOptions, ClerkProvider as ClerkReactProvider
import React from 'react';

import { buildClerk } from './singleton';
import { type StorageCache } from './utils/storage';

Clerk.sdkMetadata = {
name: PACKAGE_NAME,
Expand All @@ -14,23 +15,31 @@ __internal__setErrorThrowerOptions({
packageName: '@clerk/chrome-extension',
});

type WebSSOClerkProviderCustomProps = {
syncSessionWithTab?: boolean;
};
type WebSSOClerkProviderCustomProps =
| {
syncSessionHost?: never;
syncSessionWithTab?: false;
storageCache?: never;
}
| {
syncSessionHost?: string;
syncSessionWithTab: true;
storageCache?: StorageCache;
};

type WebSSOClerkProviderProps = ClerkReactProviderProps & WebSSOClerkProviderCustomProps;

const WebSSOClerkProvider = (props: WebSSOClerkProviderProps): JSX.Element | null => {
const { children, ...rest } = props;
const { children, storageCache: runtimeStorageCache, syncSessionWithTab, ...rest } = props;
const { publishableKey = '' } = props;

const [clerkInstance, setClerkInstance] = React.useState<ClerkProp>(null);

React.useEffect(() => {
void (async () => {
setClerkInstance(await buildClerk({ publishableKey }));
setClerkInstance(await buildClerk({ publishableKey, storageCache: runtimeStorageCache }));
})();
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

if (!clerkInstance) {
return null;
Expand Down Expand Up @@ -60,8 +69,9 @@ const StandaloneClerkProvider = (props: ClerkReactProviderProps): JSX.Element =>
);
};

type ChromeExtensionClerkProviderProps = WebSSOClerkProviderProps;
export type ChromeExtensionClerkProviderProps = WebSSOClerkProviderProps;

export function ClerkProvider({ syncSessionWithTab, ...rest }: ChromeExtensionClerkProviderProps): JSX.Element | null {
return syncSessionWithTab ? <WebSSOClerkProvider {...rest} /> : <StandaloneClerkProvider {...rest} />;
export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
const { syncSessionHost, storageCache, syncSessionWithTab, ...rest } = props;
return syncSessionWithTab ? <WebSSOClerkProvider {...props} /> : <StandaloneClerkProvider {...rest} />;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`public exports should not include a breaking change 1`] = `
[
Array [
"AuthenticateWithRedirectCallback",
"ClerkLoaded",
"ClerkLoading",
Expand Down
8 changes: 4 additions & 4 deletions packages/chrome-extension/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createExtensionSyncManager, events } from '@clerk/shared/extensionSyncM

import { STORAGE_KEY_CLIENT_JWT } from './constants';
import { ClerkChromeExtensionError, logErrorHandler } from './errors';
import { ChromeStorageCache } from './utils/storage';
import { BrowserStorageCache } from './utils/storage';

export const ContentScript = {
init(publishableKey: string) {
Expand Down Expand Up @@ -40,12 +40,12 @@ export const ContentScript = {
return;
}

const KEY = ChromeStorageCache.createKey(data.frontendApi, STORAGE_KEY_CLIENT_JWT);
const KEY = BrowserStorageCache.createKey(data.frontendApi, STORAGE_KEY_CLIENT_JWT);

if (data.action === 'set') {
void ChromeStorageCache.set(KEY, data.token);
void BrowserStorageCache.set(KEY, data.token);
} else if (data.action === 'remove') {
void ChromeStorageCache.remove(KEY);
void BrowserStorageCache.remove(KEY);
}
});
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion packages/chrome-extension/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// error handler that logs the error (used in cookie retrieval and token saving)
export const logErrorHandler = (err: Error) => console.error(err);
export const logErrorHandler = (err: Error) => console.error(err, err.stack);

export class ClerkChromeExtensionError extends Error {
clerk: boolean = true;
Expand Down
1 change: 1 addition & 0 deletions packages/chrome-extension/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from '@clerk/clerk-react';
export { ContentScript } from './content';
export type { ChromeExtensionClerkProviderProps } from './ClerkProvider';

// order matters since we want override @clerk/clerk-react ClerkProvider
export { ClerkProvider } from './ClerkProvider';
46 changes: 30 additions & 16 deletions packages/chrome-extension/src/singleton.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,70 @@
import { Clerk } from '@clerk/clerk-js';
import type { ClerkProp } from '@clerk/clerk-react';
import { parsePublishableKey } from '@clerk/shared';
import { CLIENT_JWT_KEY, DEV_BROWSER_JWT_MARKER } from '@clerk/shared';
import browser from 'webextension-polyfill';

import { STORAGE_KEY_CLIENT_JWT } from './constants';
import { logErrorHandler } from './errors';
import { getClientCookie } from './utils/cookies';
import { ChromeStorageCache } from './utils/storage';
import { BrowserStorageCache, type StorageCache } from './utils/storage';
import { parseAndValidatePublishableKey, validateManifest } from './utils/validation';

export let clerk: ClerkProp;

type BuildClerkOptions = {
publishableKey: string;
storageCache?: StorageCache;
syncSessionHost?: string;
};

export async function buildClerk({ publishableKey }: BuildClerkOptions): Promise<ClerkProp> {
export async function buildClerk({
publishableKey,
storageCache = BrowserStorageCache,
syncSessionHost = 'http://localhost',
}: BuildClerkOptions): Promise<ClerkProp> {
if (clerk) {
return clerk;
}

const { frontendApi, instanceType } = parsePublishableKey(publishableKey) || {};
// Will throw if manifest is invalid
validateManifest(browser.runtime.getManifest());

if (!frontendApi || !instanceType) {
throw new Error('Invalid publishable key.');
}
// Will throw if publishableKey is invalid
const { frontendApi, instanceType } = parseAndValidatePublishableKey(publishableKey);

const clientCookie = await getClientCookie(frontendApi).catch(logErrorHandler);
const isProd = instanceType === 'production';

// TODO: Listen to client cookie changes and sync updates
// https://developer.chrome.com/docs/extensions/reference/cookies/#event-onChanged
// Get client cookie from browser
const clientCookie = await (isProd
? getClientCookie(frontendApi, CLIENT_JWT_KEY)
: getClientCookie(syncSessionHost, DEV_BROWSER_JWT_MARKER)
).catch(logErrorHandler);

const KEY = ChromeStorageCache.createKey(frontendApi, STORAGE_KEY_CLIENT_JWT);
// Create StorageCache key
const CACHE_KEY = storageCache.createKey(isProd ? frontendApi : syncSessionHost, STORAGE_KEY_CLIENT_JWT);

if (clientCookie) {
await ChromeStorageCache.set(KEY, clientCookie.value).catch(logErrorHandler);
await storageCache.set(CACHE_KEY, clientCookie.value).catch(logErrorHandler);
}

clerk = new Clerk(publishableKey);

// @ts-expect-error - Clerk doesn't expose this unstable method
// @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 ChromeStorageCache.get(KEY);
const jwt = await storageCache.get(CACHE_KEY);

(requestInit.headers as Headers).set('authorization', jwt || '');
});

// @ts-expect-error - Clerk doesn't expose this unstable method
// @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 ChromeStorageCache.set(KEY, authHeader);
await storageCache.set(CACHE_KEY, authHeader);
}
});

Expand Down
55 changes: 18 additions & 37 deletions packages/chrome-extension/src/utils/cookies.test.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,34 @@
import browser from 'webextension-polyfill';

import { getClientCookie } from './cookies';

const domain = 'clerk.domain.com';
const createCookie = (
name: string,
value: string,
opts: Partial<Omit<chrome.cookies.Cookie, 'name' | 'value'>>,
): chrome.cookies.Cookie => ({
domain: 'clerk.domain.com',
secure: true,
type RequiredCookieOpts = 'domain' | 'name' | 'value';
type CreateCookieOpts<T extends keyof browser.Cookies.Cookie> = Pick<browser.Cookies.Cookie, T> &
Partial<Omit<browser.Cookies.Cookie, T>>;

const createCookie = (opts: CreateCookieOpts<RequiredCookieOpts>): browser.Cookies.Cookie => ({
firstPartyDomain: opts.domain,
hostOnly: false,
httpOnly: true,
path: '/',
storeId: '0',
sameSite: 'lax',
secure: true,
session: false,
hostOnly: false,
sameSite: 'no_restriction',
storeId: '0',
...opts,
name,
value,
});

describe('utils', () => {
const _chrome = globalThis.chrome;

// export function get(details: Details): Promise<Cookie | null>;

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<Cookie | null>;
describe('browser.cookies', () => {
describe('getClientCookie', () => {
const domain = 'clerk.domain.com';
const url = `https://${domain}`;
const name = '__client';
const cookie = createCookie(name, 'foo', { domain });
const cookie = createCookie({ name, value: '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);
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)).toBe(cookie);
expect(await getClientCookie(url, name)).toBe(cookie);

expect(getMock).toHaveBeenCalledTimes(1);
expect(getMock).toHaveBeenCalledWith({ url, name });
Expand Down
6 changes: 4 additions & 2 deletions packages/chrome-extension/src/utils/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export async function getClientCookie(url: string) {
return chrome.cookies.get({ url, name: '__client' });
import browser from 'webextension-polyfill';

export async function getClientCookie(url: string, name: string) {
return browser.cookies.get({ url, name });
}
Loading

0 comments on commit 7fc6a2d

Please sign in to comment.