packages/core: add implementation of OAuthRequest API

This commit is contained in:
Patrik Oldsberg
2020-05-14 00:35:46 +02:00
parent 348e35d60b
commit 08732d57d0
7 changed files with 635 additions and 0 deletions
@@ -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<string>,
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<string> {
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;
}
}
@@ -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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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);
});
});
@@ -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<ResultType> = {
scopes: OAuthScopes;
resolve: (value?: ResultType | PromiseLike<ResultType> | undefined) => void;
reject: (reason: Error) => void;
};
export type PendingRequest<ResultType> = {
scopes: OAuthScopes | undefined;
resolve: (value: ResultType) => void;
reject: (reason: Error) => void;
};
export type OAuthPendingRequestsApi<ResultType> = {
request(scopes: OAuthScopes): Promise<ResultType>;
resolve(scopes: OAuthScopes, result: ResultType): void;
reject(error: Error): void;
pending(): Observable<PendingRequest<ResultType>>;
};
export class OAuthPendingRequests<ResultType>
implements OAuthPendingRequestsApi<ResultType> {
private requests: RequestQueueEntry<ResultType>[] = [];
private listeners: ZenObservable.SubscriptionObserver<
PendingRequest<ResultType>
>[] = [];
request(scopes: OAuthScopes): Promise<ResultType> {
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<PendingRequest<ResultType>> {
return new Observable((subscriber) => {
this.listeners.push(subscriber);
subscriber.next(this.getCurrentPending());
return () => {
this.listeners = this.listeners.filter((l) => l !== subscriber);
};
});
}
private getCurrentPending(): PendingRequest<ResultType> {
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);
}
},
};
}
}
@@ -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);
});
});
@@ -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<AuthRequest[]>;
private readonly observers = new Set<
ZenObservable.SubscriptionObserver<AuthRequest[]>
>();
private currentRequests: AuthRequest[] = [];
private handlerCount = 0;
constructor() {
this.requests$ = new Observable<AuthRequest[]>((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<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
const handler = new OAuthPendingRequests<T>();
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<any>,
options: AuthRequesterOptions<any>,
): 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<AuthRequest[]> {
return this.requests$;
}
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
return new Promise((resolve, reject) => {
const width = options.width || 500;
const height = options.height || 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const popup = window.open(
options.url,
options.name,
`menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`,
);
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
reject(new Error('Failed to open auth popup.'));
return;
}
const messageListener = (event: MessageEvent) => {
if (event.source !== popup) {
return;
}
if (event.origin !== options.origin) {
return;
}
const { data } = event;
if (data.type !== 'oauth-result') {
return;
}
if (data.payload.error) {
const error = new Error(data.payload.error.message);
error.name = data.payload.error.name;
// TODO: proper error type
// error.extra = data.payload.error.extra;
reject(error);
} else {
resolve(data.payload);
}
done();
};
const intervalId = setInterval(() => {
if (popup.closed) {
const error = new Error('Login failed, popup was closed');
error.name = 'PopupClosedError';
reject(error);
done();
}
}, 100);
function done() {
window.removeEventListener('message', messageListener);
clearInterval(intervalId);
}
window.addEventListener('message', messageListener);
});
}
}
@@ -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';
@@ -20,3 +20,4 @@
export * from './AlertApiForwarder';
export * from './ErrorApiForwarder';
export * from './OAuthRequestManager';