packages/core: move google auth session refresh logic out into separate session manager helper
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
+128
@@ -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();
|
||||
});
|
||||
});
|
||||
+152
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user