packages/core: remove custom OAuth scope classes in favor for early transform functions
This commit is contained in:
@@ -18,18 +18,6 @@ import { IconComponent } from '../../../icons';
|
||||
import { Observable } from '../../types';
|
||||
import { ApiRef } from '../ApiRef';
|
||||
|
||||
export type OAuthScopes = {
|
||||
extend(scopes: OAuthScopeLike): OAuthScopes;
|
||||
hasScopes(scopes: OAuthScopeLike): boolean;
|
||||
toSet(): Set<string>;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
export type OAuthScopeLike =
|
||||
| string /** Space separated scope strings */
|
||||
| string[] /** Array of individual scope strings */
|
||||
| OAuthScopes;
|
||||
|
||||
/**
|
||||
* Information about the auth provider that we're requesting a login towards.
|
||||
*
|
||||
@@ -62,7 +50,7 @@ export type AuthRequesterOptions<AuthResponse> = {
|
||||
* Implementation of the auth flow, which will be called synchronously when
|
||||
* trigger() is called on an auth requests.
|
||||
*/
|
||||
onAuthRequest(scope: OAuthScopes): Promise<AuthResponse>;
|
||||
onAuthRequest(scopes: Set<string>): Promise<AuthResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,7 +64,7 @@ export type AuthRequesterOptions<AuthResponse> = {
|
||||
* union of all requested scopes.
|
||||
*/
|
||||
export type AuthRequester<AuthResponse> = (
|
||||
scope: OAuthScopes,
|
||||
scopes: Set<string>,
|
||||
) => Promise<AuthResponse>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* 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 { OAuthScopes, OAuthScopeLike } from '../../definitions';
|
||||
|
||||
/**
|
||||
* The BasicOAuthScopes class is an implementation of OAuthScopes that
|
||||
* works for any simple comma- or space-separated format of scope.
|
||||
*/
|
||||
export class BasicOAuthScopes implements OAuthScopes {
|
||||
static from(scopes: OAuthScopeLike, normalizer?: (scope: string) => string) {
|
||||
const normalized = BasicOAuthScopes.asStrings(scopes, normalizer);
|
||||
return new BasicOAuthScopes(new Set(normalized), normalizer);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly scopes: Set<string>,
|
||||
private readonly normalizer?: (scope: string) => string,
|
||||
) {}
|
||||
|
||||
extend(requestedScopes: OAuthScopeLike): BasicOAuthScopes {
|
||||
const newScopes = new Set(this.scopes);
|
||||
BasicOAuthScopes.asStrings(requestedScopes, this.normalizer).forEach((s) =>
|
||||
newScopes.add(s),
|
||||
);
|
||||
return new BasicOAuthScopes(newScopes, this.normalizer);
|
||||
}
|
||||
|
||||
hasScopes(scopes: OAuthScopeLike): boolean {
|
||||
return BasicOAuthScopes.asStrings(scopes, this.normalizer).every((s) =>
|
||||
this.scopes.has(s),
|
||||
);
|
||||
}
|
||||
|
||||
toSet(): Set<string> {
|
||||
return this.scopes;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return Array.from(this.scopes).join(' ');
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return Array.from(this.scopes);
|
||||
}
|
||||
|
||||
static asStrings(
|
||||
input: OAuthScopeLike,
|
||||
normalizer?: (scope: string) => string,
|
||||
): string[] {
|
||||
let scopeArray: string[];
|
||||
if (typeof input === 'string') {
|
||||
scopeArray = input.split(/[,\s]/).filter(Boolean);
|
||||
} else if (Array.isArray(input)) {
|
||||
scopeArray = input;
|
||||
} else {
|
||||
scopeArray = Array.from(input.toSet());
|
||||
}
|
||||
if (normalizer) {
|
||||
scopeArray = scopeArray.map((x) => normalizer(x));
|
||||
}
|
||||
return scopeArray;
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -16,7 +16,6 @@
|
||||
|
||||
import MockOAuthApi from './MockOAuthApi';
|
||||
import PowerIcon from '@material-ui/icons/Power';
|
||||
import { BasicOAuthScopes } from './BasicOAuthScopes';
|
||||
|
||||
describe('MockOAuthApi', () => {
|
||||
it('should trigger all requests', async () => {
|
||||
@@ -36,11 +35,11 @@ describe('MockOAuthApi', () => {
|
||||
});
|
||||
|
||||
const promises = [
|
||||
requester1(BasicOAuthScopes.from('a')),
|
||||
requester1(BasicOAuthScopes.from('b')),
|
||||
requester2(BasicOAuthScopes.from('a b')),
|
||||
requester2(BasicOAuthScopes.from('b c')),
|
||||
requester2(BasicOAuthScopes.from('c a')),
|
||||
requester1(new Set(['a'])),
|
||||
requester1(new Set(['b'])),
|
||||
requester2(new Set(['a', 'b'])),
|
||||
requester2(new Set(['b', 'c'])),
|
||||
requester2(new Set(['c', 'a'])),
|
||||
];
|
||||
|
||||
await expect(
|
||||
@@ -58,9 +57,9 @@ describe('MockOAuthApi', () => {
|
||||
]);
|
||||
|
||||
expect(authHandler1).toHaveBeenCalledTimes(1);
|
||||
expect(authHandler1).toHaveBeenCalledWith(BasicOAuthScopes.from('a b'));
|
||||
expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b']));
|
||||
expect(authHandler2).toHaveBeenCalledTimes(1);
|
||||
expect(authHandler2).toHaveBeenCalledWith(BasicOAuthScopes.from('a b c'));
|
||||
expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
it('should reject all requests', async () => {
|
||||
@@ -79,11 +78,11 @@ describe('MockOAuthApi', () => {
|
||||
});
|
||||
|
||||
const promises = [
|
||||
requester1(BasicOAuthScopes.from('a')),
|
||||
requester1(BasicOAuthScopes.from('b')),
|
||||
requester2(BasicOAuthScopes.from('a b')),
|
||||
requester2(BasicOAuthScopes.from('b c')),
|
||||
requester2(BasicOAuthScopes.from('c a')),
|
||||
requester1(new Set(['a'])),
|
||||
requester1(new Set(['b'])),
|
||||
requester2(new Set(['a', 'b'])),
|
||||
requester2(new Set(['b', 'c'])),
|
||||
requester2(new Set(['c', 'a'])),
|
||||
];
|
||||
|
||||
await expect(
|
||||
|
||||
+12
-13
@@ -16,7 +16,6 @@
|
||||
|
||||
import { wait } from '@testing-library/react';
|
||||
import { OAuthPendingRequests } from './OAuthPendingRequests';
|
||||
import { BasicOAuthScopes } from './BasicOAuthScopes';
|
||||
|
||||
describe('OAuthPendingRequests', () => {
|
||||
it('notifies new observers about current state', async () => {
|
||||
@@ -24,7 +23,7 @@ describe('OAuthPendingRequests', () => {
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
|
||||
const input = BasicOAuthScopes.from('a b');
|
||||
const input = new Set(['a', 'b']);
|
||||
target.pending().subscribe({ next, error });
|
||||
target.request(input);
|
||||
|
||||
@@ -39,11 +38,11 @@ describe('OAuthPendingRequests', () => {
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request2 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request1 = target.request(new Set(['a']));
|
||||
const request2 = target.request(new Set(['a']));
|
||||
target.pending().subscribe({ next, error });
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session1');
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session2');
|
||||
target.resolve(new Set(['a']), 'session1');
|
||||
target.resolve(new Set(['a']), 'session2');
|
||||
|
||||
await expect(request1).resolves.toBe('session1');
|
||||
await expect(request2).resolves.toBe('session1');
|
||||
@@ -53,10 +52,10 @@ describe('OAuthPendingRequests', () => {
|
||||
|
||||
it('can resolve through the observable', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn((pendingRequest) => pendingRequest.resolve('done'));
|
||||
const next = jest.fn(pendingRequest => pendingRequest.resolve('done'));
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request1 = target.request(new Set(['a']));
|
||||
target.pending().subscribe({ next, error });
|
||||
|
||||
await expect(request1).resolves.toBe('done');
|
||||
@@ -70,11 +69,11 @@ describe('OAuthPendingRequests', () => {
|
||||
const error = jest.fn();
|
||||
const rejection = new Error('eek');
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request2 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request1 = target.request(new Set(['a']));
|
||||
const request2 = target.request(new Set(['a']));
|
||||
target.pending().subscribe({ next, error });
|
||||
target.reject(rejection);
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session');
|
||||
target.resolve(new Set(['a']), 'session');
|
||||
|
||||
await expect(request1).rejects.toBe(rejection);
|
||||
await expect(request2).rejects.toBe(rejection);
|
||||
@@ -85,10 +84,10 @@ describe('OAuthPendingRequests', () => {
|
||||
it('can reject through the observable', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const rejection = new Error('nope');
|
||||
const next = jest.fn((pendingRequest) => pendingRequest.reject(rejection));
|
||||
const next = jest.fn(pendingRequest => pendingRequest.reject(rejection));
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request1 = target.request(new Set(['a']));
|
||||
target.pending().subscribe({ next, error });
|
||||
|
||||
await expect(request1).rejects.toBe(rejection);
|
||||
|
||||
+37
-9
@@ -14,22 +14,50 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { OAuthScopes } from '../../definitions';
|
||||
import { BehaviorSubject } from '../lib';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
type RequestQueueEntry<ResultType> = {
|
||||
scopes: OAuthScopes;
|
||||
scopes: Set<string>;
|
||||
resolve: (value?: ResultType | PromiseLike<ResultType> | undefined) => void;
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type PendingRequest<ResultType> = {
|
||||
scopes: OAuthScopes | undefined;
|
||||
scopes: Set<string> | undefined;
|
||||
resolve: (value: ResultType) => void;
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export function hasScopes(
|
||||
searched: Set<string>,
|
||||
searchFor: Set<string>,
|
||||
): boolean {
|
||||
for (const scope of searchFor) {
|
||||
if (!searched.has(scope)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function joinScopes(
|
||||
scopes: Set<string>,
|
||||
...moreScopess: Set<string>[]
|
||||
): Set<string> {
|
||||
const result = new Set(scopes);
|
||||
|
||||
for (const moreScopes of moreScopess) {
|
||||
for (const scope of moreScopes) {
|
||||
if (!result.has(scope)) {
|
||||
result.add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OAuthPendingRequests class is a utility for managing and observing
|
||||
* a stream of requests for oauth scopes for a single provider, and resolving
|
||||
@@ -41,7 +69,7 @@ export class OAuthPendingRequests<ResultType> {
|
||||
this.getCurrentPending(),
|
||||
);
|
||||
|
||||
request(scopes: OAuthScopes): Promise<ResultType> {
|
||||
request(scopes: Set<string>): Promise<ResultType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.requests.push({ scopes, resolve, reject });
|
||||
|
||||
@@ -49,9 +77,9 @@ export class OAuthPendingRequests<ResultType> {
|
||||
});
|
||||
}
|
||||
|
||||
resolve(scopes: OAuthScopes, result: ResultType): void {
|
||||
this.requests = this.requests.filter((request) => {
|
||||
if (scopes.hasScopes(request.scopes)) {
|
||||
resolve(scopes: Set<string>, result: ResultType): void {
|
||||
this.requests = this.requests.filter(request => {
|
||||
if (hasScopes(scopes, request.scopes)) {
|
||||
request.resolve(result);
|
||||
return false;
|
||||
}
|
||||
@@ -62,7 +90,7 @@ export class OAuthPendingRequests<ResultType> {
|
||||
}
|
||||
|
||||
reject(error: Error) {
|
||||
this.requests.forEach((request) => request.reject(error));
|
||||
this.requests.forEach(request => request.reject(error));
|
||||
this.requests = [];
|
||||
|
||||
this.subject.next(this.getCurrentPending());
|
||||
@@ -79,7 +107,7 @@ export class OAuthPendingRequests<ResultType> {
|
||||
: this.requests
|
||||
.slice(1)
|
||||
.reduce(
|
||||
(acc, current) => acc.extend(current.scopes),
|
||||
(acc, current) => joinScopes(acc, current.scopes),
|
||||
this.requests[0].scopes,
|
||||
);
|
||||
|
||||
|
||||
+1
-2
@@ -16,7 +16,6 @@
|
||||
|
||||
import ProviderIcon from '@material-ui/icons/AcUnit';
|
||||
import { OAuthRequestManager } from './OAuthRequestManager';
|
||||
import { BasicOAuthScopes } from './BasicOAuthScopes';
|
||||
|
||||
describe('OAuthRequestManager', () => {
|
||||
it('should forward a requests', async () => {
|
||||
@@ -38,7 +37,7 @@ describe('OAuthRequestManager', () => {
|
||||
expect(reqSpy).toHaveBeenCalledTimes(2);
|
||||
expect(reqSpy).toHaveBeenLastCalledWith([]);
|
||||
|
||||
const req = requester(BasicOAuthScopes.from('my-scope'));
|
||||
const req = requester(new Set(['my-scope']));
|
||||
|
||||
expect(reqSpy).toHaveBeenCalledTimes(3);
|
||||
expect(reqSpy).toHaveBeenLastCalledWith([
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ export class OAuthRequestManager implements OAuthRequestApi {
|
||||
this.handlerCount++;
|
||||
|
||||
handler.pending().subscribe({
|
||||
next: (scopeRequest) => {
|
||||
next: scopeRequest => {
|
||||
const newRequests = this.currentRequests.slice();
|
||||
const request = this.makeAuthRequest(scopeRequest, options);
|
||||
if (!request) {
|
||||
@@ -57,7 +57,7 @@ export class OAuthRequestManager implements OAuthRequestApi {
|
||||
},
|
||||
});
|
||||
|
||||
return (scopes) => {
|
||||
return scopes => {
|
||||
return handler.request(scopes);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
import GoogleAuth from './GoogleAuth';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
const theFuture = new Date(Date.now() + 3600000);
|
||||
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 });
|
||||
@@ -41,7 +42,7 @@ describe('GoogleAuth', () => {
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
createSession.mockResolvedValue({
|
||||
scopes: GoogleScopes.from('a'),
|
||||
scopes: new Set([`${PREFIX}a`]),
|
||||
expiresAt: theFuture,
|
||||
});
|
||||
await googleAuth.getSession({ scope: 'a' });
|
||||
@@ -59,11 +60,11 @@ describe('GoogleAuth', () => {
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('NOPE'))
|
||||
.mockResolvedValue({ scopes: GoogleScopes.from('a') });
|
||||
.mockResolvedValue({ scopes: new Set([`${PREFIX}a`]) });
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
createSession.mockResolvedValue({
|
||||
scopes: GoogleScopes.from('a'),
|
||||
scopes: new Set([`${PREFIX}a`]),
|
||||
expiresAt: thePast,
|
||||
});
|
||||
|
||||
@@ -150,7 +151,7 @@ describe('GoogleAuth', () => {
|
||||
const refreshSession = jest.fn().mockResolvedValue({
|
||||
accessToken: 'access-token',
|
||||
expiresAt: theFuture,
|
||||
scopes: GoogleScopes.from('not-enough'),
|
||||
scopes: new Set([`${PREFIX}not-enough`]),
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
@@ -169,7 +170,7 @@ describe('GoogleAuth', () => {
|
||||
const initialSession = {
|
||||
idToken: 'token1',
|
||||
expiresAt: theFuture,
|
||||
scopes: GoogleScopes.empty(),
|
||||
scopes: new Set(),
|
||||
};
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
@@ -177,7 +178,7 @@ describe('GoogleAuth', () => {
|
||||
.mockResolvedValue({
|
||||
idToken: 'token2',
|
||||
expiresAt: theFuture,
|
||||
scopes: GoogleScopes.empty(),
|
||||
scopes: new Set(),
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
|
||||
import GoogleIcon from '@material-ui/icons/AcUnit';
|
||||
import { AuthHelper } from '../../lib/AuthHelper';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { GoogleSession } from './types';
|
||||
import { OAuthScopes } from '../../..';
|
||||
import {
|
||||
OAuthApi,
|
||||
OpenIdConnectApi,
|
||||
@@ -26,6 +24,7 @@ import {
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi } from '../../../definitions';
|
||||
import { GenericAuthHelper } from '../../lib/AuthHelper/AuthHelper';
|
||||
import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests';
|
||||
|
||||
export type GoogleAuthResponse = {
|
||||
accessToken: string;
|
||||
@@ -34,6 +33,13 @@ export type GoogleAuthResponse = {
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -50,7 +56,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
return {
|
||||
idToken: res.idToken,
|
||||
accessToken: res.accessToken,
|
||||
scopes: GoogleScopes.from(res.scopes),
|
||||
scopes: GoogleAuth.normalizeScopes(res.scopes),
|
||||
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
|
||||
};
|
||||
},
|
||||
@@ -86,14 +92,16 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
optional?: boolean;
|
||||
scope?: string | string[];
|
||||
}): Promise<GoogleSession | undefined> {
|
||||
if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) {
|
||||
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 (refreshedSession.scopes.hasScopes(this.currentSession!.scopes)) {
|
||||
if (hasScopes(refreshedSession.scopes, this.currentSession!.scopes)) {
|
||||
this.currentSession = refreshedSession;
|
||||
}
|
||||
return refreshedSession;
|
||||
@@ -125,7 +133,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
|
||||
// We can call authRequester multiple times, the returned session will contain all requested scopes.
|
||||
this.currentSession = await this.helper.createSession(
|
||||
this.getExtendedScope(options.scope),
|
||||
this.getExtendedScope(normalizedScope),
|
||||
);
|
||||
return this.currentSession;
|
||||
}
|
||||
@@ -137,7 +145,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
|
||||
private sessionExistsAndHasScope(
|
||||
session: GoogleSession | undefined,
|
||||
scope?: string | string[],
|
||||
scope?: Set<string>,
|
||||
): boolean {
|
||||
if (!session) {
|
||||
return false;
|
||||
@@ -145,7 +153,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
if (!scope) {
|
||||
return true;
|
||||
}
|
||||
return session.scopes.hasScopes(scope);
|
||||
return hasScopes(session.scopes, scope);
|
||||
}
|
||||
|
||||
private sessionWillExpire(session: GoogleSession) {
|
||||
@@ -153,15 +161,45 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
return expiresInSec < 60 * 5;
|
||||
}
|
||||
|
||||
private getExtendedScope(scope?: string | string[]) {
|
||||
let newScope: OAuthScopes = GoogleScopes.default();
|
||||
private getExtendedScope(scopes: Set<string>) {
|
||||
const newScope = new Set(DEFAULT_SCOPES);
|
||||
if (this.currentSession) {
|
||||
newScope = this.currentSession.scopes;
|
||||
for (const scope of this.currentSession.scopes) {
|
||||
newScope.add(scope);
|
||||
}
|
||||
}
|
||||
if (scope) {
|
||||
newScope = newScope.extend(scope);
|
||||
for (const scope of scopes) {
|
||||
newScope.add(scope);
|
||||
}
|
||||
return newScope;
|
||||
}
|
||||
|
||||
private static normalizeScopes(scopes?: string | string[]): Set<string> {
|
||||
if (!scopes) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const scopeList = Array.isArray(scopes)
|
||||
? scopes
|
||||
: scopes.split(' ').filter(Boolean);
|
||||
|
||||
const normalizedScopes = scopeList.map(scope => {
|
||||
if (scope === 'openid') {
|
||||
return scope;
|
||||
}
|
||||
|
||||
if (scope === 'profile' || scope === 'email') {
|
||||
return `${SCOPE_PREFIX}userinfo.${scope}`;
|
||||
}
|
||||
|
||||
if (scope.startsWith(SCOPE_PREFIX)) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
return `${SCOPE_PREFIX}${scope}`;
|
||||
});
|
||||
|
||||
return new Set(normalizedScopes);
|
||||
}
|
||||
}
|
||||
export default GoogleAuth;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* 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 GoogleScopes from './GoogleScopes';
|
||||
|
||||
const PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
describe('GoogleScopes', () => {
|
||||
it('should be created from scopes', () => {
|
||||
const scopes = GoogleScopes.from('a openid b profile');
|
||||
expect(scopes.toString()).toBe(
|
||||
`${PREFIX}a openid ${PREFIX}b ${PREFIX}userinfo.profile`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be created with default scopes', () => {
|
||||
expect(GoogleScopes.default().toString()).toBe(
|
||||
`openid ${PREFIX}userinfo.email ${PREFIX}userinfo.profile`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should have or not have scopes', () => {
|
||||
const scopes = GoogleScopes.from(`a b ${PREFIX}c`);
|
||||
expect(scopes.hasScopes('a')).toBe(true);
|
||||
expect(scopes.hasScopes('a b')).toBe(true);
|
||||
expect(scopes.hasScopes('b')).toBe(true);
|
||||
expect(scopes.hasScopes('b c')).toBe(true);
|
||||
expect(scopes.hasScopes('a b c')).toBe(true);
|
||||
expect(scopes.hasScopes(`a b ${PREFIX}c`)).toBe(true);
|
||||
expect(scopes.hasScopes(`a ${PREFIX}b c`)).toBe(true);
|
||||
expect(scopes.hasScopes('a b c d')).toBe(false);
|
||||
expect(scopes.hasScopes('d')).toBe(false);
|
||||
expect(scopes.hasScopes('')).toBe(true);
|
||||
expect(scopes.hasScopes('abc')).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}a`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle scope shorthands correctly', () => {
|
||||
const scopes = GoogleScopes.default();
|
||||
expect(scopes.hasScopes('email')).toBe(true);
|
||||
expect(scopes.hasScopes('profile')).toBe(true);
|
||||
expect(scopes.hasScopes('openid')).toBe(true);
|
||||
expect(scopes.hasScopes('userinfo.email')).toBe(true);
|
||||
expect(scopes.hasScopes('userinfo.profile')).toBe(true);
|
||||
expect(scopes.hasScopes('userinfo.openid')).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}userinfo.email`)).toBe(true);
|
||||
expect(scopes.hasScopes(`${PREFIX}userinfo.profile`)).toBe(true);
|
||||
expect(scopes.hasScopes(`${PREFIX}userinfo.openid`)).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}email`)).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}profile`)).toBe(false);
|
||||
expect(scopes.hasScopes(`${PREFIX}openid`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be extended', () => {
|
||||
const scopes = GoogleScopes.from('a b');
|
||||
expect(scopes.extend('')).not.toBe(scopes);
|
||||
expect(scopes.extend('d').toString()).toBe(
|
||||
`${PREFIX}a ${PREFIX}b ${PREFIX}d`,
|
||||
);
|
||||
expect(scopes.extend('profile').toString()).toBe(
|
||||
`${PREFIX}a ${PREFIX}b ${PREFIX}userinfo.profile`,
|
||||
);
|
||||
expect(scopes.extend('d profile').toString()).toBe(
|
||||
`${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`,
|
||||
);
|
||||
expect(scopes.extend(`${PREFIX}d profile`).toString()).toBe(
|
||||
`${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`,
|
||||
);
|
||||
expect(scopes.extend('a').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('b').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('b a').toString()).toBe(scopes.toString());
|
||||
expect(scopes.extend('b a b a a').toString()).toBe(scopes.toString());
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* 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 { BasicOAuthScopes } from '../../OAuthRequestManager/BasicOAuthScopes';
|
||||
import { OAuthScopeLike } from '../../..';
|
||||
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
export default class GoogleScopes extends BasicOAuthScopes {
|
||||
static from(scope: OAuthScopeLike): GoogleScopes {
|
||||
return new GoogleScopes(
|
||||
new Set(BasicOAuthScopes.asStrings(scope, GoogleScopes.canonicalScope)),
|
||||
);
|
||||
}
|
||||
|
||||
static default(): GoogleScopes {
|
||||
return new GoogleScopes(
|
||||
new Set([
|
||||
'openid',
|
||||
`${SCOPE_PREFIX}userinfo.email`,
|
||||
`${SCOPE_PREFIX}userinfo.profile`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
static empty(): GoogleScopes {
|
||||
return new GoogleScopes(new Set());
|
||||
}
|
||||
|
||||
constructor(scopes: Set<string>) {
|
||||
super(scopes, GoogleScopes.canonicalScope);
|
||||
}
|
||||
|
||||
private static canonicalScope(scope: string): string {
|
||||
if (scope === 'openid') {
|
||||
return scope;
|
||||
}
|
||||
|
||||
if (scope === 'profile' || scope === 'email') {
|
||||
return `${SCOPE_PREFIX}userinfo.${scope}`;
|
||||
}
|
||||
|
||||
if (scope.startsWith(SCOPE_PREFIX)) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
return `${SCOPE_PREFIX}${scope}`;
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
export type GoogleSession = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: GoogleScopes;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import ProviderIcon from '@material-ui/icons/AcUnit';
|
||||
import { AuthHelper } from './AuthHelper';
|
||||
import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi';
|
||||
import { BasicOAuthScopes } from '../../OAuthRequestManager/BasicOAuthScopes';
|
||||
import * as loginPopup from '../loginPopup';
|
||||
|
||||
const anyFetch = fetch as any;
|
||||
@@ -33,7 +32,7 @@ const defaultOptions = {
|
||||
oauthRequestApi: new MockOAuthApi(),
|
||||
sessionTransform: ({ expiresInSeconds, ...res }: any) => ({
|
||||
...res,
|
||||
scopes: BasicOAuthScopes.from(res.scopes),
|
||||
scopes: new Set(res.scopes.split(' ')),
|
||||
expiresAt: new Date(Date.now() + expiresInSeconds * 1000),
|
||||
}),
|
||||
};
|
||||
@@ -58,7 +57,7 @@ describe('AuthHelper', () => {
|
||||
const session = await helper.refreshSession();
|
||||
expect(session.idToken).toBe('mock-id-token');
|
||||
expect(session.accessToken).toBe('mock-access-token');
|
||||
expect(session.scopes.hasScopes('a b c')).toBe(true);
|
||||
expect(session.scopes).toEqual(new Set(['a', 'b', 'c']));
|
||||
expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000);
|
||||
expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000);
|
||||
});
|
||||
@@ -87,7 +86,7 @@ describe('AuthHelper', () => {
|
||||
...defaultOptions,
|
||||
oauthRequestApi: mockOauth,
|
||||
});
|
||||
const promise = helper.createSession(BasicOAuthScopes.from('a b'));
|
||||
const promise = helper.createSession(new Set(['a', 'b']));
|
||||
await mockOauth.rejectAll();
|
||||
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
|
||||
});
|
||||
@@ -107,7 +106,7 @@ describe('AuthHelper', () => {
|
||||
oauthRequestApi: mockOauth,
|
||||
});
|
||||
|
||||
const sessionPromise = helper.createSession(BasicOAuthScopes.from('a b'));
|
||||
const sessionPromise = helper.createSession(new Set(['a', 'b']));
|
||||
|
||||
await mockOauth.triggerAll();
|
||||
|
||||
@@ -119,7 +118,7 @@ describe('AuthHelper', () => {
|
||||
await expect(sessionPromise).resolves.toEqual({
|
||||
idToken: 'my-id-token',
|
||||
accessToken: 'my-access-token',
|
||||
scopes: expect.any(BasicOAuthScopes),
|
||||
scopes: expect.any(Set),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,7 @@
|
||||
*/
|
||||
|
||||
import { AuthRequester } from '../../..';
|
||||
import {
|
||||
OAuthRequestApi,
|
||||
AuthProvider,
|
||||
OAuthScopes,
|
||||
} from '../../../definitions';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { showLoginPopup } from '../loginPopup';
|
||||
|
||||
const DEFAULT_BASE_PATH = '/api/auth/';
|
||||
@@ -37,7 +33,7 @@ type Options<AuthSession> = {
|
||||
export type GenericAuthHelper<AuthSession> = {
|
||||
refreshSession(): Promise<AuthSession>;
|
||||
removeSession(): Promise<void>;
|
||||
createSession(scope: OAuthScopes): Promise<AuthSession>;
|
||||
createSession(scopes: Set<string>): Promise<AuthSession>;
|
||||
};
|
||||
|
||||
export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
|
||||
@@ -59,12 +55,12 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
sessionTransform = (id) => id,
|
||||
sessionTransform = id => id,
|
||||
} = options;
|
||||
|
||||
this.authRequester = oauthRequestApi.createAuthRequester({
|
||||
provider,
|
||||
onAuthRequest: (scopes) => this.showPopup(scopes.toString()),
|
||||
onAuthRequest: scopes => this.showPopup([...scopes].join(' ')),
|
||||
});
|
||||
|
||||
this.apiOrigin = apiOrigin;
|
||||
@@ -95,7 +91,7 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch((error) => {
|
||||
}).catch(error => {
|
||||
throw new Error(`Auth refresh request failed, ${error}`);
|
||||
});
|
||||
|
||||
@@ -133,8 +129,8 @@ export class AuthHelper<AuthSession> implements AuthHelper<AuthSession> {
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(scope: OAuthScopes): Promise<AuthSession> {
|
||||
return this.authRequester(scope);
|
||||
async createSession(scopes: Set<string>): Promise<AuthSession> {
|
||||
return this.authRequester(scopes);
|
||||
}
|
||||
|
||||
private async showPopup(scope: string): Promise<AuthSession> {
|
||||
|
||||
Reference in New Issue
Block a user