Skip to content

Commit a19e3b4

Browse files
fix(auth): improve popup closure detection for third-party login (#2763)
1 parent 865dcbf commit a19e3b4

2 files changed

Lines changed: 22 additions & 158 deletions

File tree

packages/auth/src/Auth.test.ts

Lines changed: 21 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -186,21 +186,11 @@ describe('Auth', () => {
186186
});
187187
});
188188

189-
describe('loginWithPopup - popup closure detection', () => {
189+
describe('loginWithPopup', () => {
190190
let mockUserManager: any;
191-
let mockPopupWindow: any;
192-
let originalWindowOpen: any;
193191
let originalCryptoRandomUUID: any;
194192

195193
beforeEach(() => {
196-
// Mock window.open to return a controllable popup reference
197-
originalWindowOpen = window.open;
198-
mockPopupWindow = {
199-
closed: false,
200-
close: jest.fn(),
201-
};
202-
window.open = jest.fn().mockReturnValue(mockPopupWindow);
203-
204194
// Mock crypto.randomUUID
205195
originalCryptoRandomUUID = window.crypto.randomUUID;
206196
window.crypto.randomUUID = jest.fn().mockReturnValue('test-popup-id');
@@ -212,17 +202,13 @@ describe('Auth', () => {
212202
extraQueryParams: {},
213203
},
214204
};
215-
216-
jest.useFakeTimers();
217205
});
218206

219207
afterEach(() => {
220-
window.open = originalWindowOpen;
221208
window.crypto.randomUUID = originalCryptoRandomUUID;
222-
jest.useRealTimers();
223209
});
224210

225-
it('resolves successfully when authentication completes before popup closes', async () => {
211+
it('successfully completes authentication and returns user', async () => {
226212
const mockOidcUser = {
227213
id_token: 'token',
228214
access_token: 'access',
@@ -236,12 +222,7 @@ describe('Auth', () => {
236222
passport: undefined,
237223
});
238224

239-
// Simulate successful authentication that resolves quickly
240-
mockUserManager.signinPopup.mockImplementation(() => new Promise((resolve) => {
241-
setTimeout(() => {
242-
resolve(mockOidcUser);
243-
}, 100);
244-
}));
225+
mockUserManager.signinPopup.mockResolvedValue(mockOidcUser);
245226

246227
const auth = Object.create(Auth.prototype) as Auth;
247228
(auth as any).userManager = mockUserManager;
@@ -250,60 +231,17 @@ describe('Auth', () => {
250231
};
251232
getDetailMock.mockReturnValue('runtime-id-value');
252233

253-
// Pass directLoginOptions to skip embeddedLoginPrompt
254-
const loginPromise = (auth as any).loginWithPopup({
234+
const user = await (auth as any).loginWithPopup({
255235
directLoginMethod: 'google',
256236
marketingConsentStatus: 'opted_in',
257237
});
258238

259-
// Fast-forward time to complete authentication
260-
jest.advanceTimersByTime(150);
261-
262-
const user = await loginPromise;
263-
264239
expect(user).toBeDefined();
265240
expect(user.profile.sub).toBe('user-123');
266-
expect(mockPopupWindow.close).toHaveBeenCalled();
267-
});
268-
269-
it('rejects with error when popup is closed manually before authentication completes', async () => {
270-
// Simulate authentication that takes a long time
271-
mockUserManager.signinPopup.mockImplementation(() => new Promise((resolve) => {
272-
setTimeout(() => {
273-
resolve({
274-
id_token: 'token',
275-
access_token: 'access',
276-
refresh_token: 'refresh',
277-
expired: false,
278-
profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
279-
});
280-
}, 5000); // Takes 5 seconds
281-
}));
282-
283-
const auth = Object.create(Auth.prototype) as Auth;
284-
(auth as any).userManager = mockUserManager;
285-
(auth as any).config = {
286-
popupOverlayOptions: { disableHeadlessLoginPromptOverlay: true },
287-
};
288-
getDetailMock.mockReturnValue('runtime-id-value');
289-
290-
// Pass directLoginOptions to skip embeddedLoginPrompt
291-
const loginPromise = (auth as any).loginWithPopup({
292-
directLoginMethod: 'google',
293-
marketingConsentStatus: 'opted_in',
294-
});
295-
296-
// Simulate user closing popup after 1 second
297-
jest.advanceTimersByTime(1000);
298-
mockPopupWindow.closed = true;
299-
300-
// Advance time to trigger the polling check
301-
jest.advanceTimersByTime(500);
302-
303-
await expect(loginPromise).rejects.toThrow('Popup closed by user');
241+
expect(user.profile.email).toBe('test@example.com');
304242
});
305243

306-
it('does not reject when popup closes after authentication completes successfully', async () => {
244+
it('calls signinPopup with correct configuration', async () => {
307245
const mockOidcUser = {
308246
id_token: 'token',
309247
access_token: 'access',
@@ -317,12 +255,7 @@ describe('Auth', () => {
317255
passport: undefined,
318256
});
319257

320-
// Simulate successful authentication
321-
mockUserManager.signinPopup.mockImplementation(() => new Promise((resolve) => {
322-
setTimeout(() => {
323-
resolve(mockOidcUser);
324-
}, 100);
325-
}));
258+
mockUserManager.signinPopup.mockResolvedValue(mockOidcUser);
326259

327260
const auth = Object.create(Auth.prototype) as Auth;
328261
(auth as any).userManager = mockUserManager;
@@ -331,41 +264,24 @@ describe('Auth', () => {
331264
};
332265
getDetailMock.mockReturnValue('runtime-id-value');
333266

334-
// Pass directLoginOptions to skip embeddedLoginPrompt
335-
const loginPromise = (auth as any).loginWithPopup({
267+
await (auth as any).loginWithPopup({
336268
directLoginMethod: 'google',
337269
marketingConsentStatus: 'opted_in',
338270
});
339271

340-
// Complete authentication
341-
jest.advanceTimersByTime(150);
342-
343-
// Now close the popup AFTER auth completed
344-
mockPopupWindow.closed = true;
345-
346-
// Advance polling interval - should NOT reject
347-
jest.advanceTimersByTime(600);
348-
349-
const user = await loginPromise;
350-
expect(user).toBeDefined();
351-
expect(user.profile.sub).toBe('user-123');
272+
expect(mockUserManager.signinPopup).toHaveBeenCalledWith(
273+
expect.objectContaining({
274+
popupWindowTarget: 'test-popup-id',
275+
popupWindowFeatures: { width: 410, height: 450 },
276+
popupAbortOnClose: true,
277+
extraQueryParams: expect.any(Object),
278+
}),
279+
);
352280
});
353281

354-
it('stops polling after authentication completes', async () => {
355-
const mockOidcUser = {
356-
id_token: 'token',
357-
access_token: 'access',
358-
refresh_token: 'refresh',
359-
expired: false,
360-
profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
361-
};
362-
363-
(decodeJwtPayload as jest.Mock).mockReturnValue({
364-
username: 'username123',
365-
passport: undefined,
366-
});
367-
368-
mockUserManager.signinPopup.mockResolvedValue(mockOidcUser);
282+
it('rejects when signinPopup rejects', async () => {
283+
const error = new Error('Authentication failed');
284+
mockUserManager.signinPopup.mockRejectedValue(error);
369285

370286
const auth = Object.create(Auth.prototype) as Auth;
371287
(auth as any).userManager = mockUserManager;
@@ -374,26 +290,10 @@ describe('Auth', () => {
374290
};
375291
getDetailMock.mockReturnValue('runtime-id-value');
376292

377-
const setIntervalSpy = jest.spyOn(global, 'setInterval');
378-
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
379-
380-
// Pass directLoginOptions to skip embeddedLoginPrompt
381-
const loginPromise = (auth as any).loginWithPopup({
293+
await expect((auth as any).loginWithPopup({
382294
directLoginMethod: 'google',
383295
marketingConsentStatus: 'opted_in',
384-
});
385-
386-
// Let authentication complete
387-
await Promise.resolve();
388-
jest.runAllTimers();
389-
390-
await loginPromise;
391-
392-
// Verify that clearInterval was called (timer stopped)
393-
expect(clearIntervalSpy).toHaveBeenCalled();
394-
395-
setIntervalSpy.mockRestore();
396-
clearIntervalSpy.mockRestore();
296+
})).rejects.toThrow('Authentication failed');
397297
});
398298
});
399299
});

packages/auth/src/Auth.ts

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import { isAccessTokenExpiredOrExpiring } from './utils/token';
4141
import LoginPopupOverlay from './overlay/loginPopupOverlay';
4242
import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage';
4343

44-
const LOGIN_POPUP_CLOSED_POLLING_DURATION = 500;
4544
const formUrlEncodedHeaders = {
4645
'Content-Type': 'application/x-www-form-urlencoded',
4746
};
@@ -499,7 +498,7 @@ export class Auth {
499498
const signinPopup = async () => {
500499
const extraQueryParams = this.buildExtraQueryParams(directLoginOptionsToUse, imPassportTraceId);
501500

502-
const userPromise = this.userManager.signinPopup({
501+
return this.userManager.signinPopup({
503502
extraQueryParams,
504503
popupWindowFeatures: {
505504
width: 410,
@@ -509,41 +508,6 @@ export class Auth {
509508
// Enable oidc-client-ts native popup close detection (works for initial screen)
510509
popupAbortOnClose: true,
511510
});
512-
513-
// Additional polling workaround to detect popup closure during navigation
514-
// (e.g., when user navigates to third-party login, passwordless, or captcha screens)
515-
// This complements oidc-client-ts native detection which only checks once at start
516-
const popupRef = window.open('', popupWindowTarget);
517-
if (popupRef) {
518-
// Flag to track if authentication completed (success or failure)
519-
let authenticationCompleted = false;
520-
521-
// Create a promise that rejects when popup is closed
522-
const popupClosedPromise = new Promise<never>((_, reject) => {
523-
const timer = setInterval(() => {
524-
// Only reject if popup closed AND authentication hasn't completed yet
525-
if (popupRef.closed && !authenticationCompleted) {
526-
clearInterval(timer);
527-
reject(new Error('Popup closed by user'));
528-
}
529-
}, LOGIN_POPUP_CLOSED_POLLING_DURATION);
530-
531-
// Clean up timer when the user promise resolves/rejects
532-
userPromise.finally(() => {
533-
authenticationCompleted = true;
534-
clearInterval(timer);
535-
// Close popup if still open (e.g., auth completed but popup didn't auto-close)
536-
if (!popupRef.closed) {
537-
popupRef.close();
538-
}
539-
});
540-
});
541-
542-
// Race between user authentication and popup being closed
543-
return Promise.race([userPromise, popupClosedPromise]);
544-
}
545-
546-
return userPromise;
547511
};
548512

549513
return new Promise((resolve, reject) => {

0 commit comments

Comments
 (0)