Skip to content

Commit

Permalink
feat(clerk-js,chrome-extension,shared): Expand WebSSO capabilities (C…
Browse files Browse the repository at this point in the history
…ontent Scripts) [SDK-836]
  • Loading branch information
tmilewski committed Dec 4, 2023
1 parent 71dae1d commit 6cb7922
Show file tree
Hide file tree
Showing 20 changed files with 384 additions and 202 deletions.
29 changes: 29 additions & 0 deletions .changeset/shiny-glasses-switch.md
Original file line number Diff line number Diff line change
@@ -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 || "");
```
33 changes: 7 additions & 26 deletions packages/chrome-extension/src/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<ClerkProp>(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 }));
})();
}, []);

Expand Down Expand Up @@ -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 ? (
<WebSSOClerkProvider
{...props}
tokenCache={tokenCache}
/>
) : (
<StandaloneClerkProvider {...rest} />
);
export function ClerkProvider({ syncSessionWithTab, ...rest }: ChromeExtensionClerkProviderProps): JSX.Element | null {
return syncSessionWithTab ? <WebSSOClerkProvider {...rest} /> : <StandaloneClerkProvider {...rest} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ exports[`public exports should not include a breaking change 1`] = `
"ClerkLoaded",
"ClerkLoading",
"ClerkProvider",
"ContentScript",
"CreateOrganization",
"EmailLinkErrorCode",
"Experimental__Gate",
Expand Down
31 changes: 0 additions & 31 deletions packages/chrome-extension/src/cache.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/chrome-extension/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const STORAGE_KEY_CLIENT_JWT = '__clerk_client_jwt';
55 changes: 55 additions & 0 deletions packages/chrome-extension/src/content.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
10 changes: 10 additions & 0 deletions packages/chrome-extension/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
3 changes: 1 addition & 2 deletions packages/chrome-extension/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
69 changes: 37 additions & 32 deletions packages/chrome-extension/src/singleton.ts
Original file line number Diff line number Diff line change
@@ -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<ClerkProp> {
if (clerk) {
return clerk;
}

export async function buildClerk({ publishableKey, tokenCache }: BuildClerkOptions): Promise<ClerkProp> {
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;
}
67 changes: 0 additions & 67 deletions packages/chrome-extension/src/utils.test.ts

This file was deleted.

17 changes: 0 additions & 17 deletions packages/chrome-extension/src/utils.ts

This file was deleted.

Loading

0 comments on commit 6cb7922

Please sign in to comment.