packages/core: move out showLoginPopup from OAuthRequestApi to separate lib module + tweak and document payload format
This commit is contained in:
@@ -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<any>;
|
||||
|
||||
/**
|
||||
* A utility for showing login popups or similar things, and merging together multiple requests for
|
||||
* different scopes into one request that inclues all scopes.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<T>(options: AuthRequesterOptions<T>) {
|
||||
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<any> {
|
||||
// Working around linter complaints, can't remove options since we want correct mock types
|
||||
return options ? this.popupResult : this.popupResult;
|
||||
}
|
||||
}
|
||||
|
||||
-161
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import {
|
||||
OAuthRequestApi,
|
||||
LoginPopupOptions,
|
||||
PendingAuthRequest,
|
||||
AuthRequester,
|
||||
AuthRequesterOptions,
|
||||
@@ -90,64 +89,4 @@ export class OAuthRequestManager implements OAuthRequestApi {
|
||||
authRequest$(): Observable<PendingAuthRequest[]> {
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AuthSession> implements AuthHelper<AuthSession> {
|
||||
private readonly providerPath: string;
|
||||
private readonly environment: string;
|
||||
private readonly provider: AuthProvider;
|
||||
private readonly oauthRequestApi: OAuthRequestApi;
|
||||
private readonly authRequester: AuthRequester<AuthSession>;
|
||||
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
|
||||
|
||||
@@ -72,7 +72,6 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
|
||||
this.providerPath = providerPath;
|
||||
this.environment = environment;
|
||||
this.provider = provider;
|
||||
this.oauthRequestApi = oauthRequestApi;
|
||||
this.sessionTransform = sessionTransform;
|
||||
}
|
||||
|
||||
@@ -141,7 +140,7 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
|
||||
private async showPopup(scope: string): Promise<AuthSession> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<any> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user