Skip to content

Commit

Permalink
feat(chrome-extension): Remove requirement for syncSessionHost
Browse files Browse the repository at this point in the history
  • Loading branch information
tmilewski authored and SokratisVidros committed Dec 13, 2023
1 parent 67265c4 commit cdaf16b
Show file tree
Hide file tree
Showing 14 changed files with 339 additions and 150 deletions.
26 changes: 17 additions & 9 deletions .changeset/shiny-glasses-switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,32 @@ Expand the ability for `@clerk/chrome-extension` WebSSO to sync with host applic

### How to Update

**WebSSO Local Development Host Permissions:**
**WebSSO Host Permissions:**

_Local Development: You must have your explicit development domain added to your `manifest.json` file in order to use the WebSSO flow._

Example:

Add `*://localhost:*/*` to the `host_permissions` array in your `manifest.json` file:
```json
{
"host_permissions": ["*://localhost:*/*"]
"host_permissions": [
// ...
"http://localhost"
// ...
]
}
```

If you're using a local domain other than `localhost`, you'll want replace that entry with your domain: `*://<DOMAIN>/*`
_Production: You must have your explicit Clerk Frontend API domain added to your `manifest.json` file in order to use the WebSSO flow._

Example:
```json
{
"host_permissions": ["*://<DOMAIN>/*"]
"host_permissions": [
// ...
"https://clerk.example.com"
// ...
]
}
```

Expand All @@ -34,10 +46,6 @@ If you're using a local domain other than `localhost`, you'll want replace that

// tokenCache is now storageCache (See below)
storageCache={/* ... */}

// [Development Only]: If the host application isn't on
// `http://localhost`, you can provide it here:
syncSessionHost="http://<DOMAIN>"
>
```

Expand Down
32 changes: 12 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 4 additions & 8 deletions packages/chrome-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,20 +152,16 @@ You must enable the following permissions in your `manifest.json` file:

You must enable the following host permissions in your `manifest.json` file:

- **Development:** `"host_permissions": ["*://localhost:*/*"]`
- If you're using a domain other than `localhost`, you'll want replace that entry with your domain: `*://<DOMAIN>/*`
- **Production:** `"host_permissions": ["*://<YOUR_CLERK_FRONTEND_API_GOES_HERE>/"]`
- **Development:** `"host_permissions": ["http://localhost"]`
- If you're using a domain other than `localhost`, you'll want replace that entry with your domain: `http://<DOMAIN>`
- **Production:** `"host_permissions": ["https://<YOUR_CLERK_FRONTEND_API_GOES_HERE>/"]`
- Your Frontend API URL can be found in `Clerk Dashboard > API Keys > Advanced > Clerk API URLs`.

For more info on host permissions: (Google Developer `host_permissions` Reference)[https://developer.chrome.com/docs/extensions/mv3/declare_permissions/#host-permissions]

#### ClerkProvider

If your plan to sync sessions with a host application not on `localhost`, you'll need to use the `syncSessionHost` prop on `ClerkProvider` to specify the domain you're using, inclusive of the protocol. (eg: `https://<domain>`)

<a name="clerk-settings"></a>

### Clerk settings
### Clerk Settings

Add your Chrome extension origin to your instance allowed_origins using BAPI:

Expand Down
1 change: 1 addition & 0 deletions packages/chrome-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@types/webextension-polyfill": "^0.10.7",
"eslint-config-custom": "*",
"tsup": "*",
"type-fest": "^4.8.3",
"typescript": "*"
},
"peerDependencies": {
Expand Down
4 changes: 1 addition & 3 deletions packages/chrome-extension/src/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@ __internal__setErrorThrowerOptions({

type WebSSOClerkProviderCustomProps =
| {
syncSessionHost?: never;
syncSessionWithTab?: false;
storageCache?: never;
}
| {
syncSessionHost?: string;
syncSessionWithTab: true;
storageCache?: StorageCache;
};
Expand Down Expand Up @@ -72,6 +70,6 @@ const StandaloneClerkProvider = (props: ClerkReactProviderProps): JSX.Element =>
type ChromeExtensionClerkProviderProps = WebSSOClerkProviderProps;

export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
const { syncSessionHost, storageCache, syncSessionWithTab, ...rest } = props;
const { storageCache, syncSessionWithTab, ...rest } = props;
return syncSessionWithTab ? <WebSSOClerkProvider {...props} /> : <StandaloneClerkProvider {...rest} />;
}
2 changes: 2 additions & 0 deletions packages/chrome-extension/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export const CLIENT_JWT_KEY = '__client';
export const STORAGE_KEY_CLIENT_JWT = '__clerk_client_jwt';
export const VALID_HOST_PERMISSION_REGEX = /(https?:\/\/[\w.-]+)/;
export const DEFAULT_LOCAL_HOST_PERMISSION = 'http://localhost';
41 changes: 27 additions & 14 deletions packages/chrome-extension/src/singleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,60 @@ import { DEV_BROWSER_JWT_MARKER } from '@clerk/shared';
import { parsePublishableKey } from '@clerk/shared/keys';
import browser from 'webextension-polyfill';

import { CLIENT_JWT_KEY, STORAGE_KEY_CLIENT_JWT } from './constants';
import { CLIENT_JWT_KEY, DEFAULT_LOCAL_HOST_PERMISSION, STORAGE_KEY_CLIENT_JWT } from './constants';
import type { GetClientCookieParams } from './utils/cookies';
import { getClientCookie } from './utils/cookies';
import { assertPublishableKey, errorLogger } from './utils/errors';
import { getValidPossibleManifestHosts, validateHostPermissionExistence, validateManifest } from './utils/manifest';
import { BrowserStorageCache, type StorageCache } from './utils/storage';
import { validateManifest } from './utils/validation';

export let clerk: ClerkProp;

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

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

// Will throw if manifest is invalid
validateManifest(browser.runtime.getManifest());

// Parse publishableKey and assert it's present/valid
// Parse publishableKey and assert it's present/valid, throw if not
const key = parsePublishableKey(publishableKey);
assertPublishableKey(key);

// Will throw if publishableKey is invalid
const isProd = key.instanceType === 'production';
const hostHint = isProd ? key.frontendApi : DEFAULT_LOCAL_HOST_PERMISSION;
const manifest = browser.runtime.getManifest();

// Will throw if manifest is invalid
validateManifest(manifest);

const validHosts = getValidPossibleManifestHosts(manifest);

// Will throw if manifest host_permissions doesn't contain a valid host
validateHostPermissionExistence(validHosts, hostHint);

// Set up cookie params based on environment
const getClientCookieParams: GetClientCookieParams = isProd
? {
urls: key.frontendApi,
name: CLIENT_JWT_KEY,
}
: {
urls: validHosts,
name: DEV_BROWSER_JWT_MARKER,
};

// Get client cookie from browser
const clientCookie = await (isProd
? getClientCookie(key.frontendApi, CLIENT_JWT_KEY)
: getClientCookie(syncSessionHost, DEV_BROWSER_JWT_MARKER)
).catch(errorLogger);
const clientCookie = await getClientCookie(getClientCookieParams).catch(errorLogger);

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

// Set client cookie in StorageCache
if (clientCookie) {
Expand Down
86 changes: 77 additions & 9 deletions packages/chrome-extension/src/utils/cookies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,87 @@ const createCookie = (opts: CreateCookieOpts<RequiredCookieOpts>): browser.Cooki
});

describe('Cookies', () => {
const domain = 'clerk.domain.com';
const urls = [`https://${domain}`, 'https://foo.com'];
const name = '__client';
const cookie = createCookie({ name, value: 'foo', domain });

const getMock = jest.mocked(browser.cookies.get);

afterEach(() => getMock.mockReset());
afterAll(() => getMock.mockRestore());

function expectMockCalls(mockedFn: typeof getMock, name: string, urls: string[]) {
expect(mockedFn).toHaveBeenCalledTimes(urls.length);
expect(mockedFn.mock.calls.flat()).toEqual(urls.map(url => ({ url, name })));
}

describe('getClientCookie', () => {
const domain = 'clerk.domain.com';
const url = `https://${domain}`;
const name = '__client';
const cookie = createCookie({ name, value: 'foo', domain });
describe('Single Host', () => {
test('returns cookie value from browser.cookies if is set for url', async () => {
const url = urls[0];

getMock.mockResolvedValue(cookie);

expect(await getClientCookie({ urls: url, name })).toBe(cookie);

expectMockCalls(getMock, name, [url]);
});
});

describe('Multiple Hosts', () => {
test('with valid urls', async () => {
getMock.mockResolvedValueOnce(cookie).mockResolvedValueOnce(null).mockResolvedValueOnce(null);

expect(await getClientCookie({ urls, name })).toBe(cookie);

expectMockCalls(getMock, name, urls);
});

test('with invalid urls', async () => {
const urls = ['foo'];

getMock.mockResolvedValue(null);
expect(await getClientCookie({ urls, name })).toBe(null);

expectMockCalls(getMock, name, urls);
});

test('with single result', async () => {
getMock.mockResolvedValueOnce(cookie).mockResolvedValueOnce(null);

expect(await getClientCookie({ urls, name })).toBe(cookie);

expectMockCalls(getMock, name, urls);
});

test('with multiple results - should pick first result', async () => {
const cookie2 = createCookie({ name, value: 'result2', domain });

getMock.mockResolvedValueOnce(cookie).mockResolvedValueOnce(cookie2);

expect(await getClientCookie({ urls, name })).toBe(cookie);

expectMockCalls(getMock, name, urls);
});

test('with rejected result', async () => {
const urls = [`https://${domain}`, 'https://foo.com'];

getMock.mockResolvedValueOnce(cookie).mockRejectedValueOnce(null);

expect(await getClientCookie({ urls, name })).toBe(cookie);

expectMockCalls(getMock, name, urls);
});

test('returns cookie value from browser.cookies if is set for url', async () => {
const getMock = jest.mocked(browser.cookies.get).mockResolvedValue(cookie);
test('with empty result', async () => {
getMock.mockResolvedValueOnce(null).mockRejectedValueOnce(null);

expect(await getClientCookie(url, name)).toBe(cookie);
expect(await getClientCookie({ urls, name })).toBe(null);

expect(getMock).toHaveBeenCalledTimes(1);
expect(getMock).toHaveBeenCalledWith({ url, name });
expectMockCalls(getMock, name, urls);
});
});
});
});
24 changes: 22 additions & 2 deletions packages/chrome-extension/src/utils/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import browser from 'webextension-polyfill';

export async function getClientCookie(url: string, name: string) {
return await browser.cookies.get({ url, name });
export type GetClientCookieParams = {
urls: string | string[];
name: string;
};

export async function getClientCookie({ urls, name }: GetClientCookieParams) {
// Handle single host request
if (typeof urls === 'string') {
return browser.cookies.get({ url: urls, name });
}

// Handle multi-host request
const cookiePromises = urls.map(url => browser.cookies.get({ url, name }));
const cookieResults = await Promise.allSettled(cookiePromises);

for (const cookie of cookieResults) {
if (cookie.status === 'fulfilled') {
return cookie.value;
}
}

return null;
}
6 changes: 6 additions & 0 deletions packages/chrome-extension/src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { buildErrorThrower } from '@clerk/shared/error';

export type HostPermissionHintOpts = {
hostHint: string;
};

export const errorLogger = (err: Error) => console.error(err, err.stack);
export const errorThrower = buildErrorThrower({ packageName: '@clerk/chrome-extension' });

export const missingManifestKeyError = (key: string) => `Missing \`${key}\` entry in manifest.json`;
export const missingValidManifestHostPermission = (hostHint: string) =>
`You're missing a valid host permission. Please add ${hostHint} to \`host_permissions\` in manifest.json.`;

export function assertPublishableKey(publishableKey: unknown): asserts publishableKey {
if (!publishableKey) {
Expand Down
Loading

0 comments on commit cdaf16b

Please sign in to comment.