From e6537acc9c53e2e1d9eb8a380449646782f33ab2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 20 May 2020 12:03:45 +0200 Subject: [PATCH] packages/core: move google auth session refresh logic out into separate session manager helper --- .../auth/google/GoogleAuth.test.ts | 137 +++------------- .../implementations/auth/google/GoogleAuth.ts | 126 +++------------ .../lib/AuthHelper/AuthHelper.ts | 24 +-- .../lib/AuthHelper/MockAuthHelper.ts | 2 +- .../implementations/lib/AuthHelper/index.ts | 1 + .../implementations/lib/AuthHelper/types.ts | 26 +++ .../RefreshingAuthSessionManager.test.ts | 128 +++++++++++++++ .../RefreshingAuthSessionManager.ts | 152 ++++++++++++++++++ .../lib/AuthSessionManager/index.ts | 18 +++ .../lib/AuthSessionManager/types.ts | 35 ++++ 10 files changed, 407 insertions(+), 242 deletions(-) create mode 100644 packages/core/src/api/apis/implementations/lib/AuthHelper/types.ts create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.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 index b386806636..fde1256ea8 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts @@ -22,138 +22,48 @@ const thePast = new Date(Date.now() - 10); const PREFIX = 'https://www.googleapis.com/auth/'; 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: new Set([`${PREFIX}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: new Set([`${PREFIX}a`]) }); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); - - createSession.mockResolvedValue({ - scopes: new Set([`${PREFIX}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 + const getSession = jest .fn() .mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); + const googleAuth = new GoogleAuth({ getSession } as any); expect(await googleAuth.getAccessToken()).toBe('access-token'); - expect(refreshSession).toBeCalledTimes(1); + expect(getSession).toBeCalledTimes(1); }); it('should get refreshed id token', async () => { - const refreshSession = jest + const getSession = jest .fn() .mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); + const googleAuth = new GoogleAuth({ getSession } as any); expect(await googleAuth.getIdToken()).toBe('id-token'); - expect(refreshSession).toBeCalledTimes(1); + expect(getSession).toBeCalledTimes(1); }); it('should get optional id token', async () => { - const refreshSession = jest + const getSession = jest .fn() .mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); + const googleAuth = new GoogleAuth({ getSession } 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); + expect(getSession).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: new Set([`${PREFIX}not-enough`]), - }); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); + 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'); @@ -162,8 +72,7 @@ describe('GoogleAuth', () => { 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); + expect(getSession).toBeCalledTimes(3); }); it('should wait for all session refreshes', async () => { @@ -172,7 +81,7 @@ describe('GoogleAuth', () => { expiresAt: theFuture, scopes: new Set(), }; - const refreshSession = jest + const getSession = jest .fn() .mockResolvedValueOnce(initialSession) .mockResolvedValue({ @@ -180,11 +89,11 @@ describe('GoogleAuth', () => { expiresAt: theFuture, scopes: new Set(), }); - const googleAuth = new GoogleAuth({ refreshSession } as any); + const googleAuth = new GoogleAuth({ getSession } as any); // Grab the expired session first await expect(googleAuth.getIdToken()).resolves.toBe('token1'); - expect(refreshSession).toBeCalledTimes(1); + expect(getSession).toBeCalledTimes(1); initialSession.expiresAt = thePast; @@ -194,6 +103,6 @@ describe('GoogleAuth', () => { 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 + expect(getSession).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 index 1ae94ae9c6..0b32db2023 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts @@ -23,8 +23,8 @@ import { IdTokenOptions, } from '../../../definitions/auth'; import { OAuthRequestApi } from '../../../definitions'; -import { GenericAuthHelper } from '../../lib/AuthHelper/AuthHelper'; -import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests'; +import { SessionManager } from '../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../lib/AuthSessionManager'; export type GoogleAuthResponse = { accessToken: string; @@ -34,15 +34,8 @@ export type GoogleAuthResponse = { }; const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; -const DEFAULT_SCOPES = [ - 'openid', - `${SCOPE_PREFIX}userinfo.email`, - `${SCOPE_PREFIX}userinfo.profile`, -]; class GoogleAuth implements OAuthApi, OpenIdConnectApi { - private currentSession: GoogleSession | undefined; - static create(oauthRequestApi: OAuthRequestApi) { const helper = new AuthHelper({ providerPath: 'google/', @@ -62,116 +55,41 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { }, }); - return new GoogleAuth(helper); + const sessionManager = new RefreshingAuthSessionManager({ + helper, + defaultScopes: new Set([ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ]), + }); + + return new GoogleAuth(sessionManager); } - constructor(private readonly helper: GenericAuthHelper) {} + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken(scope?: string | string[]) { - const session = await this.getSession({ optional: false, scope }); + 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.getSession({ optional: optional || false }); + const session = await this.sessionManager.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 { - const normalizedScope = GoogleAuth.normalizeScopes(options.scope); - - if (this.sessionExistsAndHasScope(this.currentSession, normalizedScope)) { - if (!this.sessionWillExpire(this.currentSession!)) { - return this.currentSession!; - } - - try { - const refreshedSession = await this.helper.refreshSession(); - if (hasScopes(refreshedSession.scopes, 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(normalizedScope), - ); - return this.currentSession; - } - async logout() { - await this.helper.removeSession(); - window.location.reload(); - } - - private sessionExistsAndHasScope( - session: GoogleSession | undefined, - scope?: Set, - ): boolean { - if (!session) { - return false; - } - if (!scope) { - return true; - } - return hasScopes(session.scopes, scope); - } - - private sessionWillExpire(session: GoogleSession) { - const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; - } - - private getExtendedScope(scopes: Set) { - const newScope = new Set(DEFAULT_SCOPES); - if (this.currentSession) { - for (const scope of this.currentSession.scopes) { - newScope.add(scope); - } - } - for (const scope of scopes) { - newScope.add(scope); - } - return newScope; + await this.sessionManager.removeSession(); } private static normalizeScopes(scopes?: string | string[]): Set { diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts index e1d9d12b31..d855c17076 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/AuthHelper.ts @@ -30,12 +30,6 @@ type Options = { sessionTransform?(response: any): AuthSession | Promise; }; -export type GenericAuthHelper = { - refreshSession(): Promise; - removeSession(): Promise; - createSession(scopes: Set): Promise; -}; - export class AuthHelper implements AuthHelper { private readonly apiOrigin: string; private readonly basePath: string; @@ -45,8 +39,6 @@ export class AuthHelper implements AuthHelper { private readonly authRequester: AuthRequester; private readonly sessionTransform: (response: any) => Promise; - private refreshPromise?: Promise; - constructor(options: Options) { const { apiOrigin = window.location.origin, @@ -71,21 +63,7 @@ export class AuthHelper implements AuthHelper { this.sessionTransform = sessionTransform; } - 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 { + async refreshSession(): Promise { const res = await fetch(this.buildUrl('/token', { optional: true }), { headers: { 'x-requested-with': 'XMLHttpRequest', diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts index c86854bfc2..e793ebd047 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/MockAuthHelper.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { GenericAuthHelper } from './AuthHelper'; +import { GenericAuthHelper } from './types'; export const mockAccessToken = 'mock-access-token'; diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts index 29066e4a4b..e472ca06de 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/index.ts @@ -15,3 +15,4 @@ */ export { AuthHelper } from './AuthHelper'; +export * from './types'; diff --git a/packages/core/src/api/apis/implementations/lib/AuthHelper/types.ts b/packages/core/src/api/apis/implementations/lib/AuthHelper/types.ts new file mode 100644 index 0000000000..b9ccae11c8 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthHelper/types.ts @@ -0,0 +1,26 @@ +/* + * 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 BaseAuthSession = { + scopes: Set; + expiresAt: Date; +}; + +export type GenericAuthHelper = { + refreshSession(): Promise; + removeSession(): Promise; + createSession(scopes: Set): Promise; +}; diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts new file mode 100644 index 0000000000..511f860f75 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -0,0 +1,128 @@ +/* + * 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 theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +describe('RefreshingAuthSessionManager', () => { + it('should save result form createSession', async () => { + const createSession = jest.fn().mockResolvedValue({ expiresAt: theFuture }); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + helper: { createSession, refreshSession }, + } 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({ + helper: { createSession, refreshSession }, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expiresAt: theFuture, + }); + 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({ + helper: { createSession, refreshSession }, + } as any); + + createSession.mockResolvedValue({ + scopes: new Set(['a']), + expiresAt: thePast, + }); + + 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({ + helper: { createSession, refreshSession }, + } 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({ + helper: { createSession, refreshSession }, + } 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({ + helper: { removeSession }, + } as any); + + await manager.removeSession(); + expect(window.location.reload).toHaveBeenCalled(); + expect(removeSession).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts new file mode 100644 index 0000000000..5670a0f7df --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -0,0 +1,152 @@ +/* + * 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 { BaseAuthSession, GenericAuthHelper } from '../AuthHelper'; + +type Options = { + helper: GenericAuthHelper; + defaultScopes?: Set; +}; + +/** + * RefreshingAuthSessionManager manages an underlying session that has + * and expiration time and needs to be refreshed periodically. + */ +export class RefreshingAuthSessionManager + implements SessionManager { + private readonly helper: GenericAuthHelper; + private readonly defaultScopes?: Set; + + private refreshPromise?: Promise; + private currentSession: AuthSession | undefined; + + constructor(options: Options) { + const { helper, defaultScopes = new Set() } = options; + + this.helper = helper; + this.defaultScopes = defaultScopes; + } + + async getSession(options: { + optional: false; + scope?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scope?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scope?: Set; + }): Promise { + if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) { + if (!this.sessionWillExpire(this.currentSession!)) { + return this.currentSession!; + } + + try { + const refreshedSession = await this.collapsedSessionRefresh(); + if (hasScopes(refreshedSession.scopes, 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.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.helper.createSession( + this.getExtendedScope(options.scope), + ); + return this.currentSession; + } + + async removeSession() { + await this.helper.removeSession(); + window.location.reload(); // TODO(Rugvip): make this work without reload? + } + + private sessionExistsAndHasScope( + session: AuthSession | undefined, + scope?: Set, + ): boolean { + if (!session) { + return false; + } + if (!scope) { + return true; + } + return hasScopes(session.scopes, scope); + } + + private sessionWillExpire(session: AuthSession) { + const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + } + + private getExtendedScope(scopes?: Set) { + const newScope = new Set(this.defaultScopes); + if (this.currentSession) { + for (const scope of this.currentSession.scopes) { + newScope.add(scope); + } + } + if (scopes) { + for (const scope of scopes) { + newScope.add(scope); + } + } + return newScope; + } + + private async collapsedSessionRefresh(): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.helper.refreshSession(); + + try { + return await this.refreshPromise; + } finally { + delete this.refreshPromise; + } + } +} diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts new file mode 100644 index 0000000000..426c514646 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/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 { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +export * from './types'; diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts new file mode 100644 index 0000000000..6475776505 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts @@ -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 { BaseAuthSession } from '../AuthHelper/types'; + +/** + * 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 = { + getSession(options: { + optional: false; + scope?: Set; + }): Promise; + getSession(options: { + optional?: boolean; + scope?: Set; + }): Promise; + + removeSession(): Promise; +};