Skip to content

Commit 7012d57

Browse files
authored
fix(passport): ID-3950 Fix login calls not rejecting (#2687)
1 parent feda1f0 commit 7012d57

File tree

4 files changed

+215
-12
lines changed

4 files changed

+215
-12
lines changed

packages/passport/sdk-sample-app/src/components/Message.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function Message() {
2020
sx={{ width: '100%' }}
2121
position={{ x: 'right', y: 'top' }}
2222
>
23-
<Form>
23+
<Form style={{ width: 'inherit' }}>
2424
<Form.Group>
2525
<Form.Control
2626
as="textarea"

packages/passport/sdk/src/Passport.int.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,18 @@ describe('Passport', () => {
9999
const mockLoginWithOidc = jest.fn();
100100
const mockMagicRequest = jest.fn();
101101
const mockMagicUserIsLoggedIn = jest.fn();
102+
let originalWindowOpen: any;
102103

103104
beforeEach(() => {
104105
jest.resetAllMocks();
105106

107+
// Mock window.open to handle popup detection in authManager
108+
originalWindowOpen = window.open;
109+
window.open = jest.fn().mockReturnValue({
110+
closed: false,
111+
close: jest.fn(),
112+
});
113+
106114
mockMagicUserIsLoggedIn.mockResolvedValue(true);
107115
(UserManager as jest.Mock).mockImplementation(() => ({
108116
signinPopup: mockSigninPopup,
@@ -122,6 +130,8 @@ describe('Passport', () => {
122130

123131
afterEach(() => {
124132
resetMswHandlers();
133+
// Restore original window.open
134+
window.open = originalWindowOpen;
125135
});
126136

127137
afterAll(async () => {

packages/passport/sdk/src/authManager.test.ts

Lines changed: 146 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,6 @@ const zkEvmProfileData = {
8282
const mockErrorMsg = 'NONO';
8383

8484
describe('AuthManager', () => {
85-
afterEach(jest.resetAllMocks);
86-
8785
let authManager: AuthManager;
8886
let mockSigninPopup: jest.Mock;
8987
let mockSigninCallback: jest.Mock;
@@ -96,8 +94,16 @@ describe('AuthManager', () => {
9694
let mockOverlayAppend: jest.Mock;
9795
let mockOverlayRemove: jest.Mock;
9896
let mockRevokeTokens: jest.Mock;
97+
let originalWindowOpen: any;
9998

10099
beforeEach(() => {
100+
// Store original window.open and replace with mock globally
101+
originalWindowOpen = window.open;
102+
window.open = jest.fn().mockReturnValue({
103+
closed: false,
104+
close: jest.fn(),
105+
});
106+
101107
mockSigninPopup = jest.fn();
102108
mockSigninCallback = jest.fn();
103109
mockSigninRedirectCallback = jest.fn();
@@ -132,6 +138,12 @@ describe('AuthManager', () => {
132138
authManager = new AuthManager(getConfig());
133139
});
134140

141+
afterEach(() => {
142+
jest.resetAllMocks();
143+
// Restore original window.open
144+
window.open = originalWindowOpen;
145+
});
146+
135147
describe('constructor', () => {
136148
it('should initialise AuthManager with the correct default configuration', () => {
137149
const config = getConfig();
@@ -204,6 +216,90 @@ describe('AuthManager', () => {
204216
});
205217
});
206218

219+
describe('popup closed detection', () => {
220+
let mockPopupRef: any;
221+
let mockSetInterval: jest.SpyInstance;
222+
let mockClearInterval: jest.SpyInstance;
223+
224+
beforeEach(() => {
225+
mockPopupRef = {
226+
closed: false,
227+
close: jest.fn(),
228+
};
229+
230+
// Override the global mock for these tests
231+
(window.open as jest.Mock).mockReturnValue(mockPopupRef);
232+
233+
mockSetInterval = jest.spyOn(global, 'setInterval');
234+
mockClearInterval = jest.spyOn(global, 'clearInterval');
235+
});
236+
237+
afterEach(() => {
238+
mockSetInterval.mockRestore();
239+
mockClearInterval.mockRestore();
240+
});
241+
242+
it('should reject with "Popup closed by user" when popup is closed during authentication', async () => {
243+
// Mock signinPopup to return a promise that never resolves (simulating long authentication)
244+
const signinPromise = new Promise<any>(() => {
245+
// Promise never resolves to simulate ongoing authentication
246+
});
247+
mockSigninPopup.mockReturnValue(signinPromise);
248+
249+
// Start the login process
250+
const loginPromise = authManager.login();
251+
252+
// Wait for the polling to be set up
253+
await new Promise((resolve) => {
254+
setTimeout(resolve, 10);
255+
});
256+
257+
// Verify polling was set up
258+
expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 500);
259+
260+
// Get the polling function and simulate popup being closed
261+
const pollingCallback = mockSetInterval.mock.calls[0][0];
262+
mockPopupRef.closed = true;
263+
pollingCallback(); // Manually trigger the polling callback
264+
265+
// Expect the login to reject with popup closed error
266+
await expect(loginPromise).rejects.toThrow('Popup closed by user');
267+
});
268+
269+
it('should clean up timer when user authentication completes successfully', async () => {
270+
mockSigninPopup.mockResolvedValue(mockOidcUser);
271+
272+
await authManager.login();
273+
274+
// Verify timer was cleared
275+
expect(mockClearInterval).toHaveBeenCalled();
276+
// Verify popup was closed
277+
expect(mockPopupRef.close).toHaveBeenCalled();
278+
});
279+
280+
it('should fall back to original behavior when popup reference is not available', async () => {
281+
// Mock window.open to return null (popup blocked or failed)
282+
(window.open as jest.Mock).mockReturnValue(null);
283+
mockSigninPopup.mockResolvedValue(mockOidcUser);
284+
285+
const result = await authManager.login();
286+
287+
expect(result).toEqual(mockUser);
288+
// Should not set up polling when no popup reference
289+
expect(mockSetInterval).not.toHaveBeenCalled();
290+
});
291+
292+
it('should use correct polling duration constant', async () => {
293+
const neverResolvingPromise = new Promise(() => {});
294+
mockSigninPopup.mockReturnValue(neverResolvingPromise);
295+
296+
authManager.login().catch(() => {}); // Ignore rejection for this test
297+
298+
// Verify the polling interval uses the correct constant (500ms)
299+
expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 500);
300+
});
301+
});
302+
207303
describe('when the user has registered for imx', () => {
208304
it('should populate the imx object', async () => {
209305
mockSigninPopup.mockResolvedValue(mockOidcUser);
@@ -269,7 +365,9 @@ describe('AuthManager', () => {
269365
});
270366

271367
it('should throw the error if user is failed to login', async () => {
272-
mockSigninPopup.mockRejectedValue(new Error(mockErrorMsg));
368+
mockSigninPopup.mockImplementation(() => {
369+
throw new Error(mockErrorMsg);
370+
});
273371

274372
await expect(() => authManager.login()).rejects.toThrow(
275373
new PassportError(
@@ -282,7 +380,9 @@ describe('AuthManager', () => {
282380

283381
describe('when the popup is blocked', () => {
284382
beforeEach(() => {
285-
mockSigninPopup.mockRejectedValueOnce(new Error('Attempted to navigate on a disposed window'));
383+
mockSigninPopup.mockImplementationOnce(() => {
384+
throw new Error('Attempted to navigate on a disposed window');
385+
});
286386
});
287387

288388
it('should render the blocked popup overlay', async () => {
@@ -326,7 +426,9 @@ describe('AuthManager', () => {
326426

327427
describe('and the user closes the popup', () => {
328428
it('should throw an error', async () => {
329-
mockSigninPopup.mockRejectedValueOnce(new Error('Popup closed by user'));
429+
mockSigninPopup.mockImplementationOnce(() => {
430+
throw new Error('Popup closed by user');
431+
});
330432

331433
await expect(() => authManager.login()).rejects.toThrow(
332434
new Error('Popup closed by user'),
@@ -368,7 +470,9 @@ describe('AuthManager', () => {
368470

369471
describe('when getUser throws an error', () => {
370472
it('calls attempts to sign in the user using signinPopup', async () => {
371-
mockGetUser.mockRejectedValue(new Error(mockErrorMsg));
473+
mockGetUser.mockImplementation(() => {
474+
throw new Error(mockErrorMsg);
475+
});
372476
mockSigninPopup.mockReturnValue(mockOidcUser);
373477
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
374478

@@ -380,6 +484,30 @@ describe('AuthManager', () => {
380484
});
381485

382486
describe('loginCallback', () => {
487+
let mockSigninPopupCallback: jest.Mock;
488+
489+
beforeEach(() => {
490+
mockSigninPopupCallback = jest.fn();
491+
(UserManager as jest.Mock).mockImplementation((config) => ({
492+
signinPopup: mockSigninPopup,
493+
signinCallback: mockSigninCallback,
494+
signinPopupCallback: mockSigninPopupCallback,
495+
signinRedirectCallback: mockSigninRedirectCallback,
496+
signoutRedirect: mockSignoutRedirect,
497+
signoutSilent: mockSignoutSilent,
498+
getUser: mockGetUser,
499+
signinSilent: mockSigninSilent,
500+
storeUser: mockStoreUser,
501+
revokeTokens: mockRevokeTokens,
502+
settings: {
503+
metadata: {
504+
end_session_endpoint: config.metadata?.end_session_endpoint,
505+
},
506+
},
507+
}));
508+
authManager = new AuthManager(getConfig());
509+
});
510+
383511
it('should call login callback', async () => {
384512
await authManager.loginCallback();
385513

@@ -437,7 +565,9 @@ describe('AuthManager', () => {
437565
logoutMode: 'redirect',
438566
});
439567
const manager = new AuthManager(configuration);
440-
mockRevokeTokens.mockRejectedValue(new Error(mockErrorMsg));
568+
mockRevokeTokens.mockImplementation(() => {
569+
throw new Error(mockErrorMsg);
570+
});
441571

442572
await expect(() => manager.logout()).rejects.toThrow(
443573
new PassportError(
@@ -453,7 +583,9 @@ describe('AuthManager', () => {
453583
logoutMode: 'silent',
454584
});
455585
const manager = new AuthManager(configuration);
456-
mockRevokeTokens.mockRejectedValue(new Error(mockErrorMsg));
586+
mockRevokeTokens.mockImplementation(() => {
587+
throw new Error(mockErrorMsg);
588+
});
457589

458590
await expect(() => manager.logout()).rejects.toThrow(
459591
new PassportError(
@@ -471,7 +603,9 @@ describe('AuthManager', () => {
471603
});
472604
const manager = new AuthManager(configuration);
473605

474-
mockSignoutRedirect.mockRejectedValue(new Error(mockErrorMsg));
606+
mockSignoutRedirect.mockImplementation(() => {
607+
throw new Error(mockErrorMsg);
608+
});
475609

476610
await expect(() => manager.logout()).rejects.toThrow(
477611
new PassportError(
@@ -488,7 +622,9 @@ describe('AuthManager', () => {
488622
});
489623
const manager = new AuthManager(configuration);
490624

491-
mockSignoutSilent.mockRejectedValue(new Error(mockErrorMsg));
625+
mockSignoutSilent.mockImplementation(() => {
626+
throw new Error(mockErrorMsg);
627+
});
492628

493629
await expect(() => manager.logout()).rejects.toThrow(
494630
new PassportError(

packages/passport/sdk/src/authManager.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { PassportConfiguration } from './config';
3131
import Overlay from './overlay';
3232
import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage';
3333

34+
const LOGIN_POPUP_CLOSED_POLLING_DURATION = 500;
35+
3436
const formUrlEncodedHeader = {
3537
headers: {
3638
'Content-Type': 'application/x-www-form-urlencoded',
@@ -230,14 +232,44 @@ export default class AuthManager {
230232
const signinPopup = async () => {
231233
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions);
232234

233-
return this.userManager.signinPopup({
235+
const userPromise = this.userManager.signinPopup({
234236
extraQueryParams,
235237
popupWindowFeatures: {
236238
width: 410,
237239
height: 450,
238240
},
239241
popupWindowTarget,
240242
});
243+
244+
// ID-3950: https://github.com/authts/oidc-client-ts/issues/2043
245+
// The promise returned from `signinPopup` no longer rejects when the popup is closed.
246+
// We can prevent this from impacting consumers by obtaining a reference to the popup and rejecting the promise
247+
// that is returned by this method if the popup is closed by the user.
248+
249+
// Attempt to get a reference to the popup window
250+
const popupRef = window.open('', popupWindowTarget);
251+
if (popupRef) {
252+
// Create a promise that rejects when popup is closed
253+
const popupClosedPromise = new Promise<never>((_, reject) => {
254+
const timer = setInterval(() => {
255+
if (popupRef.closed) {
256+
clearInterval(timer);
257+
reject(new Error('Popup closed by user'));
258+
}
259+
}, LOGIN_POPUP_CLOSED_POLLING_DURATION);
260+
261+
// Clean up timer when the user promise resolves/rejects
262+
userPromise.finally(() => {
263+
clearInterval(timer);
264+
popupRef.close();
265+
});
266+
});
267+
268+
// Race between user authentication and popup being closed
269+
return Promise.race([userPromise, popupClosedPromise]);
270+
}
271+
272+
return userPromise;
241273
};
242274

243275
// This promise attempts to open the signin popup, and displays the blocked popup overlay if necessary.
@@ -301,8 +333,33 @@ export default class AuthManager {
301333
return user || this.login();
302334
}
303335

336+
private static shouldUseSigninPopupCallback(): boolean {
337+
// ID-3950: https://github.com/authts/oidc-client-ts/issues/2043
338+
// Detect when the login was initiated via a popup
339+
try {
340+
const urlParams = new URLSearchParams(window.location.search);
341+
const stateParam = urlParams.get('state');
342+
const localStorageKey = `oidc.${stateParam}`;
343+
344+
const localStorageValue = localStorage.getItem(localStorageKey);
345+
const loginState = JSON.parse(localStorageValue || '{}');
346+
347+
return loginState?.request_type === 'si:p';
348+
} catch (err) {
349+
return false;
350+
}
351+
}
352+
304353
public async loginCallback(): Promise<undefined | User> {
305354
return withPassportError<undefined | User>(async () => {
355+
// ID-3950: https://github.com/authts/oidc-client-ts/issues/2043
356+
// When using `signinPopup` to initiate a login, call the `signinPopupCallback` method and
357+
// set the `keepOpen` flag to `true`, as the `login` method is now responsible for closing the popup.
358+
// See the comment in the `login` method for more details.
359+
if (AuthManager.shouldUseSigninPopupCallback()) {
360+
await this.userManager.signinPopupCallback(undefined, true);
361+
return undefined;
362+
}
306363
const oidcUser = await this.userManager.signinCallback();
307364
if (!oidcUser) {
308365
return undefined;

0 commit comments

Comments
 (0)