Skip to content
Draft
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
31 changes: 31 additions & 0 deletions .changeset/loopback-oauth-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@clerk/electron': minor
'@clerk/clerk-js': patch
---

Add an `http` loopback redirect strategy for native OAuth, alongside the existing (default) custom-scheme deep link.

The `http` strategy receives the OAuth callback on `http://127.0.0.1:<port>` and serves a completion page, so the system browser tab resolves instead of stalling on a custom-scheme redirect — and it needs no OS protocol registration (RFC 8252 §7.3).

The redirect is configured as a discriminated union on `createClerkBridge`, with `httpRedirectStrategy` / `deepLinkRedirectStrategy` helpers:

```ts
import { createClerkBridge, httpRedirectStrategy } from '@clerk/electron';

createClerkBridge({
storage: storage(),
renderer: { scheme: 'my-app', host: 'app' },
// default: { type: 'deep-link' }
oauth: {
redirect: httpRedirectStrategy({
port: 45789, // default
successUrl: 'https://myapp.com/signed-in', // optional: 302 the browser to your own page
// successHtml: '<h1>Signed in</h1>', // optional alternative: custom page
}),
},
});
```

For `{ type: 'http' }`, add `http://127.0.0.1:<port>/sso-callback` (default port `45789`) to your instance's allowed redirect URLs. `successUrl` must be an absolute `http(s)` URL (it loads in the system browser); `successUrl` and `successHtml` are mutually exclusive. The default `{ type: 'deep-link' }` keeps the custom-scheme behavior (`scheme://host/`; requires `renderer`).

In `@clerk/clerk-js`, the native OAuth transport now uses the transport's redirect URL for both `redirect_url` and `action_complete_redirect_url`, so the browser's final (action-complete) redirect returns to the transport listener instead of an in-app URL.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ describe('_authenticateWithTransport', () => {
});

expect(authenticateMethod).toHaveBeenCalledWith(
expect.objectContaining({ redirectUrl: 'myapp://sso-callback', redirectUrlComplete: '/done' }),
// The native flow forces both redirect params to the transport callback URL so the browser's
// action-complete redirect returns to the transport listener (loopback / custom scheme),
// rather than the app's in-app `redirectUrlComplete` ('/done').
expect.objectContaining({ redirectUrl: 'myapp://sso-callback', redirectUrlComplete: 'myapp://sso-callback' }),
expect.any(Function),
);
expect(transport.open).toHaveBeenCalledWith(new URL('https://provider.example/auth'));
Expand Down
6 changes: 5 additions & 1 deletion packages/clerk-js/src/utils/authenticateWithTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ export async function _authenticateWithTransport(opts: {
const redirectUrl = String(await opts.transport.getRedirectUrl());

let verificationUrl: URL | string | undefined;
await opts.authenticateMethod({ ...opts.params, redirectUrl }, url => {
// In the native flow the transport's redirect URL is the single place the browser returns to, so
// it must back both `redirect_url` and `action_complete_redirect_url`. Otherwise Clerk's final
// (action-complete) redirect targets the app's in-app URL — e.g. a custom scheme — instead of the
// transport callback, and a loopback listener would never receive it.
await opts.authenticateMethod({ ...opts.params, redirectUrl, redirectUrlComplete: redirectUrl }, url => {
verificationUrl = url;
});

Expand Down
11 changes: 10 additions & 1 deletion packages/electron/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
export { createClerkBridge } from './main/create-clerk-bridge';
export { deepLinkRedirectStrategy, httpRedirectStrategy } from './main/oauth-redirect';
export type { HttpRedirectStrategyOptions } from './main/oauth-redirect';
export { setupPasskeysMain } from './main/passkey-handlers';
export type { ClerkBridge, CreateClerkBridgeOptions, ExposeClerkBridgeOptions, TokenStorage } from './shared/types';
export type {
ClerkBridge,
CreateClerkBridgeOptions,
ExposeClerkBridgeOptions,
OAuthOptions,
OAuthRedirectStrategy,
TokenStorage,
} from './shared/types';
83 changes: 81 additions & 2 deletions packages/electron/src/main/__tests__/create-clerk-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OAUTH_TRANSPORT_CHANNELS, PASSKEY_CHANNELS } from '../../shared/ipc';
import type { TokenStorage } from '../../shared/types';
import { createClerkBridge } from '../create-clerk-bridge';
import { httpRedirectStrategy } from '../oauth-redirect';

vi.mock('electron', () => ({
app: {
Expand Down Expand Up @@ -227,6 +228,7 @@ describe('createClerkBridge', () => {
host: 'renderer',
scheme: 'my-app',
},
oauth: { redirect: { type: 'deep-link' } },
});

clerk.cleanup();
Expand All @@ -248,10 +250,21 @@ describe('createClerkBridge', () => {

expect(ipcMain.handle).toHaveBeenCalledWith(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, expect.any(Function));
expect(ipcMain.handle).toHaveBeenCalledWith(OAUTH_TRANSPORT_CHANNELS.open, expect.any(Function));
});

it('registers a protocol client by default (deep-link)', () => {
createClerkBridge({
storage,
renderer: {
host: 'renderer',
scheme: 'my-app',
},
});

expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('my-app');
});

it('derives the OAuth callback URL from the renderer origin', () => {
it('derives the OAuth callback URL from the renderer origin by default', () => {
createClerkBridge({
storage,
renderer: {
Expand All @@ -267,14 +280,80 @@ describe('createClerkBridge', () => {
expect(getRedirectUrlHandler?.({} as Electron.IpcMainInvokeEvent)).toBe('my-app://renderer/');
});

it('opens OAuth URLs externally and resolves with the matching deep-link callback URL', async () => {
it('does not register a protocol client when oauth.redirect is http', () => {
createClerkBridge({
storage,
renderer: {
host: 'renderer',
scheme: 'my-app',
},
oauth: { redirect: { type: 'http' } },
});

expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalled();
expect(app.on).not.toHaveBeenCalledWith('open-url', expect.any(Function));
});

it('uses a loopback redirect URL when oauth.redirect is http', () => {
createClerkBridge({
storage,
renderer: {
host: 'renderer',
scheme: 'my-app',
},
oauth: { redirect: { type: 'http' } },
});

const getRedirectUrlHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === OAUTH_TRANSPORT_CHANNELS.getRedirectUrl;
})?.[1];

expect(getRedirectUrlHandler?.({} as Electron.IpcMainInvokeEvent)).toBe('http://127.0.0.1:45789/sso-callback');
});

it('accepts a redirect built with httpRedirectStrategy()', () => {
createClerkBridge({
storage,
renderer: {
host: 'renderer',
scheme: 'my-app',
},
oauth: { redirect: httpRedirectStrategy({ port: 6001 }) },
});

const getRedirectUrlHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === OAUTH_TRANSPORT_CHANNELS.getRedirectUrl;
})?.[1];

expect(getRedirectUrlHandler?.({} as Electron.IpcMainInvokeEvent)).toBe('http://127.0.0.1:6001/sso-callback');
});

it('honors a custom loopback port', () => {
createClerkBridge({
storage,
renderer: {
host: 'renderer',
scheme: 'my-app',
},
oauth: { redirect: { type: 'http', port: 51234 } },
});

const getRedirectUrlHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === OAUTH_TRANSPORT_CHANNELS.getRedirectUrl;
})?.[1];

expect(getRedirectUrlHandler?.({} as Electron.IpcMainInvokeEvent)).toBe('http://127.0.0.1:51234/sso-callback');
});

it('opens OAuth URLs externally and resolves with the matching deep-link callback URL in protocol mode', async () => {
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
createClerkBridge({
storage,
renderer: {
host: 'renderer',
scheme: 'my-app',
},
oauth: { redirect: { type: 'deep-link' } },
});

const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
Expand Down
123 changes: 123 additions & 0 deletions packages/electron/src/main/__tests__/oauth-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { ipcMain, shell } from 'electron';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { OAUTH_TRANSPORT_CHANNELS } from '../../shared/ipc';
import { setupOAuthTransportIpcHandlers } from '../oauth-transport';

vi.mock('electron', () => ({
app: {
on: vi.fn(),
removeListener: vi.fn(),
setAsDefaultProtocolClient: vi.fn(),
},
ipcMain: {
handle: vi.fn(),
removeHandler: vi.fn(),
},
shell: {
openExternal: vi.fn(),
},
BrowserWindow: {
getAllWindows: vi.fn(() => []),
},
}));

type Handler = (event: unknown, ...args: unknown[]) => unknown;

function getHandlers(): { getRedirectUrl?: Handler; open?: Handler } {
const calls = vi.mocked(ipcMain.handle).mock.calls;
return {
getRedirectUrl: calls.find(([channel]) => channel === OAUTH_TRANSPORT_CHANNELS.getRedirectUrl)?.[1] as Handler,
open: calls.find(([channel]) => channel === OAUTH_TRANSPORT_CHANNELS.open)?.[1] as Handler,
};
}

describe('setupOAuthTransportIpcHandlers (http loopback)', () => {
let cleanup: (() => void) | undefined;

afterEach(() => {
cleanup?.();
cleanup = undefined;
vi.clearAllMocks();
});

it('serves the built-in completion page and resolves with the loopback callback URL', async () => {
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
const port = 47654;
cleanup = setupOAuthTransportIpcHandlers({ oauth: { redirect: { type: 'http', port } } });
const { getRedirectUrl, open } = getHandlers();

expect(getRedirectUrl?.({})).toBe(`http://127.0.0.1:${port}/sso-callback`);

const openPromise = open?.({}, 'https://accounts.example.com/oauth') as Promise<{ callbackUrl: string }>;

// The loopback server is listening once `open` has reached `shell.openExternal`.
await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://accounts.example.com/oauth'));

const callbackUrl = `http://127.0.0.1:${port}/sso-callback?__clerk_status=complete&rotating_token_nonce=abc`;
const response = await fetch(callbackUrl);
const body = await response.text();

expect(response.status).toBe(200);
expect(body).toContain('Sign-in complete');
await expect(openPromise).resolves.toEqual({ callbackUrl });
});

it('serves custom successHtml when provided', async () => {
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
const port = 47657;
cleanup = setupOAuthTransportIpcHandlers({
oauth: { redirect: { type: 'http', port, successHtml: '<h1>Custom done</h1>' } },
});
const { open } = getHandlers();

void (open?.({}, 'https://accounts.example.com/oauth') as Promise<unknown>);
await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalled());

const response = await fetch(`http://127.0.0.1:${port}/sso-callback`);
const body = await response.text();

expect(response.status).toBe(200);
expect(body).toBe('<h1>Custom done</h1>');
});

it('redirects the browser to successUrl when provided', async () => {
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
const port = 47658;
cleanup = setupOAuthTransportIpcHandlers({
oauth: { redirect: { type: 'http', port, successUrl: 'https://myapp.example/signed-in' } },
});
const { open } = getHandlers();

void (open?.({}, 'https://accounts.example.com/oauth') as Promise<unknown>);
await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalled());

const response = await fetch(`http://127.0.0.1:${port}/sso-callback`, { redirect: 'manual' });

expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('https://myapp.example/signed-in');
});

it('refuses to open a non-http(s) OAuth URL', async () => {
cleanup = setupOAuthTransportIpcHandlers({ oauth: { redirect: { type: 'http', port: 47655 } } });
const { open } = getHandlers();

await expect(open?.({}, 'clerk://app/')).rejects.toThrow('unsupported OAuth URL protocol');
});

it('rejects a second concurrent OAuth flow', async () => {
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
const port = 47656;
cleanup = setupOAuthTransportIpcHandlers({ oauth: { redirect: { type: 'http', port } } });
const { open } = getHandlers();

const first = open?.({}, 'https://accounts.example.com/oauth') as Promise<unknown>;
await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalled());

await expect(open?.({}, 'https://accounts.example.com/oauth')).rejects.toThrow('an OAuth flow is already pending');

// Resolve the first flow so the server/timeout are cleaned up.
await fetch(`http://127.0.0.1:${port}/sso-callback`);
await first;
});
});
8 changes: 5 additions & 3 deletions packages/electron/src/main/create-clerk-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ function buildUserAgentFallback(defaultUserAgent: string, productToken: string):
/**
* Creates the Clerk bridge for Electron's main process.
*
* The bridge owns Clerk's main-process IPC handlers, token persistence, and OAuth deep-link
* transport. Call this before creating renderer windows, and call the returned `cleanup` method
* when tearing down the app or test environment.
* The bridge owns Clerk's main-process IPC handlers, token persistence, and the OAuth redirect
* transport (a custom-scheme deep link by default; opt into a loopback `http://127.0.0.1` server
* with `oauth: { redirect: httpRedirectStrategy() }`). Call this before creating renderer windows,
* and call the returned `cleanup` method when tearing down the app or test environment.
*/
export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridge {
if (!options.storage) {
Expand Down Expand Up @@ -75,6 +76,7 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg

cleanupOAuthTransport = setupOAuthTransportIpcHandlers({
renderer: options.renderer,
oauth: options.oauth,
});
}

Expand Down
29 changes: 29 additions & 0 deletions packages/electron/src/main/oauth-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { OAuthRedirectStrategy } from '../shared/types';

/** Options for {@link httpRedirectStrategy}. `successUrl` and `successHtml` are mutually exclusive. */
export type HttpRedirectStrategyOptions =
| { port?: number; successUrl?: string }
| { port?: number; successHtml?: string };

/**
* Builds an `http` (loopback) OAuth redirect strategy. The callback is received on
* `http://127.0.0.1:<port>/sso-callback` (default port `45789`). Pass an absolute `http(s)`
* `successUrl` to redirect the browser to your own page after the callback, or `successHtml` to
* serve custom markup instead of the built-in completion page.
*
* @example
* createClerkBridge({ storage: storage(), oauth: { redirect: httpRedirectStrategy({ port: 45789 }) } });
*/
export const httpRedirectStrategy = (options: HttpRedirectStrategyOptions = {}): OAuthRedirectStrategy => ({
type: 'http',
...options,
});

/**
* Builds a `deep-link` OAuth redirect strategy that receives the callback through the renderer's
* custom URI scheme (`scheme://host/`). Requires `renderer` and OS-level protocol registration.
*
* @example
* createClerkBridge({ storage: storage(), renderer, oauth: { redirect: deepLinkRedirectStrategy() } });
*/
export const deepLinkRedirectStrategy = (): OAuthRedirectStrategy => ({ type: 'deep-link' });
Loading
Loading