Merge pull request #999 from spotify/rugvip/ghe-auth

packages/core: bit of auth refactoring and added StaticAuthSessionManager and AuthSessionStore
This commit is contained in:
Patrik Oldsberg
2020-05-25 23:34:17 +02:00
committed by GitHub
9 changed files with 551 additions and 87 deletions
@@ -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;
}
@@ -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<string, string> = {};
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<string> {
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);
});
});
@@ -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<T> = {
/** The connector used for acting on the auth session */
manager: SessionManager<T>;
/** Storage key to use to store sessions */
storageKey: string;
/** Used to get the scope of the session */
sessionScopes: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed, defaults to never refresh */
sessionShouldRefresh?: SessionShouldRefreshFunc<T>;
};
/**
* AuthSessionStore decorates another SessionManager with a functionality
* to store the session in local storage.
*/
export class AuthSessionStore<T> implements SessionManager<T> {
private readonly manager: SessionManager<T>;
private readonly storageKey: string;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private readonly helper: SessionScopeHelper<T>;
constructor(options: Options<T>) {
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<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
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));
}
}
}
@@ -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 () => {
@@ -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<AuthSession> = {
/**
* The connector used for acting on the auth session.
*/
connector: AuthConnector<AuthSession>;
/**
* A function called to determine the scopes of the session.
*/
sessionScopes: (session: AuthSession) => Set<string>;
/**
* 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<T> = {
/** The connector used for acting on the auth session */
connector: AuthConnector<T>;
/** Used to get the scope of the session */
sessionScopes: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed */
sessionShouldRefresh: SessionShouldRefreshFunc<T>;
/** The default scopes that should always be present in a session, defaults to none. */
defaultScopes?: Set<string>;
};
@@ -44,17 +38,16 @@ type Options<AuthSession> = {
* RefreshingAuthSessionManager manages an underlying session that has
* and expiration time and needs to be refreshed periodically.
*/
export class RefreshingAuthSessionManager<AuthSession>
implements SessionManager<AuthSession> {
private readonly connector: AuthConnector<AuthSession>;
private readonly defaultScopes?: Set<string>;
private readonly sessionScopesFunc: (session: AuthSession) => Set<string>;
private readonly sessionShouldRefreshFunc: (session: AuthSession) => boolean;
export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
private readonly connector: AuthConnector<T>;
private readonly helper: SessionScopeHelper<T>;
private readonly sessionScopesFunc: SessionScopesFunc<T>;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private refreshPromise?: Promise<AuthSession>;
private currentSession: AuthSession | undefined;
private refreshPromise?: Promise<T>;
private currentSession: T | undefined;
constructor(options: Options<AuthSession>) {
constructor(options: Options<T>) {
const {
connector,
defaultScopes = new Set(),
@@ -63,24 +56,26 @@ export class RefreshingAuthSessionManager<AuthSession>
} = options;
this.connector = connector;
this.defaultScopes = defaultScopes;
this.sessionScopesFunc = sessionScopes;
this.sessionShouldRefreshFunc = sessionShouldRefresh;
this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes });
}
async getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined> {
if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) {
scopes?: Set<string>;
}): Promise<T | undefined> {
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<AuthSession>
// 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<AuthSession>
// 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.helper.getExtendedScope(this.currentSession, options.scopes),
);
return this.currentSession;
}
@@ -132,37 +127,7 @@ export class RefreshingAuthSessionManager<AuthSession>
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
private sessionExistsAndHasScope(
session: AuthSession | undefined,
scope?: Set<string>,
): boolean {
if (!session) {
return false;
}
if (!scope) {
return true;
}
const sessionScopes = this.sessionScopesFunc(session);
return hasScopes(sessionScopes, scope);
}
private getExtendedScope(scopes?: Set<string>) {
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<AuthSession> {
private async collapsedSessionRefresh(): Promise<T> {
if (this.refreshPromise) {
return this.refreshPromise;
}
@@ -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();
});
});
@@ -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<T> = {
/** The connector used for acting on the auth session */
connector: AuthConnector<T>;
/** Used to get the scope of the session */
sessionScopes: (session: T) => Set<string>;
/** The default scopes that should always be present in a session, defaults to none. */
defaultScopes?: Set<string>;
};
/**
* StaticAuthSessionManager manages an underlying session that does not expire.
*/
export class StaticAuthSessionManager<T> implements SessionManager<T> {
private readonly connector: AuthConnector<T>;
private readonly helper: SessionScopeHelper<T>;
private currentSession: T | undefined;
constructor(options: Options<T>) {
const { connector, defaultScopes = new Set(), sessionScopes } = options;
this.connector = connector;
this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes });
}
async getSession(options: {
optional: false;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
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?
}
}
@@ -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<string>,
searchFor: Set<string>,
): boolean {
for (const scope of searchFor) {
if (!searched.has(scope)) {
return false;
}
}
return true;
}
type ScopeHelperOptions<T> = {
sessionScopes: SessionScopesFunc<T>;
defaultScopes?: Set<string>;
};
export class SessionScopeHelper<T> {
constructor(private readonly options: ScopeHelperOptions<T>) {}
sessionExistsAndHasScope(
session: T | undefined,
scopes?: Set<string>,
): 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<string>) {
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;
}
}
@@ -19,15 +19,25 @@
* multiple simultaneous requests for sessions with different scope are handled
* in a correct way.
*/
export type SessionManager<AuthSession> = {
getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
export type SessionManager<T> = {
getSession(options: { optional: false; scopes?: Set<string> }): Promise<T>;
getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
scopes?: Set<string>;
}): Promise<T | undefined>;
removeSession(): Promise<void>;
};
/**
* A function called to determine the scopes of a session.
*/
export type SessionScopesFunc<T> = (session: T) => Set<string>;
/**
* 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<T> = (session: T) => boolean;