Merge branch 'master' of github.com:spotify/backstage into ndudnik/validate-locations

This commit is contained in:
Nikita Nek Dudnik
2020-05-26 09:13:59 +02:00
10 changed files with 593 additions and 88 deletions
+42 -1
View File
@@ -12,7 +12,48 @@ app. Until then, feel free to experiment here!
## Development
To run the backend in watch mode, use `yarn start`.
To run the example backend, first go to the project root and run
```bash
yarn install
yarn build
```
You should only need to do this once.
After that, go to the `packages/backend` directory and run
```bash
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x SENTRY_TOKEN=x yarn start
```
Substitute `x` for actual values, or leave them as
dummy values just to try out the backend without using the auth or sentry features.
The backend starts up on port 7000 per default.
## Populating The Catalog
If you want to use the catalog functionality, you need to add so called locations
to the backend. These are places where the backend can find some entity descriptor
data to consume and serve.
To get started, you can issue the following after starting the backend:
```bash
curl -i \
-H "Content-Type: application/json" \
-d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \
localhost:7000/catalog/locations
```
After a short while, you should start seeing data on `localhost:7000/catalog/entities`.
If you changed the `type` to `file` in the command above, and set the `target`
to the absolute path of a YAML file on disk, you could consume your own experimental data.
The catalog currently runs in-memory only, so feel free to try it out, but it will
need to be re-populated on next startup.
## Documentation
@@ -99,7 +99,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
const normalizedScopes = GoogleAuth.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
optional: false,
scope: normalizedScopes,
scopes: normalizedScopes,
});
return session.accessToken;
}
@@ -0,0 +1,122 @@
/*
* 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 { AuthSessionStore } from './AuthSessionStore';
import { SessionManager } from './types';
const defaultOptions = {
storageKey: 'my-key',
sessionScopes: (session: string) => new Set(session.split(' ')),
};
class LocalStorage {
private store: Record<string, string> = {};
getItem(key: string) {
return this.store[key] || null;
}
setItem(key: string, value: string) {
this.store[key] = value.toString();
}
removeItem(key: string) {
delete this.store[key];
}
}
class MockManager implements SessionManager<string> {
getSession = jest.fn();
removeSession = jest.fn();
}
describe('GheAuth AuthSessionStore', () => {
beforeEach(() => {
delete (window as any).localStorage;
(window as any).localStorage = new LocalStorage();
});
afterEach(() => {
jest.resetAllMocks();
});
it('should load session', async () => {
localStorage.setItem('my-key', '"a b c"');
const manager = new MockManager();
const store = new AuthSessionStore({ manager, ...defaultOptions });
await expect(store.getSession({})).resolves.toBe('a b c');
expect(manager.getSession).not.toHaveBeenCalled();
});
it('should not use session without enough scope', async () => {
localStorage.setItem('my-key', '"a b c"');
const manager = new MockManager();
manager.getSession.mockResolvedValue('a b c d');
const store = new AuthSessionStore({ manager, ...defaultOptions });
await expect(store.getSession({ scopes: new Set(['d']) })).resolves.toBe(
'a b c d',
);
expect(manager.getSession).toHaveBeenCalledTimes(1);
});
it('should not use expired session', async () => {
localStorage.setItem('my-key', '"a b c"');
const manager = new MockManager();
manager.getSession.mockResolvedValue('123');
const store = new AuthSessionStore({
manager,
...defaultOptions,
sessionShouldRefresh: () => true,
});
await expect(store.getSession({})).resolves.toBe('123');
expect(manager.getSession).toHaveBeenCalledTimes(1);
});
it('should not load missing session', async () => {
const manager = new MockManager();
manager.getSession.mockResolvedValue('123');
const store = new AuthSessionStore({ manager, ...defaultOptions });
await expect(store.getSession({})).resolves.toBe('123');
expect(manager.getSession).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('my-key')).toBe('"123"');
});
it('should ignore bad session values', async () => {
localStorage.setItem('my-key', 'derp');
const manager = new MockManager();
manager.getSession.mockResolvedValue('123');
const store = new AuthSessionStore({ manager, ...defaultOptions });
await expect(store.getSession({})).resolves.toBe('123');
expect(manager.getSession).toHaveBeenCalledTimes(1);
});
it('should clear session', () => {
localStorage.setItem('my-key', '"a b c"');
const manager = new MockManager();
const store = new AuthSessionStore({ manager, ...defaultOptions });
store.removeSession();
expect(localStorage.getItem('my-key')).toBe(null);
});
});
@@ -0,0 +1,117 @@
/*
* 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 {
SessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
} from './types';
import { SessionScopeHelper } from './common';
type Options<T> = {
/** The connector used for acting on the auth session */
manager: SessionManager<T>;
/** Storage key to use to store sessions */
storageKey: string;
/** Used to get the scope of the session */
sessionScopes: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed, defaults to never refresh */
sessionShouldRefresh?: SessionShouldRefreshFunc<T>;
};
/**
* AuthSessionStore decorates another SessionManager with a functionality
* to store the session in local storage.
*/
export class AuthSessionStore<T> implements SessionManager<T> {
private readonly manager: SessionManager<T>;
private readonly storageKey: string;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private readonly helper: SessionScopeHelper<T>;
constructor(options: Options<T>) {
const {
manager,
storageKey,
sessionScopes,
sessionShouldRefresh = () => false,
} = options;
this.manager = manager;
this.storageKey = storageKey;
this.sessionShouldRefreshFunc = sessionShouldRefresh;
this.helper = new SessionScopeHelper({
sessionScopes,
defaultScopes: new Set(),
});
}
async getSession(options: {
optional: false;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
const { scopes } = options;
const session = this.loadSession();
if (this.helper.sessionExistsAndHasScope(session, scopes)) {
const shouldRefresh = this.sessionShouldRefreshFunc(session!);
if (!shouldRefresh) {
return session!;
}
}
const newSession = await this.manager.getSession(options);
this.saveSession(newSession);
return newSession;
}
async removeSession() {
localStorage.removeItem(this.storageKey);
await this.manager.removeSession();
}
private loadSession(): T | undefined {
try {
const sessionJson = localStorage.getItem(this.storageKey);
if (sessionJson) {
const session = JSON.parse(sessionJson);
return session;
}
return undefined;
} catch (error) {
localStorage.removeItem(this.storageKey);
return undefined;
}
}
private saveSession(session: T | undefined) {
if (session === undefined) {
localStorage.removeItem(this.storageKey);
} else {
localStorage.setItem(this.storageKey, JSON.stringify(session));
}
}
}
@@ -51,13 +51,13 @@ describe('RefreshingAuthSessionManager', () => {
scopes: new Set(['a']),
expired: false,
});
await manager.getSession({ scope: new Set(['a']) });
await manager.getSession({ scopes: new Set(['a']) });
expect(createSession).toBeCalledTimes(1);
await manager.getSession({ scope: new Set(['a']) });
await manager.getSession({ scopes: new Set(['a']) });
expect(createSession).toBeCalledTimes(1);
await manager.getSession({ scope: new Set(['b']) });
await manager.getSession({ scopes: new Set(['b']) });
expect(createSession).toBeCalledTimes(2);
});
@@ -77,11 +77,11 @@ describe('RefreshingAuthSessionManager', () => {
expired: true,
});
await manager.getSession({ scope: new Set(['a']) });
await manager.getSession({ scopes: new Set(['a']) });
expect(createSession).toBeCalledTimes(1);
expect(refreshSession).toBeCalledTimes(1);
await manager.getSession({ scope: new Set(['a']) });
await manager.getSession({ scopes: new Set(['a']) });
expect(createSession).toBeCalledTimes(1);
expect(refreshSession).toBeCalledTimes(2);
});
@@ -95,9 +95,9 @@ describe('RefreshingAuthSessionManager', () => {
} as any);
createSession.mockRejectedValueOnce(new Error('some error'));
await expect(manager.getSession({ scope: new Set(['a']) })).rejects.toThrow(
'some error',
);
await expect(
manager.getSession({ scopes: new Set(['a']) }),
).rejects.toThrow('some error');
});
it('should not get optional session', async () => {
@@ -14,29 +14,23 @@
* limitations under the License.
*/
import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests';
import { SessionManager } from './types';
import {
SessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
} from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests';
type Options<AuthSession> = {
/**
* The connector used for acting on the auth session.
*/
connector: AuthConnector<AuthSession>;
/**
* A function called to determine the scopes of the session.
*/
sessionScopes: (session: AuthSession) => Set<string>;
/**
* A function called to determine whether it's time for a session to refresh.
*
* This should return true before the session expires, for example, if a session
* expires after 60 minutes, you could return true if the session is older than 45 minutes.
*/
sessionShouldRefresh: (session: AuthSession) => boolean;
/**
* The default scopes that should always be present in a session, defaults to none.
*/
type Options<T> = {
/** The connector used for acting on the auth session */
connector: AuthConnector<T>;
/** Used to get the scope of the session */
sessionScopes: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed */
sessionShouldRefresh: SessionShouldRefreshFunc<T>;
/** The default scopes that should always be present in a session, defaults to none. */
defaultScopes?: Set<string>;
};
@@ -44,17 +38,16 @@ type Options<AuthSession> = {
* RefreshingAuthSessionManager manages an underlying session that has
* and expiration time and needs to be refreshed periodically.
*/
export class RefreshingAuthSessionManager<AuthSession>
implements SessionManager<AuthSession> {
private readonly connector: AuthConnector<AuthSession>;
private readonly defaultScopes?: Set<string>;
private readonly sessionScopesFunc: (session: AuthSession) => Set<string>;
private readonly sessionShouldRefreshFunc: (session: AuthSession) => boolean;
export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
private readonly connector: AuthConnector<T>;
private readonly helper: SessionScopeHelper<T>;
private readonly sessionScopesFunc: SessionScopesFunc<T>;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private refreshPromise?: Promise<AuthSession>;
private currentSession: AuthSession | undefined;
private refreshPromise?: Promise<T>;
private currentSession: T | undefined;
constructor(options: Options<AuthSession>) {
constructor(options: Options<T>) {
const {
connector,
defaultScopes = new Set(),
@@ -63,24 +56,26 @@ export class RefreshingAuthSessionManager<AuthSession>
} = options;
this.connector = connector;
this.defaultScopes = defaultScopes;
this.sessionScopesFunc = sessionScopes;
this.sessionShouldRefreshFunc = sessionShouldRefresh;
this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes });
}
async getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined> {
if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) {
scopes?: Set<string>;
}): Promise<T | undefined> {
if (
this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes)
) {
const shouldRefresh = this.sessionShouldRefreshFunc(this.currentSession!);
if (!shouldRefresh) {
return this.currentSession!;
@@ -111,7 +106,7 @@ export class RefreshingAuthSessionManager<AuthSession>
// 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 the refresh attempt fails we assume we don't have a session, so continue to create one.
}
}
@@ -122,7 +117,7 @@ export class RefreshingAuthSessionManager<AuthSession>
// We can call authRequester multiple times, the returned session will contain all requested scopes.
this.currentSession = await this.connector.createSession(
this.getExtendedScope(options.scope),
this.helper.getExtendedScope(this.currentSession, options.scopes),
);
return this.currentSession;
}
@@ -132,37 +127,7 @@ export class RefreshingAuthSessionManager<AuthSession>
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
private sessionExistsAndHasScope(
session: AuthSession | undefined,
scope?: Set<string>,
): boolean {
if (!session) {
return false;
}
if (!scope) {
return true;
}
const sessionScopes = this.sessionScopesFunc(session);
return hasScopes(sessionScopes, scope);
}
private getExtendedScope(scopes?: Set<string>) {
const newScope = new Set(this.defaultScopes);
if (this.currentSession) {
const sessionScopes = this.sessionScopesFunc(this.currentSession);
for (const scope of sessionScopes) {
newScope.add(scope);
}
}
if (scopes) {
for (const scope of scopes) {
newScope.add(scope);
}
}
return newScope;
}
private async collapsedSessionRefresh(): Promise<AuthSession> {
private async collapsedSessionRefresh(): Promise<T> {
if (this.refreshPromise) {
return this.refreshPromise;
}
@@ -0,0 +1,102 @@
/*
* 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 { StaticAuthSessionManager } from './StaticAuthSessionManager';
const defaultOptions = {
sessionScopes: (session: string) => new Set(session.split(' ')),
};
describe('StaticAuthSessionManager', () => {
const baseConnector = {
refreshSession() {
throw new Error('refreshSession should not be called');
},
removeSession() {
throw new Error('removeSession should not be called');
},
};
it('should get session by creating session once', async () => {
const createSession = jest.fn().mockResolvedValue('my-session');
const manager = new StaticAuthSessionManager({
connector: { createSession, ...baseConnector },
...defaultOptions,
});
expect(createSession).toHaveBeenCalledTimes(0);
await expect(manager.getSession({})).resolves.toBe('my-session');
expect(createSession).toHaveBeenCalledTimes(1);
await expect(manager.getSession({})).resolves.toBe('my-session');
expect(createSession).toHaveBeenCalledTimes(1);
});
it('should fail to get session if user rejects the request', async () => {
const createSession = jest.fn().mockRejectedValue(new Error('NOPE'));
const manager = new StaticAuthSessionManager({
connector: { createSession, ...baseConnector },
...defaultOptions,
});
expect(createSession).toHaveBeenCalledTimes(0);
await expect(manager.getSession({})).rejects.toThrow('NOPE');
expect(createSession).toHaveBeenCalledTimes(1);
await expect(manager.getSession({ optional: true })).resolves.toBe(
undefined,
);
});
it('should only request auth once for same scopes', async () => {
const createSession = jest
.fn()
.mockImplementation(scopes => [...scopes].join(' '));
const manager = new StaticAuthSessionManager({
connector: { createSession, ...baseConnector },
...defaultOptions,
});
expect(createSession).toHaveBeenCalledTimes(0);
await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe(
'a',
);
expect(createSession).toHaveBeenCalledTimes(1);
await expect(manager.getSession({ scopes: new Set(['a']) })).resolves.toBe(
'a',
);
expect(createSession).toHaveBeenCalledTimes(1);
await expect(manager.getSession({ scopes: new Set(['b']) })).resolves.toBe(
'a b',
);
expect(createSession).toHaveBeenCalledTimes(2);
});
it('should remove session and reload', async () => {
const location = { ...window.location };
delete window.location;
window.location = location;
jest.spyOn(window.location, 'reload').mockImplementation();
const removeSession = jest.fn();
const manager = new StaticAuthSessionManager({
connector: { removeSession },
...defaultOptions,
} as any);
await manager.removeSession();
expect(window.location.reload).toHaveBeenCalled();
expect(removeSession).toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
/*
* 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 { SessionManager } from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
type Options<T> = {
/** The connector used for acting on the auth session */
connector: AuthConnector<T>;
/** Used to get the scope of the session */
sessionScopes: (session: T) => Set<string>;
/** The default scopes that should always be present in a session, defaults to none. */
defaultScopes?: Set<string>;
};
/**
* StaticAuthSessionManager manages an underlying session that does not expire.
*/
export class StaticAuthSessionManager<T> implements SessionManager<T> {
private readonly connector: AuthConnector<T>;
private readonly helper: SessionScopeHelper<T>;
private currentSession: T | undefined;
constructor(options: Options<T>) {
const { connector, defaultScopes = new Set(), sessionScopes } = options;
this.connector = connector;
this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes });
}
async getSession(options: {
optional: false;
scopes?: Set<string>;
}): Promise<T>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined>;
async getSession(options: {
optional?: boolean;
scopes?: Set<string>;
}): Promise<T | undefined> {
if (
this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes)
) {
return this.currentSession;
}
// 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.connector.createSession(
this.helper.getExtendedScope(this.currentSession, options.scopes),
);
return this.currentSession;
}
async removeSession() {
await this.connector.removeSession();
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
}
@@ -0,0 +1,68 @@
/*
* 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 { SessionScopesFunc } from './types';
export function hasScopes(
searched: Set<string>,
searchFor: Set<string>,
): boolean {
for (const scope of searchFor) {
if (!searched.has(scope)) {
return false;
}
}
return true;
}
type ScopeHelperOptions<T> = {
sessionScopes: SessionScopesFunc<T>;
defaultScopes?: Set<string>;
};
export class SessionScopeHelper<T> {
constructor(private readonly options: ScopeHelperOptions<T>) {}
sessionExistsAndHasScope(
session: T | undefined,
scopes?: Set<string>,
): boolean {
if (!session) {
return false;
}
if (!scopes) {
return true;
}
const sessionScopes = this.options.sessionScopes(session);
return hasScopes(sessionScopes, scopes);
}
getExtendedScope(session: T | undefined, scopes?: Set<string>) {
const newScope = new Set(this.options.defaultScopes);
if (session) {
const sessionScopes = this.options.sessionScopes(session);
for (const scope of sessionScopes) {
newScope.add(scope);
}
}
if (scopes) {
for (const scope of scopes) {
newScope.add(scope);
}
}
return newScope;
}
}
@@ -19,15 +19,25 @@
* multiple simultaneous requests for sessions with different scope are handled
* in a correct way.
*/
export type SessionManager<AuthSession> = {
getSession(options: {
optional: false;
scope?: Set<string>;
}): Promise<AuthSession>;
export type SessionManager<T> = {
getSession(options: { optional: false; scopes?: Set<string> }): Promise<T>;
getSession(options: {
optional?: boolean;
scope?: Set<string>;
}): Promise<AuthSession | undefined>;
scopes?: Set<string>;
}): Promise<T | undefined>;
removeSession(): Promise<void>;
};
/**
* A function called to determine the scopes of a session.
*/
export type SessionScopesFunc<T> = (session: T) => Set<string>;
/**
* A function called to determine whether it's time for a session to refresh.
*
* This should return true before the session expires, for example, if a session
* expires after 60 minutes, you could return true if the session is older than 45 minutes.
*/
export type SessionShouldRefreshFunc<T> = (session: T) => boolean;