From cb40cd4a47591e1e4290ec4a9168d0464a0a7044 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 25 May 2020 12:16:40 +0200 Subject: [PATCH 1/7] packages/core: stick to naming convention of using "scopes" for set of scopes --- .../implementations/auth/google/GoogleAuth.ts | 2 +- .../RefreshingAuthSessionManager.test.ts | 16 ++++++++-------- .../RefreshingAuthSessionManager.ts | 16 ++++++++-------- .../lib/AuthSessionManager/types.ts | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) 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 d1d5da1a02..ccf99772fc 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts @@ -99,7 +99,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { const normalizedScopes = GoogleAuth.normalizeScopes(scope); const session = await this.sessionManager.getSession({ optional: false, - scope: normalizedScopes, + scopes: normalizedScopes, }); return session.accessToken; } 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 index 6841934357..8684c932b2 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -51,13 +51,13 @@ describe('RefreshingAuthSessionManager', () => { scopes: new Set(['a']), expired: false, }); - await manager.getSession({ scope: new Set(['a']) }); + await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toBeCalledTimes(1); - await manager.getSession({ scope: new Set(['a']) }); + await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toBeCalledTimes(1); - await manager.getSession({ scope: new Set(['b']) }); + await manager.getSession({ scopes: new Set(['b']) }); expect(createSession).toBeCalledTimes(2); }); @@ -77,11 +77,11 @@ describe('RefreshingAuthSessionManager', () => { expired: true, }); - await manager.getSession({ scope: new Set(['a']) }); + await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toBeCalledTimes(1); expect(refreshSession).toBeCalledTimes(1); - await manager.getSession({ scope: new Set(['a']) }); + await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toBeCalledTimes(1); expect(refreshSession).toBeCalledTimes(2); }); @@ -95,9 +95,9 @@ describe('RefreshingAuthSessionManager', () => { } as any); createSession.mockRejectedValueOnce(new Error('some error')); - await expect(manager.getSession({ scope: new Set(['a']) })).rejects.toThrow( - 'some error', - ); + await expect( + manager.getSession({ scopes: new Set(['a']) }), + ).rejects.toThrow('some error'); }); it('should not get optional session', async () => { diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 147d9f4b49..34415ea615 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -70,17 +70,17 @@ export class RefreshingAuthSessionManager async getSession(options: { optional: false; - scope?: Set; + scopes?: Set; }): Promise; async getSession(options: { optional?: boolean; - scope?: Set; + scopes?: Set; }): Promise; async getSession(options: { optional?: boolean; - scope?: Set; + scopes?: Set; }): Promise { - if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) { + if (this.sessionExistsAndHasScope(this.currentSession, options.scopes)) { const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); if (!shouldRefresh) { return this.currentSession!; @@ -122,7 +122,7 @@ export class RefreshingAuthSessionManager // We can call authRequester multiple times, the returned session will contain all requested scopes. this.currentSession = await this.connector.createSession( - this.getExtendedScope(options.scope), + this.getExtendedScope(options.scopes), ); return this.currentSession; } @@ -134,16 +134,16 @@ export class RefreshingAuthSessionManager private sessionExistsAndHasScope( session: AuthSession | undefined, - scope?: Set, + scopes?: Set, ): boolean { if (!session) { return false; } - if (!scope) { + if (!scopes) { return true; } const sessionScopes = this.sessionScopesFunc(session); - return hasScopes(sessionScopes, scope); + return hasScopes(sessionScopes, scopes); } private getExtendedScope(scopes?: Set) { diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts index 440944cce0..5c5f700047 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts @@ -22,11 +22,11 @@ export type SessionManager = { getSession(options: { optional: false; - scope?: Set; + scopes?: Set; }): Promise; getSession(options: { optional?: boolean; - scope?: Set; + scopes?: Set; }): Promise; removeSession(): Promise; From 5ca73f6d02e3351723530b7ea1bc29fd704baea1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 25 May 2020 14:20:52 +0200 Subject: [PATCH 2/7] packages/core: separate some common things out from RefreshingAuthSessionManager --- .../RefreshingAuthSessionManager.ts | 101 ++++++------------ .../lib/AuthSessionManager/common.ts | 68 ++++++++++++ .../lib/AuthSessionManager/types.ts | 22 ++-- 3 files changed, 117 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/common.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 34415ea615..0cb0f2f1ea 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -14,29 +14,23 @@ * limitations under the License. */ -import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests'; -import { SessionManager } from './types'; +import { + SessionManager, + SessionScopesFunc, + SessionShouldRefreshFunc, +} from './types'; import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper } from './common'; +import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests'; -type Options = { - /** - * The connector used for acting on the auth session. - */ - connector: AuthConnector; - /** - * A function called to determine the scopes of the session. - */ - sessionScopes: (session: AuthSession) => Set; - /** - * A function called to determine whether it's time for a session to refresh. - * - * This should return true before the session expires, for example, if a session - * expires after 60 minutes, you could return true if the session is older than 45 minutes. - */ - sessionShouldRefresh: (session: AuthSession) => boolean; - /** - * The default scopes that should always be present in a session, defaults to none. - */ +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes: SessionScopesFunc; + /** Used to check if the session needs to be refreshed */ + sessionShouldRefresh: SessionShouldRefreshFunc; + /** The default scopes that should always be present in a session, defaults to none. */ defaultScopes?: Set; }; @@ -44,17 +38,16 @@ type Options = { * RefreshingAuthSessionManager manages an underlying session that has * and expiration time and needs to be refreshed periodically. */ -export class RefreshingAuthSessionManager - implements SessionManager { - private readonly connector: AuthConnector; - private readonly defaultScopes?: Set; - private readonly sessionScopesFunc: (session: AuthSession) => Set; - private readonly sessionShouldRefreshFunc: (session: AuthSession) => boolean; +export class RefreshingAuthSessionManager implements SessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + private readonly sessionScopesFunc: SessionScopesFunc; + private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; - private refreshPromise?: Promise; - private currentSession: AuthSession | undefined; + private refreshPromise?: Promise; + private currentSession: T | undefined; - constructor(options: Options) { + constructor(options: Options) { const { connector, defaultScopes = new Set(), @@ -63,24 +56,26 @@ export class RefreshingAuthSessionManager } = options; this.connector = connector; - this.defaultScopes = defaultScopes; this.sessionScopesFunc = sessionScopes; this.sessionShouldRefreshFunc = sessionShouldRefresh; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); } async getSession(options: { optional: false; scopes?: Set; - }): Promise; + }): Promise; async getSession(options: { optional?: boolean; scopes?: Set; - }): Promise; + }): Promise; async getSession(options: { optional?: boolean; scopes?: Set; - }): Promise { - if (this.sessionExistsAndHasScope(this.currentSession, options.scopes)) { + }): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!); if (!shouldRefresh) { return this.currentSession!; @@ -111,7 +106,7 @@ export class RefreshingAuthSessionManager // 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 the refresh attempt fails we assume we don't have a session, so continue to create one. } } @@ -122,7 +117,7 @@ export class RefreshingAuthSessionManager // We can call authRequester multiple times, the returned session will contain all requested scopes. this.currentSession = await this.connector.createSession( - this.getExtendedScope(options.scopes), + this.helper.getExtendedScope(this.currentSession, options.scopes), ); return this.currentSession; } @@ -132,37 +127,7 @@ export class RefreshingAuthSessionManager window.location.reload(); // TODO(Rugvip): make this work without reload? } - private sessionExistsAndHasScope( - session: AuthSession | undefined, - scopes?: Set, - ): boolean { - if (!session) { - return false; - } - if (!scopes) { - return true; - } - const sessionScopes = this.sessionScopesFunc(session); - return hasScopes(sessionScopes, scopes); - } - - private getExtendedScope(scopes?: Set) { - const newScope = new Set(this.defaultScopes); - if (this.currentSession) { - const sessionScopes = this.sessionScopesFunc(this.currentSession); - for (const scope of sessionScopes) { - newScope.add(scope); - } - } - if (scopes) { - for (const scope of scopes) { - newScope.add(scope); - } - } - return newScope; - } - - private async collapsedSessionRefresh(): Promise { + private async collapsedSessionRefresh(): Promise { if (this.refreshPromise) { return this.refreshPromise; } diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/common.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/common.ts new file mode 100644 index 0000000000..83b81bc81a --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/common.ts @@ -0,0 +1,68 @@ +/* + * 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 { SessionScopesFunc } from './types'; + +export function hasScopes( + searched: Set, + searchFor: Set, +): boolean { + for (const scope of searchFor) { + if (!searched.has(scope)) { + return false; + } + } + return true; +} + +type ScopeHelperOptions = { + sessionScopes: SessionScopesFunc; + defaultScopes?: Set; +}; + +export class SessionScopeHelper { + constructor(private readonly options: ScopeHelperOptions) {} + + sessionExistsAndHasScope( + session: T | undefined, + scopes?: Set, + ): boolean { + if (!session) { + return false; + } + if (!scopes) { + return true; + } + const sessionScopes = this.options.sessionScopes(session); + return hasScopes(sessionScopes, scopes); + } + + getExtendedScope(session: T | undefined, scopes?: Set) { + const newScope = new Set(this.options.defaultScopes); + if (session) { + const sessionScopes = this.options.sessionScopes(session); + for (const scope of sessionScopes) { + newScope.add(scope); + } + } + if (scopes) { + for (const scope of scopes) { + newScope.add(scope); + } + } + return newScope; + } +} diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts index 5c5f700047..5ebc8643e1 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts @@ -19,15 +19,25 @@ * multiple simultaneous requests for sessions with different scope are handled * in a correct way. */ -export type SessionManager = { - getSession(options: { - optional: false; - scopes?: Set; - }): Promise; +export type SessionManager = { + getSession(options: { optional: false; scopes?: Set }): Promise; getSession(options: { optional?: boolean; scopes?: Set; - }): Promise; + }): Promise; removeSession(): Promise; }; + +/** + * A function called to determine the scopes of a session. + */ +export type SessionScopesFunc = (session: T) => Set; + +/** + * A function called to determine whether it's time for a session to refresh. + * + * This should return true before the session expires, for example, if a session + * expires after 60 minutes, you could return true if the session is older than 45 minutes. + */ +export type SessionShouldRefreshFunc = (session: T) => boolean; From 98ebcf9c54889f845198736c14d86e51ee2d9dfc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 25 May 2020 15:55:01 +0200 Subject: [PATCH 3/7] packages/core: added StaticAuthSessionManager, for sessions without refresh --- .../StaticAuthSessionManager.test.ts | 102 ++++++++++++++++++ .../StaticAuthSessionManager.ts | 80 ++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts new file mode 100644 index 0000000000..dc13aec0c2 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { StaticAuthSessionManager } from './StaticAuthSessionManager'; + +const defaultOptions = { + sessionScopes: (session: string) => new Set(session.split(' ')), +}; + +describe('StaticAuthSessionManager', () => { + const baseConnector = { + refreshSession() { + throw new Error('refreshSession should not be called'); + }, + removeSession() { + throw new Error('removeSession should not be called'); + }, + }; + + it('should get session by creating session once', async () => { + const createSession = jest.fn().mockResolvedValue('my-session'); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({})).resolves.toBe('my-session'); + expect(createSession).toHaveBeenCalledTimes(1); + }); + + it('should fail to get session if user rejects the request', async () => { + const createSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({})).rejects.toThrow('NOPE'); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ optional: true })).resolves.toBe( + undefined, + ); + }); + + it('should only request auth once for same scopes', async () => { + const createSession = jest + .fn() + .mockImplementation(scopes => [...scopes].join(' ')); + const manager = new StaticAuthSessionManager({ + connector: { createSession, ...baseConnector }, + ...defaultOptions, + }); + + expect(createSession).toHaveBeenCalledTimes(0); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe( + 'a', + ); + expect(createSession).toHaveBeenCalledTimes(1); + await expect(manager.getSession({ scopes: new Set(['b']) })).resolves.toBe( + 'a b', + ); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it('should remove session and reload', async () => { + const location = { ...window.location }; + delete window.location; + window.location = location; + jest.spyOn(window.location, 'reload').mockImplementation(); + + const removeSession = jest.fn(); + const manager = new StaticAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(window.location.reload).toHaveBeenCalled(); + expect(removeSession).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts new file mode 100644 index 0000000000..8d057ecc58 --- /dev/null +++ b/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -0,0 +1,80 @@ +/* + * 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 } from './types'; +import { AuthConnector } from '../AuthConnector'; +import { SessionScopeHelper } from './common'; + +type Options = { + /** The connector used for acting on the auth session */ + connector: AuthConnector; + /** Used to get the scope of the session */ + sessionScopes: (session: T) => Set; + /** The default scopes that should always be present in a session, defaults to none. */ + defaultScopes?: Set; +}; + +/** + * StaticAuthSessionManager manages an underlying session that does not expire. + */ +export class StaticAuthSessionManager implements SessionManager { + private readonly connector: AuthConnector; + private readonly helper: SessionScopeHelper; + + private currentSession: T | undefined; + + constructor(options: Options) { + const { connector, defaultScopes = new Set(), sessionScopes } = options; + + this.connector = connector; + this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); + } + + async getSession(options: { + optional: false; + scopes?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scopes?: Set; + }): Promise; + async getSession(options: { + optional?: boolean; + scopes?: Set; + }): Promise { + if ( + this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) + ) { + return this.currentSession; + } + + // 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.connector.createSession( + this.helper.getExtendedScope(this.currentSession, options.scopes), + ); + return this.currentSession; + } + + async removeSession() { + await this.connector.removeSession(); + window.location.reload(); // TODO(Rugvip): make this work without reload? + } +} From 1fb3ac0695668e027720b7dcc94a0d34600a8bb3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 25 May 2020 16:30:09 +0200 Subject: [PATCH 4/7] packages/core: added AuthSessionStore --- .../AuthSessionStore.test.ts | 122 ++++++++++++++++++ .../AuthSessionManager/AuthSessionStore.ts | 117 +++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.test.ts create mode 100644 packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts 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)); + } + } +} From 503875dde3d5d777a43b60afa0d6befd8960ddee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 26 May 2020 08:10:13 +0200 Subject: [PATCH 5/7] Update README.md --- packages/backend/README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/backend/README.md b/packages/backend/README.md index 1246be60bc..6d72e1c29c 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -12,7 +12,17 @@ app. Until then, feel free to experiment here! ## Development -To run the backend in watch mode, use `yarn start`. +To run the example backend in watch mode, use + +```bash +AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x SENTRY_TOKEN=x yarn start +``` + +in the backend directory. Substitute `x` for actual values, or leave them as +dummy values just to try out the backend without using the auth or sentry features. + +You may have to issue a `yarn build` command in the project root before doing +this for the first time. ## Documentation From 253fe63cd59de669b2e5501f43933406671abd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 26 May 2020 08:23:47 +0200 Subject: [PATCH 6/7] Add catalog population info too --- packages/backend/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/backend/README.md b/packages/backend/README.md index 6d72e1c29c..3745da280f 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -24,6 +24,31 @@ dummy values just to try out the backend without using the auth or sentry featur You may have to issue a `yarn build` command in the project root before doing this for the first time. +The backend starts up on port 7000 per default. + +## Populating The Catalog + +If you want to use the catalog functionality, you need to add so called locations +to the backend. These are places where the backend can find some entity descriptor +data to consume and serve. + +To get started, you can issue the following after starting the backend: + +```bash +curl -i \ + -H "Content-Type: application/json" + -d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \ + localhost:7000/catalog/locations +``` + +After a short while, you should start seeing data on `localhost:7000/catalog/entities`. + +If you changed the `type` to `file` in the command above, and set the `target` +to the absolute path of a YAML file on disk, you could consume your own experimental data. + +The catalog currently runs in-memory only, so feel free to try it out, but it will +need to be re-populated on next startup. + ## Documentation - [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) From 0fc794f73796b18ce8b9edb4615ee4f9715d944c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 26 May 2020 09:05:45 +0200 Subject: [PATCH 7/7] Fix review comments --- packages/backend/README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/backend/README.md b/packages/backend/README.md index 3745da280f..d1cc8b99b7 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -12,18 +12,24 @@ app. Until then, feel free to experiment here! ## Development -To run the example backend in watch mode, use +To run the example backend, first go to the project root and run + +```bash +yarn install +yarn build +``` + +You should only need to do this once. + +After that, go to the `packages/backend` directory and run ```bash AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x SENTRY_TOKEN=x yarn start ``` -in the backend directory. Substitute `x` for actual values, or leave them as +Substitute `x` for actual values, or leave them as dummy values just to try out the backend without using the auth or sentry features. -You may have to issue a `yarn build` command in the project root before doing -this for the first time. - The backend starts up on port 7000 per default. ## Populating The Catalog @@ -36,7 +42,7 @@ To get started, you can issue the following after starting the backend: ```bash curl -i \ - -H "Content-Type: application/json" + -H "Content-Type: application/json" \ -d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \ localhost:7000/catalog/locations ```