packages/core: lift out internal goog auth implementation

This commit is contained in:
Patrik Oldsberg
2020-05-19 12:02:43 +02:00
parent 7ba13d0e93
commit 3fe63fbab4
12 changed files with 991 additions and 1 deletions
+2 -1
View File
@@ -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;
};
+1
View File
@@ -15,3 +15,4 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();