Merge branch 'master' of github.com:spotify/backstage into blam/github-as-source

* 'master' of github.com:spotify/backstage: (52 commits)
  build(deps): bump @rollup/plugin-json from 4.0.3 to 4.1.0 (#1360)
  build(deps-dev): bump lerna from 3.22.0 to 3.22.1 (#1390)
  build(deps): bump graphql from 15.0.0 to 15.1.0 (#1391)
  core-api: switch IdentityApi id token access to async
  Adding name of signed in user to greeting message (#1387)
  Fix CircleCI plugin (#1384)
  packages/core: update to not use default exports for components
  auth-backend: clean up naming and types of TokenFactory time handling
  auth-backend: some review feedback of oidc bits
  auth-backend: remove logging from DatabaseKeyStore
  auth-backend: more docs for TokenFactory
  auth-backend: added tests for TokenFactory and fix keyDuration being ignored
  auth-backend: added tests for DatabaseKeyStore
  auth-backend: refactor identity to remove logic from storage layer
  auth-backend: document identity types
  auth-backend: refactor identity APIs for forwards compatibility
  auth-backend: update standalone setup to use service builder and HMR
  auth-backend: issue user id tokens when signing in with google or github
  auth-backend: move logger setup out from constructors and use non-reserved fields
  auth-backend: make key duration configurable
  ...
This commit is contained in:
blam
2020-06-22 13:38:02 +02:00
114 changed files with 1657 additions and 561 deletions
+3
View File
@@ -116,3 +116,6 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Temporary change files created by Vim
*.swp
+2
View File
@@ -15,6 +15,7 @@
"@backstage/plugin-sentry": "^0.1.1-alpha.9",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.9",
"@backstage/plugin-welcome": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
@@ -22,6 +23,7 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
+14 -13
View File
@@ -15,23 +15,24 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils';
import App from './App';
describe('App', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
value: jest.fn(() => {
return {
matches: true,
addListener: jest.fn(),
removeListener: jest.fn(),
};
}),
it('should render', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [
{
data: {
app: { title: 'Test' },
},
context: 'test',
},
],
});
});
it('should render', () => {
const rendered = render(<App />);
const rendered = await renderWithEffects(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
+43 -5
View File
@@ -14,10 +14,38 @@
* limitations under the License.
*/
// Find all active hot module APIs of all ancestors of a module, including the module itself
function findAllAncestors(_module: NodeModule): NodeModule[] {
const ancestors = new Array<NodeModule>();
const parentIds = new Set<string | number>();
function add(id: string | number, m: NodeModule) {
if (parentIds.has(id)) {
return;
}
parentIds.add(id);
ancestors.push(m);
for (const parentId of (m as any).parents) {
const parent = require.cache[parentId];
if (parent) {
add(parentId, parent);
}
}
}
add(_module.id, _module);
return ancestors;
}
/**
* This function allows devs to cleanup
* ongoing effects when module gets hot-reloaded
* useHotCleanup allows cleanup of ongoing effects when a module is
* hot-reloaded during development. The cleanup function will be called
* whenever the module itself or any of its parent modules is hot-reloaded.
*
* Useful for cleaning intervals, timers, requests etc
*
* @example
* ```ts
* const intervalId = setInterval(doStuff, 1000);
@@ -28,9 +56,19 @@
*/
export function useHotCleanup(_module: NodeModule, cancelEffect: () => void) {
if (_module.hot) {
_module.hot.addDisposeHandler(() => {
cancelEffect();
});
const ancestors = findAllAncestors(_module);
let cancelled = false;
const handler = () => {
if (!cancelled) {
cancelled = true;
cancelEffect();
}
};
for (const m of ancestors) {
m.hot?.addDisposeHandler(handler);
}
}
}
+2 -1
View File
@@ -19,7 +19,8 @@ import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
database,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
return await createRouter({ logger, config, database });
}
+1 -2
View File
@@ -178,8 +178,7 @@ export function createBackendConfig(
context: paths.targetPath,
entry: [
'webpack/hot/poll?100',
paths.targetEntry,
...(paths.targetRunFile ? [paths.targetRunFile] : []),
paths.targetRunFile ? paths.targetRunFile : paths.targetEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
@@ -31,6 +31,9 @@
"lerna": "^3.20.2",
"prettier": "^1.19.1"
},
"resolutions": {
"**/esbuild": "0.5.3"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -8,10 +8,12 @@
"@material-ui/lab": "4.0.0-alpha.45",
"@backstage/cli": "^{{version}}",
"@backstage/core": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
@@ -8,47 +8,38 @@
name="description"
content="Backstage is an open platform for building developer portals"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="<%= publicPath %>/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link
rel="manifest"
href="%PUBLIC_URL%/manifest.json"
href="<%= publicPath %>/manifest.json"
crossorigin="use-credentials"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="<%= publicPath %>/favicon.ico" />
<link rel="shortcut icon" href="<%= publicPath %>/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
href="<%= publicPath %>/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
href="<%= publicPath %>/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
href="<%= publicPath %>/favicon-16x16.png"
/>
<link
rel="mask-icon"
href="%PUBLIC_URL%/safari-pinned-tab.svg"
href="<%= publicPath %>/safari-pinned-tab.svg"
color="#5bbad5"
/>
<style>
@@ -56,9 +47,9 @@
min-height: 100%;
}
</style>
<title>Backstage</title>
<title><%= app.title %></title>
</head>
<body style="margin: 0">
<body style="margin: 0;">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
@@ -1,10 +1,22 @@
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils';
import App from './App';
describe('App', () => {
it('should render', () => {
const rendered = render(<App />);
it('should render', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [
{
data: {
app: { title: 'Test' },
},
context: 'test',
},
],
});
const rendered = await renderWithEffects(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
@@ -1,26 +1,7 @@
import { makeStyles } from '@material-ui/core';
import { createApp } from '@backstage/core';
import React, { FC } from 'react';
import * as plugins from './plugins';
const useStyles = makeStyles(theme => ({
'@global': {
html: {
height: '100%',
fontFamily: theme.typography.fontFamily,
},
body: {
height: '100%',
fontFamily: theme.typography.fontFamily,
'overscroll-behavior-y': 'none',
},
a: {
color: 'inherit',
textDecoration: 'none',
},
},
}));
const app = createApp({
plugins: Object.values(plugins),
});
@@ -29,15 +10,12 @@ const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const App: FC<{}> = () => {
useStyles();
return (
<AppProvider>
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
};
const App: FC<{}> = () => (
<AppProvider>
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
export default App;
@@ -35,7 +35,7 @@ export type IdentityApi = {
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getIdToken(): string | undefined;
getIdToken(): Promise<string | undefined>;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
+23 -1
View File
@@ -157,25 +157,47 @@ export type ProfileInfoOptions = {
optional?: boolean;
};
/**
* This API provides access to profile information of the user from an auth provider.
*/
export type ProfileInfoApi = {
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
};
/**
* Profile information of the user from an auth provider.
*/
export type ProfileInfo = {
provider: string;
/**
* Email ID.
*/
email: string;
/**
* Display name that can be presented to the user.
*/
name?: string;
/**
* URL to an avatar image of the user.
*/
picture?: string;
};
/**
* Session state values passed to subscribers of the SessionStateApi.
*/
export enum SessionState {
SignedIn = 'SignedIn',
SignedOut = 'SignedOut',
}
/**
* This API provides access to an sessionState$ observable which provides an update when the
* user performs a sign in or sign out from an auth provider.
*/
export type SessionStateApi = {
sessionState$(): Observable<SessionState>;
};
/**
* Provides authentication towards Google APIs and identities.
*
-2
View File
@@ -293,8 +293,6 @@ export class PrivateAppImpl implements BackstageApp {
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
idToken: undefined,
logout: async () => {},
});
return (
+4 -4
View File
@@ -24,7 +24,7 @@ import { SignInResult } from './types';
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private idToken?: string;
private idTokenFunc?: () => Promise<string>;
private logoutFunc?: () => Promise<void>;
getUserId(): string {
@@ -36,13 +36,13 @@ export class AppIdentity implements IdentityApi {
return this.userId!;
}
getIdToken(): string | undefined {
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idToken;
return this.idTokenFunc?.();
}
async logout(): Promise<void> {
@@ -64,7 +64,7 @@ export class AppIdentity implements IdentityApi {
}
this.hasIdentity = true;
this.userId = result.userId;
this.idToken = result.idToken;
this.idTokenFunc = result.getIdToken;
this.logoutFunc = result.logout;
}
}
+2 -2
View File
@@ -32,9 +32,9 @@ export type SignInResult = {
*/
userId: string;
/**
* ID token that will be returned by the IdentityApi
* Function used to retrieve an ID token for the signed in user.
*/
idToken?: string;
getIdToken?: () => Promise<string>;
/**
* Logout handler that will be called if the user requests a logout.
*/
+1 -1
View File
@@ -25,7 +25,7 @@ import privateExports, {
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import Progress from '../components/Progress';
import { Progress } from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig, JsonObject } from '@backstage/config';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export * from './AlertDisplay';
export { AlertDisplay } from './AlertDisplay';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import CodeSnippet from './CodeSnippet';
import { CodeSnippet } from './CodeSnippet';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CodeSnippet from './CodeSnippet';
import { CodeSnippet } from './CodeSnippet';
const JAVASCRIPT = `const greeting = "Hello";
const world = "World";
@@ -31,7 +31,7 @@ const defaultProps = {
showLineNumbers: false,
};
const CodeSnippet: FC<Props> = props => {
export const CodeSnippet: FC<Props> = props => {
const { text, language, showLineNumbers } = {
...defaultProps,
...props,
@@ -57,5 +57,3 @@ CodeSnippet.propTypes = {
language: PropTypes.string.isRequired,
showLineNumbers: PropTypes.bool,
};
export default CodeSnippet;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CodeSnippet';
export { CodeSnippet } from './CodeSnippet';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import CopyTextButton from '.';
import { CopyTextButton } from '.';
export default {
title: 'CopyTextButton',
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CopyTextButton from './CopyTextButton';
import { CopyTextButton } from './CopyTextButton';
import {
ApiRegistry,
errorApiRef,
@@ -56,7 +56,7 @@ const defaultProps = {
tooltipText: 'Text copied to clipboard',
};
const CopyTextButton: FC<Props> = props => {
export const CopyTextButton: FC<Props> = props => {
const { text, tooltipDelay, tooltipText } = {
...defaultProps,
...props,
@@ -110,5 +110,3 @@ CopyTextButton.propTypes = {
tooltipDelay: PropTypes.number,
tooltipText: PropTypes.string,
};
export default CopyTextButton;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CopyTextButton';
export { CopyTextButton } from './CopyTextButton';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import DismissableBanner from './DismissableBanner';
import { DismissableBanner } from './DismissableBanner';
import { Link, Typography } from '@material-ui/core';
import {
ApiProvider,
@@ -17,7 +17,7 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import DismissableBanner from './DismissableBanner';
import { DismissableBanner } from './DismissableBanner';
import {
ApiRegistry,
ApiProvider,
@@ -59,7 +59,7 @@ type Props = {
id: string;
};
const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
const classes = useStyles();
const storageApi = useApi(storageApiRef);
const notificationsStore = storageApi.forBucket('notifications');
@@ -111,5 +111,3 @@ const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
</Snackbar>
);
};
export default DismissableBanner;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './DismissableBanner';
export { DismissableBanner } from './DismissableBanner';
@@ -14,10 +14,4 @@
* limitations under the License.
*/
import React from 'react';
import { useStarredEntities } from '../../hooks/useStarredEntites';
export const StarredCount: React.FC<{}> = () => {
const { starredEntities } = useStarredEntities();
return <span>{starredEntities.size}</span>;
};
export { FeatureCalloutCircular } from './FeatureCalloutCircular';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
const cardContentStyle = { height: 0, padding: 150, margin: 20 };
const containerStyle = { width: 800, height: 400, margin: 20 };
@@ -17,7 +17,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
import { Grid } from '@material-ui/core';
describe('<HorizontalScrollGrid />', () => {
@@ -181,7 +181,7 @@ function useSmoothScroll(
return setScrollTarget;
}
const HorizontalScrollGrid: FC<Props> = props => {
export const HorizontalScrollGrid: FC<Props> = props => {
const {
scrollStep = 100,
scrollSpeed = 50,
@@ -245,5 +245,3 @@ const HorizontalScrollGrid: FC<Props> = props => {
</div>
);
};
export default HorizontalScrollGrid;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './HorizontalScrollGrid';
export { HorizontalScrollGrid } from './HorizontalScrollGrid';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import Progress from '.';
import { Progress } from '.';
export default {
title: 'Progress',
@@ -17,7 +17,7 @@
import React, { FC, useState, useEffect } from 'react';
import { LinearProgress, LinearProgressProps } from '@material-ui/core';
const Progress: FC<LinearProgressProps> = props => {
export const Progress: FC<LinearProgressProps> = props => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
@@ -31,5 +31,3 @@ const Progress: FC<LinearProgressProps> = props => {
<div style={{ display: 'none' }} data-testid="progress" />
);
};
export default Progress;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './Progress';
export { Progress } from './Progress';
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CircleProgress, { getProgressColor } from './CircleProgress';
import { CircleProgress, getProgressColor } from './CircleProgress';
describe('<CircleProgress />', () => {
it('renders without exploding', () => {
@@ -77,7 +77,7 @@ export function getProgressColor(
return palette.status.ok;
}
const CircleProgress: FC<Props> = props => {
export const CircleProgress: FC<Props> = props => {
const classes = useStyles(props);
const theme = useTheme<BackstageTheme>();
const { value, fractional, inverse, unit, max } = {
@@ -104,5 +104,3 @@ const CircleProgress: FC<Props> = props => {
</div>
);
};
export default CircleProgress;
@@ -15,7 +15,7 @@
*/
import React from 'react';
import HorizontalProgress from './HorizontalProgress';
import { HorizontalProgress } from './HorizontalProgress';
const containerStyle = { width: 300 };
@@ -28,7 +28,7 @@ type Props = {
value: number;
};
const HorizontalProgress: FC<Props> = ({ value }) => {
export const HorizontalProgress: FC<Props> = ({ value }) => {
const theme = useTheme<BackstageTheme>();
if (isNaN(value)) {
return null;
@@ -49,5 +49,3 @@ const HorizontalProgress: FC<Props> = ({ value }) => {
</Tooltip>
);
};
export default HorizontalProgress;
@@ -15,7 +15,7 @@
*/
import React from 'react';
import ProgressCard from './ProgressCard';
import { ProgressCard } from './ProgressCard';
import { Grid } from '@material-ui/core';
const linkInfo = { title: 'Go to XYZ Location', link: '#' };
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import ProgressCard from './ProgressCard';
import { ProgressCard } from './ProgressCard';
const minProps = { title: 'Tingle upgrade', progress: 0.12 };
@@ -18,7 +18,7 @@ import React, { FC } from 'react';
import { makeStyles } from '@material-ui/core';
import { InfoCard } from '../../layout/InfoCard';
import { BottomLinkProps } from '../../layout/BottomLink';
import CircleProgress from './CircleProgress';
import { CircleProgress } from './CircleProgress';
type Props = {
title: string;
@@ -36,7 +36,7 @@ const useStyles = makeStyles({
},
});
const ProgressCard: FC<Props> = props => {
export const ProgressCard: FC<Props> = props => {
const classes = useStyles(props);
const { title, subheader, progress, deepLink, variant } = props;
@@ -53,5 +53,3 @@ const ProgressCard: FC<Props> = props => {
</div>
);
};
export default ProgressCard;
@@ -0,0 +1,19 @@
/*
* 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 { ProgressCard } from './ProgressCard';
export { CircleProgress } from './CircleProgress';
export { HorizontalProgress } from './HorizontalProgress';
@@ -16,8 +16,8 @@
import React from 'react';
import { render, fireEvent, within } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import Stepper from './SimpleStepper';
import Step from './SimpleStepperStep';
import { SimpleStepper as Stepper } from './SimpleStepper';
import { SimpleStepperStep as Step } from './SimpleStepperStep';
const getTextInSlide = (rendered: any, index: number) =>
within(rendered.getByTestId(`step${index}`)).getByText;
@@ -40,13 +40,17 @@ export interface StepperProps {
onStepChange?: (prevIndex: number, nextIndex: number) => void;
}
const Stepper: FC<StepperProps> = ({ children, elevated, onStepChange }) => {
export const SimpleStepper: FC<StepperProps> = ({
children,
elevated,
onStepChange,
}) => {
const [stepIndex, setStepIndex] = useState<number>(0);
const [stepHistory, setStepHistory] = useState<number[]>([0]);
const steps: React.ReactNode[] = [];
let endStep;
Children.forEach(children, (child) => {
Children.forEach(children, child => {
if (isValidElement(child)) {
if (child.props.end) {
endStep = child;
@@ -80,5 +84,3 @@ const Stepper: FC<StepperProps> = ({ children, elevated, onStepChange }) => {
</>
);
};
export default Stepper;
@@ -18,7 +18,7 @@ import { Button, makeStyles } from '@material-ui/core';
import { StepActions } from './SimpleStepperStep';
import { VerticalStepperContext } from './SimpleStepper';
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
root: {
marginTop: theme.spacing(3),
'& button': {
@@ -71,7 +71,7 @@ export type SimpleStepperFooterProps = {
children?: ReactNode;
};
const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
export const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
actions = {},
children,
}) => {
@@ -145,5 +145,3 @@ const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
</div>
);
};
export default SimpleStepperFooter;
@@ -21,9 +21,9 @@ import {
Typography,
makeStyles,
} from '@material-ui/core';
import SimpleStepperFooter from './SimpleStepperFooter';
import { SimpleStepperFooter } from './SimpleStepperFooter';
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
end: {
padding: theme.spacing(3),
},
@@ -53,7 +53,7 @@ export type StepProps = {
actions?: StepActions;
};
const Step: FC<StepProps> = ({
export const SimpleStepperStep: FC<StepProps> = ({
title,
children,
end,
@@ -82,5 +82,3 @@ const Step: FC<StepProps> = ({
</MuiStep>
);
};
export default Step;
@@ -14,7 +14,5 @@
* limitations under the License.
*/
import SimpleStepper from './SimpleStepper';
import SimpleStepperStep from './SimpleStepperStep';
export { SimpleStepper, SimpleStepperStep };
export { SimpleStepper } from './SimpleStepper';
export { SimpleStepperStep } from './SimpleStepperStep';
@@ -23,7 +23,7 @@ import {
StatusRunning,
StatusWarning,
} from './Status';
import Table from '../Table';
import { Table } from '../Table';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -16,7 +16,7 @@
import React, { FC } from 'react';
import { InfoCard } from '../../layout/InfoCard';
import { Grid } from '@material-ui/core';
import StructuredMetadataTable from '.';
import { StructuredMetadataTable } from './StructuredMetadataTable';
const cardContentStyle = { heightX: 200, width: 500 };
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import StructuredMetadataTable from './StructuredMetadataTable';
import { StructuredMetadataTable } from './StructuredMetadataTable';
import { startCase } from 'lodash';
describe('<StructuredMetadataTable />', () => {
@@ -56,7 +56,7 @@ function renderMap(
nested?: boolean,
options?: any,
) {
const values = Object.keys(map).map((key) => {
const values = Object.keys(map).map(key => {
const value = toValue(map[key], true);
const fmtKey =
options && options.titleFormat
@@ -98,7 +98,7 @@ function toValue(
}
function mapToItems(info: { [key: string]: string }, options: any) {
return Object.keys(info).map((key) => (
return Object.keys(info).map(key => (
<TableItem key={key} title={key} value={info[key]} options={options} />
));
}
@@ -147,7 +147,8 @@ interface ComponentProps {
dense?: boolean;
options?: any;
}
export default class StructuredMetadataTable extends Component<ComponentProps> {
export class StructuredMetadataTable extends Component<ComponentProps> {
render() {
const { metadata, dense, options } = this.props;
const metadataItems = mapToItems(metadata, options || {});
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './StructuredMetadataTable';
export { StructuredMetadataTable } from './StructuredMetadataTable';
@@ -49,7 +49,7 @@ const useStyles = makeStyles(theme => ({
},
}));
const SupportButton: FC<Props> = ({
export const SupportButton: FC<Props> = ({
slackChannel = '#backstage',
email = [],
children,
@@ -155,5 +155,3 @@ const SupportButton: FC<Props> = ({
</Fragment>
);
};
export default SupportButton;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './SupportButton';
export { SupportButton } from './SupportButton';
@@ -33,7 +33,7 @@ type SubvalueCellProps = {
subvalue: React.ReactNode;
};
const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
export const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
const classes = useSubvalueCellStyles();
return (
@@ -43,5 +43,3 @@ const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
</>
);
};
export default SubvalueCell;
@@ -15,7 +15,7 @@
*/
import React from 'react';
import Table, { SubvalueCell, TableColumn } from './';
import { Table, SubvalueCell, TableColumn } from './';
export default {
title: 'Table',
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import Table from './';
import { Table } from './Table';
const minProps = {
columns: [
+1 -3
View File
@@ -162,7 +162,7 @@ export interface TableProps extends MaterialTableProps<{}> {
subtitle?: string;
}
const Table: FC<TableProps> = ({
export const Table: FC<TableProps> = ({
columns,
options,
title,
@@ -212,5 +212,3 @@ const Table: FC<TableProps> = ({
/>
);
};
export default Table;
+2 -2
View File
@@ -14,6 +14,6 @@
* limitations under the License.
*/
export { default } from './Table';
export { Table } from './Table';
export type { TableColumn } from './Table';
export { default as SubvalueCell } from './SubvalueCell';
export { SubvalueCell } from './SubvalueCell';
+1 -1
View File
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { Tabs as default } from './Tabs';
export { Tabs } from './Tabs';
@@ -15,8 +15,8 @@
*/
import React from 'react';
import TrendLine from '.';
import Table from '../Table';
import { Table } from '../Table';
import { TrendLine } from './TrendLine';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -19,7 +19,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import TrendLine from '.';
import { TrendLine } from './TrendLine';
describe('TrendLine', () => {
describe('when no data is present', () => {
@@ -27,7 +27,7 @@ function color(data: number[], theme: BackstageTheme): string | undefined {
return theme.palette.status.error;
}
const Trendline: FC<SparklinesProps & { title?: string }> = props => {
export const TrendLine: FC<SparklinesProps & { title?: string }> = props => {
const theme = useTheme<BackstageTheme>();
if (!props.data) return null;
@@ -38,5 +38,3 @@ const Trendline: FC<SparklinesProps & { title?: string }> = props => {
</Sparklines>
);
};
export default Trendline;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './TrendLine';
export { TrendLine } from './TrendLine';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import WarningPanel from '.';
import { WarningPanel } from './WarningPanel';
import { Link, Button } from '@material-ui/core';
export default {
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import WarningPanel from './WarningPanel';
import { WarningPanel } from './WarningPanel';
const minProps = { title: 'Mock title', message: 'Some more info' };
@@ -19,7 +19,7 @@ import { Typography, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
const useErrorOutlineStyles = makeStyles<BackstageTheme>((theme) => ({
const useErrorOutlineStyles = makeStyles<BackstageTheme>(theme => ({
root: {
marginRight: theme.spacing(1),
fill: theme.palette.warningText,
@@ -30,7 +30,7 @@ const ErrorOutlineStyled = () => {
return <ErrorOutline classes={classes} />;
};
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
message: {
display: 'flex',
flexDirection: 'column',
@@ -62,7 +62,7 @@ type Props = {
title?: string;
};
const WarningPanel: FC<Props> = (props) => {
export const WarningPanel: FC<Props> = props => {
const classes = useStyles(props);
const { title, message, children } = props;
return (
@@ -82,5 +82,3 @@ const WarningPanel: FC<Props> = (props) => {
</div>
);
};
export default WarningPanel;
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default } from './WarningPanel';
export { WarningPanel } from './WarningPanel';
+36
View File
@@ -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.
*/
export * from './AlertDisplay';
export * from './Button';
export * from './CodeSnippet';
export * from './CopyTextButton';
export * from './DismissableBanner';
export * from './FeatureDiscovery';
export * from './HorizontalScrollGrid';
export * from './Lifecycle';
export * from './Link';
export * from './OAuthRequestDialog';
export * from './Progress';
export * from './ProgressBars';
export * from './SimpleStepper';
export * from './Status';
export * from './StructuredMetadataTable';
export * from './SupportButton';
export * from './Table';
export * from './Tabs';
export * from './TrendLine';
export * from './WarningPanel';
+1 -24
View File
@@ -17,28 +17,5 @@
export * from '@backstage/core-api';
export * from './api-wrappers';
export * from './components';
export * from './layout';
export { default as CodeSnippet } from './components/CodeSnippet';
export { default as DismissableBanner } from './components/DismissableBanner';
export { AlertDisplay } from './components/AlertDisplay';
export { default as HorizontalScrollGrid } from './components/HorizontalScrollGrid';
export { default as ProgressCard } from './components/ProgressBars/ProgressCard';
export { default as CircleProgress } from './components/ProgressBars/CircleProgress';
export { default as HorizontalProgress } from './components/ProgressBars/HorizontalProgress';
export { default as CopyTextButton } from './components/CopyTextButton';
export { default as Progress } from './components/Progress';
export * from './components/SimpleStepper';
export { OAuthRequestDialog } from './components/OAuthRequestDialog';
export { Lifecycle } from './components/Lifecycle';
export { default as SupportButton } from './components/SupportButton';
export { default as Table, SubvalueCell } from './components/Table';
export type { TableColumn } from './components/Table/Table';
export { default as StructuredMetadataTable } from './components/StructuredMetadataTable';
export { default as TrendLine } from './components/TrendLine';
export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular';
export * from './components/Status';
export * from './components/Button';
export * from './components/Link';
export { default as WarningPanel } from './components/WarningPanel';
export { default as Tabs } from './components/Tabs';
@@ -22,7 +22,7 @@ import { ContentHeader } from '../ContentHeader/ContentHeader';
import { Grid } from '@material-ui/core';
import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api';
import { useSignInProviders, SignInProviderId } from './providers';
import Progress from '../../components/Progress';
import { Progress } from '../../components/Progress';
export type Props = SignInPageProps & {
providers: SignInProviderId[];
@@ -28,7 +28,6 @@ import {
import isEmpty from 'lodash/isEmpty';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
import { SignInResult } from '@backstage/core-api';
const ID_TOKEN_REGEX = /^[a-z0-9+/]+\.[a-z0-9+/]+\.[a-z0-9+/]+$/i;
@@ -43,12 +42,24 @@ const useFormStyles = makeStyles(theme => ({
},
}));
type Data = {
userId: string;
idToken?: string;
};
const Component: ProviderComponent = ({ onResult }) => {
const classes = useFormStyles();
const { register, handleSubmit, errors, formState } = useForm<SignInResult>({
const { register, handleSubmit, errors, formState } = useForm<Data>({
mode: 'onChange',
});
const handleResult = ({ userId, idToken }: Data) => {
onResult({
userId,
getIdToken: idToken ? async () => idToken : undefined,
});
};
return (
<Grid item>
<InfoCard title="Custom User">
@@ -58,7 +69,7 @@ const Component: ProviderComponent = ({ onResult }) => {
This selection will not be stored.
</Typography>
<form className={classes.form} onSubmit={handleSubmit(onResult)}>
<form className={classes.form} onSubmit={handleSubmit(handleResult)}>
<FormControl>
<TextField
name="userId"
@@ -35,12 +35,12 @@ const Component: ProviderComponent = ({ onResult }) => {
const handleLogin = async () => {
try {
const idToken = await googleAuthApi.getIdToken({ instantPopup: true });
await googleAuthApi.getIdToken({ instantPopup: true });
const profile = await googleAuthApi.getProfile();
onResult({
userId: parseUserId(profile!),
idToken,
getIdToken: () => googleAuthApi.getIdToken(),
logout: async () => {
await googleAuthApi.logout();
},
@@ -69,14 +69,11 @@ const Component: ProviderComponent = ({ onResult }) => {
const loader: ProviderLoader = async apis => {
const googleAuthApi = apis.get(googleAuthApiRef)!;
const [idToken, profile] = await Promise.all([
googleAuthApi.getIdToken({ optional: true }),
googleAuthApi.getProfile({ optional: true }),
]);
const profile = await googleAuthApi.getProfile({ optional: true });
return {
userId: parseUserId(profile!),
idToken,
getIdToken: () => googleAuthApi.getIdToken(),
logout: async () => {
await googleAuthApi.logout();
},
+1 -1
View File
@@ -2,7 +2,6 @@ import {
ApiRegistry,
alertApiRef,
errorApiRef,
identityApiRef,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
@@ -12,6 +11,7 @@ import {
ErrorAlerter,
GoogleAuth,
GithubAuth,
identityApiRef,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -0,0 +1,46 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('signing_keys', table => {
table.comment(
'Signing keys that are currently in use or have recently been used to issue tokens',
);
table
.string('kid')
.primary()
.notNullable()
.comment('ID of the signing key');
table
.timestamp('created_at', { useTz: false, precision: 0 })
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the key');
table.string('key').notNullable().comment('The serialized signing key');
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTable('auth_keystore');
};
+5 -1
View File
@@ -32,12 +32,16 @@
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"jose": "^1.27.1",
"jwt-decode": "2.2.0",
"knex": "^0.21.1",
"moment": "^2.26.0",
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-saml": "^1.3.3",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -46,10 +50,10 @@
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-saml": "^1.1.2",
"@types/passport": "^1.0.3",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -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 Knex from 'knex';
import moment from 'moment';
import { DatabaseKeyStore } from './DatabaseKeyStore';
function createDB() {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
}
const keyBase = {
use: 'sig',
kty: 'plain',
alg: 'Base64',
} as const;
describe('DatabaseKeyStore', () => {
it('should store a key', async () => {
const database = createDB();
const store = await DatabaseKeyStore.create({ database });
const key = {
kid: '123',
...keyBase,
};
await expect(store.listKeys()).resolves.toEqual({ items: [] });
await store.addKey(key);
const { items } = await store.listKeys();
expect(items).toEqual([{ createdAt: expect.anything(), key }]);
expect(Math.abs(items[0].createdAt.diff(moment(), 's'))).toBeLessThan(10);
});
it('should remove stored keys', async () => {
const database = createDB();
const store = await DatabaseKeyStore.create({ database });
const key1 = { kid: '1', ...keyBase };
const key2 = { kid: '2', ...keyBase };
const key3 = { kid: '3', ...keyBase };
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1']);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1', '2']);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys([]);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys(['3', '4']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1', '2', '3']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
});
});
@@ -0,0 +1,77 @@
/*
* 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 Knex from 'knex';
import path from 'path';
import { utc } from 'moment';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-auth-backend/package.json'),
'../migrations',
);
const TABLE = 'signing_keys';
type Row = {
created_at: Date;
kid: string;
key: string;
};
type Options = {
database: Knex;
};
export class DatabaseKeyStore implements KeyStore {
static async create(options: Options): Promise<DatabaseKeyStore> {
const { database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseKeyStore(options);
}
private readonly database: Knex;
private constructor(options: Options) {
this.database = options.database;
}
async addKey(key: AnyJWK): Promise<void> {
await this.database<Row>(TABLE).insert({
kid: key.kid,
key: JSON.stringify(key),
});
}
async listKeys(): Promise<{ items: StoredKey[] }> {
const rows = await this.database<Row>(TABLE).select();
return {
items: rows.map(row => ({
key: JSON.parse(row.key),
createdAt: utc(row.created_at),
})),
};
}
async removeKeys(kids: string[]): Promise<void> {
await this.database(TABLE).delete().whereIn('kid', kids);
}
}
@@ -0,0 +1,133 @@
/*
* 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 { utc } from 'moment';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { JWKS, JSONWebKey, JWT } from 'jose';
const logger = getVoidLogger();
class MemoryKeyStore implements KeyStore {
private readonly keys = new Map<
string,
{ createdAt: moment.Moment; key: string }
>();
async addKey(key: AnyJWK): Promise<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
for (const kid of kids) {
this.keys.delete(kid);
}
}
async listKeys(): Promise<{ items: StoredKey[] }> {
return {
items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({
createdAt,
key: JSON.parse(keyStr),
})),
};
}
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
}
describe('TokenFactory', () => {
it('should issue valid tokens signed by a listed key', async () => {
const keyDurationSeconds = 5;
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
});
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
keys: keys.map(key => key as JSONWebKey),
});
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: 'foo',
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds * 1000);
});
it('should generate new signing keys when the current one expires', async () => {
const fixedTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds: 5,
logger,
});
const token1 = await factory.issueToken({ claims: { sub: 'foo' } });
const token2 = await factory.issueToken({ claims: { sub: 'foo' } });
expect(jwtKid(token1)).toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [
expect.objectContaining({
kid: jwtKid(token1),
}),
],
});
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime + 60000);
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [],
});
const token3 = await factory.issueToken({ claims: { sub: 'foo' } });
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [
expect.objectContaining({
kid: jwtKid(token3),
}),
],
});
});
});
@@ -0,0 +1,163 @@
/*
* 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 moment from 'moment';
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
const MS_IN_S = 1000;
type Options = {
logger: Logger;
/** Value of the issuer claim in issued tokens */
issuer: string;
/** Key store used for storing signing keys */
keyStore: KeyStore;
/** Expiration time of signing keys in seconds */
keyDurationSeconds: number;
};
/**
* A token issuer that is able to issue tokens in a distributed system
* backed by a single database. Tokens are issued using lazily generated
* signing keys, where each running instance of the auth service uses its own
* signing key.
*
* The public parts of the keys are all stored in the shared key storage,
* and any of the instances of the auth service will return the full list
* of public keys that are currently in storage.
*
* Signing keys are automatically rotated at the same interval as the token
* duration. Expired keys are kept in storage until there are no valid tokens
* in circulation that could have been signed by that key.
*/
export class TokenFactory implements TokenIssuer {
private readonly issuer: string;
private readonly logger: Logger;
private readonly keyStore: KeyStore;
private readonly keyDurationSeconds: number;
private keyExpiry?: moment.Moment;
private privateKeyPromise?: Promise<JSONWebKey>;
constructor(options: Options) {
this.issuer = options.issuer;
this.logger = options.logger;
this.keyStore = options.keyStore;
this.keyDurationSeconds = options.keyDurationSeconds;
}
async issueToken(params: TokenParams): Promise<string> {
const key = await this.getKey();
const iss = this.issuer;
const sub = params.claims.sub;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds * MS_IN_S;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
}
// This will be called by other services that want to verify ID tokens.
// It is important that it returns a list of all public keys that could
// have been used to sign tokens that have not yet expired.
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
const { items: keys } = await this.keyStore.listKeys();
const validKeys = [];
const expiredKeys = [];
for (const key of keys) {
// Allow for a grace period of another full key duration before we remove the keys from the database
const expireAt = key.createdAt.add(3 * this.keyDurationSeconds, 's');
if (expireAt.isBefore()) {
expiredKeys.push(key);
} else {
validKeys.push(key);
}
}
// Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e
if (expiredKeys.length > 0) {
const kids = expiredKeys.map(({ key }) => key.kid);
this.logger.info(`Removing expired signing keys, '${kids.join("', '")}'`);
// We don't await this, just let it run in the background
this.keyStore.removeKeys(kids).catch(error => {
this.logger.error(`Failed to remove expired keys, ${error}`);
});
}
// NOTE: we're currently only storing public keys, but if we start storing private keys we'd have to convert here
return { keys: validKeys.map(({ key }) => key) };
}
private async getKey(): Promise<JSONWebKey> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (this.keyExpiry?.isAfter()) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds');
const promise = (async () => {
// This generates a new signing key to be used to sign tokens until the next key rotation
const key = await JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
// We're not allowed to use the key until it has been successfully stored
// TODO: some token verification implementations aggressively cache the list of keys, and
// don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we
// may want to keep using the existing key for some period of time until we switch to
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
})();
this.privateKeyPromise = promise;
try {
// If we fail to generate a new key, we need to clear the state so that
// the next caller will try to generate another key.
await promise;
} catch (error) {
this.logger.error(`Failed to generate new signing key, ${error}`);
delete this.keyExpiry;
delete this.privateKeyPromise;
}
return promise;
}
}
@@ -0,0 +1,20 @@
/*
* 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 { createOidcRouter } from './router';
export { TokenFactory } from './TokenFactory';
export { DatabaseKeyStore } from './DatabaseKeyStore';
export type { KeyStore, TokenIssuer, TokenParams } from './types';
@@ -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 Router from 'express-promise-router';
import { TokenIssuer } from './types';
export type Options = {
baseUrl: string;
tokenIssuer: TokenIssuer;
};
export function createOidcRouter(options: Options) {
const { baseUrl, tokenIssuer } = options;
const router = Router();
const config = {
issuer: baseUrl,
token_endpoint: `${baseUrl}/v1/token`,
userinfo_endpoint: `${baseUrl}/v1/userinfo`,
jwks_uri: `${baseUrl}/v1/certs`,
response_types_supported: ['id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid'],
token_endpoint_auth_methods_supported: [],
claims_supported: ['sub'],
grant_types_supported: [],
};
router.get('/.well-known/openid-configuration', (_req, res) => {
res.json(config);
});
router.get('/.well-known/jwks.json', async (_req, res) => {
const { keys } = await tokenIssuer.listPublicKeys();
res.json({ keys });
});
router.get('/v1/token', (_req, res) => {
res.status(501).send('Not Implemented');
});
router.get('/v1/userinfo', (_req, res) => {
res.status(501).send('Not Implemented');
});
return router;
}
@@ -0,0 +1,76 @@
/*
* 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.
*/
/** Represents any form of serializable JWK */
export interface AnyJWK extends Record<string, string> {
use: 'sig';
alg: string;
kid: string;
kty: string;
}
/** Parameters used to issue new ID Tokens */
export type TokenParams = {
/** The claims that will be embedded within the token */
claims: {
/** The token subject, i.e. User ID */
sub: string;
};
};
/**
* A TokenIssuer is able to issue verifiable ID Tokens on demand.
*/
export type TokenIssuer = {
/**
* Issues a new ID Token
*/
issueToken(params: TokenParams): Promise<string>;
/**
* List all public keys that are currently being used to sign tokens, or have been used
* in the past within the token expiration time, including a grace period.
*/
listPublicKeys(): Promise<{ keys: AnyJWK[] }>;
};
/**
* A JWK stored by a KeyStore
*/
export type StoredKey = {
key: AnyJWK;
createdAt: moment.Moment;
};
/**
* A KeyStore stores JWKs for later and shared use.
*/
export type KeyStore = {
/**
* Store a new key to be used for signing.
*/
addKey(key: AnyJWK): Promise<void>;
/**
* Remove all keys with the provided kids.
*/
removeKeys(kids: string[]): Promise<void>;
/**
* List all stored keys.
*/
listKeys(): Promise<{ items: StoredKey[] }>;
};
@@ -57,6 +57,8 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers {
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.logout(req, res);
if (provider.logout) {
await provider.logout(req, res);
}
}
}
@@ -180,6 +180,10 @@ describe('OAuthProvider', () => {
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
tokenIssuer: {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
},
};
it('sets the correct headers in start', async () => {
@@ -23,6 +23,7 @@ import {
OAuthProviderHandlers,
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../identity';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -33,6 +34,7 @@ export type Options = {
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
@@ -142,6 +144,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
this.setRefreshTokenCookie(res, refreshToken);
}
user.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: user.profile.email },
});
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
@@ -198,6 +204,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
refreshToken,
scope,
);
refreshInfo.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: refreshInfo.profile?.email },
});
return res.send(refreshInfo);
} catch (error) {
return res.status(401).send(`${error.message}`);
@@ -21,13 +21,14 @@ import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
ProviderStrategy,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
params: any,
): ProfileInfo => {
const { provider, displayName: name } = profile;
const { displayName: name } = profile;
let email = '';
if (profile.emails) {
@@ -51,7 +52,6 @@ export const makeProfileInfo = (
}
return {
provider,
name,
email,
picture,
@@ -100,12 +100,12 @@ export const executeFrameHandlerStrategy = async (
};
export const executeRefreshTokenStrategy = async (
providerstrategy: passport.Strategy,
providerStrategy: passport.Strategy,
refreshToken: string,
scope: string,
): Promise<RefreshTokenResponse> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerstrategy as any;
const anyStrategy = providerStrategy as any;
const OAuth2 = anyStrategy._oauth2.constructor;
const oauth2 = new OAuth2(
anyStrategy._oauth2._clientId,
@@ -149,12 +149,12 @@ export const executeRefreshTokenStrategy = async (
};
export const executeFetchUserProfileStrategy = async (
providerstrategy: passport.Strategy,
providerStrategy: passport.Strategy,
accessToken: string,
params: any,
): Promise<ProfileInfo> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerstrategy as any;
const anyStrategy = (providerStrategy as unknown) as ProviderStrategy;
anyStrategy.userProfile(
accessToken,
(error: Error, passportProfile: passport.Profile) => {
@@ -20,6 +20,7 @@ import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -32,19 +33,22 @@ export const createAuthProviderRouter = (
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
issuer: TokenIssuer,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
const provider = factory(globalConfig, providerConfig, logger);
const provider = factory(globalConfig, providerConfig, logger, issuer);
const router = Router();
router.get('/start', provider.start.bind(provider));
router.get('/handler/frame', provider.frameHandler.bind(provider));
router.post('/handler/frame', provider.frameHandler.bind(provider));
router.post('/logout', provider.logout.bind(provider));
if (provider.logout) {
router.post('/logout', provider.logout.bind(provider));
}
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
@@ -36,6 +36,7 @@ import {
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GithubStrategy;
@@ -69,6 +70,7 @@ export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
@@ -101,6 +103,7 @@ export function createGithubProvider(
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
@@ -41,6 +41,7 @@ import {
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GoogleStrategy;
@@ -116,6 +117,7 @@ export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
@@ -148,6 +150,7 @@ export function createGoogleProvider(
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
+193 -30
View File
@@ -16,79 +16,211 @@
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientID: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackURL: string;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type EnvironmentProviderConfig = {
[key: string]: OAuthProviderConfig | SAMLProviderConfig;
};
export type AuthProviderConfig = {
baseUrl: string;
};
export type OAuthProviderConfig = {
/**
* Cookies can be marked with a secure flag to send cookies only when the request
* is over an encrypted channel (HTTPS).
*
* For development environment we don't mark the cookie as secure since we serve
* localhost over HTTP.
*/
secure: boolean;
appOrigin: string; // http://localhost:3000
/**
* The protocol://domain[:port] where the app (frontend) is hosted. This is used to post messages back
* to the window that initiates an auth request.
*/
appOrigin: string;
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
*
* For e.g
* {
* development: DevelopmentOAuthProviderConfig
* production: ProductionOAuthProviderConfig
* }
*/
[key: string]: OAuthProviderConfig;
};
export type AuthProviderConfig = {
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
* callbackURL to redirect to once the user signs in to the auth provider.
*/
baseUrl: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthProviderHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
*/
start(req: express.Request, options: any): Promise<any>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(req: express.Request): Promise<any>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(refreshToken: string, scope: string): Promise<any>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<any>;
}
/**
* Any Auth provider needs to implement this interface which handles the routes in the
* auth backend. Any auth API requests from the frontend reaches these methods.
*
* The routes in the auth backend API are tied to these methods like below
*
* /auth/[provider]/start -> start
* /auth/[provider]/handler/frame -> frameHandler
* /auth/[provider]/refresh -> refresh
* /auth/[provider]/logout -> logout
*/
export interface AuthProviderRouteHandlers {
/**
* Handles the start route of the API. This initiates a sign in request with an auth provider.
*
* Request
* - scopes for the auth request (Optional)
* Response
* - redirect to the auth provider for the user to sign in or consent.
* - sets a nonce cookie and also pass the nonce as 'state' query parameter in the redirect request
*
* @param {express.Request} req
* @param {express.Response} res
*/
start(req: express.Request, res: express.Response): Promise<any>;
frameHandler(req: express.Request, res: express.Response): Promise<any>;
refresh?(req: express.Request, res: express.Response): Promise<any>;
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
/**
* Once the user signs in or consents in the OAuth screen, the auth provider redirects to the
* callbackURL which is handled by this method.
*
* Request
* - to contain a nonce cookie and a 'state' query parameter
* Response
* - postMessage to the window with a payload that contains accessToken, expiryInSeconds?, idToken? and scope.
* - sets a refresh token cookie if the auth provider supports refresh tokens
*
* @param {express.Request} req
* @param {express.Response} res
*/
frameHandler(req: express.Request, res: express.Response): Promise<any>;
/**
* (Optional) If the auth provider supports refresh tokens then this method handles
* requests to get a new access token.
*
* Request
* - to contain a refresh token cookie and scope (Optional) query parameter.
* Response
* - payload with accessToken, expiryInSeconds?, idToken?, scope and user profile information.
*
* @param {express.Request} req
* @param {express.Response} res
*/
refresh?(req: express.Request, res: express.Response): Promise<any>;
/**
* (Optional) Handles sign out requests
*
* Response
* - removes the refresh token cookie
*
* @param {express.Request} req
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<any>;
}
export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
issuer: TokenIssuer,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
};
export type AuthInfoWithProfile = AuthInfoBase & {
profile:
| {
provider: string;
email: string;
name?: string;
picture?: string;
}
| undefined;
/**
* Profile information of the signed in user.
*/
profile: ProfileInfo | undefined;
};
export type AuthInfoPrivate = {
/**
* A refresh token issued for the signed in user.
*/
refreshToken: string;
};
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type AuthResponse =
| {
type: 'auth-result';
@@ -100,18 +232,49 @@ export type AuthResponse =
};
export type RedirectInfo = {
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
};
export type ProfileInfo = {
provider: string;
/**
* Email ID of the signed in user.
*/
email: string;
/**
* Display name that can be presented to the signed in user.
*/
name: string;
/**
* URL to an image that can be used as the display image or avatar of the
* signed in user.
*/
picture: string;
};
export type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
params: any;
};
export type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
+3 -4
View File
@@ -14,15 +14,12 @@
* limitations under the License.
*/
import yn from 'yn';
import { getRootLogger } from '@backstage/backend-common';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
startStandaloneServer({ logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -31,3 +28,5 @@ process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
module.hot?.accept();
+25 -1
View File
@@ -18,12 +18,15 @@ import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
export interface RouterOptions {
logger: Logger;
database: Knex;
config: Config;
}
@@ -33,6 +36,19 @@ export async function createRouter(
const router = Router();
const logger = options.logger.child({ plugin: 'auth' });
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
const keyDurationSeconds = 3600;
const keyStore = await DatabaseKeyStore.create({
database: options.database,
});
const tokenIssuer = new TokenFactory({
issuer: baseUrl,
keyStore,
keyDurationSeconds,
logger: logger.child({ component: 'token-factory' }),
});
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
@@ -79,7 +95,6 @@ export async function createRouter(
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
@@ -87,11 +102,20 @@ export async function createRouter(
{ baseUrl },
providerConfig,
logger,
tokenIssuer,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
}
}
router.use(
createOidcRouter({
tokenIssuer,
baseUrl,
}),
);
return router;
}
@@ -1,54 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
logger: Logger;
config: Config;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger, config } = options;
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -14,15 +14,15 @@
* limitations under the License.
*/
import Knex from 'knex';
import { Server } from 'http';
import { Logger } from 'winston';
import { createStandaloneApplication } from './standaloneApplication';
import { ConfigReader } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
import { createRouter } from './router';
import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
@@ -32,23 +32,31 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'auth-backend' });
const config = ConfigReader.fromConfigs(await loadConfig());
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger,
config,
const database = useHotMemoize(module, () => {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
const router = await createRouter({
logger,
config,
database,
});
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000', credentials: true })
.addRouter('/auth', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import React from 'react';
import React, { FC } from 'react';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../../api/types';
import { useAsync } from 'react-use';
import { CircularProgress, useTheme } from '@material-ui/core';
export const AllServicesCount: React.FC<{}> = () => {
export const AllServicesCount: FC<{}> = () => {
const theme = useTheme();
const catalogApi = useApi(catalogApiRef);
const { value, loading } = useAsync(() => catalogApi.getEntities());
@@ -29,5 +29,5 @@ export const AllServicesCount: React.FC<{}> = () => {
return <CircularProgress size={theme.spacing(2)} />;
}
return <span>{value?.length ?? '-'}</span>;
return <span>{value ?? length ?? '-'}</span>;
};

Some files were not shown because too many files have changed in this diff Show More