packages/core: refactor GoogleAuthHelper into generic helper and move to lib

This commit is contained in:
Patrik Oldsberg
2020-05-19 13:08:00 +02:00
parent dcf2c6e3bb
commit 775e339b1e
6 changed files with 165 additions and 120 deletions
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { AuthHelper } from './GoogleAuthHelper';
import GoogleIcon from '@material-ui/icons/AcUnit';
import { AuthHelper } from '../../lib/AuthHelper';
import GoogleScopes from './GoogleScopes';
import { GoogleSession } from './types';
import { OAuthScopes } from '../../..';
@@ -23,11 +24,42 @@ import {
OpenIdConnectApi,
IdTokenOptions,
} from '../../../definitions/auth';
import { OAuthRequestApi } from '../../../definitions';
import { GenericAuthHelper } from '../../lib/AuthHelper/AuthHelper';
export type GoogleAuthResponse = {
accessToken: string;
idToken: string;
scopes: string;
expiresInSeconds: number;
};
class GoogleAuth implements OAuthApi, OpenIdConnectApi {
private currentSession: GoogleSession | undefined;
constructor(private readonly helper: AuthHelper) {}
static create(oauthRequestApi: OAuthRequestApi) {
const helper = new AuthHelper({
providerPath: 'google/',
environment: 'dev',
provider: {
title: 'Google',
icon: GoogleIcon,
},
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GoogleAuthResponse): GoogleSession {
return {
idToken: res.idToken,
accessToken: res.accessToken,
scopes: GoogleScopes.from(res.scopes),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
};
},
});
return new GoogleAuth(helper);
}
constructor(private readonly helper: GenericAuthHelper<GoogleSession>) {}
async getAccessToken(scope?: string | string[]) {
const session = await this.getSession({ optional: false, scope });
@@ -129,7 +161,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
if (scope) {
newScope = newScope.extend(scope);
}
return newScope.toString();
return newScope;
}
}
export default GoogleAuth;
@@ -14,13 +14,30 @@
* limitations under the License.
*/
import GoogleAuthHelper from './GoogleAuthHelper';
import GoogleScopes from './GoogleScopes';
import ProviderIcon from '@material-ui/icons/AcUnit';
import { AuthHelper } from './AuthHelper';
import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi';
import { BasicOAuthScopes } from '../../OAuthRequestManager/BasicOAuthScopes';
const anyFetch = fetch as any;
describe('GoogleAuthHelper', () => {
const defaultOptions = {
apiOrigin: 'my-origin',
providerPath: 'my-provider',
environment: 'production',
provider: {
title: 'My Provider',
icon: ProviderIcon,
},
oauthRequestApi: new MockOAuthApi(),
sessionTransform: ({ expiresInSeconds, ...res }: any) => ({
...res,
scopes: BasicOAuthScopes.from(res.scopes),
expiresAt: new Date(Date.now() + expiresInSeconds * 1000),
}),
};
describe('AuthHelper', () => {
afterEach(() => {
jest.resetAllMocks();
anyFetch.resetMocks();
@@ -36,10 +53,7 @@ describe('GoogleAuthHelper', () => {
}),
);
const helper = new GoogleAuthHelper(
{ apiOrigin: 'my-origin', dev: false },
new MockOAuthApi(),
);
const helper = new AuthHelper<any>(defaultOptions);
const session = await helper.refreshSession();
expect(session.idToken).toBe('mock-id-token');
expect(session.accessToken).toBe('mock-access-token');
@@ -51,10 +65,7 @@ describe('GoogleAuthHelper', () => {
it('should handle failure to refresh session', async () => {
anyFetch.mockRejectOnce(new Error('Network NOPE'));
const helper = new GoogleAuthHelper(
{ apiOrigin: 'my-origin', dev: true },
new MockOAuthApi(),
);
const helper = new AuthHelper(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
'Auth refresh request failed, Error: Network NOPE',
);
@@ -63,10 +74,7 @@ describe('GoogleAuthHelper', () => {
it('should handle failure response when refreshing session', async () => {
anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' });
const helper = new GoogleAuthHelper(
{ apiOrigin: 'my-origin', dev: false },
new MockOAuthApi(),
);
const helper = new AuthHelper(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
'Auth refresh request failed with status NOPE',
);
@@ -74,11 +82,11 @@ describe('GoogleAuthHelper', () => {
it('should fail if popup was rejected', async () => {
const mockOauth = new MockOAuthApi();
const helper = new GoogleAuthHelper(
{ apiOrigin: 'my-origin', dev: false },
mockOauth,
);
const promise = helper.createSession('a b');
const helper = new AuthHelper({
...defaultOptions,
oauthRequestApi: mockOauth,
});
const promise = helper.createSession(BasicOAuthScopes.from('a b'));
await mockOauth.rejectAll();
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
});
@@ -91,25 +99,24 @@ describe('GoogleAuthHelper', () => {
expiresInSeconds: 3600,
});
const popupSpy = jest.spyOn(mockOauth, 'showLoginPopup');
const helper = new GoogleAuthHelper(
{ apiOrigin: 'my-origin', dev: false },
mockOauth,
);
const helper = new AuthHelper({
...defaultOptions,
oauthRequestApi: mockOauth,
});
const sessionPromise = helper.createSession('a b');
const sessionPromise = helper.createSession(BasicOAuthScopes.from('a b'));
await mockOauth.triggerAll();
expect(popupSpy).toBeCalledTimes(1);
expect(popupSpy.mock.calls[0][0]).toMatchObject({
url:
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
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(GoogleScopes),
scopes: expect.any(BasicOAuthScopes),
expiresAt: expect.any(Date),
});
});
@@ -14,59 +14,69 @@
* limitations under the License.
*/
import GoogleIcon from '@material-ui/icons/AcUnit';
import GoogleScopes from './GoogleScopes';
import { GoogleSession } from './types';
import { AuthRequester, OAuthRequestApi } from '../../..';
import { AuthRequester } from '../../..';
import {
OAuthRequestApi,
AuthProvider,
OAuthScopes,
} from '../../../definitions';
const API_PATH = '/api/backend/auth';
const DEFAULT_BASE_PATH = '/api/auth/';
type Options = {
apiOrigin: string;
dev: boolean;
type Options<AuthSession> = {
apiOrigin?: string;
basePath?: string;
providerPath: string;
environment: string;
provider: AuthProvider;
oauthRequestApi: OAuthRequestApi;
sessionTransform?(response: any): AuthSession | Promise<AuthSession>;
};
export type GoogleAuthResponse = {
accessToken: string;
idToken: string;
scopes: string;
expiresInSeconds: number;
};
export type AuthHelper = {
refreshSession(): Promise<GoogleSession>;
export type GenericAuthHelper<AuthSession> = {
refreshSession(): Promise<AuthSession>;
removeSession(): Promise<void>;
createSession(scope: string): Promise<GoogleSession>;
createSession(scope: OAuthScopes): Promise<AuthSession>;
};
class GoogleAuthHelper implements AuthHelper {
private readonly authRequester: AuthRequester<GoogleSession>;
private refreshPromise?: Promise<GoogleSession>;
export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
private readonly apiOrigin: string;
private readonly basePath: string;
private readonly providerPath: string;
private readonly environment: string;
private readonly provider: AuthProvider;
private readonly oauthRequestApi: OAuthRequestApi;
private readonly authRequester: AuthRequester<AuthSession>;
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
static create(oauthRequest: OAuthRequestApi) {
return new GoogleAuthHelper(
{
apiOrigin: window.location.origin,
dev: process.env.NODE_ENV === 'development',
},
oauthRequest,
);
}
private refreshPromise?: Promise<AuthSession>;
constructor(
private readonly options: Options,
private readonly oauth: OAuthRequestApi,
) {
this.authRequester = oauth.createAuthRequester({
provider: {
title: 'Google',
icon: GoogleIcon,
},
constructor(options: Options<AuthSession>) {
const {
apiOrigin = window.location.origin,
basePath = DEFAULT_BASE_PATH,
providerPath,
environment,
provider,
oauthRequestApi,
sessionTransform = (id) => id,
} = options;
this.authRequester = oauthRequestApi.createAuthRequester({
provider,
onAuthRequest: (scopes) => this.showPopup(scopes.toString()),
});
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.providerPath = providerPath;
this.environment = environment;
this.provider = provider;
this.oauthRequestApi = oauthRequestApi;
this.sessionTransform = sessionTransform;
}
async refreshSession(): Promise<GoogleSession> {
async refreshSession(): Promise<AuthSession> {
if (this.refreshPromise) {
return this.refreshPromise;
}
@@ -107,7 +117,7 @@ class GoogleAuthHelper implements AuthHelper {
}
throw error;
}
return GoogleAuthHelper.convertAuthInfo(authInfo);
return await this.sessionTransform(authInfo);
}
async removeSession(): Promise<void> {
@@ -124,22 +134,22 @@ class GoogleAuthHelper implements AuthHelper {
}
}
async createSession(scope: string): Promise<GoogleSession> {
return this.authRequester(GoogleScopes.from(scope));
async createSession(scope: OAuthScopes): Promise<AuthSession> {
return this.authRequester(scope);
}
private async showPopup(scope: string): Promise<GoogleSession> {
private async showPopup(scope: string): Promise<AuthSession> {
const popupUrl = this.buildUrl('/start', { scope });
const payload = await this.oauth.showLoginPopup({
const payload = await this.oauthRequestApi.showLoginPopup({
url: popupUrl,
name: 'google-login',
origin: this.options.apiOrigin,
name: `${this.provider.title} Login`,
origin: this.apiOrigin,
width: 450,
height: 730,
});
return GoogleAuthHelper.convertAuthInfo(payload);
return await this.sessionTransform(payload);
}
private buildUrl(
@@ -148,10 +158,10 @@ class GoogleAuthHelper implements AuthHelper {
): string {
const queryString = this.buildQueryString({
...query,
dev: this.options.dev,
env: this.environment,
});
return `${this.options.apiOrigin}${API_PATH}${path}${queryString}`;
return `${this.apiOrigin}${this.basePath}${this.providerPath}${path}${queryString}`;
}
private buildQueryString(query?: {
@@ -178,15 +188,4 @@ class GoogleAuthHelper implements AuthHelper {
}
return `?${queryString}`;
}
private static convertAuthInfo(authInfo: GoogleAuthResponse): GoogleSession {
return {
idToken: authInfo.idToken,
accessToken: authInfo.accessToken,
scopes: GoogleScopes.from(authInfo.scopes),
expiresAt: new Date(Date.now() + authInfo.expiresInSeconds * 1000),
};
}
}
export default GoogleAuthHelper;
@@ -14,23 +14,22 @@
* limitations under the License.
*/
import GoogleScopes from './GoogleScopes';
import MockAuthHelper, { mockIdToken, mockAccessToken } from './MockAuthHelper';
import MockAuthHelper, { mockAccessToken } from './MockAuthHelper';
describe('MockAuthHelper', () => {
it('should return mock tokens', async () => {
const helper = new MockAuthHelper();
await expect(helper.createSession()).resolves.toEqual({
idToken: mockIdToken,
accessToken: mockAccessToken,
expiresAt: expect.any(Date),
scopes: expect.any(GoogleScopes),
scopes: expect.any(String),
});
await expect(helper.refreshSession()).resolves.toEqual({
idToken: mockIdToken,
accessToken: mockAccessToken,
expiresAt: expect.any(Date),
scopes: expect.any(GoogleScopes),
scopes: expect.any(String),
});
});
});
@@ -14,24 +14,24 @@
* limitations under the License.
*/
import GoogleScopes from './GoogleScopes';
import { GoogleSession } from './types';
import { AuthHelper } from './GoogleAuthHelper';
import { GenericAuthHelper } from './AuthHelper';
export const mockIdToken = 'mock-id-token';
export const mockAccessToken = 'mock-access-token';
const defaultMockSession: GoogleSession = {
idToken: mockIdToken,
accessToken: mockAccessToken,
expiresAt: new Date(),
scopes: GoogleScopes.default(),
type MockSession = {
accessToken: string;
expiresAt: Date;
scopes: string;
};
export default class MockAuthHelper implements AuthHelper {
constructor(
private readonly mockSession: GoogleSession = defaultMockSession,
) {}
const defaultMockSession: MockSession = {
accessToken: mockAccessToken,
expiresAt: new Date(),
scopes: 'profile email',
};
export default class MockAuthHelper implements GenericAuthHelper<MockSession> {
constructor(private readonly mockSession: MockSession = defaultMockSession) {}
async refreshSession() {
return this.mockSession;
@@ -42,13 +42,4 @@ export default class MockAuthHelper implements AuthHelper {
async createSession() {
return this.mockSession;
}
async showPopup(scope: string) {
return {
scopes: GoogleScopes.from(scope),
idToken: 'i',
accessToken: 'a',
expiresAt: new 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 { AuthHelper } from './AuthHelper';