Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
108 changes: 108 additions & 0 deletions __tests__/Auth0Client/loginWithPopup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,5 +786,113 @@ describe('Auth0Client', () => {
false
);
});

it('should close popup immediately by default', async () => {
const auth0 = setup();
const popup = {
location: { href: '' },
close: jest.fn()
};

await loginWithPopup(auth0, {}, { popup });

expect(popup.close).toHaveBeenCalled();
});

it('should close popup immediately when closePopup is true', async () => {
const auth0 = setup();
const popup = {
location: { href: '' },
close: jest.fn()
};

await loginWithPopup(auth0, {}, { popup, closePopup: true });

expect(popup.close).toHaveBeenCalled();
});

it('should delay popup close when closePopup is false', async () => {
const auth0 = setup();
const popup = {
location: { href: '' },
close: jest.fn()
};

let tokenRequestMade = false;
mockFetch.mockImplementationOnce(() => {
tokenRequestMade = true;
// At this point during token exchange, popup should NOT be closed yet
expect(popup.close).not.toHaveBeenCalled();

return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
id_token: TEST_ID_TOKEN,
refresh_token: TEST_REFRESH_TOKEN,
access_token: TEST_ACCESS_TOKEN,
expires_in: 86400
})
});
});

await loginWithPopup(auth0, {}, { popup, closePopup: false });

expect(tokenRequestMade).toBe(true);
// Popup should be closed AFTER token exchange
expect(popup.close).toHaveBeenCalled();
});

it('should close popup on state mismatch error regardless of closePopup setting', async () => {
const auth0 = setup();
const popup = {
location: { href: '' },
close: jest.fn()
};

let error;
try {
await loginWithPopup(
auth0,
{},
{ popup, closePopup: false },
{
authorize: {
response: {
state: 'other-state'
}
}
}
);
} catch (e) {
error = e;
}

expect(error).toBeDefined();
expect(error.message).toBe('Invalid state');
expect(popup.close).toHaveBeenCalled();
});

it('should close popup even if token exchange fails with closePopup false', async () => {
const auth0 = setup();
const popup = {
location: { href: '' },
close: jest.fn()
};

await expect(
loginWithPopup(
auth0,
{},
{ popup, closePopup: false },
{ token: { success: false } }
)
).rejects.toThrowError(
'HTTP error. Unable to fetch https://auth0_domain/oauth/token'
);

// Popup should still be closed in finally block
expect(popup.close).toHaveBeenCalled();
});
});
});
38 changes: 25 additions & 13 deletions src/Auth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,27 +429,39 @@ export class Auth0Client {
});

if (params.state !== codeResult.state) {
// Always close popup on state mismatch error, regardless of closePopup setting
if (config.popup) {
config.popup.close();
}
throw new GenericError('state_mismatch', 'Invalid state');
}

const organization =
options.authorizationParams?.organization ||
this.options.authorizationParams.organization;

await this._requestToken(
{
audience: params.audience,
scope: params.scope,
code_verifier: params.code_verifier,
grant_type: 'authorization_code',
code: codeResult.code as string,
redirect_uri: params.redirect_uri
},
{
nonceIn: params.nonce,
organization
try {
await this._requestToken(
{
audience: params.audience,
scope: params.scope,
code_verifier: params.code_verifier,
grant_type: 'authorization_code',
code: codeResult.code as string,
redirect_uri: params.redirect_uri
},
{
nonceIn: params.nonce,
organization
}
);
} finally {
// Close popup after token exchange completes if closePopup was set to false
// This ensures tokens are cached before the popup (and potentially the extension) closes
if (config.closePopup === false && config.popup) {
config.popup.close();
}
);
}
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,20 @@ export interface PopupConfigOptions {
* security restrictions around when popups can be invoked (e.g. from a user click event)
*/
popup?: any;

/**
* Controls when the popup window is closed during authentication.
*
* - `true` (default): Closes the popup immediately after receiving the authorization response
* - `false`: Keeps the popup open until after token exchange completes
*
* Setting this to `false` is useful when the popup closing would interrupt the authentication flow,
* such as in Chrome extensions where closing the popup too early can terminate the extension's
* service worker before the token exchange completes.
*
* @default true
*/
closePopup?: boolean;
}

export interface GetTokenSilentlyOptions {
Expand Down
7 changes: 6 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ export const runPopup = (config: PopupConfigOptions) => {
clearTimeout(timeoutId);
clearInterval(popupTimer);
window.removeEventListener('message', popupEventListener, false);
config.popup.close();

// Only close popup immediately if closePopup is not explicitly set to false
// When false, the popup will be closed after token exchange in Auth0Client
if (config.closePopup !== false) {
config.popup.close();
}

if (e.data.response.error) {
return reject(GenericError.fromPayload(e.data.response));
Expand Down