packages/core: lift out internal goog auth implementation
This commit is contained in:
@@ -61,7 +61,8 @@
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.5",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^10.2.4"
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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 GoogleAuth from './GoogleAuth';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
|
||||
const theFuture = new Date(Date.now() + 3600000);
|
||||
const thePast = new Date(Date.now() - 10);
|
||||
|
||||
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: GoogleScopes.from('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: GoogleScopes.from('a') });
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } as any);
|
||||
|
||||
createSession.mockResolvedValue({
|
||||
scopes: GoogleScopes.from('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
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
expect(await googleAuth.getAccessToken()).toBe('access-token');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should get refreshed id token', async () => {
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
expect(await googleAuth.getIdToken()).toBe('id-token');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should get optional id token', async () => {
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
|
||||
const googleAuth = new GoogleAuth({ refreshSession } 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);
|
||||
});
|
||||
|
||||
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: GoogleScopes.from('not-enough'),
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ createSession, refreshSession } 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');
|
||||
|
||||
const promise1 = googleAuth.getAccessToken('more');
|
||||
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);
|
||||
});
|
||||
|
||||
it('should wait for all session refreshes', async () => {
|
||||
const initialSession = {
|
||||
idToken: 'token1',
|
||||
expiresAt: theFuture,
|
||||
scopes: GoogleScopes.empty(),
|
||||
};
|
||||
const refreshSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(initialSession)
|
||||
.mockResolvedValue({
|
||||
idToken: 'token2',
|
||||
expiresAt: theFuture,
|
||||
scopes: GoogleScopes.empty(),
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ refreshSession } as any);
|
||||
|
||||
// Grab the expired session first
|
||||
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
|
||||
expect(refreshSession).toBeCalledTimes(1);
|
||||
|
||||
initialSession.expiresAt = thePast;
|
||||
|
||||
const promise1 = googleAuth.getIdToken();
|
||||
const promise2 = googleAuth.getIdToken();
|
||||
const promise3 = googleAuth.getIdToken();
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 { AuthHelper } from './GoogleAuthHelper';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { GoogleAuthApi, GoogleSession, IdTokenOptions } from './types';
|
||||
import { OAuthScopes } from '../../..';
|
||||
|
||||
class GoogleAuth implements GoogleAuthApi {
|
||||
private currentSession: GoogleSession | undefined;
|
||||
|
||||
constructor(private readonly helper: AuthHelper) {}
|
||||
|
||||
async getAccessToken(scope?: string | string[]) {
|
||||
const session = await this.getSession({ optional: false, scope });
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
async getIdToken({ optional }: IdTokenOptions = {}) {
|
||||
const session = await this.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> {
|
||||
if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) {
|
||||
if (!this.sessionWillExpire(this.currentSession!)) {
|
||||
return this.currentSession!;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshedSession = await this.helper.refreshSession();
|
||||
if (refreshedSession.scopes.hasScopes(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(options.scope),
|
||||
);
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.helper.removeSession();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private sessionExistsAndHasScope(
|
||||
session: GoogleSession | undefined,
|
||||
scope?: string | string[],
|
||||
): boolean {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
if (!scope) {
|
||||
return true;
|
||||
}
|
||||
return session.scopes.hasScopes(scope);
|
||||
}
|
||||
|
||||
private sessionWillExpire(session: GoogleSession) {
|
||||
const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000;
|
||||
return expiresInSec < 60 * 5;
|
||||
}
|
||||
|
||||
private getExtendedScope(scope?: string | string[]) {
|
||||
let newScope: OAuthScopes = GoogleScopes.default();
|
||||
if (this.currentSession) {
|
||||
newScope = this.currentSession.scopes;
|
||||
}
|
||||
if (scope) {
|
||||
newScope = newScope.extend(scope);
|
||||
}
|
||||
return newScope.toString();
|
||||
}
|
||||
}
|
||||
export default GoogleAuth;
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 GoogleAuthHelper from './GoogleAuthHelper';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi';
|
||||
|
||||
const anyFetch = fetch as any;
|
||||
|
||||
describe('GoogleAuthHelper', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
anyFetch.resetMocks();
|
||||
});
|
||||
|
||||
it('should refresh a session', async () => {
|
||||
anyFetch.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
idToken: 'mock-id-token',
|
||||
accessToken: 'mock-access-token',
|
||||
scopes: 'a b c',
|
||||
expiresInSeconds: '60',
|
||||
}),
|
||||
);
|
||||
|
||||
const helper = new GoogleAuthHelper(
|
||||
{ apiOrigin: 'my-origin', dev: false },
|
||||
new MockOAuthApi(),
|
||||
);
|
||||
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.expiresAt.getTime()).toBeLessThan(Date.now() + 70000);
|
||||
expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000);
|
||||
});
|
||||
|
||||
it('should handle failure to refresh session', async () => {
|
||||
anyFetch.mockRejectOnce(new Error('Network NOPE'));
|
||||
|
||||
const helper = new GoogleAuthHelper(
|
||||
{ apiOrigin: 'my-origin', dev: true },
|
||||
new MockOAuthApi(),
|
||||
);
|
||||
await expect(helper.refreshSession()).rejects.toThrow(
|
||||
'Auth refresh request failed, Error: Network NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle failure response when refreshing session', async () => {
|
||||
anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' });
|
||||
|
||||
const helper = new GoogleAuthHelper(
|
||||
{ apiOrigin: 'my-origin', dev: false },
|
||||
new MockOAuthApi(),
|
||||
);
|
||||
await expect(helper.refreshSession()).rejects.toThrow(
|
||||
'Auth refresh request failed with status NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if popup was rejected', async () => {
|
||||
const mockOauth = new MockOAuthApi();
|
||||
const helper = new GoogleAuthHelper(
|
||||
{ apiOrigin: 'my-origin', dev: false },
|
||||
mockOauth,
|
||||
);
|
||||
const promise = helper.createSession('a b');
|
||||
await mockOauth.rejectAll();
|
||||
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
|
||||
});
|
||||
|
||||
it('should create a session', async () => {
|
||||
const mockOauth = new MockOAuthApi({
|
||||
idToken: 'my-id-token',
|
||||
accessToken: 'my-access-token',
|
||||
scopes: 'a b',
|
||||
expiresInSeconds: 3600,
|
||||
});
|
||||
const popupSpy = jest.spyOn(mockOauth, 'showLoginPopup');
|
||||
const helper = new GoogleAuthHelper(
|
||||
{ apiOrigin: 'my-origin', dev: false },
|
||||
mockOauth,
|
||||
);
|
||||
|
||||
const sessionPromise = helper.createSession('a b');
|
||||
|
||||
await mockOauth.triggerAll();
|
||||
|
||||
expect(popupSpy).toBeCalledTimes(1);
|
||||
expect(popupSpy.mock.calls[0][0]).toMatchObject({
|
||||
url:
|
||||
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
|
||||
});
|
||||
|
||||
await expect(sessionPromise).resolves.toEqual({
|
||||
idToken: 'my-id-token',
|
||||
accessToken: 'my-access-token',
|
||||
scopes: expect.any(GoogleScopes),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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 GoogleIcon from '@material-ui/icons/AcUnit';
|
||||
import GoogleScopes from './GoogleScopes';
|
||||
import { GoogleSession } from './types';
|
||||
import { AuthRequester, OAuthRequestApi } from '../../..';
|
||||
|
||||
const API_PATH = '/api/backend/auth';
|
||||
|
||||
type Options = {
|
||||
apiOrigin: string;
|
||||
dev: boolean;
|
||||
};
|
||||
|
||||
export type GoogleAuthResponse = {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scopes: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
export type AuthHelper = {
|
||||
refreshSession(): Promise<GoogleSession>;
|
||||
removeSession(): Promise<void>;
|
||||
createSession(scope: string): Promise<GoogleSession>;
|
||||
};
|
||||
|
||||
class GoogleAuthHelper implements AuthHelper {
|
||||
private readonly authRequester: AuthRequester<GoogleSession>;
|
||||
private refreshPromise?: Promise<GoogleSession>;
|
||||
|
||||
static create(oauthRequest: OAuthRequestApi) {
|
||||
return new GoogleAuthHelper(
|
||||
{
|
||||
apiOrigin: window.location.origin,
|
||||
dev: process.env.NODE_ENV === 'development',
|
||||
},
|
||||
oauthRequest,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly options: Options,
|
||||
private readonly oauth: OAuthRequestApi,
|
||||
) {
|
||||
this.authRequester = oauth.createAuthRequester({
|
||||
provider: {
|
||||
title: 'Google',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
onAuthRequest: (scopes) => this.showPopup(scopes.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<GoogleSession> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.refreshPromise = this.doAuthRefresh();
|
||||
|
||||
try {
|
||||
return await this.refreshPromise;
|
||||
} finally {
|
||||
delete this.refreshPromise;
|
||||
}
|
||||
}
|
||||
|
||||
private async doAuthRefresh(): Promise<any> {
|
||||
const res = await fetch(this.buildUrl('/token', { optional: true }), {
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch((error) => {
|
||||
throw new Error(`Auth refresh request failed, ${error}`);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error: any = new Error(
|
||||
`Auth refresh request failed with status ${res.statusText}`,
|
||||
);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const authInfo = await res.json();
|
||||
|
||||
if (authInfo.error) {
|
||||
const error = new Error(authInfo.error.message);
|
||||
if (authInfo.error.name) {
|
||||
error.name = authInfo.error.name;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return GoogleAuthHelper.convertAuthInfo(authInfo);
|
||||
}
|
||||
|
||||
async removeSession(): Promise<void> {
|
||||
const res = await fetch(this.buildUrl('/logout'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Logout request failed with status ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(scope: string): Promise<GoogleSession> {
|
||||
return this.authRequester(GoogleScopes.from(scope));
|
||||
}
|
||||
|
||||
private async showPopup(scope: string): Promise<GoogleSession> {
|
||||
const popupUrl = this.buildUrl('/start', { scope });
|
||||
|
||||
const payload = await this.oauth.showLoginPopup({
|
||||
url: popupUrl,
|
||||
name: 'google-login',
|
||||
origin: this.options.apiOrigin,
|
||||
width: 450,
|
||||
height: 730,
|
||||
});
|
||||
|
||||
return GoogleAuthHelper.convertAuthInfo(payload);
|
||||
}
|
||||
|
||||
private buildUrl(
|
||||
path: string,
|
||||
query?: { [key: string]: string | boolean | undefined },
|
||||
): string {
|
||||
const queryString = this.buildQueryString({
|
||||
...query,
|
||||
dev: this.options.dev,
|
||||
});
|
||||
|
||||
return `${this.options.apiOrigin}${API_PATH}${path}${queryString}`;
|
||||
}
|
||||
|
||||
private buildQueryString(query?: {
|
||||
[key: string]: string | boolean | undefined;
|
||||
}): string {
|
||||
if (!query) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryString = Object.entries<string | boolean | undefined>(query)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
} else if (value) {
|
||||
return encodeURIComponent(key);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
|
||||
if (!queryString) {
|
||||
return '';
|
||||
}
|
||||
return `?${queryString}`;
|
||||
}
|
||||
|
||||
private static convertAuthInfo(authInfo: GoogleAuthResponse): GoogleSession {
|
||||
return {
|
||||
idToken: authInfo.idToken,
|
||||
accessToken: authInfo.accessToken,
|
||||
scopes: GoogleScopes.from(authInfo.scopes),
|
||||
expiresAt: new Date(Date.now() + authInfo.expiresInSeconds * 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleAuthHelper;
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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';
|
||||
import MockAuthHelper, { mockIdToken, mockAccessToken } from './MockAuthHelper';
|
||||
|
||||
describe('MockAuthHelper', () => {
|
||||
it('should return mock tokens', async () => {
|
||||
const helper = new MockAuthHelper();
|
||||
await expect(helper.createSession()).resolves.toEqual({
|
||||
idToken: mockIdToken,
|
||||
accessToken: mockAccessToken,
|
||||
expiresAt: expect.any(Date),
|
||||
scopes: expect.any(GoogleScopes),
|
||||
});
|
||||
await expect(helper.refreshSession()).resolves.toEqual({
|
||||
idToken: mockIdToken,
|
||||
accessToken: mockAccessToken,
|
||||
expiresAt: expect.any(Date),
|
||||
scopes: expect.any(GoogleScopes),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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';
|
||||
import { GoogleSession } from './types';
|
||||
import { AuthHelper } from './GoogleAuthHelper';
|
||||
|
||||
export const mockIdToken = 'mock-id-token';
|
||||
export const mockAccessToken = 'mock-access-token';
|
||||
|
||||
const defaultMockSession: GoogleSession = {
|
||||
idToken: mockIdToken,
|
||||
accessToken: mockAccessToken,
|
||||
expiresAt: new Date(),
|
||||
scopes: GoogleScopes.default(),
|
||||
};
|
||||
|
||||
export default class MockAuthHelper implements AuthHelper {
|
||||
constructor(
|
||||
private readonly mockSession: GoogleSession = defaultMockSession,
|
||||
) {}
|
||||
|
||||
async refreshSession() {
|
||||
return this.mockSession;
|
||||
}
|
||||
|
||||
async removeSession() {}
|
||||
|
||||
async createSession() {
|
||||
return this.mockSession;
|
||||
}
|
||||
|
||||
async showPopup(scope: string) {
|
||||
return {
|
||||
scopes: GoogleScopes.from(scope),
|
||||
idToken: 'i',
|
||||
accessToken: 'a',
|
||||
expiresAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 * from './types';
|
||||
export { default as GoogleAuth } from './GoogleAuth';
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* This api provides access to Google OAuth credentials. It lets you request access tokens,
|
||||
* which can be used to act on behalf of the user when talking to Google APIs. It also supplies
|
||||
* ID Tokens, which can be passed to backend services to prove the user's identity.
|
||||
*
|
||||
* The API can be called directly to get access and ID tokens, which will cause a modal dialog
|
||||
* to show up if the user is not yet signed in.
|
||||
*
|
||||
* For more fine grained control of where the sign in prompt is shown, it is possible to use
|
||||
* the GoogleAuthBarrier components, which ensures that all components rendered inside it
|
||||
* have synchronous access to both access and ID tokens.
|
||||
*
|
||||
* For full examples, see https://backstage.spotify.net/docs/backstage-frontend/apis/#google-auth-api
|
||||
*/
|
||||
export type GoogleAuthApi = {
|
||||
/**
|
||||
* Requests a Google OAuth ID Token, optionally with a set of scopes. The scopes allow you to access
|
||||
* google APIs on behalf of the user. A full list of scopes can be found at https://developers.google.com/identity/protocols/googlescopes.
|
||||
*
|
||||
* Be sure to include all required scopes when requesting an access token. When testing your implementation
|
||||
* it is best to log out the Backstage Google session and then visit your plugin page directly, as
|
||||
* you might already have some required scopes in your existing session. Not requesting the correct
|
||||
* scopes can lead to 403 or other authorization errors, which can be tricky to debug.
|
||||
*
|
||||
* This method is cheap and should be called each time an access token is used. Do not for example
|
||||
* store the access token in React component state, as that could cause the token to expire. Instead
|
||||
* fetch a new access token for each request.
|
||||
*
|
||||
* If the user has not yet logged in to Google inside Backstage, a dialog window will be shown
|
||||
* that prompts the user to log in, and 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. If the
|
||||
* login fails because the user fails to log in to their google account, the dialog will simply
|
||||
* remain and ask them to try again, and the promise will still be pending.
|
||||
*/
|
||||
getAccessToken(scope?: string | string[]): Promise<string>;
|
||||
|
||||
/**
|
||||
* Requests a Google OAuth ID Token.
|
||||
*
|
||||
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*
|
||||
* This method is cheap and should be called each time an ID token is used. Do not for example
|
||||
* store the id token in React component state, as that could cause the token to expire. Instead
|
||||
* fetch a new id token for each request.
|
||||
*
|
||||
* If the user has not yet logged in to Google inside Backstage, a dialog window will be shown
|
||||
* that prompts the user to log in, and 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. If the
|
||||
* login fails because the user fails to log in to their google account, the dialog will simply
|
||||
* remain and ask them to try again, and the promise will still be pending.
|
||||
*/
|
||||
getIdToken(options?: IdTokenOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Logs out the user's Google session. This will reload the page.
|
||||
*/
|
||||
logout(): Promise<void>;
|
||||
};
|
||||
|
||||
export type GoogleSession = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: GoogleScopes;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type IdTokenOptions = {
|
||||
// If this is set to true, the user will not be prompted to log in,
|
||||
// and an empty id token will be returned if there is no existing session.
|
||||
optional?: boolean;
|
||||
};
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
Reference in New Issue
Block a user