Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/electron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,69 @@ import { ClerkProvider } from '@clerk/electron/react';
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>{/* ... */}</ClerkProvider>;
```

## Content Security Policy

`@clerk/electron` loads Clerk's prebuilt UI from Clerk's CDN at runtime rather than bundling it, so your renderer's Content Security Policy must allow Clerk's Frontend API host. If it doesn't, the UI script fails to load and Clerk components never render.

Replace `{fapi_host}` below with your instance's **Frontend API** host, found in the [Clerk Dashboard](https://dashboard.clerk.com) under **API keys**. It includes a `clerk.` segment (for example, `clerk.your-app.com` in production or `your-slug.clerk.accounts.dev` in development). This is _not_ the Account Portal URL (`your-slug.accounts.dev`) — using that host blocks the UI script from loading.

```
default-src 'self';
script-src 'self' 'unsafe-inline' https://{fapi_host} https://challenges.cloudflare.com;
connect-src 'self' https://{fapi_host};
img-src 'self' https://img.clerk.com data:;
style-src 'self' 'unsafe-inline';
worker-src 'self' blob:;
frame-src 'self' https://challenges.cloudflare.com;
form-action 'self';
```

> [!NOTE]
> This covers sign-in/up and the hotloaded UI. If you use Clerk Billing (Stripe) or other features, you'll need to allow additional origins; see Clerk's [CSP guide](https://clerk.com/docs/guides/secure/best-practices/csp-headers) for the full list.

Apply it either with a `<meta>` tag in your renderer HTML:

```html
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' https://{fapi_host} https://challenges.cloudflare.com; connect-src 'self' https://{fapi_host}; img-src 'self' https://img.clerk.com data:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https://challenges.cloudflare.com; form-action 'self';"
/>
```

or, as [Electron's security guide](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) recommends, as a response header from the main process:

```ts
// main.ts
import { app, session } from 'electron';

const fapiHost = 'clerk.your-app.com';

app.whenReady().then(() => {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
`script-src 'self' 'unsafe-inline' https://${fapiHost} https://challenges.cloudflare.com`,
`connect-src 'self' https://${fapiHost}`,
"img-src 'self' https://img.clerk.com data:",
"style-src 'self' 'unsafe-inline'",
"worker-src 'self' blob:",
"frame-src 'self' https://challenges.cloudflare.com",
"form-action 'self'",
].join('; '),
],
},
});
});
});
```

> [!NOTE]
> Loading the renderer from a dev server (such as Vite) requires looser rules for HMR: add `'unsafe-eval'` to `script-src` and your dev server's origin to `connect-src` (for example, `ws://localhost:<port> http://localhost:<port>`). Many apps skip CSP during development and apply it only to packaged builds.

## Support

For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron).
Expand Down
2 changes: 1 addition & 1 deletion packages/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"dependencies": {
"@clerk/clerk-js": "workspace:^",
"@clerk/react": "workspace:^",
"@clerk/ui": "workspace:^",
"@clerk/shared": "workspace:^",
"tslib": "catalog:repo"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions packages/electron/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ClerkUIConstructor } from '@clerk/shared/ui';

import type { OAuthTransport, TokenCache } from './shared/types';

declare const PACKAGE_NAME: string;
Expand All @@ -10,5 +12,6 @@ declare global {
tokenCache: TokenCache;
oauthTransport: OAuthTransport;
};
__internal_ClerkUICtor?: ClerkUIConstructor;
}
}
12 changes: 9 additions & 3 deletions packages/electron/src/react/__tests__/ClerkProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ let beforeRequest:
let afterResponse: ((request: unknown, response: Response) => Promise<void>) | null = null;

const clerkConstructor = vi.hoisted(() => vi.fn());
const loadClerkUIScript = vi.hoisted(() => vi.fn());

vi.mock('@clerk/clerk-js', () => ({
Clerk: class MockClerk {
Expand All @@ -34,8 +35,9 @@ vi.mock('@clerk/react/internal', () => ({
},
}));

vi.mock('@clerk/ui', () => ({
ui: { ClerkUI: 'mock-ui' },
vi.mock('@clerk/shared/loadClerkJsScript', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
loadClerkUIScript,
}));

describe('Electron ClerkProvider', () => {
Expand All @@ -60,6 +62,11 @@ describe('Electron ClerkProvider', () => {
oauthTransport,
},
});
// Resolve the UI hotload so the provider's `ui.ClerkUI` promise does not reject during render.
loadClerkUIScript.mockImplementation(() => {
(window as unknown as { __internal_ClerkUICtor?: unknown }).__internal_ClerkUICtor = 'mock-ui-ctor';
return Promise.resolve(null);
});
});

it('renders React ClerkProvider with Electron defaults', () => {
Expand All @@ -77,7 +84,6 @@ describe('Electron ClerkProvider', () => {
publishableKey: 'pk_test_provider',
signInUrl: '/sign-in',
standardBrowser: false,
ui: { ClerkUI: 'mock-ui' },
});
expect(capturedProviderProps?.Clerk).toBeDefined();
expect(capturedProviderProps?.__internal_oauthTransport).toEqual({
Expand Down
39 changes: 37 additions & 2 deletions packages/electron/src/react/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/internal';
import { ui } from '@clerk/ui';
import { loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type { ClerkUIConstructor } from '@clerk/shared/ui';
import type { ReactNode } from 'react';

import { createClerkInstance } from './create-clerk-instance';
Expand All @@ -18,6 +19,39 @@ export type ClerkProviderProps = Omit<
publishableKey: string;
};

let cachedClerkUI: { promise: Promise<ClerkUIConstructor>; publishableKey: string } | null = null;

function loadClerkUI(publishableKey: string, props: Partial<ClerkProviderProps>): Promise<ClerkUIConstructor> {
if (cachedClerkUI?.publishableKey === publishableKey) {
return cachedClerkUI.promise;
}

// Undocumented escape hatch for self-hosting/proxying the UI bundle; not part of the public props.
const { __internal_clerkUIUrl, __internal_clerkUIVersion } = props as {
__internal_clerkUIUrl?: string;
__internal_clerkUIVersion?: string;
};

const promise = loadClerkUIScript({
publishableKey,
proxyUrl: typeof props.proxyUrl === 'string' ? props.proxyUrl : undefined,
domain: typeof props.domain === 'string' ? props.domain : undefined,
nonce: props.nonce,
__internal_clerkUIUrl,
__internal_clerkUIVersion,
}).then(() => {
if (!window.__internal_ClerkUICtor) {
throw new Error(
'Clerk: Failed to load Clerk UI from the CDN. Ensure your Content Security Policy allows the Clerk Frontend API host in `script-src`. Contact support@clerk.com.',
);
}
return window.__internal_ClerkUICtor;
});

cachedClerkUI = { promise, publishableKey };
return promise;
}

function createOAuthTransport(): ClerkOAuthTransport | undefined {
const bridge = window.__clerk_internal_electron?.oauthTransport;

Expand All @@ -34,6 +68,7 @@ function createOAuthTransport(): ClerkOAuthTransport | undefined {
export function ClerkProvider({ children, publishableKey, ...props }: ClerkProviderProps): JSX.Element {
const clerk = createClerkInstance(publishableKey);
const oauthTransport = createOAuthTransport();
const clerkUI = loadClerkUI(publishableKey, props);

return (
<ReactClerkProvider
Expand All @@ -42,7 +77,7 @@ export function ClerkProvider({ children, publishableKey, ...props }: ClerkProvi
__internal_oauthTransport={oauthTransport}
publishableKey={publishableKey}
standardBrowser={false}
ui={ui}
ui={{ ClerkUI: clerkUI }}
>
{children}
</ReactClerkProvider>
Expand Down
Loading
Loading