Merge pull request #1031 from spotify/rugvip/auth-options

packages/core-api: properly forward auth options to respect the instantPopup option
This commit is contained in:
Patrik Oldsberg
2020-05-28 11:43:24 +02:00
committed by GitHub
11 changed files with 97 additions and 66 deletions
@@ -85,7 +85,10 @@ export type OAuthApi = {
* will be prompted to log in. The returned promise will not resolve until the user has
* successfully logged in. The returned promise can be rejected, but only if the user rejects the login request.
*/
getAccessToken(scope?: OAuthScope): Promise<string>;
getAccessToken(
scope?: OAuthScope,
options?: AccessTokenOptions,
): Promise<string>;
/**
* Log out the user's session. This will reload the page.
@@ -21,6 +21,7 @@ import {
OAuthApi,
OpenIdConnectApi,
IdTokenOptions,
AccessTokenOptions,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
@@ -95,19 +96,23 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(scope?: string | string[]) {
async getAccessToken(
scope?: string | string[],
options?: AccessTokenOptions,
) {
const normalizedScopes = GoogleAuth.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
optional: false,
...options,
scopes: normalizedScopes,
});
return session.accessToken;
if (session) {
return session.accessToken;
}
return '';
}
async getIdToken({ optional }: IdTokenOptions = {}) {
const session = await this.sessionManager.getSession({
optional: optional || false,
});
async getIdToken(options: IdTokenOptions = {}) {
const session = await this.sessionManager.getSession(options);
if (session) {
return session.idToken;
}
@@ -86,7 +86,7 @@ describe('DefaultAuthConnector', () => {
...defaultOptions,
oauthRequestApi: mockOauth,
});
const promise = helper.createSession(new Set(['a', 'b']));
const promise = helper.createSession({ scopes: new Set(['a', 'b']) });
await mockOauth.rejectAll();
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
});
@@ -106,7 +106,9 @@ describe('DefaultAuthConnector', () => {
oauthRequestApi: mockOauth,
});
const sessionPromise = helper.createSession(new Set(['a', 'b']));
const sessionPromise = helper.createSession({
scopes: new Set(['a', 'b']),
});
await mockOauth.triggerAll();
@@ -123,6 +125,26 @@ describe('DefaultAuthConnector', () => {
});
});
it('should instantly show popup if option is set', async () => {
const popupSpy = jest
.spyOn(loginPopup, 'showLoginPopup')
.mockResolvedValue('my-session');
const helper = new DefaultAuthConnector({
...defaultOptions,
oauthRequestApi: new MockOAuthApi(),
sessionTransform: str => str,
});
const sessionPromise = helper.createSession({
scopes: new Set(),
instantPopup: true,
});
expect(popupSpy).toBeCalledTimes(1);
await expect(sessionPromise).resolves.toBe('my-session');
});
it('should use join func to join scopes', async () => {
const mockOauth = new MockOAuthApi();
const popupSpy = jest
@@ -134,7 +156,7 @@ describe('DefaultAuthConnector', () => {
oauthRequestApi: mockOauth,
});
helper.createSession(new Set(['a', 'b']));
helper.createSession({ scopes: new Set(['a', 'b']) });
await mockOauth.triggerAll();
@@ -17,7 +17,7 @@
import { AuthRequester } from '../../apis';
import { OAuthRequestApi, AuthProvider } from '../../apis/definitions';
import { showLoginPopup } from '../loginPopup';
import { AuthConnector } from './types';
import { AuthConnector, CreateSessionOptions } from './types';
const DEFAULT_BASE_PATH = '/api/auth/';
@@ -68,6 +68,7 @@ export class DefaultAuthConnector<AuthSession>
private readonly basePath: string;
private readonly environment: string;
private readonly provider: AuthProvider & { id: string };
private readonly joinScopesFunc: (scopes: Set<string>) => string;
private readonly authRequester: AuthRequester<AuthSession>;
private readonly sessionTransform: (response: any) => Promise<AuthSession>;
@@ -84,18 +85,22 @@ export class DefaultAuthConnector<AuthSession>
this.authRequester = oauthRequestApi.createAuthRequester({
provider,
onAuthRequest: scopes => this.showPopup(joinScopes(scopes)),
onAuthRequest: scopes => this.showPopup(scopes),
});
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.environment = environment;
this.provider = provider;
this.joinScopesFunc = joinScopes;
this.sessionTransform = sessionTransform;
}
async createSession(scopes: Set<string>): Promise<AuthSession> {
return this.authRequester(scopes);
async createSession(options: CreateSessionOptions): Promise<AuthSession> {
if (options.instantPopup) {
return this.showPopup(options.scopes);
}
return this.authRequester(options.scopes);
}
async refreshSession(): Promise<any> {
@@ -142,7 +147,8 @@ export class DefaultAuthConnector<AuthSession>
}
}
private async showPopup(scope: string): Promise<AuthSession> {
private async showPopup(scopes: Set<string>): Promise<AuthSession> {
const scope = this.joinScopesFunc(scopes);
const popupUrl = this.buildUrl('/start', { scope });
const payload = await showLoginPopup({
@@ -14,12 +14,17 @@
* limitations under the License.
*/
export type CreateSessionOptions = {
scopes: Set<string>;
instantPopup?: boolean;
};
/**
* An AuthConnector is responsible for realizing auth session actions
* by for example communicating with a backend or interacting with the user.
*/
export type AuthConnector<AuthSession> = {
createSession(scopes: Set<string>): Promise<AuthSession>;
createSession(options: CreateSessionOptions): Promise<AuthSession>;
refreshSession(): Promise<AuthSession>;
removeSession(): Promise<void>;
};
@@ -18,6 +18,7 @@ import {
SessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
GetSessionOptions,
} from './types';
import { SessionScopeHelper } from './common';
@@ -59,18 +60,7 @@ export class AuthSessionStore<T> implements SessionManager<T> {
});
}
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> {
async getSession(options: GetSessionOptions): Promise<T | undefined> {
const { scopes } = options;
const session = this.loadSession();
@@ -113,6 +113,23 @@ describe('RefreshingAuthSessionManager', () => {
expect(refreshSession).toBeCalledTimes(1);
});
it('should forward option to instantly show auth popup', async () => {
const createSession = jest.fn();
const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE'));
const manager = new RefreshingAuthSessionManager({
connector: { createSession, refreshSession },
...defaultOptions,
} as any);
expect(await manager.getSession({ instantPopup: true })).toBe(undefined);
expect(createSession).toBeCalledTimes(1);
expect(createSession).toHaveBeenCalledWith({
scopes: new Set(),
instantPopup: true,
});
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
@@ -18,6 +18,7 @@ import {
SessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
GetSessionOptions,
} from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper, hasScopes } from './common';
@@ -60,18 +61,7 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
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> {
async getSession(options: GetSessionOptions): Promise<T | undefined> {
if (
this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes)
) {
@@ -115,9 +105,10 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
}
// 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),
);
this.currentSession = await this.connector.createSession({
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
return this.currentSession;
}
@@ -62,7 +62,7 @@ describe('StaticAuthSessionManager', () => {
it('should only request auth once for same scopes', async () => {
const createSession = jest
.fn()
.mockImplementation(scopes => [...scopes].join(' '));
.mockImplementation(({ scopes }) => [...scopes].join(' '));
const manager = new StaticAuthSessionManager({
connector: { createSession, ...baseConnector },
...defaultOptions,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { SessionManager } from './types';
import { SessionManager, GetSessionOptions } from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
@@ -43,18 +43,7 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
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> {
async getSession(options: GetSessionOptions): Promise<T | undefined> {
if (
this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes)
) {
@@ -67,9 +56,10 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
}
// 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),
);
this.currentSession = await this.connector.createSession({
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
return this.currentSession;
}
@@ -14,17 +14,19 @@
* limitations under the License.
*/
export type GetSessionOptions = {
optional?: boolean;
instantPopup?: boolean;
scopes?: Set<string>;
};
/**
* 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<T> = {
getSession(options: { optional: false; scopes?: Set<string> }): Promise<T>;
getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
getSession(options: GetSessionOptions): Promise<T | undefined>;
removeSession(): Promise<void>;
};