From 775e339b1e258a11fdccc3d1ca0a9fc492d4793c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 19 May 2020 13:08:00 +0200 Subject: [PATCH] packages/core: refactor GoogleAuthHelper into generic helper and move to lib --- .../implementations/auth/google/GoogleAuth.ts | 38 +++++- .../AuthHelper/AuthHelper.test.ts} | 63 +++++---- .../AuthHelper/AuthHelper.ts} | 121 +++++++++--------- .../AuthHelper}/MockAuthHelper.test.ts | 11 +- .../AuthHelper}/MockAuthHelper.ts | 35 ++--- .../implementations/lib/AuthHelper/index.ts | 17 +++ 6 files changed, 165 insertions(+), 120 deletions(-) rename packages/core/src/api/apis/implementations/{auth/google/GoogleAuthHelper.test.ts => lib/AuthHelper/AuthHelper.test.ts} (68%) rename packages/core/src/api/apis/implementations/{auth/google/GoogleAuthHelper.ts => lib/AuthHelper/AuthHelper.ts} (56%) rename packages/core/src/api/apis/implementations/{auth/google => lib/AuthHelper}/MockAuthHelper.test.ts (79%) rename packages/core/src/api/apis/implementations/{auth/google => lib/AuthHelper}/MockAuthHelper.ts (58%) create mode 100644 packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts index f0147c1805..efa5ef7079 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts @@ -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) {} 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; diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts similarity index 68% rename from packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts rename to packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts index fbd45fd779..713a5e7d05 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.test.ts @@ -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(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), }); }); diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts similarity index 56% rename from packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts rename to packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts index e6e0b8f01e..d42055d332 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts @@ -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 = { + apiOrigin?: string; + basePath?: string; + providerPath: string; + environment: string; + provider: AuthProvider; + oauthRequestApi: OAuthRequestApi; + sessionTransform?(response: any): AuthSession | Promise; }; -export type GoogleAuthResponse = { - accessToken: string; - idToken: string; - scopes: string; - expiresInSeconds: number; -}; - -export type AuthHelper = { - refreshSession(): Promise; +export type GenericAuthHelper = { + refreshSession(): Promise; removeSession(): Promise; - createSession(scope: string): Promise; + createSession(scope: OAuthScopes): Promise; }; -class GoogleAuthHelper implements AuthHelper { - private readonly authRequester: AuthRequester; - private refreshPromise?: Promise; +export class AuthHelper implements AuthHelper { + 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; + private readonly sessionTransform: (response: any) => Promise; - static create(oauthRequest: OAuthRequestApi) { - return new GoogleAuthHelper( - { - apiOrigin: window.location.origin, - dev: process.env.NODE_ENV === 'development', - }, - oauthRequest, - ); - } + private refreshPromise?: Promise; - constructor( - private readonly options: Options, - private readonly oauth: OAuthRequestApi, - ) { - this.authRequester = oauth.createAuthRequester({ - provider: { - title: 'Google', - icon: GoogleIcon, - }, + constructor(options: Options) { + 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 { + async refreshSession(): Promise { 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 { @@ -124,22 +134,22 @@ class GoogleAuthHelper implements AuthHelper { } } - async createSession(scope: string): Promise { - return this.authRequester(GoogleScopes.from(scope)); + async createSession(scope: OAuthScopes): Promise { + return this.authRequester(scope); } - private async showPopup(scope: string): Promise { + private async showPopup(scope: string): Promise { 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; diff --git a/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.test.ts similarity index 79% rename from packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts rename to packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.test.ts index c785ace087..0cb1e13163 100644 --- a/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.test.ts @@ -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), }); }); }); diff --git a/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts similarity index 58% rename from packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts rename to packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts index 4bd5d4e56e..c86854bfc2 100644 --- a/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts @@ -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 { + 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(), - }; - } } diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts new file mode 100644 index 0000000000..29066e4a4b --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AuthHelper } from './AuthHelper';