From 3fe63fbab4a4458002f0d8020fba0893389d2fae Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 19 May 2020 12:02:43 +0200 Subject: [PATCH] packages/core: lift out internal goog auth implementation --- packages/core/package.json | 3 +- .../auth/google/GoogleAuth.test.ts | 198 ++++++++++++++++++ .../implementations/auth/google/GoogleAuth.ts | 130 ++++++++++++ .../auth/google/GoogleAuthHelper.test.ts | 116 ++++++++++ .../auth/google/GoogleAuthHelper.ts | 192 +++++++++++++++++ .../auth/google/GoogleScopes.test.ts | 88 ++++++++ .../auth/google/GoogleScopes.ts | 62 ++++++ .../auth/google/MockAuthHelper.test.ts | 36 ++++ .../auth/google/MockAuthHelper.ts | 54 +++++ .../apis/implementations/auth/google/index.ts | 18 ++ .../apis/implementations/auth/google/types.ts | 94 +++++++++ packages/core/src/setupTests.ts | 1 + 12 files changed, 991 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/GoogleScopes.test.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/GoogleScopes.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/index.ts create mode 100644 packages/core/src/api/apis/implementations/auth/google/types.ts diff --git a/packages/core/package.json b/packages/core/package.json index e73c7c581f..68b8feb7d7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,8 @@ "@backstage/test-utils-core": "^0.1.1-alpha.5", "@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}" diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts new file mode 100644 index 0000000000..a6d2b082b2 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts @@ -0,0 +1,198 @@ +/* + * 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'; +import GoogleScopes from './GoogleScopes'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +describe('GoogleAuth', () => { + it('should save result form createSession', async () => { + const createSession = jest.fn().mockResolvedValue({ expiresAt: theFuture }); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); + + await googleAuth.getSession({}); + expect(createSession).toBeCalledTimes(1); + + await googleAuth.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 googleAuth = new GoogleAuth({ createSession, refreshSession } as any); + + createSession.mockResolvedValue({ + scopes: GoogleScopes.from('a'), + expiresAt: theFuture, + }); + await googleAuth.getSession({ scope: 'a' }); + expect(createSession).toBeCalledTimes(1); + + await googleAuth.getSession({ scope: 'a' }); + expect(createSession).toBeCalledTimes(1); + + await googleAuth.getSession({ scope: '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: GoogleScopes.from('a') }); + const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); + + createSession.mockResolvedValue({ + scopes: GoogleScopes.from('a'), + expiresAt: thePast, + }); + + await googleAuth.getSession({ scope: 'a' }); + expect(createSession).toBeCalledTimes(1); + expect(refreshSession).toBeCalledTimes(1); + + await googleAuth.getSession({ scope: '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 googleAuth = new GoogleAuth({ createSession, refreshSession } as any); + + createSession.mockRejectedValueOnce(new Error('some error')); + await expect(googleAuth.getSession({ scope: 'a' })).rejects.toThrow( + 'some error', + ); + }); + + it('should logout 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 googleAuth = new GoogleAuth({ removeSession } as any); + + await googleAuth.logout(); + expect(window.location.reload).toHaveBeenCalled(); + expect(removeSession).toHaveBeenCalled(); + }); + + it('should get refreshed access token', async () => { + const refreshSession = jest + .fn() + .mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture }); + const googleAuth = new GoogleAuth({ refreshSession } as any); + + expect(await googleAuth.getAccessToken()).toBe('access-token'); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should get refreshed id token', async () => { + const refreshSession = jest + .fn() + .mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); + const googleAuth = new GoogleAuth({ refreshSession } as any); + + expect(await googleAuth.getIdToken()).toBe('id-token'); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const refreshSession = jest + .fn() + .mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); + const googleAuth = new GoogleAuth({ refreshSession } as any); + + expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token'); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should not get optional id token', async () => { + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const googleAuth = new GoogleAuth({ refreshSession } as any); + + expect(await googleAuth.getIdToken({ optional: true })).toBe(''); + expect(refreshSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const createSession = jest.fn().mockRejectedValue(error); + const refreshSession = jest.fn().mockResolvedValue({ + accessToken: 'access-token', + expiresAt: theFuture, + scopes: GoogleScopes.from('not-enough'), + }); + const googleAuth = new GoogleAuth({ createSession, refreshSession } 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(refreshSession).toBeCalledTimes(1); + expect(createSession).toBeCalledTimes(2); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + idToken: 'token1', + expiresAt: theFuture, + scopes: GoogleScopes.empty(), + }; + const refreshSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + idToken: 'token2', + expiresAt: theFuture, + scopes: GoogleScopes.empty(), + }); + const googleAuth = new GoogleAuth({ refreshSession } as any); + + // Grab the expired session first + await expect(googleAuth.getIdToken()).resolves.toBe('token1'); + expect(refreshSession).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(refreshSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); +}); diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts new file mode 100644 index 0000000000..d74b159095 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts @@ -0,0 +1,130 @@ +/* + * 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 { AuthHelper } from './GoogleAuthHelper'; +import GoogleScopes from './GoogleScopes'; +import { GoogleAuthApi, GoogleSession, IdTokenOptions } from './types'; +import { OAuthScopes } from '../../..'; + +class GoogleAuth implements GoogleAuthApi { + private currentSession: GoogleSession | undefined; + + constructor(private readonly helper: AuthHelper) {} + + async getAccessToken(scope?: string | string[]) { + const session = await this.getSession({ optional: false, scope }); + return session.accessToken; + } + + async getIdToken({ optional }: IdTokenOptions = {}) { + const session = await this.getSession({ optional: optional || false }); + if (session) { + return session.idToken; + } + return ''; + } + + async getSession(options: { + optional: false; + scope?: string | string[]; + }): Promise; + async getSession(options: { + optional?: boolean; + scope?: string | string[]; + }): Promise; + async getSession(options: { + optional?: boolean; + scope?: string | string[]; + }): Promise { + if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) { + if (!this.sessionWillExpire(this.currentSession!)) { + return this.currentSession!; + } + + try { + const refreshedSession = await this.helper.refreshSession(); + if (refreshedSession.scopes.hasScopes(this.currentSession!.scopes)) { + 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.helper.refreshSession(); + 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.helper.createSession( + this.getExtendedScope(options.scope), + ); + return this.currentSession; + } + + async logout() { + await this.helper.removeSession(); + window.location.reload(); + } + + private sessionExistsAndHasScope( + session: GoogleSession | undefined, + scope?: string | string[], + ): boolean { + if (!session) { + return false; + } + if (!scope) { + return true; + } + return session.scopes.hasScopes(scope); + } + + private sessionWillExpire(session: GoogleSession) { + const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + } + + private getExtendedScope(scope?: string | string[]) { + let newScope: OAuthScopes = GoogleScopes.default(); + if (this.currentSession) { + newScope = this.currentSession.scopes; + } + if (scope) { + newScope = newScope.extend(scope); + } + return newScope.toString(); + } +} +export default GoogleAuth; diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts new file mode 100644 index 0000000000..fbd45fd779 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.test.ts @@ -0,0 +1,116 @@ +/* + * 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 GoogleAuthHelper from './GoogleAuthHelper'; +import GoogleScopes from './GoogleScopes'; +import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi'; + +const anyFetch = fetch as any; + +describe('GoogleAuthHelper', () => { + 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 GoogleAuthHelper( + { apiOrigin: 'my-origin', dev: false }, + new MockOAuthApi(), + ); + const session = await helper.refreshSession(); + expect(session.idToken).toBe('mock-id-token'); + expect(session.accessToken).toBe('mock-access-token'); + expect(session.scopes.hasScopes('a b c')).toBe(true); + 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 GoogleAuthHelper( + { apiOrigin: 'my-origin', dev: true }, + new MockOAuthApi(), + ); + 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 GoogleAuthHelper( + { apiOrigin: 'my-origin', dev: false }, + new MockOAuthApi(), + ); + 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 GoogleAuthHelper( + { apiOrigin: 'my-origin', dev: false }, + mockOauth, + ); + const promise = helper.createSession('a b'); + await mockOauth.rejectAll(); + await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); + }); + + it('should create a session', async () => { + const mockOauth = new MockOAuthApi({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: 'a b', + expiresInSeconds: 3600, + }); + const popupSpy = jest.spyOn(mockOauth, 'showLoginPopup'); + const helper = new GoogleAuthHelper( + { apiOrigin: 'my-origin', dev: false }, + mockOauth, + ); + + const sessionPromise = helper.createSession('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', + }); + + await expect(sessionPromise).resolves.toEqual({ + idToken: 'my-id-token', + accessToken: 'my-access-token', + scopes: expect.any(GoogleScopes), + expiresAt: expect.any(Date), + }); + }); +}); diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts new file mode 100644 index 0000000000..e6e0b8f01e --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuthHelper.ts @@ -0,0 +1,192 @@ +/* + * 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 GoogleScopes from './GoogleScopes'; +import { GoogleSession } from './types'; +import { AuthRequester, OAuthRequestApi } from '../../..'; + +const API_PATH = '/api/backend/auth'; + +type Options = { + apiOrigin: string; + dev: boolean; +}; + +export type GoogleAuthResponse = { + accessToken: string; + idToken: string; + scopes: string; + expiresInSeconds: number; +}; + +export type AuthHelper = { + refreshSession(): Promise; + removeSession(): Promise; + createSession(scope: string): Promise; +}; + +class GoogleAuthHelper implements AuthHelper { + private readonly authRequester: AuthRequester; + private refreshPromise?: Promise; + + static create(oauthRequest: OAuthRequestApi) { + return new GoogleAuthHelper( + { + apiOrigin: window.location.origin, + dev: process.env.NODE_ENV === 'development', + }, + oauthRequest, + ); + } + + constructor( + private readonly options: Options, + private readonly oauth: OAuthRequestApi, + ) { + this.authRequester = oauth.createAuthRequester({ + provider: { + title: 'Google', + icon: GoogleIcon, + }, + onAuthRequest: (scopes) => this.showPopup(scopes.toString()), + }); + } + + async refreshSession(): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.doAuthRefresh(); + + try { + return await this.refreshPromise; + } finally { + delete this.refreshPromise; + } + } + + private async doAuthRefresh(): Promise { + 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 GoogleAuthHelper.convertAuthInfo(authInfo); + } + + async removeSession(): Promise { + 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}`); + } + } + + async createSession(scope: string): Promise { + return this.authRequester(GoogleScopes.from(scope)); + } + + private async showPopup(scope: string): Promise { + const popupUrl = this.buildUrl('/start', { scope }); + + const payload = await this.oauth.showLoginPopup({ + url: popupUrl, + name: 'google-login', + origin: this.options.apiOrigin, + width: 450, + height: 730, + }); + + return GoogleAuthHelper.convertAuthInfo(payload); + } + + private buildUrl( + path: string, + query?: { [key: string]: string | boolean | undefined }, + ): string { + const queryString = this.buildQueryString({ + ...query, + dev: this.options.dev, + }); + + return `${this.options.apiOrigin}${API_PATH}${path}${queryString}`; + } + + private buildQueryString(query?: { + [key: string]: string | boolean | undefined; + }): string { + if (!query) { + return ''; + } + + const queryString = Object.entries(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}`; + } + + 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/GoogleScopes.test.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleScopes.test.ts new file mode 100644 index 0000000000..b4eec497bb --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleScopes.test.ts @@ -0,0 +1,88 @@ +/* + * 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 GoogleScopes from './GoogleScopes'; + +const PREFIX = 'https://www.googleapis.com/auth/'; + +describe('GoogleScopes', () => { + it('should be created from scopes', () => { + const scopes = GoogleScopes.from('a openid b profile'); + expect(scopes.toString()).toBe( + `${PREFIX}a openid ${PREFIX}b ${PREFIX}userinfo.profile`, + ); + }); + + it('should be created with default scopes', () => { + expect(GoogleScopes.default().toString()).toBe( + `openid ${PREFIX}userinfo.email ${PREFIX}userinfo.profile`, + ); + }); + + it('should have or not have scopes', () => { + const scopes = GoogleScopes.from(`a b ${PREFIX}c`); + expect(scopes.hasScopes('a')).toBe(true); + expect(scopes.hasScopes('a b')).toBe(true); + expect(scopes.hasScopes('b')).toBe(true); + expect(scopes.hasScopes('b c')).toBe(true); + expect(scopes.hasScopes('a b c')).toBe(true); + expect(scopes.hasScopes(`a b ${PREFIX}c`)).toBe(true); + expect(scopes.hasScopes(`a ${PREFIX}b c`)).toBe(true); + expect(scopes.hasScopes('a b c d')).toBe(false); + expect(scopes.hasScopes('d')).toBe(false); + expect(scopes.hasScopes('')).toBe(true); + expect(scopes.hasScopes('abc')).toBe(false); + expect(scopes.hasScopes(`${PREFIX}a`)).toBe(true); + }); + + it('should handle scope shorthands correctly', () => { + const scopes = GoogleScopes.default(); + expect(scopes.hasScopes('email')).toBe(true); + expect(scopes.hasScopes('profile')).toBe(true); + expect(scopes.hasScopes('openid')).toBe(true); + expect(scopes.hasScopes('userinfo.email')).toBe(true); + expect(scopes.hasScopes('userinfo.profile')).toBe(true); + expect(scopes.hasScopes('userinfo.openid')).toBe(false); + expect(scopes.hasScopes(`${PREFIX}userinfo.email`)).toBe(true); + expect(scopes.hasScopes(`${PREFIX}userinfo.profile`)).toBe(true); + expect(scopes.hasScopes(`${PREFIX}userinfo.openid`)).toBe(false); + expect(scopes.hasScopes(`${PREFIX}email`)).toBe(false); + expect(scopes.hasScopes(`${PREFIX}profile`)).toBe(false); + expect(scopes.hasScopes(`${PREFIX}openid`)).toBe(false); + }); + + it('should be extended', () => { + const scopes = GoogleScopes.from('a b'); + expect(scopes.extend('')).not.toBe(scopes); + expect(scopes.extend('d').toString()).toBe( + `${PREFIX}a ${PREFIX}b ${PREFIX}d`, + ); + expect(scopes.extend('profile').toString()).toBe( + `${PREFIX}a ${PREFIX}b ${PREFIX}userinfo.profile`, + ); + expect(scopes.extend('d profile').toString()).toBe( + `${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`, + ); + expect(scopes.extend(`${PREFIX}d profile`).toString()).toBe( + `${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`, + ); + expect(scopes.extend('a').toString()).toBe(scopes.toString()); + expect(scopes.extend('').toString()).toBe(scopes.toString()); + expect(scopes.extend('b').toString()).toBe(scopes.toString()); + expect(scopes.extend('b a').toString()).toBe(scopes.toString()); + expect(scopes.extend('b a b a a').toString()).toBe(scopes.toString()); + }); +}); diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleScopes.ts b/packages/core/src/api/apis/implementations/auth/google/GoogleScopes.ts new file mode 100644 index 0000000000..a29d392488 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleScopes.ts @@ -0,0 +1,62 @@ +/* + * 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 { BasicOAuthScopes } from '../../OAuthRequestManager/BasicOAuthScopes'; +import { OAuthScopeLike } from '../../..'; + +const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; + +export default class GoogleScopes extends BasicOAuthScopes { + static from(scope: OAuthScopeLike): GoogleScopes { + return new GoogleScopes( + new Set(BasicOAuthScopes.asStrings(scope, GoogleScopes.canonicalScope)), + ); + } + + static default(): GoogleScopes { + return new GoogleScopes( + new Set([ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ]), + ); + } + + static empty(): GoogleScopes { + return new GoogleScopes(new Set()); + } + + constructor(scopes: Set) { + super(scopes, GoogleScopes.canonicalScope); + } + + private static canonicalScope(scope: string): string { + 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}`; + } +} diff --git a/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts b/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts new file mode 100644 index 0000000000..c785ace087 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.test.ts @@ -0,0 +1,36 @@ +/* + * 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 GoogleScopes from './GoogleScopes'; +import MockAuthHelper, { mockIdToken, 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), + }); + await expect(helper.refreshSession()).resolves.toEqual({ + idToken: mockIdToken, + accessToken: mockAccessToken, + expiresAt: expect.any(Date), + scopes: expect.any(GoogleScopes), + }); + }); +}); diff --git a/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts b/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts new file mode 100644 index 0000000000..4bd5d4e56e --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/MockAuthHelper.ts @@ -0,0 +1,54 @@ +/* + * 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 GoogleScopes from './GoogleScopes'; +import { GoogleSession } from './types'; +import { AuthHelper } from './GoogleAuthHelper'; + +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(), +}; + +export default class MockAuthHelper implements AuthHelper { + constructor( + private readonly mockSession: GoogleSession = defaultMockSession, + ) {} + + async refreshSession() { + return this.mockSession; + } + + async removeSession() {} + + 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/auth/google/index.ts b/packages/core/src/api/apis/implementations/auth/google/index.ts new file mode 100644 index 0000000000..78e8a97c31 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/index.ts @@ -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'; diff --git a/packages/core/src/api/apis/implementations/auth/google/types.ts b/packages/core/src/api/apis/implementations/auth/google/types.ts new file mode 100644 index 0000000000..a41c306aa1 --- /dev/null +++ b/packages/core/src/api/apis/implementations/auth/google/types.ts @@ -0,0 +1,94 @@ +/* + * 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 GoogleScopes from './GoogleScopes'; + +/** + * This api provides access to Google OAuth credentials. It lets you request access tokens, + * which can be used to act on behalf of the user when talking to Google APIs. It also supplies + * ID Tokens, which can be passed to backend services to prove the user's identity. + * + * The API can be called directly to get access and ID tokens, which will cause a modal dialog + * to show up if the user is not yet signed in. + * + * For more fine grained control of where the sign in prompt is shown, it is possible to use + * the GoogleAuthBarrier components, which ensures that all components rendered inside it + * have synchronous access to both access and ID tokens. + * + * For full examples, see https://backstage.spotify.net/docs/backstage-frontend/apis/#google-auth-api + */ +export type GoogleAuthApi = { + /** + * Requests a Google OAuth ID Token, optionally with a set of scopes. The scopes allow you to access + * google APIs on behalf of the user. A full list of scopes can be found at https://developers.google.com/identity/protocols/googlescopes. + * + * Be sure to include all required scopes when requesting an access token. When testing your implementation + * it is best to log out the Backstage Google session and then visit your plugin page directly, as + * you might already have some required scopes in your existing session. Not requesting the correct + * scopes can lead to 403 or other authorization errors, which can be tricky to debug. + * + * This method is cheap and should be called each time an access token is used. Do not for example + * store the access token in React component state, as that could cause the token to expire. Instead + * fetch a new access token for each request. + * + * If the user has not yet logged in to Google inside Backstage, a dialog window will be shown + * that prompts the user to log in, and the returned promise will not resolve until the user has + * successfully logged in. + * + * The returned promise can be rejected, but only if the user rejects the login request. If the + * login fails because the user fails to log in to their google account, the dialog will simply + * remain and ask them to try again, and the promise will still be pending. + */ + getAccessToken(scope?: string | string[]): Promise; + + /** + * Requests a Google OAuth ID Token. + * + * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, + * email and expiration information. Do not rely on any other fields, as they might not be present. + * + * This method is cheap and should be called each time an ID token is used. Do not for example + * store the id token in React component state, as that could cause the token to expire. Instead + * fetch a new id token for each request. + * + * If the user has not yet logged in to Google inside Backstage, a dialog window will be shown + * that prompts the user to log in, and the returned promise will not resolve until the user has + * successfully logged in. + * + * The returned promise can be rejected, but only if the user rejects the login request. If the + * login fails because the user fails to log in to their google account, the dialog will simply + * remain and ask them to try again, and the promise will still be pending. + */ + getIdToken(options?: IdTokenOptions): Promise; + + /** + * Logs out the user's Google session. This will reload the page. + */ + logout(): Promise; +}; + +export type GoogleSession = { + idToken: string; + accessToken: string; + scopes: GoogleScopes; + expiresAt: Date; +}; + +export type IdTokenOptions = { + // If this is set to true, the user will not be prompted to log in, + // and an empty id token will be returned if there is no existing session. + optional?: boolean; +}; diff --git a/packages/core/src/setupTests.ts b/packages/core/src/setupTests.ts index 825bcd4115..e34bc46f4b 100644 --- a/packages/core/src/setupTests.ts +++ b/packages/core/src/setupTests.ts @@ -15,3 +15,4 @@ */ import '@testing-library/jest-dom'; +require('jest-fetch-mock').enableMocks();