From bf6c807d6d324053dc9d1bc2a9a0faeb58399d3c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 19 May 2020 14:56:59 +0200 Subject: [PATCH] packages/core: move out showLoginPopup from OAuthRequestApi to separate lib module + tweak and document payload format --- .../api/apis/definitions/OAuthRequestApi.ts | 40 ----- .../OAuthRequestManager/MockOAuthApi.test.ts | 12 +- .../OAuthRequestManager/MockOAuthApi.ts | 25 +-- .../OAuthRequestManager.test.ts | 161 ----------------- .../OAuthRequestManager.ts | 61 ------- .../lib/AuthHelper/AuthHelper.test.ts | 17 +- .../lib/AuthHelper/AuthHelper.ts | 5 +- .../implementations/lib/loginPopup.test.ts | 170 ++++++++++++++++++ .../apis/implementations/lib/loginPopup.ts | 127 +++++++++++++ 9 files changed, 321 insertions(+), 297 deletions(-) create mode 100644 packages/core/src/api/apis/implementations/lib/loginPopup.test.ts create mode 100644 packages/core/src/api/apis/implementations/lib/loginPopup.ts diff --git a/packages/core/src/api/apis/definitions/OAuthRequestApi.ts b/packages/core/src/api/apis/definitions/OAuthRequestApi.ts index 09e5f77718..a6441e9d1a 100644 --- a/packages/core/src/api/apis/definitions/OAuthRequestApi.ts +++ b/packages/core/src/api/apis/definitions/OAuthRequestApi.ts @@ -30,36 +30,6 @@ export type OAuthScopeLike = | string[] /** Array of individual scope strings */ | OAuthScopes; -/** - * Options used to open a login popup. - */ -export type LoginPopupOptions = { - /** - * The URL that the auth popup should point to - */ - url: string; - - /** - * The name of the popup, as in second argument to window.open - */ - name: string; - - /** - * The origin of the final popup page that will post a message to this window. - */ - origin: string; - - /** - * The width of the popup in pixels, defaults to 500 - */ - width?: number; - - /** - * The height of the popup in pixels, defaults to 700 - */ - height?: number; -}; - /** * Information about the auth provider that we're requesting a login towards. * @@ -139,16 +109,6 @@ export type PendingAuthRequest = { * Provides helpers for implemented OAuth login flows within Backstage. */ export type OAuthRequestApi = { - /** - * Show a popup pointing to a URL that starts an OAuth flow. - * - * The redirect handler of the flow should use postMessage to communicate back - * to the app window. - * - * The returned promise resolves to the contents of the message that was posted from the auth popup. - */ - showLoginPopup(options: LoginPopupOptions): Promise; - /** * A utility for showing login popups or similar things, and merging together multiple requests for * different scopes into one request that inclues all scopes. diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts index d6346c688b..61e466f200 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts @@ -20,12 +20,10 @@ import { BasicOAuthScopes } from './BasicOAuthScopes'; describe('MockOAuthApi', () => { it('should trigger all requests', async () => { - const popupResult = { is: 'done' }; - const mock = new MockOAuthApi(popupResult); + const authResult = { is: 'done' }; + const mock = new MockOAuthApi(); - const authHandler1 = jest - .fn() - .mockImplementation(() => mock.showLoginPopup()); + const authHandler1 = jest.fn().mockImplementation(() => authResult); const requester1 = mock.createAuthRequester({ provider: { icon: PowerIcon, title: 'Test' }, onAuthRequest: authHandler1, @@ -52,8 +50,8 @@ describe('MockOAuthApi', () => { await mock.triggerAll(); await expect(Promise.all(promises)).resolves.toEqual([ - popupResult, - popupResult, + authResult, + authResult, 'other', 'other', 'other', diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts index f52c50dbe5..5d539dca27 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts @@ -14,18 +14,12 @@ * limitations under the License. */ -import { - OAuthRequestApi, - AuthRequesterOptions, - LoginPopupOptions, -} from '../../definitions'; +import { OAuthRequestApi, AuthRequesterOptions } from '../../definitions'; import { OAuthRequestManager } from './OAuthRequestManager'; export default class MockOAuthApi implements OAuthRequestApi { private readonly real = new OAuthRequestManager(); - constructor(private readonly popupResult = {}) {} - createAuthRequester(options: AuthRequesterOptions) { return this.real.createAuthRequester(options); } @@ -37,10 +31,10 @@ export default class MockOAuthApi implements OAuthRequestApi { async triggerAll() { await Promise.resolve(); // Wait a tick to allow new requests to get forwarded - return new Promise((resolve) => { - const subscription = this.authRequest$().subscribe((requests) => { + return new Promise(resolve => { + const subscription = this.authRequest$().subscribe(requests => { subscription.unsubscribe(); - Promise.all(requests.map((request) => request.trigger())).then(() => + Promise.all(requests.map(request => request.trigger())).then(() => resolve(), ); }); @@ -50,17 +44,12 @@ export default class MockOAuthApi implements OAuthRequestApi { async rejectAll() { await Promise.resolve(); // Wait a tick to allow new requests to get forwarded - return new Promise((resolve) => { - const subscription = this.authRequest$().subscribe((requests) => { + return new Promise(resolve => { + const subscription = this.authRequest$().subscribe(requests => { subscription.unsubscribe(); - requests.map((request) => request.reject()); + requests.map(request => request.reject()); resolve(); }); }); } - - async showLoginPopup(options?: LoginPopupOptions): Promise { - // Working around linter complaints, can't remove options since we want correct mock types - return options ? this.popupResult : this.popupResult; - } } diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts index 115f2b2c95..922cb20a25 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts @@ -58,164 +58,3 @@ describe('OAuthRequestManager', () => { await expect(req).resolves.toBe('hello'); }); }); - -describe('OAuthApi login popup', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should show an auth popup', async () => { - const oauth = new OAuthRequestManager(); - - const popupMock = { closed: false }; - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue(popupMock as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const payloadPromise = oauth.showLoginPopup({ - url: - 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', - name: 'test-popup', - origin: 'my-origin', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(openSpy.mock.calls[0][0]).toBe( - 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', - ); - expect(openSpy.mock.calls[0][1]).toBe('test-popup'); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - - await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( - 'waiting', - ); - - listener({} as MessageEvent); - - await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( - 'waiting', - ); - - // None of these should be accepted - listener({ source: popupMock } as MessageEvent); - listener({ origin: 'my-origin' } as MessageEvent); - listener({ data: { type: 'oauth-result' } } as MessageEvent); - listener({ - source: popupMock, - origin: 'my-origin', - data: {}, - } as MessageEvent); - listener({ - source: popupMock, - origin: 'my-origin', - data: { type: 'not-oauth-result', payload: {} }, - } as MessageEvent); - - await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( - 'waiting', - ); - - const myPayload = {}; - - // This should be accepted as a valid sessions response - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'oauth-result', - payload: myPayload, - }, - } as MessageEvent); - - await expect(payloadPromise).resolves.toBe(myPayload); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should fail if popup returns error', async () => { - const oauth = new OAuthRequestManager(); - - const popupMock = { closed: false }; - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue(popupMock as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const payloadPromise = oauth.showLoginPopup({ - url: 'url', - name: 'name', - origin: 'my-origin', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'oauth-result', - payload: { - error: { - message: 'NOPE', - name: 'NopeError', - }, - }, - }, - } as MessageEvent); - - await expect(payloadPromise).rejects.toThrow({ - name: 'NopeError', - message: 'NOPE', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should fail if popup is closed', async () => { - const oauth = new OAuthRequestManager(); - - const openSpy = jest - .spyOn(window, 'open') - .mockReturnValue({ closed: false } as Window); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const popupMock = { closed: false }; - - openSpy.mockReturnValue(popupMock as Window); - - const payloadPromise = oauth.showLoginPopup({ - url: 'url', - name: 'name', - origin: 'origin', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - setTimeout(() => { - popupMock.closed = true; - }, 150); - await expect(payloadPromise).rejects.toThrow( - 'Login failed, popup was closed', - ); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); -}); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts index 632ec059eb..8b3b2d94c2 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts @@ -16,7 +16,6 @@ import { OAuthRequestApi, - LoginPopupOptions, PendingAuthRequest, AuthRequester, AuthRequesterOptions, @@ -90,64 +89,4 @@ export class OAuthRequestManager implements OAuthRequestApi { authRequest$(): Observable { return this.subject; } - - async showLoginPopup(options: LoginPopupOptions): Promise { - return new Promise((resolve, reject) => { - const width = options.width || 500; - const height = options.height || 700; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; - - const popup = window.open( - options.url, - options.name, - `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, - ); - - if (!popup || typeof popup.closed === 'undefined' || popup.closed) { - reject(new Error('Failed to open auth popup.')); - return; - } - - const messageListener = (event: MessageEvent) => { - if (event.source !== popup) { - return; - } - if (event.origin !== options.origin) { - return; - } - const { data } = event; - if (data.type !== 'oauth-result') { - return; - } - - if (data.payload.error) { - const error = new Error(data.payload.error.message); - error.name = data.payload.error.name; - // TODO: proper error type - // error.extra = data.payload.error.extra; - reject(error); - } else { - resolve(data.payload); - } - done(); - }; - - const intervalId = setInterval(() => { - if (popup.closed) { - const error = new Error('Login failed, popup was closed'); - error.name = 'PopupClosedError'; - reject(error); - done(); - } - }, 100); - - function done() { - window.removeEventListener('message', messageListener); - clearInterval(intervalId); - } - - window.addEventListener('message', messageListener); - }); - } } diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts index 713a5e7d05..5ca95cb600 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts @@ -18,6 +18,7 @@ import ProviderIcon from '@material-ui/icons/AcUnit'; import { AuthHelper } from './AuthHelper'; import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi'; import { BasicOAuthScopes } from '../../OAuthRequestManager/BasicOAuthScopes'; +import * as loginPopup from '../loginPopup'; const anyFetch = fetch as any; @@ -92,13 +93,15 @@ describe('AuthHelper', () => { }); it('should create a session', async () => { - const mockOauth = new MockOAuthApi({ - idToken: 'my-id-token', - accessToken: 'my-access-token', - scopes: 'a b', - expiresInSeconds: 3600, - }); - const popupSpy = jest.spyOn(mockOauth, 'showLoginPopup'); + const mockOauth = new MockOAuthApi(); + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: 'a b', + expiresInSeconds: 3600, + }); const helper = new AuthHelper({ ...defaultOptions, oauthRequestApi: mockOauth, diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts index d42055d332..dc79641e70 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts @@ -20,6 +20,7 @@ import { AuthProvider, OAuthScopes, } from '../../../definitions'; +import { showLoginPopup } from '../loginPopup'; const DEFAULT_BASE_PATH = '/api/auth/'; @@ -45,7 +46,6 @@ export class AuthHelper implements AuthHelper { private readonly providerPath: string; private readonly environment: string; private readonly provider: AuthProvider; - private readonly oauthRequestApi: OAuthRequestApi; private readonly authRequester: AuthRequester; private readonly sessionTransform: (response: any) => Promise; @@ -72,7 +72,6 @@ export class AuthHelper implements AuthHelper { this.providerPath = providerPath; this.environment = environment; this.provider = provider; - this.oauthRequestApi = oauthRequestApi; this.sessionTransform = sessionTransform; } @@ -141,7 +140,7 @@ export class AuthHelper implements AuthHelper { private async showPopup(scope: string): Promise { const popupUrl = this.buildUrl('/start', { scope }); - const payload = await this.oauthRequestApi.showLoginPopup({ + const payload = await showLoginPopup({ url: popupUrl, name: `${this.provider.title} Login`, origin: this.apiOrigin, diff --git a/packages/core/src/api/apis/implementations/lib/loginPopup.test.ts b/packages/core/src/api/apis/implementations/lib/loginPopup.test.ts new file mode 100644 index 0000000000..755438fead --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/loginPopup.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { showLoginPopup } from './loginPopup'; + +describe('showLoginPopup', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should show an auth popup', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + name: 'test-popup', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe( + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + ); + expect(openSpy.mock.calls[0][1]).toBe('test-popup'); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + listener({} as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + // None of these should be accepted + listener({ source: popupMock } as MessageEvent); + listener({ origin: 'my-origin' } as MessageEvent); + listener({ data: { type: 'auth-result' } } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: {}, + } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: { type: 'not-auth-result', payload: {} }, + } as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + const myPayload = {}; + + // This should be accepted as a valid sessions response + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'auth-result', + payload: myPayload, + }, + } as MessageEvent); + + await expect(payloadPromise).resolves.toBe(myPayload); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup returns error', async () => { + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'auth-result', + error: { + message: 'NOPE', + name: 'NopeError', + }, + }, + } as MessageEvent); + + await expect(payloadPromise).rejects.toThrow({ + name: 'NopeError', + message: 'NOPE', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup is closed', async () => { + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, popup was closed', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/loginPopup.ts b/packages/core/src/api/apis/implementations/lib/loginPopup.ts new file mode 100644 index 0000000000..b4af94be49 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/loginPopup.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Options used to open a login popup. + */ +export type LoginPopupOptions = { + /** + * The URL that the auth popup should point to + */ + url: string; + + /** + * The name of the popup, as in second argument to window.open + */ + name: string; + + /** + * The origin of the final popup page that will post a message to this window. + */ + origin: string; + + /** + * The width of the popup in pixels, defaults to 500 + */ + width?: number; + + /** + * The height of the popup in pixels, defaults to 700 + */ + height?: number; +}; + +type AuthResult = + | { + type: 'auth-result'; + payload: any; + } + | { + type: 'auth-result'; + error: { + name: string; + message: string; + }; + }; + +/** + * Show a popup pointing to a URL that starts an auth flow. + * + * The redirect handler of the flow should use postMessage to communicate back + * to the app window. The message posted to the app must match the AuthResult type. + * + * The returned promise resolves to the contents of the message that was posted from the auth popup. + */ +export function showLoginPopup(options: LoginPopupOptions): Promise { + return new Promise((resolve, reject) => { + const width = options.width || 500; + const height = options.height || 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + options.url, + options.name, + `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, + ); + + if (!popup || typeof popup.closed === 'undefined' || popup.closed) { + reject(new Error('Failed to open auth popup.')); + return; + } + + const messageListener = (event: MessageEvent) => { + if (event.source !== popup) { + return; + } + if (event.origin !== options.origin) { + return; + } + const { data } = event; + if (data.type !== 'auth-result') { + return; + } + const authResult = data as AuthResult; + + if ('error' in authResult) { + const error = new Error(authResult.error.message); + error.name = authResult.error.name; + // TODO: proper error type + // error.extra = authResult.error.extra; + reject(error); + } else { + resolve(authResult.payload); + } + done(); + }; + + const intervalId = setInterval(() => { + if (popup.closed) { + const error = new Error('Login failed, popup was closed'); + error.name = 'PopupClosedError'; + reject(error); + done(); + } + }, 100); + + function done() { + window.removeEventListener('message', messageListener); + clearInterval(intervalId); + } + + window.addEventListener('message', messageListener); + }); +}