Skip to content

Commit db95d2f

Browse files
committed
fix(electron): replace a stale pending OAuth flow instead of blocking new sign-ins
1 parent 9278d33 commit db95d2f

3 files changed

Lines changed: 88 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/electron': patch
3+
---
4+
5+
Fix OAuth sign-in staying blocked after a user abandons the browser tab. Starting a new OAuth sign-in now replaces the pending one instead of failing with `Clerk: an OAuth flow is already pending.` until the 3-minute callback timeout. An unsupported OAuth URL no longer cancels a pending flow, and a replaced flow's browser launch failure no longer rejects the new flow.

‎packages/electron/src/main/__tests__/create-clerk-bridge.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,4 +705,80 @@ describe('createClerkBridge', () => {
705705
await expect(openPromise).resolves.toEqual({ callbackUrl: 'my-app://renderer/?code=123' });
706706
expect(rendererWindow.focus).not.toHaveBeenCalled();
707707
});
708+
709+
it('replaces a pending OAuth flow when a new one starts', async () => {
710+
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
711+
createClerkBridge({
712+
storage,
713+
renderer: {
714+
host: 'renderer',
715+
scheme: 'my-app',
716+
},
717+
});
718+
719+
const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
720+
return channel === OAUTH_TRANSPORT_CHANNELS.open;
721+
})?.[1];
722+
const firstPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth?attempt=1');
723+
const secondPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth?attempt=2');
724+
const openUrlListener = getAppListener<(event: Electron.Event, url: string) => void>('open-url');
725+
726+
await expect(firstPromise).rejects.toThrow('replaced by a new one');
727+
openUrlListener?.({ preventDefault: vi.fn() } as unknown as Electron.Event, 'my-app://renderer/?code=123');
728+
await expect(secondPromise).resolves.toEqual({ callbackUrl: 'my-app://renderer/?code=123' });
729+
});
730+
731+
it('keeps a pending OAuth flow when a new request has an unsupported URL', async () => {
732+
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
733+
createClerkBridge({
734+
storage,
735+
renderer: {
736+
host: 'renderer',
737+
scheme: 'my-app',
738+
},
739+
});
740+
741+
const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
742+
return channel === OAUTH_TRANSPORT_CHANNELS.open;
743+
})?.[1];
744+
const pendingPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth');
745+
746+
await expect(openHandler?.(mainFrameEvent, 'file:///etc/passwd')).rejects.toThrow('unsupported OAuth URL protocol');
747+
748+
const openUrlListener = getAppListener<(event: Electron.Event, url: string) => void>('open-url');
749+
openUrlListener?.({ preventDefault: vi.fn() } as unknown as Electron.Event, 'my-app://renderer/?code=123');
750+
await expect(pendingPromise).resolves.toEqual({ callbackUrl: 'my-app://renderer/?code=123' });
751+
});
752+
753+
it("does not let a replaced flow's launch failure reject the new flow", async () => {
754+
let rejectFirstLaunch: (error: Error) => void = () => {};
755+
vi.mocked(shell.openExternal)
756+
.mockImplementationOnce(
757+
() =>
758+
new Promise<void>((_resolve, reject) => {
759+
rejectFirstLaunch = reject;
760+
}),
761+
)
762+
.mockResolvedValueOnce(undefined);
763+
createClerkBridge({
764+
storage,
765+
renderer: {
766+
host: 'renderer',
767+
scheme: 'my-app',
768+
},
769+
});
770+
771+
const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
772+
return channel === OAUTH_TRANSPORT_CHANNELS.open;
773+
})?.[1];
774+
const firstPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth?attempt=1');
775+
const secondPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth?attempt=2');
776+
777+
rejectFirstLaunch(new Error('launch failed'));
778+
await expect(firstPromise).rejects.toThrow('replaced by a new one');
779+
780+
const openUrlListener = getAppListener<(event: Electron.Event, url: string) => void>('open-url');
781+
openUrlListener?.({ preventDefault: vi.fn() } as unknown as Electron.Event, 'my-app://renderer/?code=123');
782+
await expect(secondPromise).resolves.toEqual({ callbackUrl: 'my-app://renderer/?code=123' });
783+
});
708784
});

‎packages/electron/src/main/oauth-transport.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,27 +123,28 @@ export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions):
123123
throw new Error("Clerk: OAuth request did not originate from a window's main frame.");
124124
}
125125

126-
if (pendingOAuthFlow) {
127-
throw new Error('Clerk: an OAuth flow is already pending.');
128-
}
129-
130126
assertExternalOAuthUrl(url);
127+
disposePendingOAuthFlow(new Error('Clerk: OAuth flow was replaced by a new one.'));
131128

132129
const window = BrowserWindow.fromWebContents(event.sender);
133130

131+
let flow: PendingOAuthFlow | null = null;
134132
const callbackPromise = new Promise<{ callbackUrl: string }>((resolve, reject) => {
135133
const timeout = setTimeout(() => {
136134
disposePendingOAuthFlow();
137135
reject(new Error('Clerk: OAuth callback timed out.'));
138136
}, CALLBACK_TIMEOUT_MS);
139137

140-
pendingOAuthFlow = { resolve, reject, timeout, window };
138+
flow = { resolve, reject, timeout, window };
139+
pendingOAuthFlow = flow;
141140
});
142141

143142
try {
144143
await shell.openExternal(url);
145144
} catch (err) {
146-
disposePendingOAuthFlow(err instanceof Error ? err : new Error(String(err)));
145+
if (pendingOAuthFlow === flow) {
146+
disposePendingOAuthFlow(err instanceof Error ? err : new Error(String(err)));
147+
}
147148
}
148149

149150
return callbackPromise;

0 commit comments

Comments
 (0)