Merge pull request #925 from spotify/rugvip/gauth
Add GoogleAuth, AuthConnector, and RefreshingAuthSessionManager
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -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);
|
||||
|
||||
+35
-9
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+29
-148
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+2
-63
@@ -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';
|
||||
|
||||
+146
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+35
@@ -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>;
|
||||
};
|
||||
+136
@@ -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();
|
||||
});
|
||||
});
|
||||
+178
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
Reference in New Issue
Block a user