diff --git a/.changeset/loopback-oauth-transport.md b/.changeset/loopback-oauth-transport.md new file mode 100644 index 00000000000..347c94fdf97 --- /dev/null +++ b/.changeset/loopback-oauth-transport.md @@ -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:` 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: '

Signed in

', // optional alternative: custom page + }), + }, +}); +``` + +For `{ type: 'http' }`, add `http://127.0.0.1:/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. diff --git a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts index c1883cb1658..ebef86d27a1 100644 --- a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts +++ b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts @@ -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')); diff --git a/packages/clerk-js/src/utils/authenticateWithTransport.ts b/packages/clerk-js/src/utils/authenticateWithTransport.ts index 27bf11eac30..239d94c1f04 100644 --- a/packages/clerk-js/src/utils/authenticateWithTransport.ts +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -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; }); diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 3b109dbd849..710e2de4440 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -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'; diff --git a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts index bf90b7fb745..0c11c53674e 100644 --- a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts +++ b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts @@ -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: { @@ -227,6 +228,7 @@ describe('createClerkBridge', () => { host: 'renderer', scheme: 'my-app', }, + oauth: { redirect: { type: 'deep-link' } }, }); clerk.cleanup(); @@ -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: { @@ -267,7 +280,72 @@ 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, @@ -275,6 +353,7 @@ describe('createClerkBridge', () => { host: 'renderer', scheme: 'my-app', }, + oauth: { redirect: { type: 'deep-link' } }, }); const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => { diff --git a/packages/electron/src/main/__tests__/oauth-transport.test.ts b/packages/electron/src/main/__tests__/oauth-transport.test.ts new file mode 100644 index 00000000000..6ce29fde3cd --- /dev/null +++ b/packages/electron/src/main/__tests__/oauth-transport.test.ts @@ -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: '

Custom done

' } }, + }); + const { open } = getHandlers(); + + void (open?.({}, 'https://accounts.example.com/oauth') as Promise); + 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('

Custom done

'); + }); + + 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); + 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; + 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; + }); +}); diff --git a/packages/electron/src/main/create-clerk-bridge.ts b/packages/electron/src/main/create-clerk-bridge.ts index d1a93d5d76e..637c92c9054 100644 --- a/packages/electron/src/main/create-clerk-bridge.ts +++ b/packages/electron/src/main/create-clerk-bridge.ts @@ -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) { @@ -75,6 +76,7 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg cleanupOAuthTransport = setupOAuthTransportIpcHandlers({ renderer: options.renderer, + oauth: options.oauth, }); } diff --git a/packages/electron/src/main/oauth-redirect.ts b/packages/electron/src/main/oauth-redirect.ts new file mode 100644 index 00000000000..778f793c88d --- /dev/null +++ b/packages/electron/src/main/oauth-redirect.ts @@ -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:/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' }); diff --git a/packages/electron/src/main/oauth-transport.ts b/packages/electron/src/main/oauth-transport.ts index 7cfd61414dd..a79cc3af9f4 100644 --- a/packages/electron/src/main/oauth-transport.ts +++ b/packages/electron/src/main/oauth-transport.ts @@ -1,9 +1,16 @@ -import { app, ipcMain, shell } from 'electron'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; + +import { app, BrowserWindow, ipcMain, shell } from 'electron'; import { OAUTH_TRANSPORT_CHANNELS } from '../shared/ipc'; -import type { RendererSchemeOptions } from '../shared/types'; +import type { OAuthOptions, OAuthRedirectStrategy, RendererSchemeOptions } from '../shared/types'; const CALLBACK_TIMEOUT_MS = 3 * 60 * 1000; +const DEFAULT_LOOPBACK_HOST = '127.0.0.1'; +const DEFAULT_LOOPBACK_PORT = 45789; +const LOOPBACK_CALLBACK_PATH = '/sso-callback'; + +type HttpRedirect = Extract; type PendingOAuthFlow = { resolve: (value: { callbackUrl: string }) => void; @@ -12,27 +19,28 @@ type PendingOAuthFlow = { }; type OAuthTransportOptions = { - renderer: RendererSchemeOptions; + renderer?: RendererSchemeOptions; + oauth?: OAuthOptions; }; -function buildRedirectUrl(options: OAuthTransportOptions): string { - return `${options.renderer.scheme}://${options.renderer.host}/`; -} +type CallbackListener = (callbackUrl: string) => void; -function isMatchingCallbackUrl(url: string, redirectUrl: string): boolean { - try { - const callback = new URL(url); - const expected = new URL(redirectUrl); - - return ( - callback.protocol === expected.protocol && - callback.host === expected.host && - callback.pathname === expected.pathname - ); - } catch { - return false; - } -} +/** + * A redirect handler owns the URL Clerk redirects back to and the mechanism that receives the OAuth + * callback from the system browser. The transport contract (`getRedirectUrl` / `open`) is identical + * across handlers — only the redirect-URI option differs (RFC 8252 §7.1 private-use URI scheme vs. + * §7.3 loopback interface). + */ +type RedirectHandler = { + /** Stable URL Clerk redirects back to after the provider authorizes. */ + readonly redirectUrl: string; + /** Begin listening for a single in-flight callback. May allocate per-flow resources. */ + arm: () => Promise; + /** Stop listening for the current callback and release per-flow resources. */ + disarm: () => void; + /** Permanently tear down listeners/resources registered for the handler's lifetime. */ + cleanup: () => void; +}; function assertExternalOAuthUrl(url: string): void { const parsedUrl = new URL(url); @@ -42,57 +50,232 @@ function assertExternalOAuthUrl(url: string): void { } } -export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions): () => void { - const redirectUrl = buildRedirectUrl(options); - let pendingOAuthFlow: PendingOAuthFlow | null = null; +function focusMainWindow(): void { + const window = BrowserWindow.getAllWindows()[0]; - const disposePendingOAuthFlow = (reason?: Error): void => { - if (!pendingOAuthFlow) { - return; - } + if (!window) { + return; + } - const pending = pendingOAuthFlow; - clearTimeout(pendingOAuthFlow.timeout); - pendingOAuthFlow = null; + if (window.isMinimized()) { + window.restore(); + } - if (reason) { - pending.reject(reason); - } - }; + if (!window.isVisible()) { + window.show(); + } - const handleCallbackUrl = (url: string): void => { - if (!pendingOAuthFlow || !isMatchingCallbackUrl(url, redirectUrl)) { - return; - } + window.focus(); +} - const pending = pendingOAuthFlow; - disposePendingOAuthFlow(); - pending.resolve({ callbackUrl: url }); +function buildDefaultCompletionPage(): string { + return ` + + + + + Sign-in complete + + + +
+

Sign-in complete

+

You can close this window and return to the app.

+
+ + +`; +} + +/** + * RFC 8252 §7.1 — receives the callback through a registered custom (private-use) URI scheme, + * delivered by Electron's `open-url` (macOS) and `second-instance` (Windows/Linux) events. + */ +function createDeepLinkHandler( + renderer: RendererSchemeOptions | undefined, + onCallback: CallbackListener, +): RedirectHandler { + if (!renderer) { + throw new Error( + "Clerk: oauth.redirect { type: 'deep-link' } requires a renderer { scheme, host } to be configured.", + ); + } + + const redirectUrl = `${renderer.scheme}://${renderer.host}/`; + + const isMatchingCallbackUrl = (url: string): boolean => { + try { + const callback = new URL(url); + const expected = new URL(redirectUrl); + + return ( + callback.protocol === expected.protocol && + callback.host === expected.host && + callback.pathname === expected.pathname + ); + } catch { + return false; + } }; const openUrlListener = (event: Electron.Event, url: string): void => { - if (!isMatchingCallbackUrl(url, redirectUrl)) { + if (!isMatchingCallbackUrl(url)) { return; } event.preventDefault(); - handleCallbackUrl(url); + onCallback(url); }; const secondInstanceListener = (_event: Electron.Event, argv: string[]): void => { - const callbackUrl = argv.find(url => isMatchingCallbackUrl(url, redirectUrl)); + const callbackUrl = argv.find(isMatchingCallbackUrl); if (callbackUrl) { - handleCallbackUrl(callbackUrl); + onCallback(callbackUrl); } }; - app.setAsDefaultProtocolClient(options.renderer.scheme); + app.setAsDefaultProtocolClient(renderer.scheme); app.on('open-url', openUrlListener); app.on('second-instance', secondInstanceListener); + return { + redirectUrl, + arm: () => Promise.resolve(), + disarm: () => {}, + cleanup: () => { + app.removeListener('open-url', openUrlListener); + app.removeListener('second-instance', secondInstanceListener); + }, + }; +} + +/** + * RFC 8252 §7.3 — receives the callback on a short-lived loopback (`http://127.0.0.1`) server and + * either redirects the browser to `successUrl`, serves `successHtml`, or serves a built-in + * completion page so the tab resolves instead of stalling. Requires no OS protocol registration. + */ +function createHttpHandler(config: HttpRedirect, onCallback: CallbackListener): RedirectHandler { + const host = DEFAULT_LOOPBACK_HOST; + const port = config.port ?? DEFAULT_LOOPBACK_PORT; + const redirectUrl = `http://${host}:${port}${LOOPBACK_CALLBACK_PATH}`; + let server: Server | null = null; + + const closeServer = (): void => { + if (!server) { + return; + } + + try { + server.close(); + } catch { + // Server may already be closing; ignore. + } + + server = null; + }; + + const respondToCallback = (response: ServerResponse): void => { + if (config.successUrl) { + response.writeHead(302, { Location: config.successUrl }); + response.end(); + return; + } + + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + response.end(config.successHtml ?? buildDefaultCompletionPage()); + }; + + const requestListener = (request: IncomingMessage, response: ServerResponse): void => { + try { + const requestUrl = new URL(request.url ?? '/', redirectUrl); + + if (request.method !== 'GET' || requestUrl.pathname !== LOOPBACK_CALLBACK_PATH) { + response.writeHead(404, { 'Content-Type': 'text/plain' }); + response.end('Not found.'); + return; + } + + respondToCallback(response); + focusMainWindow(); + onCallback(requestUrl.toString()); + } catch { + response.writeHead(500, { 'Content-Type': 'text/plain' }); + response.end('Sign-in callback failed.'); + } + }; + + return { + redirectUrl, + arm: () => + new Promise((resolve, reject) => { + if (server) { + resolve(); + return; + } + + const next = createServer(requestListener); + + next.once('error', error => { + server = null; + reject(error); + }); + + next.listen(port, host, () => { + next.removeListener('error', reject); + server = next; + resolve(); + }); + }), + disarm: closeServer, + cleanup: closeServer, + }; +} + +export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions): () => void { + let pendingOAuthFlow: PendingOAuthFlow | null = null; + + // Set after the handler is created; lets `settle` release per-flow resources without a forward + // reference to `handler`. + let disarmActiveFlow: () => void = () => {}; + + const settle = (result: { callbackUrl: string } | { error: Error }): void => { + if (!pendingOAuthFlow) { + return; + } + + const pending = pendingOAuthFlow; + clearTimeout(pending.timeout); + pendingOAuthFlow = null; + disarmActiveFlow(); + + if ('error' in result) { + pending.reject(result.error); + } else { + pending.resolve(result); + } + }; + + const redirect: OAuthRedirectStrategy = options.oauth?.redirect ?? { type: 'deep-link' }; + const handler: RedirectHandler = + redirect.type === 'deep-link' + ? createDeepLinkHandler(options.renderer, callbackUrl => settle({ callbackUrl })) + : createHttpHandler(redirect, callbackUrl => settle({ callbackUrl })); + + disarmActiveFlow = () => handler.disarm(); + ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, () => { - return redirectUrl; + return handler.redirectUrl; }); ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.open, async (_event, url: string) => { @@ -104,26 +287,25 @@ export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions): const callbackPromise = new Promise<{ callbackUrl: string }>((resolve, reject) => { const timeout = setTimeout(() => { - disposePendingOAuthFlow(); - reject(new Error('Clerk: OAuth callback timed out.')); + settle({ error: new Error('Clerk: OAuth callback timed out.') }); }, CALLBACK_TIMEOUT_MS); pendingOAuthFlow = { resolve, reject, timeout }; }); try { + await handler.arm(); await shell.openExternal(url); } catch (err) { - disposePendingOAuthFlow(err instanceof Error ? err : new Error(String(err))); + settle({ error: err instanceof Error ? err : new Error(String(err)) }); } return callbackPromise; }); return () => { - disposePendingOAuthFlow(new Error('Clerk: OAuth flow was cancelled.')); - app.removeListener('open-url', openUrlListener); - app.removeListener('second-instance', secondInstanceListener); + settle({ error: new Error('Clerk: OAuth flow was cancelled.') }); + handler.cleanup(); ipcMain.removeHandler(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl); ipcMain.removeHandler(OAUTH_TRANSPORT_CHANNELS.open); }; diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts index f2c310150e3..fd9d81c96d5 100644 --- a/packages/electron/src/shared/types.ts +++ b/packages/electron/src/shared/types.ts @@ -8,6 +8,52 @@ export type TokenStorage = { removeItem: (key: string) => Awaitable; }; +export type OAuthRedirectStrategy = + | { + /** + * Receive the OAuth callback on a short-lived `http://127.0.0.1:` loopback server. + * Recommended for desktop OAuth (RFC 8252 §7.3): it needs no OS protocol registration and lets + * the system browser tab resolve to a real page instead of stalling on a custom-scheme redirect. + */ + type: 'http'; + /** + * Port for the loopback callback server. Add `http://127.0.0.1:/sso-callback` to your + * instance's allowed redirect URLs. + * + * @default 45789 + */ + port?: number; + /** + * Absolute `http(s)` URL to redirect the browser to once the callback is captured (e.g. a + * hosted "you're signed in" page). Takes priority over `successHtml`. + */ + successUrl?: string; + /** + * Custom HTML served on the callback instead of the built-in "you can close this window" page. + * Ignored when `successUrl` is set. + */ + successHtml?: string; + } + | { + /** + * Receive the OAuth callback through a registered custom URI scheme (`scheme://host/`), + * delivered via Electron's `open-url` / `second-instance` deep-link events. Requires + * `renderer` and OS-level protocol registration. + */ + type: 'deep-link'; + }; + +export type OAuthOptions = { + /** + * How the native OAuth callback is delivered back to the app. Defaults to the custom-scheme deep + * link; opt into the loopback server with `{ type: 'http' }` (or the `httpRedirectStrategy()` + * helper) to get a browser-renderable completion page. + * + * @default { type: 'deep-link' } + */ + redirect?: OAuthRedirectStrategy; +}; + export type CreateClerkBridgeOptions = { /** * Storage adapter used by the main process to persist Clerk tokens. @@ -17,6 +63,11 @@ export type CreateClerkBridgeOptions = { * Registers the custom scheme used to serve the Electron renderer from a stable origin. */ renderer?: RendererSchemeOptions; + /** + * Configures how native OAuth callbacks are delivered back to the app. Defaults to a custom-scheme + * deep link; opt into an `http` loopback redirect via `oauth: { redirect: httpRedirectStrategy() }`. + */ + oauth?: OAuthOptions; /** * Registers the IPC handlers for native passkey ceremonies. Native support also requires * the optional `@clerk/electron-passkeys` package and `exposeClerkBridge({ passkeys: true })`.