diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.test.ts new file mode 100644 index 0000000000..91e55c3a48 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { AuthSessionStore } from './AuthSessionStore'; +import { SessionManager } from './types'; + +const defaultOptions = { + storageKey: 'my-key', + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +class LocalStorage { + private store: Record = {}; + + getItem(key: string) { + return this.store[key] || null; + } + setItem(key: string, value: string) { + this.store[key] = value.toString(); + } + removeItem(key: string) { + delete this.store[key]; + } +} + +class MockManager implements SessionManager { + getSession = jest.fn(); + removeSession = jest.fn(); +} + +describe('GheAuth AuthSessionStore', () => { + beforeEach(() => { + delete (window as any).localStorage; + (window as any).localStorage = new LocalStorage(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should load session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('a b c'); + expect(manager.getSession).not.toHaveBeenCalled(); + }); + + it('should not use session without enough scope', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('a b c d'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({ scopes: new Set(['d']) })).resolves.toBe( + 'a b c d', + ); + expect(manager.getSession).toHaveBeenCalledTimes(1); + }); + + it('should not use expired session', async () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ + manager, + ...defaultOptions, + sessionShouldRefresh: () => true, + }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + }); + + it('should not load missing session', async () => { + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + + expect(localStorage.getItem('my-key')).toBe('"123"'); + }); + + it('should ignore bad session values', async () => { + localStorage.setItem('my-key', 'derp'); + + const manager = new MockManager(); + manager.getSession.mockResolvedValue('123'); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + + await expect(store.getSession({})).resolves.toBe('123'); + expect(manager.getSession).toHaveBeenCalledTimes(1); + }); + + it('should clear session', () => { + localStorage.setItem('my-key', '"a b c"'); + + const manager = new MockManager(); + const store = new AuthSessionStore({ manager, ...defaultOptions }); + store.removeSession(); + + expect(localStorage.getItem('my-key')).toBe(null); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts new file mode 100644 index 0000000000..14796c6e11 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts @@ -0,0 +1,117 @@ +/* + * 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 { + SessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, +} from './types'; +import { SessionScopeHelper } from './common'; + +type Options = { + /** The connector used for acting on the auth session */ + manager: SessionManager; + /** Storage key to use to store sessions */ + storageKey: string; + /** Used to get the scope of the session */ + sessionScopes: SessionScopesFunc; + /** Used to check if the session needs to be refreshed, defaults to never refresh */ + sessionShouldRefresh?: SessionShouldRefreshFunc; +}; + +/** + * AuthSessionStore decorates another SessionManager with a functionality + * to store the session in local storage. + */ +export class AuthSessionStore implements SessionManager { + private readonly manager: SessionManager; + private readonly storageKey: string; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; + private readonly helper: SessionScopeHelper; + + constructor(options: Options) { + const { + manager, + storageKey, + sessionScopes, + sessionShouldRefresh = () => false, + } = options; + + this.manager = manager; + this.storageKey = storageKey; + this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ + sessionScopes, + defaultScopes: new Set(), + }); + } + + async getSession(options: { + optional: false; + scopes?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scopes?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scopes?: Set; + }): Promise { + const { scopes } = options; + const session = this.loadSession(); + + if (this.helper.sessionExistsAndHasScope(session, scopes)) { + const shouldRefresh = this.sessionShouldRefreshFunc(session!); + + if (!shouldRefresh) { + return session!; + } + } + + const newSession = await this.manager.getSession(options); + this.saveSession(newSession); + return newSession; + } + + async removeSession() { + localStorage.removeItem(this.storageKey); + await this.manager.removeSession(); + } + + private loadSession(): T | undefined { + try { + const sessionJson = localStorage.getItem(this.storageKey); + if (sessionJson) { + const session = JSON.parse(sessionJson); + return session; + } + + return undefined; + } catch (error) { + localStorage.removeItem(this.storageKey); + return undefined; + } + } + + private saveSession(session: T | undefined) { + if (session === undefined) { + localStorage.removeItem(this.storageKey); + } else { + localStorage.setItem(this.storageKey, JSON.stringify(session)); + } + } +}