Merge branch 'master' of github.com:spotify/backstage into ndudnik/validate-locations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+122
@@ -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);
|
||||
});
|
||||
});
|
||||
+117
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -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 () => {
|
||||
|
||||
+36
-71
@@ -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;
|
||||
}
|
||||
|
||||
+102
@@ -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();
|
||||
});
|
||||
});
|
||||
+80
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user