From 08732d57d0548d25e4ea358ed4af266478d37ffe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:35:46 +0200 Subject: [PATCH] packages/core: add implementation of OAuthRequest API --- .../OAuthRequestManager/BasicOAuthScopes.ts | 73 +++++++ .../OAuthPendingRequests.test.ts | 97 ++++++++++ .../OAuthPendingRequests.ts | 111 +++++++++++ .../OAuthRequestManager.test.ts | 178 ++++++++++++++++++ .../OAuthRequestManager.ts | 158 ++++++++++++++++ .../OAuthRequestManager/index.ts | 17 ++ .../src/api/apis/implementations/index.ts | 1 + 7 files changed, 635 insertions(+) create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts new file mode 100644 index 0000000000..053d017da8 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts @@ -0,0 +1,73 @@ +/* + * 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/oauthrequest'; + +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/OAuthPendingRequests.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts new file mode 100644 index 0000000000..be762ca936 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { wait } from '@testing-library/react'; +import { OAuthPendingRequests } from './OAuthPendingRequests'; +import { BasicOAuthScopes } from './BasicOAuthScopes'; + +describe('OAuthPendingRequests', () => { + it('notifies new observers about current state', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + + const input = BasicOAuthScopes.from('a b'); + target.pending().subscribe({ next, error }); + target.request(input); + + await wait(() => expect(next).toBeCalledTimes(2)); + expect(next.mock.calls[0][0].scopes).toBeUndefined(); + expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString()); + expect(error.mock.calls.length).toBe(0); + }); + + it('resolves requests and notifies observers', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + + const request1 = target.request(BasicOAuthScopes.from('a')); + const request2 = target.request(BasicOAuthScopes.from('a')); + target.pending().subscribe({ next, error }); + target.resolve(BasicOAuthScopes.from('a'), 'session1'); + target.resolve(BasicOAuthScopes.from('a'), 'session2'); + + await expect(request1).resolves.toBe('session1'); + await expect(request2).resolves.toBe('session1'); + expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve + expect(error).toBeCalledTimes(0); + }); + + it('can resolve through the observable', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn((pendingRequest) => pendingRequest.resolve('done')); + const error = jest.fn(); + + const request1 = target.request(BasicOAuthScopes.from('a')); + target.pending().subscribe({ next, error }); + + await expect(request1).resolves.toBe('done'); + expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution + expect(error).toBeCalledTimes(0); + }); + + it('rejects requests and notifies observers only once', async () => { + const target = new OAuthPendingRequests(); + const next = jest.fn(); + const error = jest.fn(); + const rejection = new Error('eek'); + + const request1 = target.request(BasicOAuthScopes.from('a')); + const request2 = target.request(BasicOAuthScopes.from('a')); + target.pending().subscribe({ next, error }); + target.reject(rejection); + target.resolve(BasicOAuthScopes.from('a'), 'session'); + + await expect(request1).rejects.toBe(rejection); + await expect(request2).rejects.toBe(rejection); + expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve + expect(error).toBeCalledTimes(0); + }); + + 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 error = jest.fn(); + + const request1 = target.request(BasicOAuthScopes.from('a')); + target.pending().subscribe({ next, error }); + + await expect(request1).rejects.toBe(rejection); + expect(next).toBeCalledTimes(2); + }); +}); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts new file mode 100644 index 0000000000..2398282842 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts @@ -0,0 +1,111 @@ +/* + * 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 Observable from 'zen-observable'; +import { OAuthScopes } from '../../definitions/oauthrequest'; + +type RequestQueueEntry = { + scopes: OAuthScopes; + resolve: (value?: ResultType | PromiseLike | undefined) => void; + reject: (reason: Error) => void; +}; + +export type PendingRequest = { + scopes: OAuthScopes | undefined; + resolve: (value: ResultType) => void; + reject: (reason: Error) => void; +}; + +export type OAuthPendingRequestsApi = { + request(scopes: OAuthScopes): Promise; + resolve(scopes: OAuthScopes, result: ResultType): void; + reject(error: Error): void; + pending(): Observable>; +}; + +export class OAuthPendingRequests + implements OAuthPendingRequestsApi { + private requests: RequestQueueEntry[] = []; + private listeners: ZenObservable.SubscriptionObserver< + PendingRequest + >[] = []; + + request(scopes: OAuthScopes): Promise { + return new Promise((resolve, reject) => { + this.requests.push({ scopes, resolve, reject }); + + const pending = this.getCurrentPending(); + this.listeners.forEach((listener) => listener.next(pending)); + }); + } + + resolve(scopes: OAuthScopes, result: ResultType): void { + this.requests = this.requests.filter((request) => { + if (scopes.hasScopes(request.scopes)) { + request.resolve(result); + return false; + } + return true; + }); + + const pending = this.getCurrentPending(); + this.listeners.forEach((listener) => listener.next(pending)); + } + + reject(error: Error) { + this.requests.forEach((request) => request.reject(error)); + this.requests = []; + + const pending = this.getCurrentPending(); + this.listeners.forEach((listener) => listener.next(pending)); + } + + pending(): Observable> { + return new Observable((subscriber) => { + this.listeners.push(subscriber); + subscriber.next(this.getCurrentPending()); + return () => { + this.listeners = this.listeners.filter((l) => l !== subscriber); + }; + }); + } + + private getCurrentPending(): PendingRequest { + const currentScopes = + this.requests.length === 0 + ? undefined + : this.requests + .slice(1) + .reduce( + (acc, current) => acc.extend(current.scopes), + this.requests[0].scopes, + ); + + return { + scopes: currentScopes, + resolve: (value: ResultType) => { + if (currentScopes) { + this.resolve(currentScopes, value); + } + }, + reject: (reason: Error) => { + if (currentScopes) { + this.reject(reason); + } + }, + }; + } +} diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts new file mode 100644 index 0000000000..940c0230f9 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.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 { OAuthRequestManager } from './OAuthRequestManager'; + +describe('OAuthApi login popup', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should show an auth popup', async () => { + const oauth = new OAuthRequestManager(); + + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = oauth.showLoginPopup({ + url: + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + name: 'test-popup', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe( + 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + ); + expect(openSpy.mock.calls[0][1]).toBe('test-popup'); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + listener({} as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + // None of these should be accepted + listener({ source: popupMock } as MessageEvent); + listener({ origin: 'my-origin' } as MessageEvent); + listener({ data: { type: 'oauth-result' } } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: {}, + } as MessageEvent); + listener({ + source: popupMock, + origin: 'my-origin', + data: { type: 'not-oauth-result', payload: {} }, + } as MessageEvent); + + await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe( + 'waiting', + ); + + const myPayload = {}; + + // This should be accepted as a valid sessions response + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'oauth-result', + payload: myPayload, + }, + } as MessageEvent); + + await expect(payloadPromise).resolves.toBe(myPayload); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup returns error', async () => { + const oauth = new OAuthRequestManager(); + + const popupMock = { closed: false }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(popupMock as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const payloadPromise = oauth.showLoginPopup({ + url: 'url', + name: 'name', + origin: 'my-origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + listener({ + source: popupMock, + origin: 'my-origin', + data: { + type: 'oauth-result', + payload: { + error: { + message: 'NOPE', + name: 'NopeError', + }, + }, + }, + } as MessageEvent); + + await expect(payloadPromise).rejects.toThrow({ + name: 'NopeError', + message: 'NOPE', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); + + it('should fail if popup is closed', async () => { + const oauth = new OAuthRequestManager(); + + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue({ closed: false } as Window); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const popupMock = { closed: false }; + + openSpy.mockReturnValue(popupMock as Window); + + const payloadPromise = oauth.showLoginPopup({ + url: 'url', + name: 'name', + origin: 'origin', + }); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(0); + + setTimeout(() => { + popupMock.closed = true; + }, 150); + await expect(payloadPromise).rejects.toThrow( + 'Login failed, popup was closed', + ); + + expect(openSpy).toBeCalledTimes(1); + expect(addEventListenerSpy).toBeCalledTimes(1); + expect(removeEventListenerSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts new file mode 100644 index 0000000000..239d4aebc6 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts @@ -0,0 +1,158 @@ +/* + * 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, + LoginPopupOptions, + AuthRequest, + AuthRequester, + AuthRequesterOptions, +} from '../../definitions/oauthrequest'; +import Observable from 'zen-observable'; +import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; + +export class OAuthRequestManager implements OAuthRequestApi { + private readonly requests$: Observable; + private readonly observers = new Set< + ZenObservable.SubscriptionObserver + >(); + private currentRequests: AuthRequest[] = []; + private handlerCount = 0; + + constructor() { + this.requests$ = new Observable((observer) => { + observer.next(this.currentRequests); + + this.observers.add(observer); + return () => { + this.observers.delete(observer); + }; + }).map((requests) => requests.filter(Boolean)); // Convert from sparse array to array of present items only + } + + createAuthRequester(options: AuthRequesterOptions): AuthRequester { + const handler = new OAuthPendingRequests(); + + const index = this.handlerCount; + this.handlerCount++; + + handler.pending().subscribe({ + next: (scopeRequest) => { + const newRequests = this.currentRequests.slice(); + const request = this.makeAuthRequest(scopeRequest, options); + if (!request) { + delete newRequests[index]; + } else { + newRequests[index] = request; + } + this.currentRequests = newRequests; + this.observers.forEach((observer) => observer.next(newRequests)); + }, + }); + + return (scopes) => { + return handler.request(scopes); + }; + } + + // Converts the pending request and popup options into a popup request that we can forward to subscribers. + private makeAuthRequest( + request: PendingRequest, + options: AuthRequesterOptions, + ): AuthRequest | undefined { + const { scopes } = request; + if (!scopes) { + return undefined; + } + + return { + info: options.info, + triggerAuth: async () => { + const result = await options.authHandler(scopes); + request.resolve(result); + }, + reject: () => { + const error = new Error('Login failed, rejected by user'); + error.name = 'RejectedError'; + request.reject(error); + }, + }; + } + + handleAuthRequests(): Observable { + return this.requests$; + } + + 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/OAuthRequestManager/index.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts new file mode 100644 index 0000000000..0fe9bfae0f --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/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 { OAuthRequestManager } from './OAuthRequestManager'; diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core/src/api/apis/implementations/index.ts index b5ea6467cb..ebd89b54c6 100644 --- a/packages/core/src/api/apis/implementations/index.ts +++ b/packages/core/src/api/apis/implementations/index.ts @@ -20,3 +20,4 @@ export * from './AlertApiForwarder'; export * from './ErrorApiForwarder'; +export * from './OAuthRequestManager';