Merge pull request #925 from spotify/rugvip/gauth

Add GoogleAuth, AuthConnector, and RefreshingAuthSessionManager
This commit is contained in:
Patrik Oldsberg
2020-05-22 15:49:06 +02:00
committed by GitHub
31 changed files with 1729 additions and 366 deletions
+2 -1
View File
@@ -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<{}> = () => (
<AppProvider>
<AlertDisplay forwarder={alertApiForwarder} />
<OAuthRequestDialog />
<Router>
<Root>
<AppComponent />
+18
View File
@@ -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({
+2 -1
View File
@@ -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}"
@@ -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<string>;
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<AuthResponse> = {
* Implementation of the auth flow, which will be called synchronously when
* trigger() is called on an auth requests.
*/
onAuthRequest(scope: OAuthScopes): Promise<AuthResponse>;
onAuthRequest(scopes: Set<string>): Promise<AuthResponse>;
};
/**
@@ -106,7 +64,7 @@ export type AuthRequesterOptions<AuthResponse> = {
* union of all requested scopes.
*/
export type AuthRequester<AuthResponse> = (
scope: OAuthScopes,
scopes: Set<string>,
) => Promise<AuthResponse>;
/**
@@ -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<any>;
/**
* A utility for showing login popups or similar things, and merging together multiple requests for
* different scopes into one request that inclues all scopes.
@@ -20,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';
@@ -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<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,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();
});
});
@@ -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<T>(options: AuthRequesterOptions<T>) {
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();
});
});
}
}
@@ -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<string>();
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<string>();
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);
@@ -14,22 +14,48 @@
* limitations under the License.
*/
import { OAuthScopes } from '../../definitions';
import { BehaviorSubject } from '../lib';
import { Observable } from '../../../types';
type RequestQueueEntry<ResultType> = {
scopes: OAuthScopes;
scopes: Set<string>;
resolve: (value?: ResultType | PromiseLike<ResultType> | undefined) => void;
reject: (reason: Error) => void;
};
export type PendingRequest<ResultType> = {
scopes: OAuthScopes | undefined;
scopes: Set<string> | undefined;
resolve: (value: ResultType) => void;
reject: (reason: Error) => void;
};
export function hasScopes(
searched: Set<string>,
searchFor: Set<string>,
): boolean {
for (const scope of searchFor) {
if (!searched.has(scope)) {
return false;
}
}
return true;
}
export function joinScopes(
scopes: Set<string>,
...moreScopess: Set<string>[]
): Set<string> {
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<ResultType> {
this.getCurrentPending(),
);
request(scopes: OAuthScopes): Promise<ResultType> {
request(scopes: Set<string>): Promise<ResultType> {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
@@ -49,9 +75,9 @@ export class OAuthPendingRequests<ResultType> {
});
}
resolve(scopes: OAuthScopes, result: ResultType): void {
this.requests = this.requests.filter((request) => {
if (scopes.hasScopes(request.scopes)) {
resolve(scopes: Set<string>, 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<ResultType> {
}
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<ResultType> {
: this.requests
.slice(1)
.reduce(
(acc, current) => acc.extend(current.scopes),
(acc, current) => joinScopes(acc, current.scopes),
this.requests[0].scopes,
);
@@ -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');
});
});
@@ -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<PendingAuthRequest[]> {
return this.subject;
}
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
return new Promise((resolve, reject) => {
const width = options.width || 500;
const height = options.height || 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const popup = window.open(
options.url,
options.name,
`menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`,
);
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
reject(new Error('Failed to open auth popup.'));
return;
}
const messageListener = (event: MessageEvent) => {
if (event.source !== popup) {
return;
}
if (event.origin !== options.origin) {
return;
}
const { data } = event;
if (data.type !== 'oauth-result') {
return;
}
if (data.payload.error) {
const error = new Error(data.payload.error.message);
error.name = data.payload.error.name;
// TODO: proper error type
// error.extra = data.payload.error.extra;
reject(error);
} else {
resolve(data.payload);
}
done();
};
const intervalId = setInterval(() => {
if (popup.closed) {
const error = new Error('Login failed, popup was closed');
error.name = 'PopupClosedError';
reject(error);
done();
}
}, 100);
function done() {
window.removeEventListener('message', messageListener);
clearInterval(intervalId);
}
window.addEventListener('message', messageListener);
});
}
}
@@ -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));
});
});
@@ -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<GoogleSession>) {}
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<string> {
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;
@@ -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';
@@ -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<string>;
expiresAt: Date;
};
@@ -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';
@@ -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';
@@ -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<any>(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',
});
});
});
@@ -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<AuthSession> = {
/**
* 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>) => string;
/**
* Function used to transform an auth response into the session type.
*/
sessionTransform?(response: any): AuthSession | Promise<AuthSession>;
};
function defaultJoinScopes(scopes: Set<string>) {
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<AuthSession>
implements AuthConnector<AuthSession> {
private readonly apiOrigin: string;
private readonly basePath: string;
private readonly environment: string;
private readonly provider: AuthProvider & { id: string };
private readonly authRequester: AuthRequester<AuthSession>;
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
constructor(options: Options<AuthSession>) {
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<string>): Promise<AuthSession> {
return this.authRequester(scopes);
}
async refreshSession(): Promise<any> {
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<void> {
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<AuthSession> {
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<string | boolean | undefined>(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}`;
}
}
@@ -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),
});
});
});
@@ -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<MockSession> {
constructor(private readonly mockSession: MockSession = defaultMockSession) {}
async createSession() {
return this.mockSession;
}
async refreshSession() {
return this.mockSession;
}
async removeSession() {}
}
@@ -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';
@@ -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<AuthSession> = {
createSession(scopes: Set<string>): Promise<AuthSession>;
refreshSession(): Promise<AuthSession>;
removeSession(): Promise<void>;
};
@@ -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<string> }) => 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();
});
});
@@ -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<AuthSession> = {
/**
* The connector used for acting on the auth session.
*/
connector: AuthConnector<AuthSession>;
/**
* A function called to determine the scopes of the session.
*/
sessionScopes: (session: AuthSession) => Set<string>;
/**
* 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<string>;
};
/**
* RefreshingAuthSessionManager manages an underlying session that has
* and expiration time and needs to be refreshed periodically.
*/
export class RefreshingAuthSessionManager<AuthSession>
implements SessionManager<AuthSession> {
private readonly connector: AuthConnector<AuthSession>;
private readonly defaultScopes?: Set<string>;
private readonly sessionScopesFunc: (session: AuthSession) => Set<string>;
private readonly sessionShouldRefreshFunc: (session: AuthSession) => boolean;
private refreshPromise?: Promise<AuthSession>;
private currentSession: AuthSession | undefined;
constructor(options: Options<AuthSession>) {
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<string>;
}): Promise<AuthSession>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined> {
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<string>,
): boolean {
if (!session) {
return false;
}
if (!scope) {
return true;
}
const sessionScopes = this.sessionScopesFunc(session);
return hasScopes(sessionScopes, scope);
}
private getExtendedScope(scopes?: Set<string>) {
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<AuthSession> {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.connector.refreshSession();
try {
return await this.refreshPromise;
} finally {
delete this.refreshPromise;
}
}
}
@@ -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';
@@ -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<AuthSession> = {
getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
removeSession(): Promise<void>;
};
@@ -0,0 +1,170 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { showLoginPopup } from './loginPopup';
describe('showLoginPopup', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should show an auth popup', async () => {
const popupMock = { closed: false };
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(popupMock as Window);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const payloadPromise = showLoginPopup({
url:
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
name: 'test-popup',
origin: 'my-origin',
});
expect(openSpy).toBeCalledTimes(1);
expect(openSpy.mock.calls[0][0]).toBe(
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
);
expect(openSpy.mock.calls[0][1]).toBe('test-popup');
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(0);
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
listener({} as MessageEvent);
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
// None of these should be accepted
listener({ source: popupMock } as MessageEvent);
listener({ origin: 'my-origin' } as MessageEvent);
listener({ data: { type: 'auth-result' } } as MessageEvent);
listener({
source: popupMock,
origin: 'my-origin',
data: {},
} as MessageEvent);
listener({
source: popupMock,
origin: 'my-origin',
data: { type: 'not-auth-result', payload: {} },
} as MessageEvent);
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
const myPayload = {};
// This should be accepted as a valid sessions response
listener({
source: popupMock,
origin: 'my-origin',
data: {
type: 'auth-result',
payload: myPayload,
},
} as MessageEvent);
await expect(payloadPromise).resolves.toBe(myPayload);
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(1);
});
it('should fail if popup returns error', async () => {
const popupMock = { closed: false };
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue(popupMock as Window);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const payloadPromise = showLoginPopup({
url: 'url',
name: 'name',
origin: 'my-origin',
});
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(0);
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
listener({
source: popupMock,
origin: 'my-origin',
data: {
type: 'auth-result',
error: {
message: 'NOPE',
name: 'NopeError',
},
},
} as MessageEvent);
await expect(payloadPromise).rejects.toThrow({
name: 'NopeError',
message: 'NOPE',
});
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(1);
});
it('should fail if popup is closed', async () => {
const openSpy = jest
.spyOn(window, 'open')
.mockReturnValue({ closed: false } as Window);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const popupMock = { closed: false };
openSpy.mockReturnValue(popupMock as Window);
const payloadPromise = showLoginPopup({
url: 'url',
name: 'name',
origin: 'origin',
});
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(0);
setTimeout(() => {
popupMock.closed = true;
}, 150);
await expect(payloadPromise).rejects.toThrow(
'Login failed, popup was closed',
);
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
expect(removeEventListenerSpy).toBeCalledTimes(1);
});
});
@@ -0,0 +1,127 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Options used to open a login popup.
*/
export type LoginPopupOptions = {
/**
* The URL that the auth popup should point to
*/
url: string;
/**
* The name of the popup, as in second argument to window.open
*/
name: string;
/**
* The origin of the final popup page that will post a message to this window.
*/
origin: string;
/**
* The width of the popup in pixels, defaults to 500
*/
width?: number;
/**
* The height of the popup in pixels, defaults to 700
*/
height?: number;
};
type AuthResult =
| {
type: 'auth-result';
payload: any;
}
| {
type: 'auth-result';
error: {
name: string;
message: string;
};
};
/**
* Show a popup pointing to a URL that starts an auth flow.
*
* The redirect handler of the flow should use postMessage to communicate back
* to the app window. The message posted to the app must match the AuthResult type.
*
* The returned promise resolves to the contents of the message that was posted from the auth popup.
*/
export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
return new Promise((resolve, reject) => {
const width = options.width || 500;
const height = options.height || 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const popup = window.open(
options.url,
options.name,
`menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`,
);
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
reject(new Error('Failed to open auth popup.'));
return;
}
const messageListener = (event: MessageEvent) => {
if (event.source !== popup) {
return;
}
if (event.origin !== options.origin) {
return;
}
const { data } = event;
if (data.type !== 'auth-result') {
return;
}
const authResult = data as AuthResult;
if ('error' in authResult) {
const error = new Error(authResult.error.message);
error.name = authResult.error.name;
// TODO: proper error type
// error.extra = authResult.error.extra;
reject(error);
} else {
resolve(authResult.payload);
}
done();
};
const intervalId = setInterval(() => {
if (popup.closed) {
const error = new Error('Login failed, popup was closed');
error.name = 'PopupClosedError';
reject(error);
done();
}
}, 100);
function done() {
window.removeEventListener('message', messageListener);
clearInterval(intervalId);
}
window.addEventListener('message', messageListener);
});
}
+1
View File
@@ -15,3 +15,4 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();