diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index fc2e876468..e29fac8865 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApp } from '@backstage/core'; +import { createApp, OAuthRequestDialog } from '@backstage/core'; import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import Root from './components/Root'; @@ -33,6 +33,7 @@ const AppComponent = app.getRootComponent(); const App: FC<{}> = () => ( + diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index c3380215b3..c11ea24aca 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -23,6 +23,10 @@ import { ErrorApiForwarder, featureFlagsApiRef, FeatureFlags, + GoogleAuth, + oauthRequestApiRef, + OAuthRequestManager, + googleAuthApiRef, } from '@backstage/core'; import { @@ -46,6 +50,20 @@ builder.add(featureFlagsApiRef, new FeatureFlags()); builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); +const oauthRequestApi = builder.add( + oauthRequestApiRef, + new OAuthRequestManager(), +); + +builder.add( + googleAuthApiRef, + GoogleAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + builder.add( techRadarApiRef, new TechRadar({ diff --git a/packages/core/package.json b/packages/core/package.json index de0b37e137..50a5fa7cae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,8 @@ "@backstage/test-utils-core": "^0.1.1-alpha.6", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", - "@testing-library/user-event": "^10.2.4" + "@testing-library/user-event": "^10.2.4", + "jest-fetch-mock": "^3.0.3" }, "files": [ "dist/**/*.{js,d.ts}" diff --git a/packages/core/src/api/apis/definitions/OAuthRequestApi.ts b/packages/core/src/api/apis/definitions/OAuthRequestApi.ts index 09e5f77718..52d30a763e 100644 --- a/packages/core/src/api/apis/definitions/OAuthRequestApi.ts +++ b/packages/core/src/api/apis/definitions/OAuthRequestApi.ts @@ -18,48 +18,6 @@ import { IconComponent } from '../../../icons'; import { Observable } from '../../types'; import { ApiRef } from '../ApiRef'; -export type OAuthScopes = { - extend(scopes: OAuthScopeLike): OAuthScopes; - hasScopes(scopes: OAuthScopeLike): boolean; - toSet(): Set; - toString(): string; -}; - -export type OAuthScopeLike = - | string /** Space separated scope strings */ - | 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. * @@ -92,7 +50,7 @@ export type AuthRequesterOptions = { * Implementation of the auth flow, which will be called synchronously when * trigger() is called on an auth requests. */ - onAuthRequest(scope: OAuthScopes): Promise; + onAuthRequest(scopes: Set): Promise; }; /** @@ -106,7 +64,7 @@ export type AuthRequesterOptions = { * union of all requested scopes. */ export type AuthRequester = ( - scope: OAuthScopes, + scopes: Set, ) => Promise; /** @@ -139,16 +97,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/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts index 2bb365b2ab..2e008965cd 100644 --- a/packages/core/src/api/apis/definitions/index.ts +++ b/packages/core/src/api/apis/definitions/index.ts @@ -20,6 +20,8 @@ // // If you think some API definition is missing, please open an Issue or send a PR! +export * from './auth'; + export * from './AlertApi'; export * from './AppThemeApi'; export * from './ErrorApi'; diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts deleted file mode 100644 index fb022c5385..0000000000 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 { OAuthScopes, OAuthScopeLike } from '../../definitions'; - -/** - * The BasicOAuthScopes class is an implementation of OAuthScopes that - * works for any simple comma- or space-separated format of scope. - */ -export class BasicOAuthScopes implements OAuthScopes { - static from(scopes: OAuthScopeLike, normalizer?: (scope: string) => string) { - const normalized = BasicOAuthScopes.asStrings(scopes, normalizer); - return new BasicOAuthScopes(new Set(normalized), normalizer); - } - - constructor( - private readonly scopes: Set, - private readonly normalizer?: (scope: string) => string, - ) {} - - extend(requestedScopes: OAuthScopeLike): BasicOAuthScopes { - const newScopes = new Set(this.scopes); - BasicOAuthScopes.asStrings(requestedScopes, this.normalizer).forEach((s) => - newScopes.add(s), - ); - return new BasicOAuthScopes(newScopes, this.normalizer); - } - - hasScopes(scopes: OAuthScopeLike): boolean { - return BasicOAuthScopes.asStrings(scopes, this.normalizer).every((s) => - this.scopes.has(s), - ); - } - - toSet(): Set { - return this.scopes; - } - - toString(): string { - return Array.from(this.scopes).join(' '); - } - - toJSON() { - return Array.from(this.scopes); - } - - static asStrings( - input: OAuthScopeLike, - normalizer?: (scope: string) => string, - ): string[] { - let scopeArray: string[]; - if (typeof input === 'string') { - scopeArray = input.split(/[,\s]/).filter(Boolean); - } else if (Array.isArray(input)) { - scopeArray = input; - } else { - scopeArray = Array.from(input.toSet()); - } - if (normalizer) { - scopeArray = scopeArray.map((x) => normalizer(x)); - } - return scopeArray; - } -} diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts new file mode 100644 index 0000000000..32170acc6f --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts @@ -0,0 +1,101 @@ +/* + * 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 MockOAuthApi from './MockOAuthApi'; +import PowerIcon from '@material-ui/icons/Power'; + +describe('MockOAuthApi', () => { + it('should trigger all requests', async () => { + const authResult = { is: 'done' }; + const mock = new MockOAuthApi(); + + const authHandler1 = jest.fn().mockImplementation(() => authResult); + const requester1 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler1, + }); + + const authHandler2 = jest.fn().mockResolvedValue('other'); + const requester2 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler2, + }); + + const promises = [ + requester1(new Set(['a'])), + requester1(new Set(['b'])), + requester2(new Set(['a', 'b'])), + requester2(new Set(['b', 'c'])), + requester2(new Set(['c', 'a'])), + ]; + + await expect( + Promise.race([Promise.all(promises), 'waiting']), + ).resolves.toBe('waiting'); + + await mock.triggerAll(); + + await expect(Promise.all(promises)).resolves.toEqual([ + authResult, + authResult, + 'other', + 'other', + 'other', + ]); + + expect(authHandler1).toHaveBeenCalledTimes(1); + expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b'])); + expect(authHandler2).toHaveBeenCalledTimes(1); + expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c'])); + }); + + it('should reject all requests', async () => { + const mock = new MockOAuthApi(); + + const authHandler1 = jest.fn(); + const requester1 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler1, + }); + + const authHandler2 = jest.fn(); + const requester2 = mock.createAuthRequester({ + provider: { icon: PowerIcon, title: 'Test' }, + onAuthRequest: authHandler2, + }); + + const promises = [ + requester1(new Set(['a'])), + requester1(new Set(['b'])), + requester2(new Set(['a', 'b'])), + requester2(new Set(['b', 'c'])), + requester2(new Set(['c', 'a'])), + ]; + + await expect( + Promise.race([Promise.all(promises), 'waiting']), + ).resolves.toBe('waiting'); + + await mock.rejectAll(); + + for (const promise of promises) { + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + } + + expect(authHandler1).not.toHaveBeenCalled(); + expect(authHandler2).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts new file mode 100644 index 0000000000..5d539dca27 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts @@ -0,0 +1,55 @@ +/* + * 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 { OAuthRequestApi, AuthRequesterOptions } from '../../definitions'; +import { OAuthRequestManager } from './OAuthRequestManager'; + +export default class MockOAuthApi implements OAuthRequestApi { + private readonly real = new OAuthRequestManager(); + + createAuthRequester(options: AuthRequesterOptions) { + return this.real.createAuthRequester(options); + } + + authRequest$() { + return this.real.authRequest$(); + } + + 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 => { + subscription.unsubscribe(); + Promise.all(requests.map(request => request.trigger())).then(() => + resolve(), + ); + }); + }); + } + + 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 => { + subscription.unsubscribe(); + requests.map(request => request.reject()); + resolve(); + }); + }); + } +} diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts index be762ca936..36378798d4 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts @@ -16,7 +16,6 @@ import { wait } from '@testing-library/react'; import { OAuthPendingRequests } from './OAuthPendingRequests'; -import { BasicOAuthScopes } from './BasicOAuthScopes'; describe('OAuthPendingRequests', () => { it('notifies new observers about current state', async () => { @@ -24,7 +23,7 @@ describe('OAuthPendingRequests', () => { const next = jest.fn(); const error = jest.fn(); - const input = BasicOAuthScopes.from('a b'); + const input = new Set(['a', 'b']); target.pending().subscribe({ next, error }); target.request(input); @@ -39,11 +38,11 @@ describe('OAuthPendingRequests', () => { const next = jest.fn(); const error = jest.fn(); - const request1 = target.request(BasicOAuthScopes.from('a')); - const request2 = target.request(BasicOAuthScopes.from('a')); + const request1 = target.request(new Set(['a'])); + const request2 = target.request(new Set(['a'])); target.pending().subscribe({ next, error }); - target.resolve(BasicOAuthScopes.from('a'), 'session1'); - target.resolve(BasicOAuthScopes.from('a'), 'session2'); + target.resolve(new Set(['a']), 'session1'); + target.resolve(new Set(['a']), 'session2'); await expect(request1).resolves.toBe('session1'); await expect(request2).resolves.toBe('session1'); @@ -53,10 +52,10 @@ describe('OAuthPendingRequests', () => { it('can resolve through the observable', async () => { const target = new OAuthPendingRequests(); - const next = jest.fn((pendingRequest) => pendingRequest.resolve('done')); + const next = jest.fn(pendingRequest => pendingRequest.resolve('done')); const error = jest.fn(); - const request1 = target.request(BasicOAuthScopes.from('a')); + const request1 = target.request(new Set(['a'])); target.pending().subscribe({ next, error }); await expect(request1).resolves.toBe('done'); @@ -70,11 +69,11 @@ describe('OAuthPendingRequests', () => { const error = jest.fn(); const rejection = new Error('eek'); - const request1 = target.request(BasicOAuthScopes.from('a')); - const request2 = target.request(BasicOAuthScopes.from('a')); + const request1 = target.request(new Set(['a'])); + const request2 = target.request(new Set(['a'])); target.pending().subscribe({ next, error }); target.reject(rejection); - target.resolve(BasicOAuthScopes.from('a'), 'session'); + target.resolve(new Set(['a']), 'session'); await expect(request1).rejects.toBe(rejection); await expect(request2).rejects.toBe(rejection); @@ -85,10 +84,10 @@ describe('OAuthPendingRequests', () => { it('can reject through the observable', async () => { const target = new OAuthPendingRequests(); const rejection = new Error('nope'); - const next = jest.fn((pendingRequest) => pendingRequest.reject(rejection)); + const next = jest.fn(pendingRequest => pendingRequest.reject(rejection)); const error = jest.fn(); - const request1 = target.request(BasicOAuthScopes.from('a')); + const request1 = target.request(new Set(['a'])); target.pending().subscribe({ next, error }); await expect(request1).rejects.toBe(rejection); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts index e8dd0e12ef..aa5609b1e9 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts @@ -14,22 +14,48 @@ * limitations under the License. */ -import { OAuthScopes } from '../../definitions'; import { BehaviorSubject } from '../lib'; import { Observable } from '../../../types'; type RequestQueueEntry = { - scopes: OAuthScopes; + scopes: Set; resolve: (value?: ResultType | PromiseLike | undefined) => void; reject: (reason: Error) => void; }; export type PendingRequest = { - scopes: OAuthScopes | undefined; + scopes: Set | undefined; resolve: (value: ResultType) => void; reject: (reason: Error) => void; }; +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +export function joinScopes( + scopes: Set, + ...moreScopess: Set[] +): Set { + const result = new Set(scopes); + + for (const moreScopes of moreScopess) { + for (const scope of moreScopes) { + result.add(scope); + } + } + + return result; +} + /** * The OAuthPendingRequests class is a utility for managing and observing * a stream of requests for oauth scopes for a single provider, and resolving @@ -41,7 +67,7 @@ export class OAuthPendingRequests { this.getCurrentPending(), ); - request(scopes: OAuthScopes): Promise { + request(scopes: Set): Promise { return new Promise((resolve, reject) => { this.requests.push({ scopes, resolve, reject }); @@ -49,9 +75,9 @@ export class OAuthPendingRequests { }); } - resolve(scopes: OAuthScopes, result: ResultType): void { - this.requests = this.requests.filter((request) => { - if (scopes.hasScopes(request.scopes)) { + resolve(scopes: Set, result: ResultType): void { + this.requests = this.requests.filter(request => { + if (hasScopes(scopes, request.scopes)) { request.resolve(result); return false; } @@ -62,7 +88,7 @@ export class OAuthPendingRequests { } reject(error: Error) { - this.requests.forEach((request) => request.reject(error)); + this.requests.forEach(request => request.reject(error)); this.requests = []; this.subject.next(this.getCurrentPending()); @@ -79,7 +105,7 @@ export class OAuthPendingRequests { : this.requests .slice(1) .reduce( - (acc, current) => acc.extend(current.scopes), + (acc, current) => joinScopes(acc, current.scopes), this.requests[0].scopes, ); 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 940c0230f9..46a5362f35 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts @@ -14,165 +14,46 @@ * limitations under the License. */ +import ProviderIcon from '@material-ui/icons/AcUnit'; import { OAuthRequestManager } from './OAuthRequestManager'; -describe('OAuthApi login popup', () => { - afterEach(() => { - jest.resetAllMocks(); - }); +describe('OAuthRequestManager', () => { + it('should forward a requests', async () => { + const manager = new OAuthRequestManager(); - it('should show an auth popup', async () => { - const oauth = new OAuthRequestManager(); + const reqSpy = jest.fn(); + manager.authRequest$().subscribe(reqSpy); - 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, + const requester = manager.createAuthRequester({ + provider: { + title: 'My Provider', + icon: ProviderIcon, }, - } 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', + onAuthRequest: async () => 'hello', }); - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); + expect(reqSpy).toHaveBeenCalledTimes(0); + await 'a tick'; + expect(reqSpy).toHaveBeenCalledTimes(2); + expect(reqSpy).toHaveBeenLastCalledWith([]); - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + const req = requester(new Set(['my-scope'])); - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'oauth-result', - payload: { - error: { - message: 'NOPE', - name: 'NopeError', - }, - }, - }, - } as MessageEvent); + expect(reqSpy).toHaveBeenCalledTimes(3); + expect(reqSpy).toHaveBeenLastCalledWith([ + expect.objectContaining({ + reject: expect.any(Function), + trigger: expect.any(Function), + }), + ]); - 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', + await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe( + 'not yet', ); - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); + const [request] = reqSpy.mock.calls[2][0]; + request.trigger(); + + await expect(req).resolves.toBe('hello'); }); }); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts index 632ec059eb..1168d6abd4 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, @@ -44,7 +43,7 @@ export class OAuthRequestManager implements OAuthRequestApi { this.handlerCount++; handler.pending().subscribe({ - next: (scopeRequest) => { + next: scopeRequest => { const newRequests = this.currentRequests.slice(); const request = this.makeAuthRequest(scopeRequest, options); if (!request) { @@ -58,7 +57,7 @@ export class OAuthRequestManager implements OAuthRequestApi { }, }); - return (scopes) => { + return scopes => { return handler.request(scopes); }; } @@ -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/auth/google/GoogleAuth.test.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts new file mode 100644 index 0000000000..1ee3575b89 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts @@ -0,0 +1,135 @@ +/* + * 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 GoogleAuth from './GoogleAuth'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +const PREFIX = 'https://www.googleapis.com/auth/'; + +describe('GoogleAuth', () => { + it('should get refreshed access token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture }); + const googleAuth = new GoogleAuth({ getSession } as any); + + expect(await googleAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get refreshed id token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); + const googleAuth = new GoogleAuth({ getSession } as any); + + expect(await googleAuth.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); + const googleAuth = new GoogleAuth({ getSession } as any); + + expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`${PREFIX}not-enough`]), + }) + .mockRejectedValue(error); + const googleAuth = new GoogleAuth({ getSession } as any); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(googleAuth.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = googleAuth.getAccessToken('more'); + const promise2 = googleAuth.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }); + const googleAuth = new GoogleAuth({ getSession } as any); + + // Grab the expired session first + await expect(googleAuth.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.expiresAt = thePast; + + const promise1 = googleAuth.getIdToken(); + const promise2 = googleAuth.getIdToken(); + const promise3 = googleAuth.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); + + it.each([ + ['email', [`${PREFIX}userinfo.email`]], + ['profile', [`${PREFIX}userinfo.profile`]], + ['openid', ['openid']], + ['userinfo.email', [`${PREFIX}userinfo.email`]], + [ + 'userinfo.profile email', + [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], + ], + [ + `profile ${PREFIX}userinfo.email`, + [`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`], + ], + [`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]], + ['a', [`${PREFIX}a`]], + ['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]], + [`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]], + [`${PREFIX}a`, [`${PREFIX}a`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + expect(GoogleAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + }); +}); diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts new file mode 100644 index 0000000000..d1d5da1a02 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts @@ -0,0 +1,149 @@ +/* + * 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 GoogleIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../lib/AuthConnector'; +import { GoogleSession } from './types'; +import { + OAuthApi, + OpenIdConnectApi, + IdTokenOptions, +} from '../../../definitions/auth'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../lib/AuthSessionManager'; + +type CreateOptions = { + // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type GoogleAuthResponse = { + accessToken: string; + idToken: string; + scopes: string; + expiresInSeconds: number; +}; + +const DEFAULT_PROVIDER = { + id: 'google', + title: 'Google', + icon: GoogleIcon, +}; + +const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; + +class GoogleAuth implements OAuthApi, OpenIdConnectApi { + static create({ + apiOrigin, + basePath, + environment = 'dev', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: GoogleAuthResponse): GoogleSession { + return { + idToken: res.idToken, + accessToken: res.accessToken, + scopes: GoogleAuth.normalizeScopes(res.scopes), + expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000), + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ]), + sessionScopes: session => session.scopes, + sessionShouldRefresh: session => { + const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new GoogleAuth(sessionManager); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken(scope?: string | string[]) { + const normalizedScopes = GoogleAuth.normalizeScopes(scope); + const session = await this.sessionManager.getSession({ + optional: false, + scope: normalizedScopes, + }); + return session.accessToken; + } + + async getIdToken({ optional }: IdTokenOptions = {}) { + const session = await this.sessionManager.getSession({ + optional: optional || false, + }); + if (session) { + return session.idToken; + } + return ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s]/).filter(Boolean); + + const normalizedScopes = scopeList.map(scope => { + if (scope === 'openid') { + return scope; + } + + if (scope === 'profile' || scope === 'email') { + return `${SCOPE_PREFIX}userinfo.${scope}`; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + + return new Set(normalizedScopes); + } +} +export default GoogleAuth; diff --git a/packages/core/src/api/apis/implementations/auth/google/index.ts b/packages/core/src/api/apis/implementations/auth/google/index.ts new file mode 100644 index 0000000000..78e8a97c31 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from './types'; +export { default as GoogleAuth } from './GoogleAuth'; diff --git a/packages/core/src/api/apis/implementations/auth/google/types.ts b/packages/core/src/api/apis/implementations/auth/google/types.ts new file mode 100644 index 0000000000..96c69c5d4f --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/types.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export type GoogleSession = { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; +}; diff --git a/packages/core/src/api/apis/implementations/auth/index.ts b/packages/core/src/api/apis/implementations/auth/index.ts new file mode 100644 index 0000000000..5fa6644b2a --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './google'; diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core/src/api/apis/implementations/index.ts index 8334b068fc..0bf5cbbb24 100644 --- a/packages/core/src/api/apis/implementations/index.ts +++ b/packages/core/src/api/apis/implementations/index.ts @@ -18,6 +18,7 @@ // // Plugins should rely on these APIs for functionality as much as possible. +export * from './auth'; export * from './AppThemeSelector'; export * from './AlertApiForwarder'; export * from './ErrorApiForwarder'; diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.test.ts new file mode 100644 index 0000000000..f3698b4e15 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -0,0 +1,146 @@ +/* + * 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 ProviderIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from './DefaultAuthConnector'; +import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi'; +import * as loginPopup from '../loginPopup'; + +const anyFetch = fetch as any; + +const defaultOptions = { + apiOrigin: 'my-origin', + environment: 'production', + provider: { + id: 'my-provider', + title: 'My Provider', + icon: ProviderIcon, + }, + oauthRequestApi: new MockOAuthApi(), + sessionTransform: ({ expiresInSeconds, ...res }: any) => ({ + ...res, + scopes: new Set(res.scopes.split(' ')), + expiresAt: new Date(Date.now() + expiresInSeconds * 1000), + }), +}; + +describe('DefaultAuthConnector', () => { + afterEach(() => { + jest.resetAllMocks(); + anyFetch.resetMocks(); + }); + + it('should refresh a session', async () => { + anyFetch.mockResponseOnce( + JSON.stringify({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', + scopes: 'a b c', + expiresInSeconds: '60', + }), + ); + + const helper = new DefaultAuthConnector(defaultOptions); + const session = await helper.refreshSession(); + expect(session.idToken).toBe('mock-id-token'); + expect(session.accessToken).toBe('mock-access-token'); + expect(session.scopes).toEqual(new Set(['a', 'b', 'c'])); + expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000); + expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000); + }); + + it('should handle failure to refresh session', async () => { + anyFetch.mockRejectOnce(new Error('Network NOPE')); + + const helper = new DefaultAuthConnector(defaultOptions); + await expect(helper.refreshSession()).rejects.toThrow( + 'Auth refresh request failed, Error: Network NOPE', + ); + }); + + it('should handle failure response when refreshing session', async () => { + anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' }); + + const helper = new DefaultAuthConnector(defaultOptions); + await expect(helper.refreshSession()).rejects.toThrow( + 'Auth refresh request failed with status NOPE', + ); + }); + + it('should fail if popup was rejected', async () => { + const mockOauth = new MockOAuthApi(); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: mockOauth, + }); + const promise = helper.createSession(new Set(['a', 'b'])); + await mockOauth.rejectAll(); + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + }); + + it('should create a session', async () => { + 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 DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: mockOauth, + }); + + const sessionPromise = helper.createSession(new Set(['a', 'b'])); + + await mockOauth.triggerAll(); + + expect(popupSpy).toBeCalledTimes(1); + expect(popupSpy.mock.calls[0][0]).toMatchObject({ + url: 'my-origin/api/auth/my-provider/start?scope=a%20b&env=production', + }); + + await expect(sessionPromise).resolves.toEqual({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: expect.any(Set), + expiresAt: expect.any(Date), + }); + }); + + it('should use join func to join scopes', async () => { + const mockOauth = new MockOAuthApi(); + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue({ scopes: '' }); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + joinScopes: scopes => `-${[...scopes].join('')}-`, + oauthRequestApi: mockOauth, + }); + + helper.createSession(new Set(['a', 'b'])); + + await mockOauth.triggerAll(); + + expect(popupSpy).toBeCalledTimes(1); + expect(popupSpy.mock.calls[0][0]).toMatchObject({ + url: 'my-origin/api/auth/my-provider/start?scope=-ab-&env=production', + }); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.ts new file mode 100644 index 0000000000..161015b1a8 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.ts @@ -0,0 +1,195 @@ +/* + * 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 { AuthRequester } from '../../..'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { showLoginPopup } from '../loginPopup'; +import { AuthConnector } from './types'; + +const DEFAULT_BASE_PATH = '/api/auth/'; + +type Options = { + /** + * Origin of auth requests, defaults to location.origin + */ + apiOrigin?: string; + /** + * Base path of the auth requests, defaults to /api/auth/ + */ + basePath?: string; + /** + * Environment hint passed on to auth backend, for example 'production' or 'development' + */ + environment: string; + /** + * Information about the auth provider to be shown to the user. + * The ID Must match the backend auth plugin configuration, for example 'google'. + */ + provider: AuthProvider & { id: string }; + /** + * API used to instantiate an auth requester. + */ + oauthRequestApi: OAuthRequestApi; + /** + * Function used to join together a set of scopes, defaults to joining with a space character. + */ + joinScopes?: (scopes: Set) => string; + /** + * Function used to transform an auth response into the session type. + */ + sessionTransform?(response: any): AuthSession | Promise; +}; + +function defaultJoinScopes(scopes: Set) { + return [...scopes].join(' '); +} + +/** + * DefaultAuthConnector is the default auth connector in Backstage. It talks to the + * backend auth plugin through the standardized API, and requests user permission + * via the OAuthRequestApi. + */ +export class DefaultAuthConnector + implements AuthConnector { + private readonly apiOrigin: string; + private readonly basePath: string; + private readonly environment: string; + private readonly provider: AuthProvider & { id: string }; + private readonly authRequester: AuthRequester; + private readonly sessionTransform: (response: any) => Promise; + + constructor(options: Options) { + const { + apiOrigin = window.location.origin, + basePath = DEFAULT_BASE_PATH, + environment, + provider, + joinScopes = defaultJoinScopes, + oauthRequestApi, + sessionTransform = id => id, + } = options; + + this.authRequester = oauthRequestApi.createAuthRequester({ + provider, + onAuthRequest: scopes => this.showPopup(joinScopes(scopes)), + }); + + this.apiOrigin = apiOrigin; + this.basePath = basePath; + this.environment = environment; + this.provider = provider; + this.sessionTransform = sessionTransform; + } + + async createSession(scopes: Set): Promise { + return this.authRequester(scopes); + } + + async refreshSession(): Promise { + const res = await fetch(this.buildUrl('/token', { optional: true }), { + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Auth refresh request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error( + `Auth refresh request failed with status ${res.statusText}`, + ); + error.status = res.status; + throw error; + } + + const authInfo = await res.json(); + + if (authInfo.error) { + const error = new Error(authInfo.error.message); + if (authInfo.error.name) { + error.name = authInfo.error.name; + } + throw error; + } + return await this.sessionTransform(authInfo); + } + + async removeSession(): Promise { + const res = await fetch(this.buildUrl('/logout'), { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }); + + if (!res.ok) { + throw new Error(`Logout request failed with status ${res.status}`); + } + } + + private async showPopup(scope: string): Promise { + const popupUrl = this.buildUrl('/start', { scope }); + + const payload = await showLoginPopup({ + url: popupUrl, + name: `${this.provider.title} Login`, + origin: this.apiOrigin, + width: 450, + height: 730, + }); + + return await this.sessionTransform(payload); + } + + private buildUrl( + path: string, + query?: { [key: string]: string | boolean | undefined }, + ): string { + const queryString = this.buildQueryString({ + ...query, + env: this.environment, + }); + + return `${this.apiOrigin}${this.basePath}${this.provider.id}${path}${queryString}`; + } + + private buildQueryString(query?: { + [key: string]: string | boolean | undefined; + }): string { + if (!query) { + return ''; + } + + const queryString = Object.entries(query) + .map(([key, value]) => { + if (typeof value === 'string') { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } else if (value) { + return encodeURIComponent(key); + } + return undefined; + }) + .filter(Boolean) + .join('&'); + + if (!queryString) { + return ''; + } + return `?${queryString}`; + } +} diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.test.ts b/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.test.ts new file mode 100644 index 0000000000..cd7986ffd0 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { MockAuthConnector, mockAccessToken } from './MockAuthConnector'; + +describe('MockAuthConnector', () => { + it('should return mock tokens', async () => { + const helper = new MockAuthConnector(); + + await expect(helper.createSession()).resolves.toEqual({ + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(String), + }); + + await expect(helper.refreshSession()).resolves.toEqual({ + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(String), + }); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.ts b/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.ts new file mode 100644 index 0000000000..9134fd0773 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.ts @@ -0,0 +1,45 @@ +/* + * 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 { AuthConnector } from './types'; + +export const mockAccessToken = 'mock-access-token'; + +type MockSession = { + accessToken: string; + expiresAt: Date; + scopes: string; +}; + +const defaultMockSession: MockSession = { + accessToken: mockAccessToken, + expiresAt: new Date(), + scopes: 'profile email', +}; + +export class MockAuthConnector implements AuthConnector { + constructor(private readonly mockSession: MockSession = defaultMockSession) {} + + async createSession() { + return this.mockSession; + } + + async refreshSession() { + return this.mockSession; + } + + async removeSession() {} +} diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/index.ts b/packages/core/src/api/apis/implementations/lib/AuthConnector/index.ts new file mode 100644 index 0000000000..db5c582328 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthConnector/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { DefaultAuthConnector } from './DefaultAuthConnector'; +export * from './types'; diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/types.ts b/packages/core/src/api/apis/implementations/lib/AuthConnector/types.ts new file mode 100644 index 0000000000..146c31cbe1 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthConnector/types.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * An AuthConnector is responsible for realizing auth session actions + * by for example communicating with a backend or interacting with the user. + */ +export type AuthConnector = { + createSession(scopes: Set): Promise; + refreshSession(): Promise; + removeSession(): Promise; +}; diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts new file mode 100644 index 0000000000..6841934357 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; + +const defaultOptions = { + sessionScopes: (session: { scopes: Set }) => session.scopes, + sessionShouldRefresh: (session: { expired: boolean }) => session.expired, +}; + +describe('RefreshingAuthSessionManager', () => { + it('should save result form createSession', async () => { + const createSession = jest.fn().mockResolvedValue({ expired: false }); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({}); + expect(createSession).toBeCalledTimes(1); + + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should ask consent only if scopes have changed', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: false, + }); + await manager.getSession({ scope: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scope: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + + await manager.getSession({ scope: new Set(['b']) }); + expect(createSession).toBeCalledTimes(2); + }); + + it('should check for session expiry', async () => { + const createSession = jest.fn(); + const refreshSession = jest + .fn() + .mockRejectedValueOnce(new Error('NOPE')) + .mockResolvedValue({ scopes: new Set(['a']) }); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expired: true, + }); + + await manager.getSession({ scope: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(1); + + await manager.getSession({ scope: new Set(['a']) }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(2); + }); + + it('should handle user closed popup', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + createSession.mockRejectedValueOnce(new Error('some error')); + await expect(manager.getSession({ scope: new Set(['a']) })).rejects.toThrow( + 'some error', + ); + }); + + it('should not get optional session', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ optional: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(0); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should remove session and reload', async () => { + // This is a workaround that is used by Facebook and the Jest core team + // It is a limitation with the newest versions of JSDOM, and newer browser standards + // where window.location and all of its properties are read-only. So we re-construct it! + // See https://github.com/facebook/jest/issues/890#issuecomment-209698782 + const location = { ...window.location }; + delete window.location; + window.location = location; + jest.spyOn(window.location, 'reload').mockImplementation(); + + const removeSession = jest.fn(); + const manager = new RefreshingAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(window.location.reload).toHaveBeenCalled(); + expect(removeSession).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts new file mode 100644 index 0000000000..147d9f4b49 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -0,0 +1,178 @@ +/* + * 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 { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests'; +import { SessionManager } from './types'; +import { AuthConnector } from '../AuthConnector'; + +type Options = { + /** + * The connector used for acting on the auth session. + */ + connector: AuthConnector; + /** + * A function called to determine the scopes of the session. + */ + sessionScopes: (session: AuthSession) => Set; + /** + * A function called to determine whether it's time for a session to refresh. + * + * This should return true before the session expires, for example, if a session + * expires after 60 minutes, you could return true if the session is older than 45 minutes. + */ + sessionShouldRefresh: (session: AuthSession) => boolean; + /** + * The default scopes that should always be present in a session, defaults to none. + */ + defaultScopes?: Set; +}; + +/** + * RefreshingAuthSessionManager manages an underlying session that has + * and expiration time and needs to be refreshed periodically. + */ +export class RefreshingAuthSessionManager + implements SessionManager { + private readonly connector: AuthConnector; + private readonly defaultScopes?: Set; + private readonly sessionScopesFunc: (session: AuthSession) => Set; + private readonly sessionShouldRefreshFunc: (session: AuthSession) => boolean; + + private refreshPromise?: Promise; + private currentSession: AuthSession | undefined; + + constructor(options: Options) { + const { + connector, + defaultScopes = new Set(), + sessionScopes, + sessionShouldRefresh, + } = options; + + this.connector = connector; + this.defaultScopes = defaultScopes; + this.sessionScopesFunc = sessionScopes; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + } + + async getSession(options: { + optional: false; + scope?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scope?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scope?: Set; + }): Promise { + if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) { + const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); + if (!shouldRefresh) { + return this.currentSession!; + } + + try { + const refreshedSession = await this.collapsedSessionRefresh(); + const currentScopes = this.sessionScopesFunc(this.currentSession!); + const refreshedScopes = this.sessionScopesFunc(refreshedSession); + if (hasScopes(refreshedScopes, currentScopes)) { + this.currentSession = refreshedSession; + } + return refreshedSession; + } catch (error) { + if (options.optional) { + return undefined; + } + throw error; + } + } + + // The user may still have a valid refresh token in their cookies. Attempt to + // initiate a fresh session through the backend using that refresh token. + if (!this.currentSession) { + try { + const newSession = await this.collapsedSessionRefresh(); + this.currentSession = newSession; + // The session might not have the scopes requested so go back and check again + return this.getSession(options); + } catch { + // If the refresh attemp fails we assume we don't have a session, so continue to create one. + } + } + + // If we continue here we will show a popup, so exit if this is an optional session request. + if (options.optional) { + return undefined; + } + + // We can call authRequester multiple times, the returned session will contain all requested scopes. + this.currentSession = await this.connector.createSession( + this.getExtendedScope(options.scope), + ); + return this.currentSession; + } + + async removeSession() { + await this.connector.removeSession(); + window.location.reload(); // TODO(Rugvip): make this work without reload? + } + + private sessionExistsAndHasScope( + session: AuthSession | undefined, + scope?: Set, + ): boolean { + if (!session) { + return false; + } + if (!scope) { + return true; + } + const sessionScopes = this.sessionScopesFunc(session); + return hasScopes(sessionScopes, scope); + } + + private getExtendedScope(scopes?: Set) { + const newScope = new Set(this.defaultScopes); + if (this.currentSession) { + const sessionScopes = this.sessionScopesFunc(this.currentSession); + for (const scope of sessionScopes) { + newScope.add(scope); + } + } + if (scopes) { + for (const scope of scopes) { + newScope.add(scope); + } + } + return newScope; + } + + private async collapsedSessionRefresh(): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.connector.refreshSession(); + + try { + return await this.refreshPromise; + } finally { + delete this.refreshPromise; + } + } +} diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts new file mode 100644 index 0000000000..426c514646 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +export * from './types'; diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts new file mode 100644 index 0000000000..440944cce0 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/** + * A sessions manager keeps track of the current session and makes sure that + * multiple simultaneous requests for sessions with different scope are handled + * in a correct way. + */ +export type SessionManager = { + getSession(options: { + optional: false; + scope?: Set; + }): Promise; + getSession(options: { + optional?: boolean; + scope?: Set; + }): Promise; + + removeSession(): Promise; +}; 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); + }); +} diff --git a/packages/core/src/setupTests.ts b/packages/core/src/setupTests.ts index 825bcd4115..e34bc46f4b 100644 --- a/packages/core/src/setupTests.ts +++ b/packages/core/src/setupTests.ts @@ -15,3 +15,4 @@ */ import '@testing-library/jest-dom'; +require('jest-fetch-mock').enableMocks();