packages/core: move google auth session refresh logic out into separate session manager helper

This commit is contained in:
Patrik Oldsberg
2020-05-20 12:03:45 +02:00
parent 2d65c9a38f
commit e6537acc9c
10 changed files with 407 additions and 242 deletions
@@ -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
});
});
@@ -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<GoogleSession>) {}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
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<GoogleSession>;
async getSession(options: {
optional?: boolean;
scope?: string | string[];
}): Promise<GoogleSession | undefined>;
async getSession(options: {
optional?: boolean;
scope?: string | string[];
}): Promise<GoogleSession | undefined> {
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<string>,
): 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<string>) {
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<string> {
@@ -30,12 +30,6 @@ type Options<AuthSession> = {
sessionTransform?(response: any): AuthSession | Promise<AuthSession>;
};
export type GenericAuthHelper<AuthSession> = {
refreshSession(): Promise<AuthSession>;
removeSession(): Promise<void>;
createSession(scopes: Set<string>): Promise<AuthSession>;
};
export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
private readonly apiOrigin: string;
private readonly basePath: string;
@@ -45,8 +39,6 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
private readonly authRequester: AuthRequester<AuthSession>;
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
private refreshPromise?: Promise<AuthSession>;
constructor(options: Options<AuthSession>) {
const {
apiOrigin = window.location.origin,
@@ -71,21 +63,7 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
this.sessionTransform = sessionTransform;
}
async refreshSession(): Promise<AuthSession> {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.doAuthRefresh();
try {
return await this.refreshPromise;
} finally {
delete this.refreshPromise;
}
}
private async doAuthRefresh(): Promise<any> {
async refreshSession(): Promise<any> {
const res = await fetch(this.buildUrl('/token', { optional: true }), {
headers: {
'x-requested-with': 'XMLHttpRequest',
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { GenericAuthHelper } from './AuthHelper';
import { GenericAuthHelper } from './types';
export const mockAccessToken = 'mock-access-token';
@@ -15,3 +15,4 @@
*/
export { AuthHelper } from './AuthHelper';
export * from './types';
@@ -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<string>;
expiresAt: Date;
};
export type GenericAuthHelper<AuthSession> = {
refreshSession(): Promise<AuthSession>;
removeSession(): Promise<void>;
createSession(scopes: Set<string>): Promise<AuthSession>;
};
@@ -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();
});
});
@@ -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<AuthSession extends BaseAuthSession> = {
helper: GenericAuthHelper<AuthSession>;
defaultScopes?: Set<string>;
};
/**
* RefreshingAuthSessionManager manages an underlying session that has
* and expiration time and needs to be refreshed periodically.
*/
export class RefreshingAuthSessionManager<AuthSession extends BaseAuthSession>
implements SessionManager<AuthSession> {
private readonly helper: GenericAuthHelper<AuthSession>;
private readonly defaultScopes?: Set<string>;
private refreshPromise?: Promise<AuthSession>;
private currentSession: AuthSession | undefined;
constructor(options: Options<AuthSession>) {
const { helper, defaultScopes = new Set() } = options;
this.helper = helper;
this.defaultScopes = defaultScopes;
}
async getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined> {
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<string>,
): 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<string>) {
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<AuthSession> {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.helper.refreshSession();
try {
return await this.refreshPromise;
} finally {
delete this.refreshPromise;
}
}
}
@@ -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';
@@ -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<AuthSession extends BaseAuthSession> = {
getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
removeSession(): Promise<void>;
};